Repository navigation
fix(router): turn away a load that starts after a sign-out the router hasn't heard of - #1918
Merged
Merged
Conversation
… 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
marked this pull request as ready for review
October 12, 2026 18:31
Coverage Report
File Coverage
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Contributor
There was a problem hiding this comment.
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.
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.
Problem
Two tests from the recent auth stack were blocking stage deploys.
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 wasexpect(toast.error).not.toHaveBeenCalled()(line 201). The toast had idrequest-forbiddenand said "We had some trouble!". The route error page was not rendered.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:
updateListenersawaitssleep(1), thenAppRoutedre-renders and callsrouter.invalidate().A route load can start inside that tick. When it does:
dashboardLayout's guard, which still sees the old context ({}, "check pending", or the signed-in user);beforeLoadand fetches on the ended session;queryErrorHandler. The user gets a toast on the sign-in page and RUM gets aconsole.errorreport.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 apopstatein a later task (Document#_queueATask→setImmediate). That event normally fires whileopenDeepLinkawaits 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 beforerender, or dispatching apopstatefrom the 401 handler. The trace for the forced case:2. Test-only: cold transform inside the test budget
rememberedClusterDeepLinkrunsvi.resetModules()and then imports the wholerootRouteTreeinside each test. The first test therefore transforms the entire app graph within vitest's 5s budget. The flow it actually checks takes about 50ms.The same pattern made the first case of
endedSessionRouteFailurestime 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 firstinstanceLayoutRouteimport (#1854) timed out the same way once.Fix
Product (
fix(router)): every route guard now reads the app sign-in through the newsrc/router/guardedAppSignIn.ts.guardedAppSignInreturns the router context, except that a sign-out the store already knows of wins.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.dashboardLayout,signInRouteandlocalSignInRoute./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.checkClusterInstanceAuthenticationBeforeLoadand 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.mddocuments the rule.Tests:
popstateis dispatched from a response interceptor registered right behind the 401 handler. The case pins exactly one organization request, no toast and no report.clearAuthStateLocally()and dispatches apopstate. It pins that sign-in renders with no error page and no second organization request.authRouteTree, with onlyAuthLayoutandSignInstubbed.settled(), which waits forqueryClient.isFetching()to reach 0. Any failure that could still toast has fired by then.history.replaceState, which queues nothing.resetModulesand evaluates a fresh copy, so what the tests pin is unchanged. No timeout was raised. This applies toendedSessionRouteFailures(top-levelmockAppModules()plusawait import),rememberedClusterDeepLinkandentityAuthCheckScope(side-effect imports).isKnownSignedOut.test.tscovers 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 singleawaitseparates the guard from its read. I have not reproduced the Fabric Connect path. The instance guard's ownoverallAuthread (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:
guardedAppSignInreturns the context)expected [ …(2) ] to have a length of 1 but got 2(a second organization request on the ended session)guardedAppSignIn__root__", the redirect loop)clearAuthStateLocallysettled()) and the first new case both failisKnownSignedOutgetConnectionById(the "do less" alternative)routeLoadFailureReporting,routeErrorReporting,OrgRouteError,adminRoutesand the unit test, all of which build a context with no store answerTimings:
endedSessionRouteFailuresfirst case: ~800ms (5s timeout once) → 39ms.rememberedClusterDeepLinkfirst case: 1.5–4s+ → ~170ms.entityAuthCheckScopeguard 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: 0npx oxlint --format stylish .: 0npx dprint check: 0npx vitest runthree times: 3/3 green (458 files, 4340 tests), with machine load average 20–25 from other sessions.pnpm test, lint, format) passed on both commits.endedSessionRouteFailurespassed 15/15;rememberedClusterDeepLinkandentityAuthCheckScopeeach passed 10/10.One earlier full run at load average ~40 timed out in two unrelated files,
src/lib/monaco/editorApi.test.ts(30s) andEditRoleModal.test.tsx(5s). The files changed here passed in every run.Cross-model review
I ran one round (codex
gpt-5.6-solgraded, gemini, harper-domain):independent=true. Cursor legs were not requested..tsextensions to the new local imports". This repo resolves extensionless imports (moduleResolution: bundler, and noallowImportingTsExtensions), and every existing import omits the extension. The domain leg also dropped it.guardedAppSignIn. TheisKnownSignedOutdoc 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.sleep(1): dropping it would fix every guard that reads the context. But thesleep(1)has no recorded reason (it dates from574d0f602), 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.🤖 Generated with Claude Code