fix(mock): let the empty-session redirect past the navigation lock - #132
Conversation
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
|
One more path the same stale predicate was breaking, found while reviewing: It is declared above Covered by the same fix, no extra change needed. |
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
|
Review pass done - three findings, all addressed in f89da5d.
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 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 - Verified three ways: current tree 0 failures, pre-fix hook 5 failures, layout effect moved below the blocker call 0 failures (no false positive). |
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 toinitialSession()whenhasRealAnswers()is false, so/mock-interviewrenders its<Navigate to="/" replace />fallback. ButuseBlockerhands its predicate to the router from auseEffect, so the router holds the function from the previous committed render - closed overactive === true, the session having beenStoppingone render earlier.<Navigate>navigates from an effect of its own, and a child's passive effect runs before its parent's, so:<Navigate>asks to navigate; the stale predicate blocks it.useInterviewNavigationLock's effect callsblocker.reset()and the navigation is gone.<Navigate>'s dep array is stable, so it never asks again. The route rendersnullfor the rest of the session./mainescapes this by accident:useEndLiveSessionawaits several IPC round trips betweenbeginInterviewExit()and itsnavigate('/'), 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
useCallbackthat readsactive/exiting/signedOutoff 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 ofaudio-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 plainuseEffector an inline arrow. 5 of its 7 checks fail against the code before this change.Ran locally, all green:
pnpm lint, bothtscconfigs,pnpm exec vite build,pnpm test:main.🤖 Generated with Claude Code
https://claude.ai/code/session_01Kt5ucR5XPzzzxqPYzSpXaL