feat(jarl-atoms): 778 — veto any navigation with navigationGuardAtom - #99
Open
randomdevpete wants to merge 3 commits into
Open
Conversation
Every way the URL can move now passes through a guard: in-app route atom writes are vetoed at locationAtom's write, and same-document navigations made outside jarl are vetoed through the Navigation API's navigate event, which also closes the pre-existing gap where a third-party history.pushState was invisible to jarl. Leaving the document is handled by beforeunload. Ticket: 778
Enforces a guard atom for as long as the calling component is mounted, so the state a guard reads and the guard itself can live together. Ticket: 778
A link click, a useNavigate call, a third-party history.pushState and the browser's back/forward buttons, each with the guard both allowing and blocking. Ticket: 778
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.
Public API
A guard atom returns the confirm message to block a navigation with, or
nullto allow it.Several guards compose: the first that returns non-null wins.
enforceNavigationGuardsis theeffect that makes registered guards bite, for one store — call it once near the root, or per
component via
useNavigationGuard, which enforces for as long as the calling component ismounted.
Two choke points
Every in-app navigation (
Link,useNavigate, a route atom orlocationAtomwrite) funnelsthrough
locationAtom's writer, so it's vetoed there, synchronously, before jotai-location callshistory.pushState— no history entry, no rollback, no flicker.That single choke point isn't enough: it only sees writes that go through jarl's own atoms. A
third-party
history.pushState, and the browser's own back/forward buttons, never touchlocationAtom. Those are caught by the second choke point — theNavigation API's
navigateevent,which fires before commit for every same-document navigation regardless of source and whose
preventDefault()genuinely cancels it.locationAtomalso now passes asubscribeoverride toatomWithLocationthat listens tonavigation.currententrychange(falling back topopstatewhere the Navigation API is absent) — closing a pre-existing gap where a third-party
history.pushStatewas invisible to jarl entirely.A module-level reentrancy flag (
approvingOwnNavigation) suppresses the second choke point for anavigation jarl's own
locationAtomwrite already approved, so it isn't asked to confirm twice forone navigation. It's module-level rather than per-store because
window.navigation/historyareone global per page — there's only ever one in-flight "our own"
pushStateto track, not one perstore.
What each navigation source gets
Link,useNavigate, a route atom orlocationAtomwritelocationAtom's write. No browser support required.history.pushState, a fragment change, same-document back/forwardnavigateevent. Unguarded in a browser without it.beforeunload. The browser shows its own wording, not the guard's message.navigateevent at all; reachesbeforeunloadand nothing else.Design notes carried from the ticket
History-based fallback would need
pushState/replaceStatepatching and a rollback path;revisit only if a real consumer reports an unsupported browser.
window.confirm, synchronously — also OWNER's call.preventDefault()must be calledsynchronously, so an async custom modal would need
precommitHandler, which Safari 26.2 doesn'tyet ship.
Stacking
Stacked on #98 (
task-675-coalesce-resolvedatom-and-asyncrouteatom) perdepends_onresolution:
merge-treefound a conflict between the two branches ine2e/fixture-app/src/routes.ts(675 renamesresolvedAtomtoasyncRouteAtomin that file'simport list; 778 adds
navigationGuardAtomalongside), and CLAUDE.md's ticket-order tiebreak putsthe lower id first. Resolved during the restack — the two changes are independent edits to the same
import list, so the resolution is a plain three-way merge with nothing to reconcile logically.
#96 (ticket 676) is held pending this PR. It ships a
GuardedLinkbuilt onuseLink, guardingonly click-throughs on that one component — the userland workaround this ticket replaces with a
real primitive. Once this merges, 676 is reworked onto it: the fixture page swaps to
navigationGuardAtom/useNavigationGuard, andGuardedLinkis dropped.Style-guide exceptions
None in this diff.