The navigation layer
Why the unsaved-changes guard is a sibling package, why it copies TanStack Router's shape, why Next needs a door of its own, why nothing in it patches history, and why a route's sidebar intent is not the person's preference.
The navigation guard pages are what @kanzo-tech/navigation ships. This
page is why it is shaped that way. It was issue #6: a host had a guard that patched
history.pushState, it was removed as a hack, and what was left covered reload and close but no
link inside the app.
A router integration is a sibling package
@kanzo-tech/navigation is not in @kanzo-tech/ui. Where specificity is allowed to
live puts any router integration in the
products, and this is one: nothing in it draws, and its only reason to exist is a router that
commits too late. It lives in this repository for the build, the release and the guards, and it
depends on nothing of ui's, as @kanzo-tech/auth does.
The issue's own proposal was a NavigationGuard context that SidebarMenuButton asChild,
BreadcrumbLink and MenuItem asChild would consult. It fails on the parts' own terms: those
parts never navigate. asChild hands navigation to the child, and the child is the product's
router link. It would also have covered only the links drawn with those parts — a second, partial
guard beside whatever caught the rest.
What would reverse it: a ruling that router integrations may live in ui — the philosophy
sentence edited first — at which point this package collapses into a ui/next subpath, because it
already has the shape of one.
Held by packages/ui/src/index.test.ts, "keeps the navigation guard in its own package",
!NavigationGuard and !useBlocker; packages/navigation/package.json, //peers.
One hook, in TanStack Router's shape
useBlocker({ shouldBlockFn, enableBeforeUnload, disabled, withResolver }) returning
{ status, current, next, action, proceed, reset } is TanStack Router's API, name for name, and so
are the semantics: blockers are asked in registration order, the first true blocks, proceed
continues and reset stays, and a replayed navigation is not blocked a second time. It was picked
over React Router's useBlocker (data routers only, SPA navigations only, no action, no
beforeunload) and SvelteKit's beforeNavigate (the cleanest of the three, but cancel() is
synchronous and has no resolver for a dialog of one's own). A product that moves to TanStack Router
keeps its call sites.
What differs is stated rather than hidden:
currentandnextare URL parts, not{ routeId, fullPath, params }: Next matches routes on the server, and a client can only honestly name a URL.- No
Blockcomponent — a render-prop spelling of the same hook is a second way to write it. No deprecated overloads (blockerFn,condition), and noconfirmoption (next-navigation-guard's), becausewithResolveralready is one. - The options are read at navigation time, and the blocker registers once. TanStack re-registers
whenever
shouldBlockFnchanges identity, which for an inline function is every render — and which moves the blocker to the end of the order. Order is part of the contract, so it is kept. - A synchronous
falselets the navigation go untouched. See below.
enableBeforeUnload defaults to true, as TanStack's does, and is not derived from
shouldBlockFn: the browser asks synchronously and shouldBlockFn may not answer that way. The
consequence is written on the consumer page as the idiom — disabled: !dirty — because the
alternative asks on every reload of a clean page.
What would reverse it: the hosts leaving Next for TanStack Router, which deletes this package in favour of TanStack's own hook with no call site changing shape. Or TanStack changing its shape, which this follows.
Held by packages/navigation/src/surface.test.ts, "is one hook at the root" and "offers no Block
component and no useBeforeUnload"; packages/navigation/src/use-blocker.test.tsx, "asks
beforeunload by enableBeforeUnload, never by shouldBlockFn" and "keeps its place in the order when
its options change identity".
A /next door, because Next commits before it reports
Read in Next's source — 16.1.6, which that host ran, and canary:
- Next writes history after the new route has rendered.
HistoryUpdatercallshistory.pushStateinsideuseInsertionEffect, during the commit of the new router state. A guard that cancels atpushState— that host's patch — is too late: the new page is on screen under the old URL. A Navigation APInavigatelistener sees that same late same-document push, and has the same problem. The commentTODO: Use Navigation API if availableis still on canary. Linkdoes not go throughAppRouterContext. Its click handler dispatches straight to the router instance, so wrapping the context —next-navigation-guard's technique — cannot see a link, which is why that library also captures clicks on the document.- The one public, cancellable moment is
Link'sonNavigate, since 15.3. Next's own documentation solves this problem with it — a context and a customLink— and so does Vercel's answer on discussion #41934.onRouterTransitionStartonly observes: its return value is ignored. There is no blocking API on canary.
So the root holds what the browser can cancel, and /next holds the two things Next can: a Link
with onNavigate wired to the blockers, and a useRouter whose push and replace ask first. It
imports next/link and next/navigation and nothing under them, and relies on three behaviours
rather than on internals — that onNavigate runs before the dispatch, that Next's own history write
is a same-document push, and that back and forward reach Next only through popstate. The
end-to-end fixture drives all three against the installed next.
What would reverse it: Next shipping a blocking API, or moving its navigations onto
navigation.navigate() — its own TODO points there. Either shrinks /next to nothing, because the
root's navigate listener would then see Link and push before they commit.
Held by packages/navigation/src/next.test.tsx, "cancels in onNavigate and re-issues the push on
proceed" and "guards push and replace, and replays them with their options on proceed";
packages/navigation/src/surface.test.ts, "keeps next out of everything the root reaches";
docs/scripts/navigation-guard.e2e.mjs, ROWS.
Nothing is patched
No history.pushState or replaceState, no AppRouterContext, no next/dist/*, no
window.next.router, no capture-phase click listener on the document, and no synthetic
popstate. That list is next-navigation-guard's technique, item for item, and it is the reason
that library was not adopted — beside the fact that its published release declares no Next 16.
What is not caught is written in the coverage table with the
reason, instead of being reached by a patch: a server action's redirect(), a client redirect()
or notFound(), next/form, router.refresh, a POST form. In practice the first three follow a
save, when there is nothing left to block.
What would reverse it: a navigation that a host's users actually take with unsaved work, that only a patch can reach — measured in the product, not imagined here. None of the uncovered rows is one today.
Held by packages/navigation/src/surface.test.ts, "patches no history, reads no router internals
and captures no clicks".
Back and forward are cancelled, not replayed
Traversals go through the Navigation API's navigate event with navigationType: "traverse", which
fires before the traversal commits: cancelled there, it never produces a popstate, so Next is
never told. proceed is navigation.traverseTo(key) past the blocker.
The alternative — let the traversal happen, swallow the popstate, history.go(-delta) back — is
what TanStack's own history does, because TanStack owns history. Next does, so doing it here means
hiding Next's popstate listener from it, which is a patch.
The platform limits this on purpose. Browser-UI Back is cancellable only while the page holds history-action activation, and cancelling spends it, so a second Back with no click in between goes through. That is the platform's rule against trapping somebody on a page, and it stands.
Safari (26.x, measured 2026-09-30 in real Safari): cancelling a traversal is not honoured. Back
goes through, and the dialog appears on the next Forward — the block for the Back, arriving late. A
Safari bug, not guarded against. Playwright's WebKit 26.5 fails differently and no better: it does
cancel, but moves its own history index anyway, so after Stay the next Back skips an entry and
after Leave traverseTo reloads. Chromium 151 and Firefox 153 are clean.
The guard is the same code in every browser. It does not test the user agent, and it does not try
to detect the outcome of a cancellation and quietly drop the question either: both are a second
code path for one engine's defect, and neither can be exercised in CI against the engine that
needs it. Per the rule written before the measurement, the answer is this caveat in the coverage
table, not a history.go(-delta) replay.
What would reverse it: Safari honouring navigate cancellation for traversals — the e2e row
printing pass in WebKit deletes the caveat and its known entry. In the other direction, Safari's
share of the product's users making a late dialog worse than a lost form — then traversals stop
being cancelled at all and the row reads not covered in every browser.
Held by packages/navigation/src/use-blocker.test.tsx, "cancels a back traversal and says what it
was" and "holds a blocked navigation until proceed, then replays it once";
docs/scripts/navigation-guard.e2e.mjs, known.
Cancel now, decide later
onNavigate and navigate both demand a synchronous preventDefault(), and shouldBlockFn may
return a promise and withResolver always waits for a person. So while a blocker says anything but
a synchronous false, the navigation is cancelled on the spot and replayed if the answer turns
out to be go — through router.push for a Link, navigation.navigate for an anchor,
traverseTo for a traversal — with a one-shot pass so the replay is not asked about again. That is
TanStack's model, and its ignoreNextBeforeUnload is the same pass.
A synchronous false is the one case where nothing is cancelled: the navigation goes exactly as it
would have, so a clean form's Link keeps its pending state and its transition. The cost lands
only on a navigation somebody actually confirmed.
What would reverse it: an asynchronous cancellation point — Next awaiting onNavigate, or the
Navigation API letting a cross-document navigation be deferred — at which point there is nothing to
replay.
Held by packages/navigation/src/use-blocker.test.tsx, "lets a navigation through untouched when
shouldBlockFn answers false synchronously", "cancels now and replays later when shouldBlockFn
answers with a promise" and "lets the replay of a cross-document navigation unload without asking
again".
Listeners exist only while a blocker does
The navigate and beforeunload listeners are attached when the first blocker registers and
removed with the last. MDN: a beforeunload listener costs a page its back/forward cache in
Firefox, so a page with nothing to protect must carry none — and disabled: !dirty is how a clean
page has nothing to protect.
What would reverse it: nothing, because the cost is the browser's and a listener that does nothing buys nothing.
Held by packages/navigation/src/use-blocker.test.tsx, "listens only while a blocker is
registered".
No dialog ships
The confirm is the product's AlertDialog driven by status, proceed and reset. The package
ships no dialog composite and no copy, for the argument the auth layer
makes about a log-out flow: the wording is the product's, and a library that picks it has picked
wrong for every product but one.
What would reverse it: two products whose confirm dialogs differ in nothing — not the title, not the buttons, not the language. There is one product today.
Held by packages/navigation/package.json, //peers — no @kanzo-tech/ui and no
tailwind-variants, so there is nothing here to draw a dialog with.
A route's intent is not a preference
This one is @kanzo-tech/ui's sidebar, not the guard package, and it is about what a route may ask
of the shell. A full-canvas page wants the sidebar collapsed while it is shown. The provider had one
state, the person's preference, written to sidebar_state on every change — so the only way for a
route to collapse it was to overwrite what the person chose, and the next page inherited the route's
choice as theirs. And the cookie was written and never read back, so every load started open.
Two inputs, one shown state: open = intent ?? preference. SidebarIntent is the intent, a
component a route renders, and it never writes the cookie. parseSidebarCookie and
SIDEBAR_COOKIE_NAME are the preference's way back in, as defaultOpen from a server layout; they
take the raw value and no framework's cookie API, and they live outside the client module because a
Server Component importing a constant from one receives a reference, not a string. The shape is
shadcn's — a provider with open / onOpenChange over a cookie — with the second input added.
A toggle under an intent is the person's, and it wins: it is an override that lasts as long as the
set of mounted intents does and is never persisted, so it calls no onOpenChange. That is VS Code's
zen mode — a temporary layout the person may adjust inside, which does not rewrite their settings when
it ends. Mounted intents that disagree resolve to collapsed rather than to the last mounted, because
effects run children before parents and "last" would let a layout overrule the page inside it.
What would reverse it: people who toggle the sidebar open inside an immersive route and expect it open the next time they come back — a per-route remembered override, which is a second stored preference and the first thing this declines. Or a router that can tell the server which intents a route declares, which would let a full page load render the intent instead of collapsing on hydration.
Held by packages/ui/src/composites/sidebar.test.tsx, "never writes the cookie", "lets a toggle
win for the life of the intent, unpersisted, and leaves the preference untouched" and "is collapsed
when mounted intents disagree, whichever mounted last".
The auth layer
Why authentication is a sibling package rather than a component, why a browser draws instead of deciding, and why two deployment patterns share one session.
How a failure leaves the library
The thrown value whole, by one path; the codes kanzo-ui names itself; and the waits it bounds. The argument across fossil, kanzo-ui and their hosts is fossil's page, not this one.