0015: Navigation scroll management
Status: Accepted Date: 2026-05-10
Context
ADR 0013 shipped interceptLinks()
and explicitly punted on scroll restoration / focus management:
No focus management / scroll restoration. A real router needs to handle scroll position on back-nav, focus on route change, and announce navigation to screen readers. Each is its own follow-up.
The most visible piece — scrolling to the top of the new page on forward navigation — is the gap users notice first. Without it, SPAs feel jarring: click a link mid-page, the URL changes, the content updates, but the scroll position stays wherever the user was on the previous page. Every production router (React Router, SvelteKit, SolidStart) ships scroll-to-top by default.
Browser behavior covers two of the three cases for free:
- Back / forward navigation —
history.scrollRestorationis'auto'by default; the browser restores the saved scroll position when popstate fires. Nothing for the framework to do. - Hash-only navigation —
<a href="#x">clicks firehashchangeand the browser scrolls to the anchor element natively. Again nothing for the framework. - Forward
pushStatenavigation — the browser DOES NOT scroll on pushState. SPAs need to scroll themselves. This is the gap.
The right shape, given the rest of the router design, is a small
opt-in primitive that subscribes to navigate() and scrolls
appropriately. It composes with interceptLinks() (which calls
navigate()) so the typical client entry is three lines:
hydrate(root, App);
interceptLinks();
manageNavScroll();
A general-purpose subscription hook is also useful here — apps may
want to do focus management, analytics, view transitions, or other
per-nav side effects without re-deriving "did the user just
navigate?" from the reactive URL signal. Exposing a public
onNavigate(listener) makes the scroll manager a tiny consumer
rather than a special case.
Decision
Add two primitives to @purityjs/core:
-
onNavigate(listener): () => void— a public subscription hook fired synchronously after every programmaticnavigate()call. Listeners receive(url: URL, replace: boolean). Returns a teardown. Does NOT fire on browser-driven popstate / hashchange (the reactive URL accessors already re-fire for those sources;onNavigateis specifically about "did the framework route us forward?"). -
manageNavScroll(options?)— a thin consumer that registers anonNavigatelistener and scrolls appropriately:- URL has a hash + the target element exists →
el.scrollIntoView() - URL has a hash + the target doesn't exist →
window.scrollTo(0, 0) - URL has no hash →
window.scrollTo(0, 0)
Scrolls are deferred a microtask so DOM updates triggered by the same
navigate()(route handler re-render mounting the hash target) have a chance to land first.options.onNavigatefully replaces the default handler when you want custom behavior (smooth scroll, restore-from-storage, focus a<main>element, anything). - URL has a hash + the target element exists →
Both are no-ops on the server. Both return teardown functions for HMR / tests.
// entry.client.ts
import { hydrate, interceptLinks, manageNavScroll } from '@purityjs/core';
import { App } from './app.ts';
hydrate(document.getElementById('app')!, App);
interceptLinks();
manageNavScroll();
Explicit non-features
- No scroll-position persistence across reload. The Web Platform's built-in scroll restoration handles same-tab back/forward. Persisting scroll across full reloads (Phoenix LiveView style) is a larger feature; defer.
- No focus management. ADR 0013 mentioned this; still its own
follow-up. Patterns vary (focus
<main>, focus an announce region, integrate with<title>change for screen readers). - No view transitions API integration.
document.startViewTransitionpairs withnavigate()but needs its own design (which routes should transition, fallback for unsupported browsers, prefers- reduced-motion). Separate ADR. - No prefetching, no scroll-position-save-on-pushState. Bigger features that change the navigation lifecycle; defer.
- No
<a href="#x">interception bymanageNavScroll.interceptLinks()'s default predicate skips hash-only same-page links so the browser scrolls natively.manageNavScrolldoesn't re-implement that; it only handlesnavigate()-driven nav.
Consequences
Positive:
- Three-line opt-in closes the visible UX gap. Apps without
interceptLinks+manageNavScrollstill work; apps with them get full-fidelity scroll handling. onNavigateis general: scroll, focus, analytics, custom transitions all subscribe to the same hook. The framework owns one event surface instead of five.- Microtask deferral handles the common "I navigated to /page#section and that section is mounted in the page's template" case correctly. Without it, the hash target wouldn't exist yet when we tried to find it.
Negative:
onNavigateis the second sub-public hook in the router (alongsideinterceptLinks's teardown). The router module is getting a small zoo of opt-ins. Acceptable while each opt-in is genuinely useful; worth consolidating if more accrete.navigate()now iterates a listener Set on every call. For typical apps the Set has 1–2 entries (manageNavScroll, maybe an analytics hook); cost is negligible. Hot-loop callers should be aware.- Custom
onNavigatehandlers replace the default scroll behavior entirely — same "replace, don't extend" semantic asinterceptLinks'sshouldIntercept. Documented, deliberate.
Neutral:
- Three exports added (
onNavigate,manageNavScroll,ManageNavScrollOptions). Tree-shakable when unused. - The microtask deferral makes scroll timing observable as
asynchronous in tests; users who want sync behavior call
window.scrollTo(0, 0)directly from theironNavigatelistener instead of usingmanageNavScroll.
Alternatives considered
Bake scroll handling into navigate() directly. No opt-in,
just always scroll to top on forward nav. Rejected: framework would
make UX decisions that some apps want to override (smooth scroll,
preserve position for filter changes, transitions). Opt-in
primitive matches the rest of the router's contract.
Monkey-patch window.history.pushState and replaceState.
React Router-style. Catches direct History API calls outside
navigate(). Rejected: invasive global mutation, hard to test
clean teardown, and the framework's navigation contract is "use
navigate()." Users who call pushState directly opt out of
framework features intentionally.
Use IntersectionObserver / MutationObserver to detect the
hash target appearing. Handles "hash target mounts asynchronously
after navigate" without requiring queueMicrotask. Rejected:
substantially more complex; microtask deferral covers the typical
case where route handlers run synchronously in response to the
reactive URL signal.
No onNavigate export — implement scroll directly inside
router.ts without a hook. Rejected: locks future side-effect-on-
nav features (focus, transitions, analytics) into either growing
the router module or re-deriving via the URL signal. Public
onNavigate gives downstream features the right primitive.
A useRouteEvent / onRouteChange that fires on all URL changes
(navigate + popstate + hashchange). Convenient but redundant with
the reactive URL accessors — those already re-fire on every URL
change, and watch(() => currentPath()) is the right way to react
to any nav. onNavigate is specifically for the "did the framework
route forward?" semantic, which back/forward doesn't share.