fix(auth): read login error param synchronously to close a render race - #3034
Open
fra-shipper wants to merge 1 commit into
Open
fix(auth): read login error param synchronously to close a render race#3034fra-shipper wants to merge 1 commit into
fra-shipper wants to merge 1 commit into
Conversation
Login read the ?error= query param via a useEffect, so the first render always had error=''. That flowed into LoginForm's own useState(error) initial value, and LoginForm only caught up once both components' effects had flushed -- a two-hop async chain before the [role=alert] error message ever appears in the DOM. Initialize the state from the query param directly (lazy useState initializer) so the first render already carries it, removing one of the two async hops. The existing effect is kept to re-sync error if the query changes after mount. Added Login.spec.tsx: mounts <Login> with createRoot + flushSync, bypassing Testing Library's act()-wrapped render() (which flushes effects synchronously and would hide the bug), and asserts the [role="alert"] node is present on the very first commit. Confirmed it fails on the pre-fix code and passes with this change. This is the same class of bug as the thread_resume race fixed in Chainlit#3021 (a stale-DOM assertion racing an async render). It is a plausible but unconfirmed contributor to Chainlit#3023: a single windows-latest CI occurrence where the oauth_auth spec's 'shows a specific message for oauthSignin error' test hit the 30s Cypress command timeout on attempt 1 and passed on retry, filed needs-triage with the root cause still unconfirmed. The render gap this fix closes resolves within a single synchronous commit, orders of magnitude smaller than a 30s command timeout, so this is not a confirmed diagnosis of Chainlit#3023 -- it is a genuine render-timing bug worth fixing on its own merits, in the same code path Chainlit#3023's test exercises.
fra-shipper
requested review from
asvishnyakov,
hayescode and
sandangel
as code owners
August 31, 2026 05:04
Contributor
There was a problem hiding this comment.
1 issue found across 2 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="frontend/tests/Login.spec.tsx">
<violation number="1" location="frontend/tests/Login.spec.tsx:56">
P2: Both tests create a React root with createRoot(container) but never unmount it. Because the shared cleanup() in tests/setup-tests.ts only handles containers rendered via @testing-library, the manual roots stay mounted with Login's passive useEffect still pending; that effect flushes later outside act(), producing act() warnings and leaking mounted component trees across tests. Track the root and call root.unmount() in afterEach.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
|
|
||
| afterEach(() => { | ||
| if (container) { | ||
| document.body.removeChild(container); |
Contributor
There was a problem hiding this comment.
P2: Both tests create a React root with createRoot(container) but never unmount it. Because the shared cleanup() in tests/setup-tests.ts only handles containers rendered via @testing-library, the manual roots stay mounted with Login's passive useEffect still pending; that effect flushes later outside act(), producing act() warnings and leaking mounted component trees across tests. Track the root and call root.unmount() in afterEach.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At frontend/tests/Login.spec.tsx, line 56:
<comment>Both tests create a React root with createRoot(container) but never unmount it. Because the shared cleanup() in tests/setup-tests.ts only handles containers rendered via @testing-library, the manual roots stay mounted with Login's passive useEffect still pending; that effect flushes later outside act(), producing act() warnings and leaking mounted component trees across tests. Track the root and call root.unmount() in afterEach.</comment>
<file context>
@@ -0,0 +1,98 @@
+
+ afterEach(() => {
+ if (container) {
+ document.body.removeChild(container);
+ container = null;
+ }
</file context>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #3023.
Root cause
Loginread the?error=query param via auseEffect, soerroralways started as''on the first render. That empty value flowed intoLoginForm's ownuseState(error)initial value.LoginFormonly picked up the real error message once both components' effects had flushed:Loginmounts witherror=''.Login'suseEffectfires, callingsetError(query.get('error') || ''), re-renderingLoginand passing the correcterrorprop toLoginForm.LoginForm's ownuseEffect(which syncserrorStatefrom theerrorprop) fires and finally updateserrorState, causing the[role="alert"]element to appear.That is a two-hop async chain between two components' effects before the error message exists in the DOM at all -- the same class of stale-DOM-vs-async-render bug already diagnosed and fixed for
thread_resumein #3021, just at the login page instead of the chat composer.#3023 reports a single
windows-latestCI occurrence where theoauth_authspec'sshows a specific message for oauthSignin errortest hit Cypress's 30sdefaultCommandTimeouton attempt 1 and passed on retry. This render gap resolves within a single synchronous commit -- orders of magnitude smaller than a 30s command timeout -- so this PR does not claim to be a confirmed diagnosis of #3023. It fixes a genuine render-timing bug in the exact code path that test exercises; I could not reproduce the CI flake locally to confirm it is the same mechanism (a single occurrence, unknown rate, Windows-only real-browser timing).Fix
Initialize
errorfrom the query param synchronously with a lazyuseStateinitializer, instead of viauseEffect. The first render now already carries the correct value, soLoginForm's initialerrorStateis correct from its very first render too -- no async hop is needed for the initial-load case. The existinguseEffectis kept soerrorstill re-syncs if the query string changes while the component stays mounted.Testing
pnpm lint frontend/src/pages/Login.tsx-- clean.pnpm format-check:files frontend/src/pages/Login.tsx-- "Checking formatting... All matched files use Prettier code style!"pnpm --filter @chainlit/app type-check(after buildinglibs/react-client, a pre-existing workspace build-order requirement) -- 0 errors; on unmodified HEAD viagit stashthe same command produced 262 pre-existingerror TS...lines from the unbuilt@chainlit/react-client/client-typesworkspace package, confirming those are pre-existing and unrelated to this change.frontend/tests/Login.spec.tsx: mounts<Login>withcreateRoot+flushSync(bypassing React Testing Library'sact()-wrappedrender(), which flushes effects synchronously and would hide the bug) and asserts[role="alert"]is present on the very first commit when mounted with?error=..., plus a negative-control assertion for the no-error-param case. Confirmed this fails on pre-fixLogin.tsx(AssertionError: expected null not to be null) and passes post-fix.pnpm --filter @chainlit/app test--Test Files 1 failed | 5 passed (6),Tests 3 failed | 31 passed (34). The one failing file (displayModePrecedence.spec.ts, a jsdom/localStorage environment issue) is identical before and after this change -- re-verified on bothHEADandHEAD~1'sLogin.tsx-- and unrelated toLogin.tsx.pnpm --filter @chainlit/app type-checkon staged files) ran automatically ongit commitand passed without--no-verify.Not run: Cypress e2e (
cypress/e2e/oauth_auth). It requires spinning up a full Chainlit backend viauv run chainlit run ...percypress.config.ts'sbefore:spechook, and more importantly the flake this fix targets is a Windows-only, real-browser timing race with a single observed occurrence that I have no way to force or deterministically re-verify locally either way.Summary by cubic
Fixes a render race on the login page so the OAuth error message appears on the first render instead of after two effect hops.
?error=synchronously via a lazyuseStateinitializer instead ofuseEffect, soLoginFormgets the correct error prop from the start.LoginwithcreateRoot+flushSyncto verify the alert is present on the first commit.Written for commit 26c74c9. Summary will update on new commits.