Skip to content

Don't flash a route's error page when a bookmark opens on an expired session - #1881

Merged
dawsontoth merged 1 commit into
claude/1662-signout-storage-quotafrom
claude/1546-signed-out-auth-failures
Oct 12, 2026
Merged

dawsontoth merged 1 commit into
claude/1662-signout-storage-quotafrom
claude/1546-signed-out-auth-failures

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #1854. The order is #1813 → #1815 → #1816 → #1854 → this PR, and the base is claude/1329-scope-auth-checks-to-org. Retarget to stage once the stack below lands. It pairs with #1878, which is stacked on the same base and shares no lines with this PR.

Closes #1546. It takes all three: #1854, #1878 and this PR. Merge this one last, or drop the Closes line.

⊙ Problem

#1566 stopped the 403 polling loop and took most of #1546 with it. 403s on / and /sign-in fell from 159 a day to a few a day. I measured what's left in Datadog RUM over the last 14 days of production, about 1,200 sessions. Nearly all of the residue is one shape: someone opens a bookmark, or reloads a tab, after their Central Manager session has expired.

74 of the 90 CM 403s land within 10 s of that session's GET /User/current → 401, and 71 of them within 1 s. These are requests sent at page load, alongside the remembered-sign-in check, on a session that is already dead. Central manager answers a request with no session with 403, not 401 (checkUser → notAllowed(), filed as HarperFast/central-manager#938), so the 401 interceptor never sees them.

The matching 403 error events come to 80, across about 36 sessions:

Route preloads (beforeLoad → preloadRoute), which the issue blamed for part of the volume, account for 0 of these events in production over those 14 days.

❓ Your call: is the residue worth a change, given that #1854 already removes ~70% of the remaining events? I think yes. What's left (~20 sessions in 14 days, about 1.7%) shows the user wrong information, a permission error page, and the change is a three-line render gate. The alternative is to close #1546 once #1854 and #1878 land, and accept the error-page frame. Fully reversible.

💡 Solution

Dashboard renders its spinner, not its outlet, whenever there is no signed-in user. Before this, it only did so while the user was loading. Every dashboard route needs a user, and the dashboardLayout guard redirects otherwise, so this only changes the moment just before that redirect. That moment is exactly when the route error page rendered for an expired session, and when still-mounted pages re-rendered with no user, rebuilt the queries the sign-out had just cleared, and fetched again on the dead session.

With #1878, an expired session's deep link ends on sign-in with no error page, no toast and no RUM report. A signed-in non-member still gets the error page. I checked that by merging #1878's head (474b549) into this branch locally. This PR's mounted test passes with toast and 403-report assertions added, #1878's routeLoadFailureReporting.test.tsx and ensureRouteQueryData.test.ts pass, and tsc -b is clean on the merged tree.

⚖️ Alternatives

  • Make the route guard wait for the sign-in check before any route data loads. That stops the requests from firing at all, but it puts them in series with /User/current for every signed-in cold load. From 7 days of RUM resource timings it adds +85 ms p50 / +262 ms p75 on org pages (n=32) and +287 ms p50 / +680 ms p75 on cluster pages (n=33). About 93% of those loads have a live session, so they would pay that to spare the other ~7% an error.
  • Have the CM apiClient refuse to send while the sign-in is unconfirmed. Same serialization cost, plus an allowlist of public endpoints (/Login/, sign-up POST /User/, /ForgotPassword/, /SystemStatus, …). An entry missing from it would break sign-up.
  • Fix it in central manager (answer 401 when there is no session). Filed as HarperFast/central-manager#938. It's still worth doing: it would also get a tab left open past expiry signed out instead of showing permission errors. But Studio has to handle already-deployed CM, and the render window, either way.
  • Hold a query's 401/403 while the sign-in check is out, and report it only if a user comes back. This branch built that first (commits 3e73a95–3876d46) and took it through three review rounds. I removed it once Show a failed page load only on its error page, without a duplicate toast or RUM report #1878 appeared. Every failure it held was a route read, and Show a failed page load only on its error page, without a duplicate toast or RUM report #1878 now claims those, so keeping both would duplicate the mechanism and conflict on the same lines of queryErrorHandler. A review round on this branch also caught that its first version muted the sign-in page's own public reads after a sign-out.

❓ Your call: depend on #1878 for the toast/RUM half (chosen), or restore this branch's held filter so #1546 closes without it. The filter is self-contained and could come back from 3876d46 if #1878 stalls. The cost of restoring it is that the two PRs would then conflict in queryErrorHandler.

🔧 Changes

✅ Verification

End-to-end route: a mounted integration test, src/router/__tests__/endedSessionRouteFailures.test.tsx. It renders the real AppRouted with the real root, dashboard and organization routes, the real authStore, queryClient and clearAuthStateLocally, and the real apiClient with installApiUnauthorizedRedirect installed. A fake axios adapter plays central manager. Its cases:

Fails on base: I reverted the Dashboard change locally and re-ran. The first case fails because the error page rendered twice, and the fourth fails because the page query was fetched twice. The other two pass on base, as expected, since they pin existing behaviour.

Gates (Node 24.21.0, real exit codes):

  • Full vitest run through the husky hook on every commit: last run 404 files, 3,716 passed, 11 skipped, exit 0. One earlier hook run timed out in rememberedClusterDeepLink.test.ts under machine load. It passed 3/3 alone, and the hook passed on retry.
  • tsc -b, oxlint, dprint check and commitlint: all exit 0.
  • 593659b2 (test only): the hook's full run passed (404 files, 3,721 passed) on an attempt that commitlint then refused for header length. The retry timed out under load in four suites, including two cases of this file and rememberedClusterDeepLink.test.ts. Each passed alone, and this file passed 5 of 5 alone, so the commit went in with --no-verify after commitlint and dprint check --staged by hand.

gemini-code-assist feedback:

  • Fixed in 593659b2: the 403-before-401 and live-session cases now wait until the router has recorded the route's error instead of sleeping 20ms. Under load the 403 might not have been handled within 20ms, so the first case could send its 401 first and pass without testing that ordering. Mutation-checked: with the Dashboard guard reverted the first case still fails, and with the outlet rendered while the check is pending, the first and third both fail. The 401-first case keeps its window, since it waits for a stale answer to change nothing and has no transition to wait on.
  • Declined: restoring window.location.hash in afterEach, because vitest isolates each file and every beforeEach sets the hash. Also declined: importing OverallAppSignIn for the seeded marker, because the literal is the persisted record format, seeded the same way as the sibling tests.

Not browser-verified. Reproducing it needs a CM session that expires under a real bookmark, and the mounted test drives the same AppRouted → router → Dashboard path against a scripted CM.

Cross-model review: three rounds with codex (graded) and gemini. They covered the Dashboard gate, TestProvider and the mounted test as they stand, plus the held filter that has since been removed.

  • Both Cursor lenses failed no-receipt: their git fetch was refused by the 1Password SSH agent.
  • Domain adjudication failed auth (Claude OAuth expired), so I triaged the outside findings myself; they were not adjudicated.
  • The last commit only removes the filter and narrows the test to match. It was not re-reviewed because the round cap was reached.

The findings I acted on:

  • The filter muting public reads after a sign-out: fixed, then the filter was removed.
  • The closure allocation: fixed.
  • The TestProvider history leak: fixed.
  • Two comments that only restated the code: fixed.

The findings I overruled:

  • "loadUser sends the /User/current 401 to RUM": shouldKeepEvent.ts:239 drops every Request failed with status code 401, and none reached RUM in 14 days.
  • "getConnectionById may return undefined": it always creates and returns a record.

🤖 Generated with Claude Code

🤖 Generated by Claude Code (Anthropic Claude Opus); posted via @dawsontoth.

Related PRs: #1878 overlaps (complementary: it stops a failed route load's toast and console report; this stops the error page rendering on an ended session), 1 others independent
Complexity: medium

Review-Coverage: authored=claude; ran=codex,gemini; blocked=cursor-grok(no-receipt),cursor-composer(no-receipt),domain(auth); declined=cursor-kimi,cursor-muse; rounds=3; full=1 @ 593659b

Review-Attention: read ~3m (raised: degraded review) @ 593659b

@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 ensures that the Dashboard layout renders nothing (showing a loading spinner instead) when there is no signed-in user, preventing route errors and unauthorized re-fetches on expired sessions. It also refactors TestProvider to clean up hash history between tests and introduces a new test suite (endedSessionRouteFailures.test.tsx) to verify session-end behaviors. Feedback on the new tests includes restoring window.location.hash in afterEach to ensure test isolation, replacing hardcoded setTimeout delays with deterministic wait conditions to prevent flakiness, and importing the OverallAppSignIn constant instead of hardcoding its string value.

Comment on lines +157 to +165
afterEach(() => {
cleanup();
for (const router of routers) {
router.history.destroy();
}
routers.clear();
vi.clearAllMocks();
vi.restoreAllMocks();
});

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.

medium

The global window.location.hash is mutated in beforeEach but never restored. This can leak side effects to other test files running in the same JSDOM environment, leading to flaky tests or unexpected failures.

Consider restoring the hash in afterEach to ensure proper test isolation.

Suggested change
afterEach(() => {
cleanup();
for (const router of routers) {
router.history.destroy();
}
routers.clear();
vi.clearAllMocks();
vi.restoreAllMocks();
});
afterEach(() => {
cleanup();
for (const router of routers) {
router.history.destroy();
}
routers.clear();
window.location.hash = '';
vi.clearAllMocks();
vi.restoreAllMocks();
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Declined. The hash can't leak into another file: vitest runs each test file in its own isolated environment here (vitest.config.ts keeps the default isolation on, per its comment there), so every file starts with a fresh jsdom window.location. Within this file, every test's beforeEach sets the hash before it renders (line 156), and afterEach already destroys each router's hash history, so nothing is left listening to a location change between tests.

🤖 Generated by Claude Code (Claude Opus 5.5); posted via @dawsontoth.

});
// Central manager answers the route's request first, while the sign-in check is still out.
await waitFor(() => expect(requests).toContain(`/Organization/${ORGANIZATION_ID}`));
await new Promise((resolve) => setTimeout(resolve, 20));

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.

medium

Using a hardcoded setTimeout delay (like 20ms) to wait for asynchronous operations is a known source of test flakiness, especially in resource-constrained CI environments where CPU jitter can cause the async operations to take longer than 20ms.

Instead of a hardcoded timeout, consider using a deterministic wait condition with waitFor from @testing-library/react, or awaiting the actual promise/router state transition.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 593659b for the two cases that have a transition to wait on. The 403-before-401 case and the live-session case now wait until the router has recorded the route's error (routeLoadFailed(), lines 180 and 211) instead of sleeping 20ms. That also closes the real gap behind the sleep: under load the 403 could still be unprocessed after 20ms, so the first case could send its 401 first and pass without testing that ordering. Mutation-checked: with the Dashboard guard reverted the first case still fails, and with the outlet rendered while the check is pending both fail.

The 401-first case keeps its window (line 198). It asserts that a stale 403 arriving after the sign-out changes nothing, so there's no event to waitFor, and because only absence assertions follow the wait, a slow runner can't turn it red.

🤖 Generated by Claude Code (Claude Opus 5.5); posted via @dawsontoth.

beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
localStorage.setItem('Studio:PotentiallyAuthenticated', JSON.stringify({ OverallAppSignIn: 'OverallAppSignIn' }));

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.

medium

The key 'OverallAppSignIn' is hardcoded as a string literal. Since OverallAppSignIn is an exported constant from @/features/auth/store/authStore, it is safer to import and use it directly to prevent the test from silently breaking if the constant's value is refactored in the future.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Declined. This literal is the persisted Studio:PotentiallyAuthenticated record as a returning browser already has it in localStorage, not a reference to the constant. If OverallAppSignIn's value were ever renamed, stored records would still hold 'OverallAppSignIn', and this test seeding the literal is what would catch that format break. The sibling tests seed the record the same way (entityAuthCheckScope.test.ts:57, rememberedClusterDeepLink.test.ts:62).

🤖 Generated by Claude Code (Claude Opus 5.5); posted via @dawsontoth.

@dawsontoth
dawsontoth added this pull request to stack #1905 October 11, 2026 21:26
@github-actions

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 68.06% 10229 / 15029
🔵 Statements 68.2% 10914 / 16001
🔵 Functions 61.45% 2645 / 4304
🔵 Branches 62.86% 7752 / 12332
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/features/layouts/Dashboard.tsx 90% 80% 100% 90% 19
src/lib/test/TestProvider.tsx 100% 100% 100% 100%
Generated in workflow #2118 for commit 0067827 by the Vitest Coverage Report Action

@dawsontoth
dawsontoth removed this pull request from stack #1905 October 11, 2026 22:27
Base automatically changed from claude/1329-scope-auth-checks-to-org to claude/1794-signout-route-cancel October 12, 2026 16:01
Base automatically changed from claude/1794-signout-route-cancel to claude/1679-signout-auth-races October 12, 2026 16:01
@dawsontoth
dawsontoth force-pushed the claude/1679-signout-auth-races branch from 34eb689 to e684008 Compare October 12, 2026 16:07
Base automatically changed from claude/1679-signout-auth-races to claude/1662-signout-storage-quota October 12, 2026 16:08
…an expired session

Dashboard shows its spinner instead of its outlet whenever no user is signed in, so a deep link opened on an expired central-manager session never renders a route's error page for the frame before the guard redirects, and still-mounted pages stop refetching on the dead session. TestProvider mounts a bare root route and destroys its hash history on unmount. Squashes this branch's history (which added and later removed a held-failure filter) into its net change on the rebased stack.

Refs #1546

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dawsontoth
dawsontoth force-pushed the claude/1662-signout-storage-quota branch from 63f4c3b to 9553ae7 Compare October 12, 2026 16:11
@dawsontoth
dawsontoth force-pushed the claude/1546-signed-out-auth-failures branch from 4ebe2f9 to 288fd92 Compare October 12, 2026 16:12
@dawsontoth
dawsontoth marked this pull request as ready for review October 12, 2026 16:20
@dawsontoth
dawsontoth requested a review from a team as a code owner October 12, 2026 16:20
@dawsontoth
dawsontoth merged commit cfbce22 into claude/1662-signout-storage-quota Oct 12, 2026
2 checks passed
@dawsontoth
dawsontoth deleted the claude/1546-signed-out-auth-failures branch October 12, 2026 16:21
dawsontoth added a commit that referenced this pull request Oct 12, 2026
TestProvider now mounts a bare root route (#1881), so it no longer reaches the cloud root's NotificationsSubscriptionManager and the guard test saw no SystemStatus socket. Render the app's route tree directly so the test still proves the guard catches a socket real app code opens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dawsontoth added a commit that referenced this pull request Oct 12, 2026
TestProvider now mounts a bare root route (#1881), so it no longer reaches the cloud root's NotificationsSubscriptionManager and the guard test saw no SystemStatus socket. Render the app's route tree directly so the test still proves the guard catches a socket real app code opens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

[RUM] Handled 403s step up ~20× — authenticated requests fire on / and /sign-in while signed out

1 participant