Skip to content

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

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

fra-shipper wants to merge 2 commits 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 login render race that could delay the OAuth error message until after two effects; it now appears on the first commit. This covers the code path in #3023, though the reported CI flake remains unconfirmed.

  • Initializes the error from ?error= synchronously and still re-syncs when the query changes.
  • Adds first-commit tests for error and no-error cases using createRoot and flushSync.
  • Unmounts manually created roots after each test to prevent leaked effects.

Written for commit 10428c5. 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.

All reported issues were addressed across 2 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread frontend/tests/Login.spec.tsx
Both createRoot-based tests left their React root mounted after the
test; the shared cleanup() in tests/setup-tests.ts only handles trees
rendered via Testing Library's render(), so the manual roots and
Login's pending passive effect leaked across tests. Track the root and
unmount it in afterEach, before removing the container.
@fra-shipper

Copy link
Copy Markdown
Author

Addressed the cubic review finding: Login.spec.tsx's two manual createRoot roots were never unmounted, only their container div was removed from the DOM. The shared cleanup() in tests/setup-tests.ts only covers Testing Library's render() trees, so these roots (and Login's pending passive effect) leaked across tests. Now tracking the root in a describe-scoped variable and calling root.unmount() in afterEach before removing the container.

Verified: pnpm lint, pnpm format-check:files, and pnpm --filter @chainlit/app type-check all clean on the touched file; vitest run tests/Login.spec.tsx passes both tests with no act() warnings.

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