Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
navigatelistener replaces the global click/popstate/hashchange listeners:.vp-raw, button wrappers,download/target, cross-origin, non-HTML paths) are reproduced viasourceElementscroll: 'manual'+focusReset: 'manual'preserve the existingscrollTohelper's a11y focus handlingupdateCurrentEntryand restored ontraverse#:~:text=…) hash navigations fall through to native browser handlingrouter.gocallsnavigation.navigate(...).finished; a__vpFromGoinfo flag preventsonBeforeRouteChangefrom running twiceskipInternalNavigateguard prevents re-entering the listener during the same-documenthistory.replaceStateURL fix-up inloadPageSplit the router into a small folder with two composable strategies sharing the same
{ go, replaceUrl }shape:loadPagedelegates same-document URL fix-up tostrategy.replaceUrlso 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.