fix(spa): dirty guard covers browser Back/Forward (popstate) (#230 finding 1)
canLeaveCurrentScreen() was only consulted in App's navigate() (sidebar/nav clicks); browser Back/Forward switched screens unguarded. A popstate can't be cancelled, so App's popstate handler now, on a vetoed guard, re-pushes the pre-pop path (tracked in currentPathRef, updated on every approved navigation) and leaves activeRoute untouched — undoing the browser's URL change. The same handler covers the synthetic pop navigateToPath() dispatches. Re-pushing is safe: only one screen is mounted at a time and the guard-registering screen (schedules) owns no internal popstate listener, so no sub-path screen's pathname state can desync. Effect cleanup keeps StrictMode double-mount from double-registering. Docs: spa-conventions §8 rewritten from navigate-only to describe popstate coverage. Regression: App.test.tsx dirties the schedules draft, simulates popstate → confirm called; cancel keeps route + re-pushes path; accept switches route and unmounts the draft. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+16
-5
@@ -211,11 +211,22 @@ draft to navigation. The shared module `web/src/navigationGuard.ts` is the seam:
|
||||
window.confirm(...))`, returning the unregister fn from the mount `useEffect` for cleanup. Only one
|
||||
guard is active at a time (the mounted screen); a stale unregister only clears its own guard.
|
||||
- `App.tsx`'s `navigate` handler calls `canLeaveCurrentScreen()` before `pushState` — a `false`
|
||||
return aborts the in-app sidebar/nav click. This covers React-router-less in-app navigation only.
|
||||
- The screen **also** guards the three paths the module can't see — a **schedule switch** and any
|
||||
in-screen action that would replace the draft use their own `window.confirm(...)`, and a
|
||||
`beforeunload` listener (installed while `dirty`) covers full-page unloads (reload / close tab /
|
||||
external link).
|
||||
return aborts the in-app sidebar/nav click.
|
||||
- **Browser Back/Forward (`popstate`) is also covered.** `App.tsx`'s `popstate` handler consults
|
||||
`canLeaveCurrentScreen()` too. A `popstate` cannot be cancelled — by the time it fires the URL has
|
||||
already changed — so on a veto the handler **re-pushes the pre-pop path** (tracked in a
|
||||
`currentPathRef` updated on every approved navigation) via `history.pushState` and leaves
|
||||
`activeRoute` untouched, effectively undoing the browser's URL change. This same handler also runs
|
||||
for the **synthetic** pop `navigateToPath()` dispatches, so programmatic in-app navigation is
|
||||
guarded as well. The re-push does **not** re-fire `popstate`; that's safe because only one screen
|
||||
is mounted at a time and the guard-registering screen (the schedules editor) owns no internal
|
||||
`popstate`/sub-path listener — so a blocked pop cannot desync a sub-path screen's own pathname
|
||||
state (§2). A sub-path screen that both registers a dirty guard *and* tracks its own pathname would
|
||||
need to re-validate this; none does today.
|
||||
- The screen **also** guards the paths the module still can't see — an in-screen action that would
|
||||
replace the draft (schedule switch, opening the properties editor) uses its own
|
||||
`window.confirm(...)`, and a `beforeunload` listener (installed while `dirty`) covers full-page
|
||||
unloads (reload / close tab / external link).
|
||||
|
||||
Keep the guard predicate reading a **ref** (`dirtyRef`), not the `dirty` state value, so
|
||||
`canLeaveCurrentScreen()` sees the current dirtiness synchronously at click time.
|
||||
|
||||
Reference in New Issue
Block a user