Skip to content

fix(auth): read login error param synchronously to close a render race - #3034

Open
fra-shipper wants to merge 1 commit into
Chainlit:mainfrom
fra-shipper:fix/oauth-login-error-render-race
Open

fix(auth): read login error param synchronously to close a render race#3034
fra-shipper wants to merge 1 commit into
Chainlit:mainfrom
fra-shipper:fix/oauth-login-error-render-race

Conversation

@fra-shipper

@fra-shipper fra-shipper commented Aug 31, 2026

Copy link
Copy Markdown

Refs #3023.

Root cause

Login read the ?error= query param via a useEffect, so error always started as '' on the first render. That empty value flowed into LoginForm's own useState(error) initial value. LoginForm only picked up the real error message once both components' effects had flushed:

  1. Login mounts with error=''.
  2. Login's useEffect fires, calling setError(query.get('error') || ''), re-rendering Login and passing the correct error prop to LoginForm.
  3. LoginForm's own useEffect (which syncs errorState from the error prop) fires and finally updates errorState, 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_resume in #3021, just at the login page instead of the chat composer.

#3023 reports a single windows-latest CI occurrence where the oauth_auth spec's shows a specific message for oauthSignin error test hit Cypress's 30s defaultCommandTimeout on 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 error from the query param synchronously with a lazy useState initializer, instead of via useEffect. The first render now already carries the correct value, so LoginForm's initial errorState is correct from its very first render too -- no async hop is needed for the initial-load case. The existing useEffect is kept so error still 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 building libs/react-client, a pre-existing workspace build-order requirement) -- 0 errors; on unmodified HEAD via git stash the same command produced 262 pre-existing error TS... lines from the unbuilt @chainlit/react-client/client-types workspace package, confirming those are pre-existing and unrelated to this change.
  • Added frontend/tests/Login.spec.tsx: mounts <Login> with createRoot + flushSync (bypassing React Testing Library's act()-wrapped render(), 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-fix Login.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 both HEAD and HEAD~1's Login.tsx -- and unrelated to Login.tsx.
  • The repo's husky pre-commit hook (lint-staged: prettier + eslint --fix + pnpm --filter @chainlit/app type-check on staged files) ran automatically on git commit and passed without --no-verify.

Not run: Cypress e2e (cypress/e2e/oauth_auth). It requires spinning up a full Chainlit backend via uv run chainlit run ... per cypress.config.ts's before:spec hook, 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.

  • Reads ?error= synchronously via a lazy useState initializer instead of useEffect, so LoginForm gets the correct error prop from the start.
  • Keeps the effect to re-sync if the query param changes later.
  • Adds a regression test that mounts Login with createRoot + flushSync to verify the alert is present on the first commit.

Written for commit 26c74c9. Summary will update on new commits.

Review in cubic

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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant