Skip to content

Navigation policy — one back model app-wide (#1016)

The app previously mixed three unrelated "back" mechanisms — hardcoded canonical hrefs posing as back links, component-state steppers invisible to browser history, and undocumented goto() push/replace semantics — making back behavior unpredictable on every screen. This is the single policy all navigation follows, with the shared helpers in src/lib/nav.ts.

The back rule

An in-app "back" affordance behaves like history.back() whenever in-app history exists, and falls back to its canonical parent URL only on a fresh entry / deep link. Returning to the previous view preserves everything that view held in its URL — filters, pagination, search, tab state — because it is literally the previous history entry, not a reconstructed canonical page.

Use it via:

  • backOr(fallback) — imperative form (buttons, programmatic back).
  • backAction(fallback) — click handler for <a href={fallback}> back links: plain left-clicks get the history-aware behavior; modified clicks (new tab) and JS-off keep native anchor semantics on the canonical href.

The tracker is fed by the root layout's afterNavigate — no per-page wiring.

Breadcrumbs are not back links. A breadcrumb (DetailHeader) names the canonical hierarchy and always navigates canonically — that is its contract. Anything labeled back ("← Admin", "Back to pages") follows the back rule.

The push vs. replace rule for goto()

NavigationModeWhy
New logical view: route change, opening a detail page, advancing a flow steppush (default)Back must return to the previous view/step
View-state refinement of the same view: tab switch, filter/sort/param sync, compare column swap, results-view param clearingreplaceState: trueBack must step through views, never through intermediate query-param permutations

Rule of thumb: if the user would call it "a different place", push; if they would call it "the same place, adjusted", replace.

Flows and browser back

A multi-step flow (the guided selection stepper) must make its steps visible to browser history via shallow routing (pushState + page.state), so the browser back button steps back one question and only exits the flow from its first step. In-app "previous" affordances inside a flow use the same history entries — the two back buttons never disagree.