Skip to content

feat(jarl-atoms): 778 — veto any navigation with navigationGuardAtom - #99

Open
randomdevpete wants to merge 3 commits into
task-675-coalesce-resolvedatom-and-asyncrouteatomfrom
task-778-no-navigation-blocking-api
Open

feat(jarl-atoms): 778 — veto any navigation with navigationGuardAtom#99
randomdevpete wants to merge 3 commits into
task-675-coalesce-resolvedatom-and-asyncrouteatomfrom
task-778-no-navigation-blocking-api

Conversation

@randomdevpete

Copy link
Copy Markdown
Owner

Public API

// jarl-atoms
type NavigationGuardAtom = Atom<string | null>;
const navigationGuardAtom: (guard: (get: Getter) => string | null) => NavigationGuardAtom;
const enforceNavigationGuards: (store: Store, guards: ReadonlyArray<NavigationGuardAtom>) => () => void;

// jarl-react
const useNavigationGuard: (guard: NavigationGuardAtom) => void;

A guard atom returns the confirm message to block a navigation with, or null to allow it.
Several guards compose: the first that returns non-null wins. enforceNavigationGuards is the
effect 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 is
mounted.

Two choke points

Every in-app navigation (Link, useNavigate, a route atom or locationAtom write) funnels
through locationAtom's writer, so it's vetoed there, synchronously, before jotai-location calls
history.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 touch
locationAtom. Those are caught by the second choke point — the
Navigation API's navigate event,
which fires before commit for every same-document navigation regardless of source and whose
preventDefault() genuinely cancels it. locationAtom also now passes a subscribe override to
atomWithLocation that listens to navigation.currententrychange (falling back to popstate
where the Navigation API is absent) — closing a pre-existing gap where a third-party
history.pushState was invisible to jarl entirely.

A module-level reentrancy flag (approvingOwnNavigation) suppresses the second choke point for a
navigation jarl's own locationAtom write already approved, so it isn't asked to confirm twice for
one navigation. It's module-level rather than per-store because window.navigation/history are
one global per page — there's only ever one in-flight "our own" pushState to track, not one per
store.

What each navigation source gets

Source Outcome
In-app: Link, useNavigate, a route atom or locationAtom write Vetoed at locationAtom's write. No browser support required.
Same-document, from outside jarl: third-party history.pushState, a fragment change, same-document back/forward Vetoed through the Navigation API's navigate event. Unguarded in a browser without it.
Leaving the document: reload, a cross-document link, closing the tab beforeunload. The browser shows its own wording, not the guard's message.
Cross-document back/forward Never cancelable by platform design (anti-trapping) — un-vetoable.
A back/forward traversal repeated without interacting with the page in between Consumes the user activation that permits cancelling — un-vetoable.
Browser-initiated navigation: URL bar, a bookmark, the reload button Fires no navigate event at all; reaches beforeunload and nothing else.

Design notes carried from the ticket

  • Navigation API only, no History-API fallback — OWNER's call, "fine until it's not". A
    History-based fallback would need pushState/replaceState patching and a rollback path;
    revisit only if a real consumer reports an unsupported browser.
  • window.confirm, synchronously — also OWNER's call. preventDefault() must be called
    synchronously, so an async custom modal would need precommitHandler, which Safari 26.2 doesn't
    yet ship.

Stacking

Stacked on #98 (task-675-coalesce-resolvedatom-and-asyncrouteatom) per depends_on
resolution: merge-tree found a conflict between the two branches in
e2e/fixture-app/src/routes.ts (675 renames resolvedAtom to asyncRouteAtom in that file's
import list; 778 adds navigationGuardAtom alongside), and CLAUDE.md's ticket-order tiebreak puts
the 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 GuardedLink built on useLink, guarding
only 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, and GuardedLink is dropped.

Style-guide exceptions

None in this diff.

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
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.

1 participant