Skip to content

fix(mock): let the empty-session redirect past the navigation lock - #132

Merged
alpha5611331 merged 2 commits into
mainfrom
fix/mock-empty-end-blank-page
Sep 15, 2026
Merged

alpha5611331 merged 2 commits into
mainfrom
fix/mock-empty-end-blank-page

Conversation

@alpha5611331

Copy link
Copy Markdown
Member

Closes #131.

The bug

Start a mock interview, press End interview before answering anything, and the app lands on a blank screen and stays there.

Why

The redirect and the lock that permits it land in the same commit, and the lock loses.

MockInterviewService.endSession() resets the session to initialSession() when hasRealAnswers() is false, so /mock-interview renders its <Navigate to="/" replace /> fallback. But useBlocker hands its predicate to the router from a useEffect, so the router holds the function from the previous committed render - closed over active === true, the session having been Stopping one render earlier. <Navigate> navigates from an effect of its own, and a child's passive effect runs before its parent's, so:

  1. <Navigate> asks to navigate; the stale predicate blocks it.
  2. useInterviewNavigationLock's effect calls blocker.reset() and the navigation is gone.
  3. <Navigate>'s dep array is stable, so it never asks again. The route renders null for the rest of the session.

/main escapes this by accident: useEndLiveSession awaits several IPC round trips between beginInterviewExit() and its navigate('/'), which gives React time to commit the re-registration. Anything that navigates in the same commit as the state change hits the same wall.

The fix

The predicate is a stable useCallback that reads active / exiting / signedOut off a ref written in a layout effect. Layout effects for the whole tree run during the commit, before any passive effect in it, so the router is always asked with this render's answer - for both guarded routes, not only the one that showed the symptom. Nothing about what the lock refuses changes.

Tests

test/interview-lock.test.mjs, source-level in the shape of audio-device-switch.test.mjs, for the same reason: what it pins is an effect ordering, invisible to the type checker and to eslint, and the kind of thing a later tidy-up collapses back into a plain useEffect or an inline arrow. 5 of its 7 checks fail against the code before this change.

Ran locally, all green: pnpm lint, both tsc configs, pnpm exec vite build, pnpm test:main.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Kt5ucR5XPzzzxqPYzSpXaL

Ending a mock interview with nothing recorded resets the session to Idle,
and `/mock-interview` renders `<Navigate to="/" replace />` for that. The
redirect never fired: `useBlocker` registers its predicate from a
`useEffect`, so the router still held the one closed over the previous
render's `active` - true, the session having been `Stopping` a render
earlier - while `<Navigate>` navigates from a child effect, which runs
first. The navigation was blocked, `reset()` dropped it, and `<Navigate>`
never retried, leaving the route rendering nothing at all.

The predicate is now stable and reads its answer off a ref written in a
layout effect, which runs during the commit ahead of every passive effect
in it. `/main` only escaped this because `useEndLiveSession` awaits
several IPC round trips between `beginInterviewExit()` and its navigate.

Closes #131

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kt5ucR5XPzzzxqPYzSpXaL
@alpha5611331

Copy link
Copy Markdown
Member Author

One more path the same stale predicate was breaking, found while reviewing: /mock-interview's sign-out redirect.

useEffect(() => {
  if (appState?.isLoggedIn !== false || redirectedToLogin.current) return;
  redirectedToLogin.current = true;
  navigate('/auth/login', { replace: true });
}, [appState?.isLoggedIn, navigate]);

It is declared above useInterviewNavigationLock in the same component, so it runs before the lock re-registers its predicate. A token expiring mid-session flips signedOut true and raises that navigate in the same commit - against a predicate that still read signedOut: false, active: true, so it was blocked. redirectedToLogin.current is latched true by then, so nothing ever retried it: the candidate sat on a mock interview screen that could no longer talk to the backend, with sign-out explicitly documented as the one thing the lock must never refuse.

Covered by the same fix, no extra change needed.

@gitar-bot

gitar-bot Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

Gitar is working

Gitar

Review follow-up on the redirect this branch unblocks.

`session.error` is how main explains an Idle it reached on its own, and
nothing in the renderer read it. It did not show while the Idle branch was
unreachable; with the redirect working it would have become a silent bounce
to the launch cards. The case that matters is a dead or muted microphone -
the silence backstop skips through a session whose questions are already
billed, and `finishToScoring` resets with a message naming the microphone.

Deduplicated by the message rather than reported from one place: `start()`
writes the same string onto the session *and* throws, so the broadcast
races the rejection, while a microphone the renderer cannot open fails
before main hears about it and has only the rejection.

Also scopes the new test's slices to the blocker call, so moving the layout
effect below it fails nothing - it is the same shape, and an unbounded
slice reported a defect that was not there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kt5ucR5XPzzzxqPYzSpXaL
@alpha5611331

Copy link
Copy Markdown
Member Author

Review pass done - three findings, all addressed in f89da5d.

session.error dropped on the way out (medium). Real, and this branch is what makes it live: nothing in the renderer read session.error, which did not show while the Idle branch was unreachable and would have turned the blank page into a silent bounce to the launch cards. The case is a dead or muted microphone - the silence backstop skips through a session whose questions are already billed, finishToScoring resets with a message naming the microphone. /mock-interview now reports it, deduplicated by the message rather than from one place: start() writes the same string onto the session and throws, so the broadcast races the rejection, while a microphone the renderer cannot open fails before main hears about it and has only the rejection. The dedupe ref is seeded from whatever is on the session at mount, since that is an earlier session's ending and start() only clears it after this route is up.

Render-body ref write (low). Kept the layout effect, and wrote down why so it does not get churned. It is one case short of the render body - layout effects run child-first, so a child navigating from one of its own would still read this render's predecessor - but no caller does that, and a render React discards would leave the router holding values that were never committed. A commit is the earliest point the answer is true, and it is where react-router's own useNavigateStable writes its activeRef.

Over-broad test slice (low). Correct, and it was worse than reported: scoping exposed that the closure check went vacuous against a concise-body arrow, which is the shape the bug had - balanced found no braces and returned an empty string that passes everything. Slices are now bounded to the blocker call with a fallback to the whole call, and the layout-effect check asks for the shape rather than Prettier's spacing.

Verified three ways: current tree 0 failures, pre-fix hook 5 failures, layout effect moved below the blocker call 0 failures (no false positive). pnpm lint, both tsc configs, vite build and pnpm test:main all green locally.

@alpha5611331
alpha5611331 merged commit 75e9b46 into main Sep 15, 2026
1 check passed
@alpha5611331
alpha5611331 deleted the fix/mock-empty-end-blank-page branch September 15, 2026 14:39
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.

Ending an empty mock interview leaves a blank page

1 participant