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-atomstypeNavigationGuardAtom=Atom<string|null>;constnavigationGuardAtom: (guard: (get: Getter)=>string|null)=>NavigationGuardAtom;constenforceNavigationGuards: (store: Store,guards: ReadonlyArray<NavigationGuardAtom>)=>()=>void;// jarl-reactconstuseNavigationGuard: (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

SourceOutcome
In-app: Link, useNavigate, a route atom or locationAtom writeVetoed at locationAtom's write. No browser support required.
Same-document, from outside jarl: third-party history.pushState, a fragment change, same-document back/forwardVetoed through the Navigation API's navigate event. Unguarded in a browser without it.
Leaving the document: reload, a cross-document link, closing the tabbeforeunload. The browser shows its own wording, not the guard's message.
Cross-document back/forwardNever cancelable by platform design (anti-trapping) — un-vetoable.
A back/forward traversal repeated without interacting with the page in betweenConsumes the user activation that permits cancelling — un-vetoable.
Browser-initiated navigation: URL bar, a bookmark, the reload buttonFires 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 freeto 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

@randomdevpete
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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-atomstypeNavigationGuardAtom=Atom<string|null>;constnavigationGuardAtom: (guard: (get: Getter)=>string|null)=>NavigationGuardAtom;constenforceNavigationGuards: (store: Store,guards: ReadonlyArray<NavigationGuardAtom>)=>()=>void;// jarl-reactconstuseNavigationGuard: (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

SourceOutcome
In-app: Link, useNavigate, a route atom or locationAtom writeVetoed at locationAtom's write. No browser support required.
Same-document, from outside jarl: third-party history.pushState, a fragment change, same-document back/forwardVetoed through the Navigation API's navigate event. Unguarded in a browser without it.
Leaving the document: reload, a cross-document link, closing the tabbeforeunload. The browser shows its own wording, not the guard's message.
Cross-document back/forwardNever cancelable by platform design (anti-trapping) — un-vetoable.
A back/forward traversal repeated without interacting with the page in betweenConsumes the user activation that permits cancelling — un-vetoable.
Browser-initiated navigation: URL bar, a bookmark, the reload buttonFires 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 freeto 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

@randomdevpete
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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-atomstypeNavigationGuardAtom=Atom<string|null>;constnavigationGuardAtom: (guard: (get: Getter)=>string|null)=>NavigationGuardAtom;constenforceNavigationGuards: (store: Store,guards: ReadonlyArray<NavigationGuardAtom>)=>()=>void;// jarl-reactconstuseNavigationGuard: (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

SourceOutcome
In-app: Link, useNavigate, a route atom or locationAtom writeVetoed at locationAtom's write. No browser support required.
Same-document, from outside jarl: third-party history.pushState, a fragment change, same-document back/forwardVetoed through the Navigation API's navigate event. Unguarded in a browser without it.
Leaving the document: reload, a cross-document link, closing the tabbeforeunload. The browser shows its own wording, not the guard's message.
Cross-document back/forwardNever cancelable by platform design (anti-trapping) — un-vetoable.
A back/forward traversal repeated without interacting with the page in betweenConsumes the user activation that permits cancelling — un-vetoable.
Browser-initiated navigation: URL bar, a bookmark, the reload buttonFires 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 freeto 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

@randomdevpete
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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-atomstypeNavigationGuardAtom=Atom<string|null>;constnavigationGuardAtom: (guard: (get: Getter)=>string|null)=>NavigationGuardAtom;constenforceNavigationGuards: (store: Store,guards: ReadonlyArray<NavigationGuardAtom>)=>()=>void;// jarl-reactconstuseNavigationGuard: (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

SourceOutcome
In-app: Link, useNavigate, a route atom or locationAtom writeVetoed at locationAtom's write. No browser support required.
Same-document, from outside jarl: third-party history.pushState, a fragment change, same-document back/forwardVetoed through the Navigation API's navigate event. Unguarded in a browser without it.
Leaving the document: reload, a cross-document link, closing the tabbeforeunload. The browser shows its own wording, not the guard's message.
Cross-document back/forwardNever cancelable by platform design (anti-trapping) — un-vetoable.
A back/forward traversal repeated without interacting with the page in betweenConsumes the user activation that permits cancelling — un-vetoable.
Browser-initiated navigation: URL bar, a bookmark, the reload buttonFires 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 freeto 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

@randomdevpete
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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-atomstypeNavigationGuardAtom=Atom<string|null>;constnavigationGuardAtom: (guard: (get: Getter)=>string|null)=>NavigationGuardAtom;constenforceNavigationGuards: (store: Store,guards: ReadonlyArray<NavigationGuardAtom>)=>()=>void;// jarl-reactconstuseNavigationGuard: (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

SourceOutcome
In-app: Link, useNavigate, a route atom or locationAtom writeVetoed at locationAtom's write. No browser support required.
Same-document, from outside jarl: third-party history.pushState, a fragment change, same-document back/forwardVetoed through the Navigation API's navigate event. Unguarded in a browser without it.
Leaving the document: reload, a cross-document link, closing the tabbeforeunload. The browser shows its own wording, not the guard's message.
Cross-document back/forwardNever cancelable by platform design (anti-trapping) — un-vetoable.
A back/forward traversal repeated without interacting with the page in betweenConsumes the user activation that permits cancelling — un-vetoable.
Browser-initiated navigation: URL bar, a bookmark, the reload buttonFires 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 freeto 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

@randomdevpete
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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-atomstypeNavigationGuardAtom=Atom<string|null>;constnavigationGuardAtom: (guard: (get: Getter)=>string|null)=>NavigationGuardAtom;constenforceNavigationGuards: (store: Store,guards: ReadonlyArray<NavigationGuardAtom>)=>()=>void;// jarl-reactconstuseNavigationGuard: (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

SourceOutcome
In-app: Link, useNavigate, a route atom or locationAtom writeVetoed at locationAtom's write. No browser support required.
Same-document, from outside jarl: third-party history.pushState, a fragment change, same-document back/forwardVetoed through the Navigation API's navigate event. Unguarded in a browser without it.
Leaving the document: reload, a cross-document link, closing the tabbeforeunload. The browser shows its own wording, not the guard's message.
Cross-document back/forwardNever cancelable by platform design (anti-trapping) — un-vetoable.
A back/forward traversal repeated without interacting with the page in betweenConsumes the user activation that permits cancelling — un-vetoable.
Browser-initiated navigation: URL bar, a bookmark, the reload buttonFires 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 freeto 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

@randomdevpete
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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-atomstypeNavigationGuardAtom=Atom<string|null>;constnavigationGuardAtom: (guard: (get: Getter)=>string|null)=>NavigationGuardAtom;constenforceNavigationGuards: (store: Store,guards: ReadonlyArray<NavigationGuardAtom>)=>()=>void;// jarl-reactconstuseNavigationGuard: (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

SourceOutcome
In-app: Link, useNavigate, a route atom or locationAtom writeVetoed at locationAtom's write. No browser support required.
Same-document, from outside jarl: third-party history.pushState, a fragment change, same-document back/forwardVetoed through the Navigation API's navigate event. Unguarded in a browser without it.
Leaving the document: reload, a cross-document link, closing the tabbeforeunload. The browser shows its own wording, not the guard's message.
Cross-document back/forwardNever cancelable by platform design (anti-trapping) — un-vetoable.
A back/forward traversal repeated without interacting with the page in betweenConsumes the user activation that permits cancelling — un-vetoable.
Browser-initiated navigation: URL bar, a bookmark, the reload buttonFires 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 freeto 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

@randomdevpete
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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-atomstypeNavigationGuardAtom=Atom<string|null>;constnavigationGuardAtom: (guard: (get: Getter)=>string|null)=>NavigationGuardAtom;constenforceNavigationGuards: (store: Store,guards: ReadonlyArray<NavigationGuardAtom>)=>()=>void;// jarl-reactconstuseNavigationGuard: (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

SourceOutcome
In-app: Link, useNavigate, a route atom or locationAtom writeVetoed at locationAtom's write. No browser support required.
Same-document, from outside jarl: third-party history.pushState, a fragment change, same-document back/forwardVetoed through the Navigation API's navigate event. Unguarded in a browser without it.
Leaving the document: reload, a cross-document link, closing the tabbeforeunload. The browser shows its own wording, not the guard's message.
Cross-document back/forwardNever cancelable by platform design (anti-trapping) — un-vetoable.
A back/forward traversal repeated without interacting with the page in betweenConsumes the user activation that permits cancelling — un-vetoable.
Browser-initiated navigation: URL bar, a bookmark, the reload buttonFires 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 freeto 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

@randomdevpete