Skip to content

fix(router): turn away a load that starts after a sign-out the router hasn't heard of - #1918

Merged
dawsontoth merged 2 commits into
stagefrom
claude/flaky-auth-router-tests
Oct 12, 2026
Merged

dawsontoth merged 2 commits into
stagefrom
claude/flaky-auth-router-tests

Conversation

@dawsontoth

Copy link
Copy Markdown
Contributor

Problem

Two tests from the recent auth stack were blocking stage deploys.

  1. endedSessionRouteFailures.test.tsx › "reports nothing when the sign-in check 401s first and the data 403s after the sign-out" failed the post-merge Deploy to Stage run (38281376549). The failing line was expect(toast.error).not.toHaveBeenCalled() (line 201). The toast had id request-forbidden and said "We had some trouble!". The route error page was not rendered.
  2. rememberedClusterDeepLink.test.ts › "sends an ended session to sign in, even when the route data failed on it first" timed out at 5000ms whenever the machine was loaded.

Cause

1. A real product race, not just a test race

The bug is in shipped code. The auth store learns of a sign-out right away and clears the query cache. The router context hears about it a tick later: updateListeners awaits sleep(1), then AppRouted re-renders and calls router.invalidate().

A route load can start inside that tick. When it does:

  • it passes dashboardLayout's guard, which still sees the old context ({}, "check pending", or the signed-in user);
  • it runs the organization route's beforeLoad and fetches on the ended session;
  • the redirect that follows abandons the load, which releases the route's claim (Show a failed page load only on its error page, without a duplicate toast or RUM report #1878);
  • so when the request fails, the failure goes to the global queryErrorHandler. The user gets a toast on the sign-in page and RUM gets a console.error report.

The window is about 1ms plus one React render, so it is rare in production. Anything that starts a load in that window is enough, for example Back or a preload.

In the test, the second load came from the test's own setup. window.location.hash = … makes jsdom queue a popstate in a later task (Document#_queueATask → setImmediate). That event normally fires while openDeepLink awaits its imports. If the render beats it, it fires in exactly that tick, because the sign-in check's 401 is handled in the same task as the render's effects.

I could not get that ordering to happen on its own locally. I tried about 130 loaded runs: CPU burners, --maxWorkers=3, and full suites. Forcing it does reproduce the CI assertion every time, with the same toast id and no error page. I forced it two ways: setting the hash right before render, or dispatching a popstate from the 401 handler. The trace for the forced case:

cache removed ["org-…"]                       ← the 401 handler cleared the session
POPSTATE fired → onBeforeLoad ctxAuth={}       ← new load, old context
cache added / fetch ["org-…"]; request /Organization/…   ← fetch on the ended session
auth update delivered → AppRouted render → redirect to /sign-in (abandons that load)
answer 403 → onError claimed=false → TOAST request-forbidden

2. Test-only: cold transform inside the test budget

rememberedClusterDeepLink runs vi.resetModules() and then imports the whole rootRouteTree inside each test. The first test therefore transforms the entire app graph within vitest's 5s budget. The flow it actually checks takes about 50ms.

  • In CI the test took 1.5s.
  • In loaded local full runs the import alone took 1.9–4.0s.
  • It timed out in 1 of 3 full runs, and both times two workers cold-transformed the graph at once.

The same pattern made the first case of endedSessionRouteFailures time out once in a loaded full run. The second case then failed with "multiple elements" because the first case's render was left behind. entityAuthCheckScope's first instanceLayoutRoute import (#1854) timed out the same way once.

Fix

Product (fix(router)): every route guard now reads the app sign-in through the new src/router/guardedAppSignIn.ts.

  • guardedAppSignIn returns the router context, except that a sign-out the store already knows of wins.
  • The store side is the new authStore.isKnownSignedOut. It is true only once the store has an answer: a check that came back with no user, or a sign-out. A router built with a context and no store answer (many tests) behaves exactly as before.
  • Three guards use it: dashboardLayout, signInRoute and localSignInRoute.
  • The sign-in guards need it too. With only the dashboard guard changed, a load from a signed-in page bounces back and forth. The dashboard guard sends it to /sign-in, and the sign-in guard, still reading the stale user, sends it back. After 20 redirects the router renders "Too many redirects" at the root. The domain review leg found this; a test now pins it.
  • This follows what checkClusterInstanceAuthenticationBeforeLoad and the cluster sign-in guards already do. They read live store state because the context lags (Direct Sign In Fallsback to Fabric Connect Too Aggressively #1333).
  • src/router/DESIGN.md documents the rule.

Tests:

  • Two new cases start a load in that tick:
    • "fetches nothing for a load that starts after the sign-out, before the router hears of it": the context still says the check is pending. A popstate is dispatched from a response interceptor registered right behind the 401 handler. The case pins exactly one organization request, no toast and no report.
    • "lands on sign-in for a load in that gap from a signed-in page, without bouncing back": the context still holds the signed-in user. The case calls clearAuthStateLocally() and dispatches a popstate. It pins that sign-in renders with no error page and no second organization request.
  • Real sign-in routes. The test tree now uses the real authRouteTree, with only AuthLayout and SignIn stubbed.
  • Fixed sleep removed. The 20ms wait is now settled(), which waits for queryClient.isFetching() to reach 0. Any failure that could still toast has fired by then.
  • No queued event in setup. The hash is set with history.replaceState, which queues nothing.
  • No transform inside a test's 5s. The module graph a test needs is imported once while the file is collected, which vitest does not time. I checked that: a file whose top level awaits 11s still passes. Each test still runs resetModules and evaluates a fresh copy, so what the tests pin is unchanged. No timeout was raised. This applies to endedSessionRouteFailures (top-level mockAppModules() plus await import), rememberedClusterDeepLink and entityAuthCheckScope (side-effect imports).
  • isKnownSignedOut.test.ts covers three states: no answer yet, signed out, and signed back in.

Not covered here: a sign-out can still land while a load is already past the guard and waiting on something other than a query. For example, the instance guard waits on tryEstablishFabricConnect (instanceLayoutRoute.ts:60, :94). In that case the reads after it could still fetch on the ended session. Reads awaited before it are cancelled by the cache clear and never settle (#1816). For the org route only a single await separates the guard from its read. I have not reproduced the Fabric Connect path. The instance guard's own overallAuth read (instanceLayoutRoute.ts:84) is unchanged. In a load that starts after a sign-out, the dashboard guard redirects before that guard runs.

Verification

Mutation checks. With each guard removed locally, the case meant to catch it fails:

Removed Result
The store override (guardedAppSignIn returns the context) Both new cases fail: expected [ …(2) ] to have a length of 1 but got 2 (a second organization request on the ended session)
Only the sign-in guard's use of guardedAppSignIn The signed-in case fails: the error page renders twice ("Error in route match: __root__", the redirect loop)
The query-cache clear in clearAuthStateLocally "reports nothing…" (the 403 toasts during settled()) and the first new case both fail
The checked-answer gate in isKnownSignedOut The "no answer" unit case fails
The gate, plus reading getConnectionById (the "do less" alternative) 14 tests fail across routeLoadFailureReporting, routeErrorReporting, OrgRouteError, adminRoutes and the unit test, all of which build a context with no store answer
(none, but the setup's popstate forced after render, old guard) "reports nothing…" fails exactly like CI; with the fix all cases pass

Timings:

  • endedSessionRouteFailures first case: ~800ms (5s timeout once) → 39ms.
  • rememberedClusterDeepLink first case: 1.5–4s+ → ~170ms.
  • entityAuthCheckScope guard case: timed out once → 23ms.

Gates, all run with Node 24.21.0 and real exit codes, after rebasing onto the current stage:

  • npx tsc -b: 0
  • npx oxlint --format stylish .: 0
  • npx dprint check: 0
  • Full npx vitest run three times: 3/3 green (458 files, 4340 tests), with machine load average 20–25 from other sessions.
  • husky pre-commit (full pnpm test, lint, format) passed on both commits.
  • Under 10 CPU burners: endedSessionRouteFailures passed 15/15; rememberedClusterDeepLink and entityAuthCheckScope each passed 10/10.

One earlier full run at load average ~40 timed out in two unrelated files, src/lib/monaco/editorApi.test.ts (30s) and EditRoleModal.test.tsx (5s). The files changed here passed in every run.

Cross-model review

I ran one round (codex gpt-5.6-sol graded, gemini, harper-domain): independent=true. Cursor legs were not requested.

  • domain, minor, fixed: the sign-in redirect bounce described above. The fix and the test came after the round, so they have not had a second round.
  • codex nit, rejected: "add .ts extensions to the new local imports". This repo resolves extensionless imports (moduleResolution: bundler, and no allowImportingTsExtensions), and every existing import omits the extension. The domain leg also dropped it.
  • gemini / domain nit, partly taken: "no new comments" / "duplicated explanations". The guard's comment is gone, because the reasoning now lives once in guardedAppSignIn. The isKnownSignedOut doc is now a single sentence. The three warm-up comments are cut to two lines each. They stay because each side-effect import otherwise looks removable.
  • Decisions from the ledger:
    • compensate in the guards vs drop sleep(1): dropping it would fix every guard that reads the context. But the sleep(1) has no recorded reason (it dates from 574d0f602), every auth listener depends on its timing, and notifying synchronously from inside a render could hit the render-phase update errors AGENTS.md records for the router. That is a wider change than this fix, and I have not tested it.
    • one-directional override: the store only wins toward signed-out. Overriding toward signed-in would admit a just-signed-in user before the context has the user, which the pages read.
    • warm-up vs raised timeout: the warm-up moves the work out of the timed budget instead of hiding it.
    • do-less alternative: measured; see the mutation table.

🤖 Generated with Claude Code

dawsontoth and others added 2 commits October 12, 2026 14:23
… hasn't heard of

The store learns of a sign-out at once and clears the query cache; the router context hears a
tick later, through the store's listeners (`updateListeners` awaits `sleep(1)`, then AppRouted
re-renders and invalidates). A load that starts in that tick passed the dashboard guard on the old
context, ran the organization route's `beforeLoad` on the ended session and fetched there. The
redirect that followed abandoned the load, which released the route's claim, so the failure went to
the global handler: an "Error / We had some trouble!" toast (id `request-forbidden`) on the sign-in
page, plus a console.error that RUM reports.

That is the failure in Deploy to Stage run 38281376549. The case "reports nothing when the sign-in
check 401s first and the data 403s after the sign-out" set the deep link with
`window.location.hash = …`, and jsdom dispatches that popstate a task later (setImmediate). It
normally fires while `openDeepLink` awaits its imports. When the render beat it, it landed in exactly
that tick and started the second load. Forcing that ordering, or dispatching a popstate from the
401 handler, reproduces the CI assertion every time.

The guards now read the app sign-in through `guardedAppSignIn`. It returns the router context,
except that a sign-out the store already knows of (`authStore.isKnownSignedOut`) wins. The dashboard
guard and both sign-in guards use it. If only the dashboard guard heard, a load from a signed-in page
would bounce between it and the sign-in guard until the router gave up with "Too many redirects".
`isKnownSignedOut` is true only once the store has an answer, so a router built with a context and
no store answer behaves as before. Reading the live connection directly instead fails 13 existing
route tests that build exactly that.

Tests:
- Two new cases start a load in that tick, one from a pending context (a popstate dispatched right
  after the 401 clears the session) and one from a signed-in page. Both pin that no second
  organization request goes out. The first also pins no toast and no report; the second pins that
  sign-in renders without the error page. Both fail without the override, and the second also fails
  when only the dashboard guard has it.
- The tree uses the real sign-in routes, with only `AuthLayout` and `SignIn` stubbed.
- The 20ms sleep becomes a wait for the query cache to go idle, which fails the case when the
  sign-out's cache clear is removed.
- The setup sets the hash with `history.replaceState`, which queues no event.
- The modules a page load evaluates are imported once while the file is collected, so the first
  case no longer transforms them inside its 5s. It timed out there in a loaded local full run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t test's 5s

`rememberedClusterDeepLink` resets modules and imports the whole `rootRouteTree` inside each test.
The first test therefore transformed the entire app graph inside vitest's 5s budget. The test took
1.5s in CI. In loaded local full runs the import alone took 1.9-4.0s, it timed out at 5s in one run
of three, and it timed out both times two workers cold-transformed the graph together. The flow it
pins takes ~50ms.

A side-effect import at the top does the transform while the file is collected, which vitest does
not time. Each test still resets modules and evaluates a fresh copy, so what the tests pin is
unchanged. The first test now takes ~170ms.

`entityAuthCheckScope` (same stack, #1854) timed out the same way on its first
`instanceLayoutRoute` import in a loaded full run, and gets the same fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dawsontoth
dawsontoth marked this pull request as ready for review October 12, 2026 18:31
@dawsontoth
dawsontoth requested a review from a team as a code owner October 12, 2026 18:31
@github-actions

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 72.51% 11770 / 16232
🔵 Statements 72.61% 12542 / 17273
🔵 Functions 67.11% 3124 / 4655
🔵 Branches 68.24% 9261 / 13571
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/features/auth/routes.ts 61.11% 12.5% 52.63% 61.11% 30-32, 38-45, 53, 59, 66, 73, 80, 87
src/features/auth/store/authStore.ts 88.21% 75.7% 90.27% 88.75% 111, 197-208, 213-216, 264, 285, 306-317, 324-333, 435-438, 739-745, 819, 843
src/router/dashboardRoute.ts 100% 100% 100% 100%
src/router/guardedAppSignIn.ts 100% 100% 100% 100%
Generated in workflow #2257 for commit 607b615 by the Vitest Coverage Report Action

@dawsontoth
dawsontoth merged commit dfffebc into stage Oct 12, 2026
3 checks passed
@dawsontoth
dawsontoth deleted the claude/flaky-auth-router-tests branch October 12, 2026 18:34

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request introduces a new utility function, guardedAppSignIn, to resolve race conditions between the auth store and the router context during sign-out events. By checking if the store already knows a user is signed out, the application can now prevent unnecessary fetches on ended sessions and avoid 'Too many redirects' errors. The changes include updates to route guards, the addition of a new method isKnownSignedOut to the AuthStore, and expanded test coverage to verify these scenarios. I have no feedback to provide as there were no review comments.

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