Skip to content

feat(client): use Navigation API for routing when available#5182

Draft
brc-dd wants to merge 5 commits intomainfrom
router
Draft

feat(client): use Navigation API for routing when available#5182
brc-dd wants to merge 5 commits intomainfrom
router

Conversation

@brc-dd
Copy link
Copy Markdown
Member

@brc-dd brc-dd commented Apr 17, 2026

Implement the client router against the Navigation API and keep the existing History API + click/popstate implementation as a fallback. Feature detection gates on NavigateEvent.prototype.sourceElement — its presence implies a sufficiently complete implementation.

On the Navigation API path, a single navigate listener replaces the global click/popstate/hashchange listeners:

  • link-click filters (.vp-raw, button wrappers, download/target, cross-origin, non-HTML paths) are reproduced via sourceElement
  • scroll: 'manual' + focusReset: 'manual' preserve the existing scrollTo helper's a11y focus handling
  • scroll position is persisted on the outgoing entry via updateCurrentEntry and restored on traverse
  • fragment-directive (#:~:text=…) hash navigations fall through to native browser handling
  • router.go calls navigation.navigate(...).finished; a __vpFromGo info flag prevents onBeforeRouteChange from running twice
  • a skipInternalNavigate guard prevents re-entering the listener during the same-document history.replaceState URL fix-up in loadPage

Split the router into a small folder with two composable strategies sharing the same { go, replaceUrl } shape:

src/client/app/router/
  router.ts         - public API, picks a strategy, wires loadPage
  shared.ts         - types, fakeHost, normalizeHref, scrollTo, …
  legacy.ts         - createLegacyRouterStrategy (History API)
  navigationApi.ts  - hasNavigationApi + Navigation API strategy

loadPage delegates same-document URL fix-up to strategy.replaceUrl so each strategy handles its own re-entry semantics.


The PoC is AI generated. Needs code clean up and optimizations. TODO: use native paths where possible.

brc-dd added 5 commits April 17, 2026 15:55
Implement the client router against the Navigation API and keep the
existing History API + click/popstate implementation as a fallback.
Feature detection gates on `NavigateEvent.prototype.sourceElement` —
its presence implies a sufficiently complete implementation.

On the Navigation API path, a single `navigate` listener replaces the
global click/popstate/hashchange listeners:

  - link-click filters (`.vp-raw`, button wrappers, `download`/`target`,
    cross-origin, non-HTML paths) are reproduced via `sourceElement`
  - `scroll: 'manual'` + `focusReset: 'manual'` preserve the existing
    `scrollTo` helper's a11y focus handling
  - scroll position is persisted on the outgoing entry via
    `updateCurrentEntry` and restored on `traverse`
  - fragment-directive (`#:~:text=…`) hash navigations fall through to
    native browser handling
  - `router.go` calls `navigation.navigate(...).finished`; a `__vpFromGo`
    info flag prevents `onBeforeRouteChange` from running twice
  - a `skipInternalNavigate` guard prevents re-entering the listener
    during the same-document `history.replaceState` URL fix-up in
    `loadPage`

Split the router into a small folder with two composable strategies
sharing the same `{ go, replaceUrl }` shape:

  src/client/app/router/
    router.ts         - public API, picks a strategy, wires loadPage
    shared.ts         - types, fakeHost, normalizeHref, scrollTo, …
    legacy.ts         - createLegacyRouterStrategy (History API)
    navigationApi.ts  - hasNavigationApi + Navigation API strategy

`loadPage` delegates same-document URL fix-up to `strategy.replaceUrl`
so each strategy handles its own re-entry semantics.
On the Navigation API path we previously opted out with `scroll: 'manual'`
and drove the scroll ourselves (including `updateCurrentEntry`-based
scroll-position tracking for traverses), mostly so our `scrollTo` helper
could focus the hash target for accessibility. With `await nextTick()`
inside the intercept handler, the browser's built-in post-handler scroll
already lands on the right element — we only need to keep focus manual.

  - keep `focusReset: 'manual'`; drop `scroll: 'manual'` from `intercept`
  - `await nextTick()` after `loadPage` so the new DOM exists when the
    browser scrolls on handler resolution
  - drop `updateCurrentEntry` / `destination.getState()`; traverse scroll
    restoration is now handled by the browser
  - split `scrollTo` into shared helpers and export `focusHashTarget`,
    used by the Navigation API strategy for the a11y focus step

As a side effect, `loadPage` no longer carries scroll responsibility:

  - remove `scrollPosition` / `initialLoad` from its options (and the
    `PageLoadOptions` type — `LoadPage` is just `(href) => Promise<void>`)
  - the legacy strategy now does its own `await nextTick()` + `scrollTo`
    after `loadPage` in `go()` and the popstate listener

Verified: push to new page scrolls to top, push with hash scrolls to
target + focuses it, back traversal restores prior scroll position, and
link-click interception still works.
On the Navigation API path, hash-only navigations no longer run through
our manual scroll helper. We still `intercept()` them — but with a
minimal handler that only:

  - runs `onBeforeRouteChange` / `onAfterRouteChange` (so hook semantics
    stay consistent with the legacy path and with path-level navs)
  - syncs `route.hash` / `route.query`
  - moves focus to the target heading for a11y

Scroll is left to the browser's built-in post-handler behaviour, which
already handles the fragment scroll (push/replace) and scroll-position
restoration (traverse), both respecting `scroll-margin`.

Text-fragment navigations (`#:~:text=…`) remain un-intercepted so the
browser's native fragment-directive processing runs; the `hashchange`
listener keeps state in sync for that case.

Verified: push-hash, programmatic `router.go('/foo#bar')`, hash traversal
via back/forward, and `onBeforeRouteChange` cancellation all behave
correctly, with focus landing on the target heading.
`event.downloadRequest != null` already catches links with a `download`
attribute (including `download=""`), so the `sourceElement.hasAttribute('download')`
check was dead. Keep the `target` check — `target="_self"` and named
targets still fire the navigate event in the current window and should
not be intercepted.
Add a parameterised e2e suite that runs the same 22 scenarios against
both router implementations (Navigation API and legacy History API).
The legacy strategy is forced on via `window.__VP_USE_LEGACY_ROUTER__`,
set via Playwright `addInitScript` on a second browser context — the
shared browser and default `page` / `goto` from vitestSetup.ts are
reused for the Navigation API run, so we don't relaunch Playwright.

Scenarios covered, for each mode:

  - strategy selection reflects the flag
  - link click performs SPA navigation (no full reload)
  - hash anchor updates URL, scrolls into view, focuses target heading
  - back / forward navigate between entries
  - scroll position restored on back traversal
  - onBeforeRouteChange / onAfterRouteChange fire for full and hash navs
  - onBeforeRouteChange returning false cancels the nav
  - programmatic router.go
  - router.go with { replace: true }
  - same-URL click scrolls without pushing a new history entry
  - onBeforePageLoad / onAfterPageLoad fire
  - onBeforePageLoad returning false cancels page load
  - links inside .vp-raw trigger a full reload
  - external / download / target / non-HTML links are not intercepted
  - clicks on buttons inside links are not intercepted
  - route.path / route.hash / route.query stay in sync
  - unknown paths fall back to the 404 page
  - route.data.relativePath is populated from the loaded page

A small init-script-installed `resolveRouterOrThrow()` finds the router
via the Vue app's provides, so `page.evaluate` call sites stay terse and
typed against the public `Router` interface — no `any` in test code.

To support the flag, `hasNavigationApi()` now honours
`window.__VP_USE_LEGACY_ROUTER__` as a forced opt-out; this is documented
in-source as a test-only hook.
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