You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Clicking an in-page fragment link (<a href="#section">) re-fetches the current URL and re-swaps the page, destroying live DOM identity and any hydrated component state outside the anchor. The reader loses in-progress input held in a component, anything an app script attached to a node, and, most visibly, the jump itself: the browser scrolls to the anchor and the router's swap then puts the reader back at the top.
Chromium, Firefox and WebKit all fire popstate for a same-document fragment navigation, per the HTML spec's "navigate to a fragment" algorithm. The handler onPopState treats every popstate as a back/forward traversal and re-navigates unconditionally, so an ordinary anchor click enters the full navigation pipeline.
Measured on this HEAD (e0abcf96, 2026-08-21), in a real Chromium driven through Playwright, against a boundary-marked fixture served over HTTP with the router loaded from packages/core/src/router-client.js. Every click was dispatched from PAGE context with el.click(), never through the Playwright click API, which scrolls a target into view before dispatching and produced a confidently wrong diagnosis once already during #1429.
Trigger
location.href after
Fetches issued
Injected node outside the anchor
window.scrollY
<a href="#target"> clicked
/repro#target
1 page fetch, no x-webjs-frame
GONE
3044 immediately after the click, then back to 0
location.hash = 'target'
/repro#target
1 page fetch
GONE
0
history.back() between two fragment states
/repro
1 page fetch
GONE
unchanged
<a href="#"> clicked
/repro#
1 page fetch
GONE
0, and no native jump at all
The location.hash row matters: it never touches the click handler, so it isolates the popstate half as an independent defect rather than a side effect of link interception.
The scroll column is the user-visible headline. The browser performs the jump correctly (0 to 3044), and the router's swap then undoes it. The href="#" row is the reverse shape: preventDefault ran, so the browser never jumped, and the router did a full page navigation whose only visible effect was a scroll to top.
Corrections to the previous version of this issue
Three claims in the earlier body are stale or wrong at e0abcf96 and are corrected here.
The router DOES track a current URL. The old body said no lastUrl / currentUrl state exists anywhere in router-client/. currentPageUrl is declared at navigator.js:51, seeded at :141, cleared at :174, and written in the finally of performNavigation (:672) and performSubmission (:790). It is the right comparand, with one caveat the fix has to handle (see Design).
Turbo does intercept some same-page anchors. The old body said Turbo "does not intercept same-page anchors at all". What Turbo actually does, at ~/Documents/Projects/frameworks/turbo/src/util.js:262-273, is bow out when the raw href ATTRIBUTE starts with #. A fully spelled same-page link (/one.html#element-id) IS intercepted and becomes an advance visit, asserted in src/tests/functional/navigation_tests.js:229-236. WebJs's existing bow-out compares the RESOLVED pathname and search, so it already covers both spellings and is broader than Turbo's. That is the better behaviour and is kept.
Design / approach
The principle. A fragment is not sent to the server. Two URLs that differ only in their fragment therefore resolve to the same server response, so there is nothing to fetch and nothing to swap. The browser has already done the only work such a navigation requires. The router's job is to recognise that shape and stand down, in both places where it currently fails to.
Decision 1: a fragment-only popstate bows out
The handler onPopState returns early when the destination differs from the page's current URL only by fragment.
The comparand is currentPageUrl in navigator.js, which is the URL the reader is looking at. It exists precisely because on popstate the browser has already updated location.href to the destination before firing the event, so location.href alone cannot tell you where you came from. Its docstring at navigator.js:38-51 says so.
The discriminator is "same pathname, same search, different href". Comparing URL.hash is not sufficient, because the URL serializer collapses a NULL fragment and an EMPTY one to the same '', while href keeps them apart. Running these three through node shows it.
new URL('#', 'http://x/a?b=1').href === 'http://x/a?b=1#' hash === ''
new URL('', 'http://x/a?b=1').href === 'http://x/a?b=1' hash === ''
new URL('#s', 'http://x/a?b=1').href === 'http://x/a?b=1#s' hash === '#s'
Turbo hit the same wall and solved it the same way, in getAnchor at ~/Documents/Projects/frameworks/turbo/src/core/url.js:7-15, which falls back to an href.match(/#(.*)$/) when url.hash is falsy, so an empty-but-present fragment is distinguished from an absent one.
Why href inequality and not just path plus search equality. Two history entries can share a pathname and a search and still be genuinely different entries, because fetch-apply.js:199 and :311 push with history.pushState(null, '', finalUrl) and nothing forbids pushing the URL the page is already on. A form POST that re-renders its own page at 422 is exactly that shape. Requiring the hrefs to DIFFER keeps every such case on today's path, so the guard can only ever swallow a traversal whose URLs differ by fragment alone, which by the principle above is provably not a navigation.
The guard must also RECORD, and this is the part that is easy to get wrong. A first attempt that only returned early was measured and it FAILED the reverse traversal: clicking #target and then pressing Back still issued a fetch and still destroyed the injected node. The cause is that a bow-out skips performNavigation, whose finally is the only writer of currentPageUrl, so the tracker stayed at the pre-click URL and the Back then compared /repro against /repro, read equal hrefs, and re-navigated. So the bow-out sets currentPageUrl to the destination. With that one line added, all six measured cases pass (see the verification table below). Turbo's equivalent branch does the same thing: historyPoppedWithEmptyState in ~/Documents/Projects/frameworks/turbo/src/core/session.js:219-223 records the new location and caches, and navigates nothing.
Nothing else in the popstate path is owed that bookkeeping. Every step performNavigation would have run was checked against being skipped:
snapshotCurrent(currentPageUrl) at navigator.js:488 writes the snapshot cache. Its key strips the fragment (cacheKey at snapshot-cache.js:105-109 returns pathname + search), so a fragment jump can never need a different entry than the one already there. Skipping it is a saving, not a loss. That same hash-free key is also why a currentPageUrl carrying a fragment is harmless to every later navigation.
bumpRestoreGeneration plus releaseScrollAnchor plus releaseHeightReservation at :472-476 end a restore window an earlier Back left open. Both close themselves on a floor plus the revalidation settle, and ANCHOR_RELEASE_EVENTS in constants.js:128 includes pointerdown, so a real click has already closed the window before the fragment jump happens. A programmatic location.hash write inside an open window is the only reachable overlap and the window is same-page, so a held reservation of this page's own height is inert.
The in-flight abort() at :445 currently cancels a real navigation when the reader clicks a fragment link mid-flight. Not aborting is the better behaviour, since the reader asked for both and the navigation is the one that was going somewhere.
setNavigating is deferred 150ms and never armed on this path.
webjs:navigate is dispatched by fetchAndApply for a swap that now does not happen, and its one listener is refreshPrefetchObservers, which re-scans anchors after a swap. No swap, no new anchors, nothing to re-scan.
reportFallback is never reached, so no webjs:navigation-fallback is dispatched. Bowing out of a same-document jump is not a degradation and must not report one.
shouldFullLoadDuringParse at diagnostics.js:119-126 already returns false for every popstate, so guard ordering relative to it is free.
Decision 2: the empty fragment bows out on the click path too
events.js:34 tests url.hash for TRUTHINESS, and an empty fragment is ''. So href="#", the most common back-to-top spelling, is not bowed out today: it is intercepted, resolved against the enclosing <webjs-frame> if there is one, and since #1429 a frame nav writes no scroll, so a bare <a href="#">Back to top</a> inside a frame does nothing visible. Outside a frame it does a full page re-navigation whose only visible effect is the scroll to top it would have got for free.
Decision: an empty fragment bows out, and href="" does not. The test becomes url.href.includes('#'), which is exactly "has a non-null fragment". A # cannot appear anywhere else in a serialized URL, because the URL parser percent-encodes it in the path and treats it as the fragment delimiter in the query (new URL('/a?b=#1', base).search === '?b=' and .hash === '#1').
That split is the HTML spec's own. "Navigate to a fragment" applies when the destination URL's fragment is NON-NULL and the URL is otherwise equal to the document's. href="#" has an empty, non-null fragment, so it is a fragment navigation that scrolls to the document element. href="" resolves to the base URL with the fragment REMOVED, so its fragment is null, it is not a fragment navigation, and the spec says it reloads. Reloading is what the router's client navigation already approximates, so href="" correctly stays intercepted. Running the empty relative reference through node, as above, shows it serializes with no #.
Turbo agrees on both halves. Its findLinkFromClickTarget bails on getLinkHrefString(link).startsWith("#"), which catches the bare # and does not catch the empty href, and src/tests/functional/navigation_tests.js:364-374 asserts a hash-only skip link scrolls to the anchor "without a visit", checking that the body does not change. That is the same assertion this issue asks for.
The same predicate is duplicated at prefetch.js:233 inside eligibleAnchorHref, whose own docstring claims to be shared with onClick. It is not shared today, the click path carries its own copy, and letting one drift from the other is how a same-page link becomes prefetch-eligible. Both lines change in this PR. The network effect is already covered by #1106 (prefetch.js:347 compares against cacheKey(location.href, frameId), which strips the fragment, so a # link already matched the current page and was skipped), so this half is drift prevention rather than a behaviour change, and it should be stated as such in the commit body.
Frames
resolveTargetFrameId runs at events.js:42, AFTER the bow-out at :34. So once the guard is in place, a fragment click inside a <webjs-frame> never reaches frame resolution: the router calls no preventDefault, the browser performs its native jump, and the frame is untouched. That is the behaviour the docs currently describe as a trap, and it is the fix for it.
Prefetch is not implicated. A same-page anchor resolves to the current page's cache key in every dimension, and #1106 refuses to prefetch the current URL in the same dimension, including the frame dimension. After the eligibility change such an anchor is rejected one step earlier.
Alternatives considered and rejected
Tag the router's own history entries and ignore untagged popstates, which is Turbo's history.state.turbo model (~/Documents/Projects/frameworks/turbo/src/core/drive/history.js:38-45 and :80-94). Rejected: WebJs pushes null state (fetch-apply.js:199), so adopting this means changing every push AND doing a replaceState at boot to tag the entry the document loaded on, which overwrites whatever state the app had put there. It is a wider blast radius than the defect, and it buys nothing the URL comparison does not already give.
Compare only pathname and search, ignoring the fragment. Rejected: it swallows a popstate between two distinct entries that share a URL, which a same-URL form re-render produces, and that is the "early return breaks the next navigation" failure this guard has to avoid.
Compare URL.hash instead of href. Rejected: measured to collapse the empty and null fragments, so href="#" would be missed on the popstate side even after the click side is fixed.
Keep the predicate pure and write currentPageUrl from events.js. Rejected: the only writer available outside navigator.js is _setCurrentPageUrl, which is underscore-prefixed test-only surface. Mutable module state belongs with its writers, so the decision and the write live together in navigator.js and the handler just asks.
Put the early return inside performNavigation. Rejected: it would need an isPopState gate to avoid catching navigate() and refreshPage, and the sibling decision for clicks already lives in events.js. Interception decisions stay in the event layer.
Also bow out of a fragment-only navigate('#x'). Rejected as scope: navigate is an explicit programmatic call rather than an interception decision, and no report exists against it. Noted under Out of scope.
Verification of the proposed patch
The exact patch below was applied to the served module graph (rewritten in the HTTP layer, the repo untouched) and re-measured in the same Chromium session. All six cases behave:
Case
Fetches
Live injected node
location.href
window.scrollY
<a href="#target"> clicked
0
present
/repro#target
3044, #target at viewport top
Back
0
present
/repro
0
Forward
0
present
/repro#target
3044, #target at viewport top
<a href="#"> clicked
0
present
/repro#
0
Back from #
0
present
/repro#target
3044
Click to /elsewhere, then Back
1 each
swapped, as it must be
/elsewhere, then /repro#target
n/a
The Back and Forward rows are also the scroll proof. The router writes no scroll on either, and the offsets are correct, because under scrollRestoration: 'auto' the UA records an offset per entry and replays it on a traverse. The click row is the browser's own fragment jump. Both halves of the acceptance criterion are the UA's work, and the router doing nothing is what makes them right.
Implementation plan
All line anchors are against HEAD e0abcf96. Everything is plain .js with JSDoc.
Step 1: add the decide-and-record function to packages/core/src/router-client/navigator.js
Insert immediately after the currentPageUrl declaration, which today ends at line 51 with:
letcurrentPageUrl=null;
Add below it:
/** * Absorb a popstate that is not a navigation, and report that it was one. * * A traversal whose URL differs from the page's current URL only by FRAGMENT is * same-document by construction: the fragment is never sent to the server, so * both entries resolve to the same response, and the browser has already * performed the jump by the time this runs. Re-navigating it re-fetches the * page and re-swaps the DOM, which destroys live node identity and hydrated * state outside the anchor and undoes the jump the reader just asked for * (#1437). Fires for an ordinary `<a href="#section">` click too, since the * spec's "navigate to a fragment" ends by firing popstate. * * The comparison is pathname plus search EQUAL and `href` DIFFERENT, not * `hash` different, for two separate reasons. * * `href` rather than `hash`, because the URL serializer collapses a NULL * fragment and an EMPTY one to the same `''`, while `href` keeps the `#`. That * is what makes `href="#"` (a real fragment navigation, to the document * element) readable here. Turbo's `getAnchor` falls back to the same * `href.match(/#(.*)$/)` for the same reason. * * DIFFERENT rather than merely same-path, because two history entries can share * a pathname and a search and still be distinct: `fetchAndApply` pushes * whatever url the response settled on, including the one the page is already * on, which is what a form POST re-rendering its own page at 422 produces. * Requiring the hrefs to differ leaves every such popstate on the normal path, * so this can only ever absorb a traversal that provably needs no fetch. * * It RECORDS as well as deciding, and that is load-bearing rather than tidy. * `currentPageUrl` is otherwise written only in the `finally` of a completed * navigation, so a bow-out that did not record would leave the tracker at the * pre-jump url and the REVERSE traversal would then compare two equal hrefs and * re-navigate after all (measured). Turbo's `historyPoppedWithEmptyState` also * records the new location and navigates nothing. * * @param {string} href The destination, i.e. `location.href` at popstate time. * @returns {boolean} True when the popstate was absorbed and the caller must do * nothing further. */exportfunctionrecordFragmentTraversal(href){if(!currentPageUrl)returnfalse;/** @type {URL} */letprev;/** @type {URL} */letnext;try{prev=newURL(currentPageUrl);next=newURL(href,location.href);}catch{returnfalse;}// Both sides are serializations of this document's own `location.href`, so// the origin cannot differ and is not compared.if(prev.pathname!==next.pathname||prev.search!==next.search)returnfalse;if(prev.href===next.href)returnfalse;currentPageUrl=next.href;returntrue;}
Do NOT re-export it from packages/core/src/router-client.js. It is internal, and the barrel's non-underscore exports are the public ./client-router surface that client-router-export-parity.test.js checks against src/router-client.d.ts.
Step 2: call it from onPopState in packages/core/src/router-client/events.js
Today, lines 46-51 read:
/** @param {PopStateEvent} _e */exportfunctiononPopState(_e){// popstate has no DOM anchor, so no frame context: restore via cache or// refetch the whole document.performNavigation(location.href,true,null);}
After:
/** @param {PopStateEvent} _e */exportfunctiononPopState(_e){// A traversal that differs only by fragment is not a navigation: same// document, same server response, and the browser has already jumped. Absorb// it (which also records the new url) rather than re-fetching and re-swapping// the page out from under the reader (#1437). This is the popstate sibling of// the same-page bow-out on the click path above.if(recordFragmentTraversal(location.href))return;// popstate has no DOM anchor, so no frame context: restore via cache or// refetch the whole document.performNavigation(location.href,true,null);}
// `href.includes('#')` rather than `url.hash`, because the URL serializer// reports BOTH a null fragment and an empty one as `''`, and only the second// is a fragment navigation. `href="#"` keeps its `#` in `href` and per the// spec navigates to the document element (the back-to-top idiom), while// `href=""` resolves to the current url with the fragment REMOVED, which the// spec reloads rather than jumping, so it must stay a router navigation. A// `#` cannot appear anywhere else in a serialized url: the parser encodes it// in the path and starts the fragment at it in the query (#1437).if(url.pathname===location.pathname&&url.search===location.search&&url.href.includes('#'))return;
Step 4: mirror it at packages/core/src/router-client/prefetch.js:233
Today the body of eligibleAnchorHref carries the same test.
// A pure same-page hash jump is not a navigation we fetch.if(url.pathname===location.pathname&&url.search===location.search&&url.hash)returnnull;
After:
// A pure same-page fragment jump is not a navigation we fetch. Tested by// `href` rather than by `hash` for the reason spelled out at the matching// line in `events.js` (a null and an empty fragment both read as `''`). These// two lines must stay in lockstep: `onClick` keeps its own copy rather than// calling this, so drift here means a link the click path ignores becomes// prefetch-eligible. #1106 already refuses the current url in every// dimension, so this is drift prevention, not a change in what is fetched.if(url.pathname===location.pathname&&url.search===location.search&&url.href.includes('#'))returnnull;
While there, correct the function's docstring at prefetch.js:215-217, which claims it is "Shared by onClick and the prefetch listeners so eligibility never drifts between them". Say instead that it MIRRORS the filtering onClick performs inline, and that the two must be changed together.
Step 5: harden one existing unit test that reads ambient state
packages/core/test/routing/router-client.test.js:2889 ("onPopState: triggers a router navigation to location.href") stubs location but never sets currentPageUrl, so it navigates on whatever a previous test happened to leave behind. The new guard cannot trip on it today (it requires the hrefs to differ, and an inherited value that happened to equal /popped reads as "not a fragment traversal"), but the test asserts a navigation while depending on state it does not set. Add _setCurrentPageUrl('http://localhost/before-pop') in the setup and restore the previous value in the finally, matching the pattern the popstate tests at :1959, :2023 and :2073 already use.
Step 6: the docs, in the same commit
See the Docs section. The hook .claude/hooks/require-docs-with-src.sh blocks a commit that stages packages/*/src with no doc surface beside it, and this change reverses documented behaviour in two places, so the doc edit is required on the merits as well as by the gate.
Tests
Browser layer (mandatory, and the headline)
New file: packages/core/test/routing/browser/fragment-jump.test.js. Sibling naming is a short kebab-case description of the behaviour (query-params.test.js, frame-missing.test.js, frame-swap-scroll.test.js).
Copy the fixture discipline from frame-swap-scroll.test.js, and the real-traversal discipline from nav-scroll-anchor-restore.test.js. Carry over all of the following.
open with a header note stating why this cannot be a unit test, reusing the reasoning at the top of frame-swap-scroll.test.js. linkedom implements no layout, no scrolling, and no history traversal, so a scroll assertion never moves and history.back() drives nothing. The DECISION is unit-testable and is covered below; the OBSERVABLE behaviour is not;
installNavGuard() from test/browser-nav-guard.js, plus enableClientRouter() and disableClientRouter() around each case, exactly as the siblings do;
every URL is built from the LIVE location.href with an extra search param, like entryUrl at nav-scroll-anchor-restore.test.js:146. A link that replaces the search string pushes the page out of its web-test-runner session and takes down the whole run while every test still reports passing;
a tall spacer so the document can hold a non-zero offset, and the anchor target ABOVE it so a jump moves the viewport by an unmistakable margin;
stub window.fetch, count calls, and tag each with whether it carried x-webjs-frame;
teardown restores the URL with history.replaceState(null, '', origUrl), drains any pushed entries, restores fetch, and re-enables the router.
Cases, all clicking with el.click() from page context and never through the harness's click API:
a named-fragment click issues no fetch and leaves the live DOM alone. Inject a node the fixture's response does not contain, stamp an expando on it, click <a href="#target">, settle. Assert zero fetches, the injected node still isConnected, the expando still on the same node object, and document.getElementById('target').getBoundingClientRect().top at 0. Assert the pre-click offset first, so a fixture that never scrolled cannot pass for the wrong reason.
a bare href="#" scrolls to top natively and issues no fetch. Scroll to a non-zero offset, click <a href="#">, settle. Assert zero fetches, window.scrollY === 0, location.href ends with #, and the injected node survives.
href="" is NOT a fragment jump and still navigates. Click an <a href=""> and assert exactly one fetch, which is what keeps Decision 2 narrow.
Back and Forward between two fragment states re-navigate nothing. Push a fragment entry, history.back() awaiting a real popstate, then history.forward(). Assert zero fetches on each, the injected node alive across both, and the offset landing where the UA recorded it (0 on the way back, the anchor on the way forward).
a genuine cross-document popstate still re-navigates. Push an entry with a DIFFERENT search, go back, assert exactly one fetch and that the swap applied. This is the test that reds if the guard is ever widened to compare pathname alone.
no webjs:navigation-fallback is dispatched for a fragment jump. Listen on document across cases 1, 2 and 4 and assert the listener never fired.
a fragment click inside a <webjs-frame> does not drive a frame nav. Put the anchor inside a frame and the target outside it. Assert zero fetches of any kind, no x-webjs-frame request, and that the window moved. This is the documented trap, inverted.
Why DOM survival is asserted with an INJECTED node and not only an expando. Measured on this HEAD: after the destructive re-swap, an expando on #stamp SURVIVED, because the incoming response also contains #stamp and the morph reconciles it in place and keeps node identity. Only the injected node, which the response does not contain, was removed. So an expando alone on a node the response also carries is a test that passes against the bug. Assert the injected node, and keep the expando as the secondary signal.
Unit layer (packages/core/test/routing/router-client.test.js)
The bow-out DECISION is pure and belongs here, driven through the existing _onPopState seam with a stubbed location and _setCurrentPageUrl, the pattern already used at :1959 and :3284. Add the four cases below beside the existing popstate block, which starts at line 2886.
onPopState: a fragment-only popstate does not navigate (#1437). _setCurrentPageUrl('http://localhost/p'), stub location at /p#x, assert fetch was never called.
onPopState: a same-url popstate still navigates (#1437). Same tracker and a stubbed location at exactly /p, assert fetch WAS called. This is the narrowness proof, and the one that guards the same-URL form re-render.
onPopState: an absorbed fragment traversal records the new url (#1437). After case 1, assert _currentPageUrl() === 'http://localhost/p#x'. This is the regression test for the failure mode that the first attempted patch actually exhibited, so it is not a hypothetical.
onPopState: the empty fragment is absorbed too (#1437). Tracker at /p, stubbed location at /p#, assert no fetch.
Extend eligibleAnchorHref: rejects a pure same-page hash jump at :3707 with assert.equal(_eligibleAnchorHref(mkAnchor('http://localhost/#')), null) and an assertion that 'http://localhost/' is still ACCEPTED, which pins the href="" half.
Layers that do NOT apply, and why
e2e (test/e2e/*.test.mjs). The behaviour is entirely client-side and needs a real history stack and a real scroll, both of which the browser layer above gives with far better isolation. No server response changes.
Bun parity. Not applicable. This is browser-only client code with no runtime-sensitive server surface (no serializer, no listener, no dispatch, no crypto, no stripper), so .claude/hooks/require-bun-parity-with-runtime-src.sh will not fire and no test/bun/** file is owed.
Smoke (test/examples/*/smoke/*). Nothing about a rendered page changes.
webjs check rules. No new convention, no correctness rule.
The counterfactual, named concretely
Reverting step 2 alone (the popstate guard) must red, in this order of directness: browser case 1 on injectedNode.isConnected and on fetchCount === 0, browser case 4 on both traversals, and unit cases 1, 3 and 4. Reverting step 1's recording line alone (keeping the early return) must red browser case 4's Back leg and unit case 3, which is the exact failure the first patch attempt produced.
Reverting step 3 alone (the click bow-out) must red browser case 2 on fetchCount === 0 and on location.href, and unit case in eligibleAnchorHref for http://localhost/#.
Browser case 5 and unit case 2 must stay GREEN throughout. They are the proof that the guard did not swallow a real navigation.
Run npm test for the node layer and npm run test:browser for the browser layer, and report both. npm test does not run the browser suite. The root web-test-runner.config.js runs Chromium, Firefox and WebKit concurrently, and a fast local loop narrows to one with WEBJS_BROWSERS=chromium npx wtr. Chromium there launches WITHOUT --hide-scrollbars, so it is the engine whose scrollbar takes real layout width. That does not change any assertion here, since every offset is compared against a value read in the same page, never against a hard-coded pixel.
Docs
Two surfaces carry the href="#" trap as documented behaviour, and this change REVERSES it. Both must be rewritten, not merely touched.
Line 142 ends with "It does NOT cover a pure fragment link to a NAMED anchor (#section, same path and query), because the router never sees one". Widen "NAMED anchor" to any fragment, empty included.
Line 144, the whole paragraph beginning "The EMPTY fragment is the trap, and it goes the other way", is now false and is deleted. Replace it with a short statement that a fragment link on the current path and query is left to the browser in every spelling, that this includes the bare # back-to-top idiom, and that href="" is NOT a fragment link (it carries no fragment at all, so the spec reloads and the router navigates it).
Line 45, which lists "pure same-page hash jumps" among the auto-skipped, is correct as prose and stays; no change needed.
Add one sentence to the snapshot-cache paragraph at line 57 saying a back/forward step between two fragment states of one page is not a navigation and restores nothing, since the document never changed.
website/app/docs/client-router/page.ts.
Line 115 mirrors the "named anchor" wording. Widen it the same way.
Line 116, the <p><strong>The empty fragment is the trap...</strong></p> paragraph, is deleted and replaced with the corrected statement, matching the skill's new prose.
Line 283, the <li>Pure hash fragments on the same page (browser jumps to the anchor).</li> bullet in the auto-skipped list, is correct and stays.
packages/cli/templates/.agents/skills/webjs/references/. DO NOT create or edit anything there. Verified: the path does not exist in the working tree, and scripts/sync-scaffold-skill.mjs copies the repo-root canonical skill into it at prepack and removes it again at postpack (wired at packages/cli/package.json:10-11). Editing the repo-root file IS the scaffold update.
AGENTS.md. Add one sentence to the client-router paragraph at line 420, after the webjs:navigation-fallback sentence: a same-document fragment jump is the browser's, on the click and on the traversal alike, so an in-page anchor never re-fetches and never re-swaps and live DOM identity survives it.
Changelog. None by hand. The conventional PR title drives it, so the title must be fix: prefixed (single-commit PRs squash using the COMMIT subject, so set the prefix on the commit as well).
Acceptance criteria
Clicking <a href="#section"> on the current path and query issues zero fetches and performs zero swaps
A node injected into the live DOM outside the anchor, and an expando stamped on it, both survive that click
The viewport lands on the fragment on the click, written by the browser and not by the router
Back and Forward between two fragment states of one page issue zero fetches and preserve live DOM identity, with the offset replayed by the UA under scrollRestoration: 'auto'
A bare <a href="#"> scrolls to the top natively, issues no fetch, and does so inside a <webjs-frame> too
<a href=""> still performs a router navigation, since it carries no fragment
A genuine cross-document popstate (different pathname or search) still re-navigates and still restores from the snapshot cache
A popstate between two entries that share a URL exactly still re-navigates
No webjs:navigation-fallback is dispatched for any same-document jump
currentPageUrl is up to date after an absorbed traversal, proven by the reverse traversal also bowing out
The counterfactual is demonstrated: reverting the popstate guard reds the named browser and unit assertions, and reverting the recording line alone reds the reverse-traversal case
Browser-layer tests green on Chromium, Firefox and WebKit
npm test and npm run test:browser both green, and both results reported
The skill reference and the docs site no longer document the href="#" trap, and AGENTS.md states the rule
Out of scope
Making navigate('#x') bow out. It is an explicit programmatic call rather than an interception decision, and nothing has reported it. Leave performNavigation alone apart from the new function's placement.
Unifying onClick with eligibleAnchorHref. They differ materially (onClick resolves its anchor through findAnchorInPath for composed paths and SVG links, and runs after the modifier and button filtering), so folding one into the other is a refactor of the click path, not part of a bug fix. The two same-page lines are kept in lockstep with cross-referencing comments instead.
Tagging history entries with router state. Rejected above; do not introduce a history.pushState state payload or a boot-time replaceState.
Focus management for skip links. Turbo has a separate behaviour for moving focus to a fragment target. WebJs leaves that to the browser, and changing it is a different decision on a different surface.
Filing follow-up issues for anything found while implementing. Fold a small tweak in a file this PR already touches into this PR, and report anything genuinely separate in the PR description instead.
Problem
Clicking an in-page fragment link (
<a href="#section">) re-fetches the current URL and re-swaps the page, destroying live DOM identity and any hydrated component state outside the anchor. The reader loses in-progress input held in a component, anything an app script attached to a node, and, most visibly, the jump itself: the browser scrolls to the anchor and the router's swap then puts the reader back at the top.Chromium, Firefox and WebKit all fire
popstatefor a same-document fragment navigation, per the HTML spec's "navigate to a fragment" algorithm. The handleronPopStatetreats every popstate as a back/forward traversal and re-navigates unconditionally, so an ordinary anchor click enters the full navigation pipeline.Measured on this HEAD (
e0abcf96, 2026-08-21), in a real Chromium driven through Playwright, against a boundary-marked fixture served over HTTP with the router loaded frompackages/core/src/router-client.js. Every click was dispatched from PAGE context withel.click(), never through the Playwright click API, which scrolls a target into view before dispatching and produced a confidently wrong diagnosis once already during #1429.location.hrefafterwindow.scrollY<a href="#target">clicked/repro#targetx-webjs-framelocation.hash = 'target'/repro#targethistory.back()between two fragment states/repro<a href="#">clicked/repro#The
location.hashrow matters: it never touches the click handler, so it isolates the popstate half as an independent defect rather than a side effect of link interception.The scroll column is the user-visible headline. The browser performs the jump correctly (0 to 3044), and the router's swap then undoes it. The
href="#"row is the reverse shape:preventDefaultran, so the browser never jumped, and the router did a full page navigation whose only visible effect was a scroll to top.Corrections to the previous version of this issue
Three claims in the earlier body are stale or wrong at
e0abcf96and are corrected here.mainstill setshistory.scrollRestoration = 'manual'atnavigator.js:110and told the implementer to read fix: leave scroll restoration to the browser so iOS previews the back-swipe #1430 first. fix: leave scroll restoration to the browser so iOS previews the back-swipe #1430 has landed (merge commitad81d4b3) and research: client-router scroll architecture, remove the two-phase restore instead of managing it #1433 is closed.packages/core/src/router-client/navigator.js:135-138now readsprevScrollRestoration = history.scrollRestoration; history.scrollRestoration = 'auto';, with a 30-line comment above it stating that the restore is the BROWSER's. So there is no scroll decision left to make in this issue, and the fix writes no scroll of its own.lastUrl/currentUrlstate exists anywhere inrouter-client/.currentPageUrlis declared atnavigator.js:51, seeded at:141, cleared at:174, and written in thefinallyofperformNavigation(:672) andperformSubmission(:790). It is the right comparand, with one caveat the fix has to handle (see Design).~/Documents/Projects/frameworks/turbo/src/util.js:262-273, is bow out when the raw href ATTRIBUTE starts with#. A fully spelled same-page link (/one.html#element-id) IS intercepted and becomes an advance visit, asserted insrc/tests/functional/navigation_tests.js:229-236. WebJs's existing bow-out compares the RESOLVED pathname and search, so it already covers both spellings and is broader than Turbo's. That is the better behaviour and is kept.Design / approach
The principle. A fragment is not sent to the server. Two URLs that differ only in their fragment therefore resolve to the same server response, so there is nothing to fetch and nothing to swap. The browser has already done the only work such a navigation requires. The router's job is to recognise that shape and stand down, in both places where it currently fails to.
Decision 1: a fragment-only popstate bows out
The handler
onPopStatereturns early when the destination differs from the page's current URL only by fragment.The comparand is
currentPageUrlinnavigator.js, which is the URL the reader is looking at. It exists precisely because on popstate the browser has already updatedlocation.hrefto the destination before firing the event, solocation.hrefalone cannot tell you where you came from. Its docstring atnavigator.js:38-51says so.The discriminator is "same pathname, same search, different
href". ComparingURL.hashis not sufficient, because the URL serializer collapses a NULL fragment and an EMPTY one to the same'', whilehrefkeeps them apart. Running these three throughnodeshows it.Turbo hit the same wall and solved it the same way, in
getAnchorat~/Documents/Projects/frameworks/turbo/src/core/url.js:7-15, which falls back to anhref.match(/#(.*)$/)whenurl.hashis falsy, so an empty-but-present fragment is distinguished from an absent one.Why
hrefinequality and not just path plus search equality. Two history entries can share a pathname and a search and still be genuinely different entries, becausefetch-apply.js:199and:311push withhistory.pushState(null, '', finalUrl)and nothing forbids pushing the URL the page is already on. A form POST that re-renders its own page at 422 is exactly that shape. Requiring the hrefs to DIFFER keeps every such case on today's path, so the guard can only ever swallow a traversal whose URLs differ by fragment alone, which by the principle above is provably not a navigation.The guard must also RECORD, and this is the part that is easy to get wrong. A first attempt that only returned early was measured and it FAILED the reverse traversal: clicking
#targetand then pressing Back still issued a fetch and still destroyed the injected node. The cause is that a bow-out skipsperformNavigation, whosefinallyis the only writer ofcurrentPageUrl, so the tracker stayed at the pre-click URL and the Back then compared/reproagainst/repro, read equal hrefs, and re-navigated. So the bow-out setscurrentPageUrlto the destination. With that one line added, all six measured cases pass (see the verification table below). Turbo's equivalent branch does the same thing:historyPoppedWithEmptyStatein~/Documents/Projects/frameworks/turbo/src/core/session.js:219-223records the new location and caches, and navigates nothing.Nothing else in the popstate path is owed that bookkeeping. Every step
performNavigationwould have run was checked against being skipped:snapshotCurrent(currentPageUrl)atnavigator.js:488writes the snapshot cache. Its key strips the fragment (cacheKeyatsnapshot-cache.js:105-109returnspathname + search), so a fragment jump can never need a different entry than the one already there. Skipping it is a saving, not a loss. That same hash-free key is also why acurrentPageUrlcarrying a fragment is harmless to every later navigation.bumpRestoreGenerationplusreleaseScrollAnchorplusreleaseHeightReservationat:472-476end a restore window an earlier Back left open. Both close themselves on a floor plus the revalidation settle, andANCHOR_RELEASE_EVENTSinconstants.js:128includespointerdown, so a real click has already closed the window before the fragment jump happens. A programmaticlocation.hashwrite inside an open window is the only reachable overlap and the window is same-page, so a held reservation of this page's own height is inert.abort()at:445currently cancels a real navigation when the reader clicks a fragment link mid-flight. Not aborting is the better behaviour, since the reader asked for both and the navigation is the one that was going somewhere.setNavigatingis deferred 150ms and never armed on this path.webjs:navigateis dispatched byfetchAndApplyfor a swap that now does not happen, and its one listener isrefreshPrefetchObservers, which re-scans anchors after a swap. No swap, no new anchors, nothing to re-scan.reportFallbackis never reached, so nowebjs:navigation-fallbackis dispatched. Bowing out of a same-document jump is not a degradation and must not report one.shouldFullLoadDuringParseatdiagnostics.js:119-126already returns false for every popstate, so guard ordering relative to it is free.Decision 2: the empty fragment bows out on the click path too
events.js:34testsurl.hashfor TRUTHINESS, and an empty fragment is''. Sohref="#", the most common back-to-top spelling, is not bowed out today: it is intercepted, resolved against the enclosing<webjs-frame>if there is one, and since #1429 a frame nav writes no scroll, so a bare<a href="#">Back to top</a>inside a frame does nothing visible. Outside a frame it does a full page re-navigation whose only visible effect is the scroll to top it would have got for free.Decision: an empty fragment bows out, and
href=""does not. The test becomesurl.href.includes('#'), which is exactly "has a non-null fragment". A#cannot appear anywhere else in a serialized URL, because the URL parser percent-encodes it in the path and treats it as the fragment delimiter in the query (new URL('/a?b=#1', base).search === '?b='and.hash === '#1').That split is the HTML spec's own. "Navigate to a fragment" applies when the destination URL's fragment is NON-NULL and the URL is otherwise equal to the document's.
href="#"has an empty, non-null fragment, so it is a fragment navigation that scrolls to the document element.href=""resolves to the base URL with the fragment REMOVED, so its fragment is null, it is not a fragment navigation, and the spec says it reloads. Reloading is what the router's client navigation already approximates, sohref=""correctly stays intercepted. Running the empty relative reference throughnode, as above, shows it serializes with no#.Turbo agrees on both halves. Its
findLinkFromClickTargetbails ongetLinkHrefString(link).startsWith("#"), which catches the bare#and does not catch the empty href, andsrc/tests/functional/navigation_tests.js:364-374asserts a hash-only skip link scrolls to the anchor "without a visit", checking that the body does not change. That is the same assertion this issue asks for.The same predicate is duplicated at
prefetch.js:233insideeligibleAnchorHref, whose own docstring claims to be shared withonClick. It is not shared today, the click path carries its own copy, and letting one drift from the other is how a same-page link becomes prefetch-eligible. Both lines change in this PR. The network effect is already covered by #1106 (prefetch.js:347compares againstcacheKey(location.href, frameId), which strips the fragment, so a#link already matched the current page and was skipped), so this half is drift prevention rather than a behaviour change, and it should be stated as such in the commit body.Frames
resolveTargetFrameIdruns atevents.js:42, AFTER the bow-out at:34. So once the guard is in place, a fragment click inside a<webjs-frame>never reaches frame resolution: the router calls nopreventDefault, the browser performs its native jump, and the frame is untouched. That is the behaviour the docs currently describe as a trap, and it is the fix for it.Prefetch is not implicated. A same-page anchor resolves to the current page's cache key in every dimension, and #1106 refuses to prefetch the current URL in the same dimension, including the frame dimension. After the eligibility change such an anchor is rejected one step earlier.
Alternatives considered and rejected
history.state.turbomodel (~/Documents/Projects/frameworks/turbo/src/core/drive/history.js:38-45and:80-94). Rejected: WebJs pushesnullstate (fetch-apply.js:199), so adopting this means changing every push AND doing areplaceStateat boot to tag the entry the document loaded on, which overwrites whatever state the app had put there. It is a wider blast radius than the defect, and it buys nothing the URL comparison does not already give.URL.hashinstead ofhref. Rejected: measured to collapse the empty and null fragments, sohref="#"would be missed on the popstate side even after the click side is fixed.currentPageUrlfromevents.js. Rejected: the only writer available outsidenavigator.jsis_setCurrentPageUrl, which is underscore-prefixed test-only surface. Mutable module state belongs with its writers, so the decision and the write live together innavigator.jsand the handler just asks.performNavigation. Rejected: it would need anisPopStategate to avoid catchingnavigate()andrefreshPage, and the sibling decision for clicks already lives inevents.js. Interception decisions stay in the event layer.navigate('#x'). Rejected as scope:navigateis an explicit programmatic call rather than an interception decision, and no report exists against it. Noted under Out of scope.Verification of the proposed patch
The exact patch below was applied to the served module graph (rewritten in the HTTP layer, the repo untouched) and re-measured in the same Chromium session. All six cases behave:
location.hrefwindow.scrollY<a href="#target">clicked/repro#target#targetat viewport top/repro/repro#target#targetat viewport top<a href="#">clicked/repro##/repro#target/elsewhere, then Back/elsewhere, then/repro#targetThe Back and Forward rows are also the scroll proof. The router writes no scroll on either, and the offsets are correct, because under
scrollRestoration: 'auto'the UA records an offset per entry and replays it on a traverse. The click row is the browser's own fragment jump. Both halves of the acceptance criterion are the UA's work, and the router doing nothing is what makes them right.Implementation plan
All line anchors are against HEAD
e0abcf96. Everything is plain.jswith JSDoc.Step 1: add the decide-and-record function to
packages/core/src/router-client/navigator.jsInsert immediately after the
currentPageUrldeclaration, which today ends at line 51 with:Add below it:
Do NOT re-export it from
packages/core/src/router-client.js. It is internal, and the barrel's non-underscore exports are the public./client-routersurface thatclient-router-export-parity.test.jschecks againstsrc/router-client.d.ts.Step 2: call it from
onPopStateinpackages/core/src/router-client/events.jsToday, lines 46-51 read:
After:
And widen the import on line 15 from:
to:
events.jsalready importsnavigator.js, so this adds no module edge and no cycle.Step 3: make the click bow-out see an empty fragment, in
packages/core/src/router-client/events.js:34Today:
After:
Step 4: mirror it at
packages/core/src/router-client/prefetch.js:233Today the body of
eligibleAnchorHrefcarries the same test.After:
While there, correct the function's docstring at
prefetch.js:215-217, which claims it is "Shared by onClick and the prefetch listeners so eligibility never drifts between them". Say instead that it MIRRORS the filteringonClickperforms inline, and that the two must be changed together.Step 5: harden one existing unit test that reads ambient state
packages/core/test/routing/router-client.test.js:2889("onPopState: triggers a router navigation to location.href") stubslocationbut never setscurrentPageUrl, so it navigates on whatever a previous test happened to leave behind. The new guard cannot trip on it today (it requires the hrefs to differ, and an inherited value that happened to equal/poppedreads as "not a fragment traversal"), but the test asserts a navigation while depending on state it does not set. Add_setCurrentPageUrl('http://localhost/before-pop')in the setup and restore the previous value in thefinally, matching the pattern the popstate tests at:1959,:2023and:2073already use.Step 6: the docs, in the same commit
See the Docs section. The hook
.claude/hooks/require-docs-with-src.shblocks a commit that stagespackages/*/srcwith no doc surface beside it, and this change reverses documented behaviour in two places, so the doc edit is required on the merits as well as by the gate.Tests
Browser layer (mandatory, and the headline)
New file:
packages/core/test/routing/browser/fragment-jump.test.js. Sibling naming is a short kebab-case description of the behaviour (query-params.test.js,frame-missing.test.js,frame-swap-scroll.test.js).Copy the fixture discipline from
frame-swap-scroll.test.js, and the real-traversal discipline fromnav-scroll-anchor-restore.test.js. Carry over all of the following.frame-swap-scroll.test.js. linkedom implements no layout, no scrolling, and no history traversal, so a scroll assertion never moves andhistory.back()drives nothing. The DECISION is unit-testable and is covered below; the OBSERVABLE behaviour is not;installNavGuard()fromtest/browser-nav-guard.js, plusenableClientRouter()anddisableClientRouter()around each case, exactly as the siblings do;location.hrefwith an extra search param, likeentryUrlatnav-scroll-anchor-restore.test.js:146. A link that replaces the search string pushes the page out of its web-test-runner session and takes down the whole run while every test still reports passing;window.fetch, count calls, and tag each with whether it carriedx-webjs-frame;history.replaceState(null, '', origUrl), drains any pushed entries, restoresfetch, and re-enables the router.Cases, all clicking with
el.click()from page context and never through the harness's click API:a named-fragment click issues no fetch and leaves the live DOM alone. Inject a node the fixture's response does not contain, stamp an expando on it, click<a href="#target">, settle. Assert zero fetches, the injected node stillisConnected, the expando still on the same node object, anddocument.getElementById('target').getBoundingClientRect().topat 0. Assert the pre-click offset first, so a fixture that never scrolled cannot pass for the wrong reason.a bare href="#" scrolls to top natively and issues no fetch. Scroll to a non-zero offset, click<a href="#">, settle. Assert zero fetches,window.scrollY === 0,location.hrefends with#, and the injected node survives.href="" is NOT a fragment jump and still navigates. Click an<a href="">and assert exactly one fetch, which is what keeps Decision 2 narrow.Back and Forward between two fragment states re-navigate nothing. Push a fragment entry,history.back()awaiting a realpopstate, thenhistory.forward(). Assert zero fetches on each, the injected node alive across both, and the offset landing where the UA recorded it (0 on the way back, the anchor on the way forward).a genuine cross-document popstate still re-navigates. Push an entry with a DIFFERENT search, go back, assert exactly one fetch and that the swap applied. This is the test that reds if the guard is ever widened to compare pathname alone.no webjs:navigation-fallback is dispatched for a fragment jump. Listen ondocumentacross cases 1, 2 and 4 and assert the listener never fired.a fragment click inside a <webjs-frame> does not drive a frame nav. Put the anchor inside a frame and the target outside it. Assert zero fetches of any kind, nox-webjs-framerequest, and that the window moved. This is the documented trap, inverted.Why DOM survival is asserted with an INJECTED node and not only an expando. Measured on this HEAD: after the destructive re-swap, an expando on
#stampSURVIVED, because the incoming response also contains#stampand the morph reconciles it in place and keeps node identity. Only the injected node, which the response does not contain, was removed. So an expando alone on a node the response also carries is a test that passes against the bug. Assert the injected node, and keep the expando as the secondary signal.Unit layer (
packages/core/test/routing/router-client.test.js)The bow-out DECISION is pure and belongs here, driven through the existing
_onPopStateseam with a stubbedlocationand_setCurrentPageUrl, the pattern already used at:1959and:3284. Add the four cases below beside the existing popstate block, which starts at line 2886.onPopState: a fragment-only popstate does not navigate (#1437)._setCurrentPageUrl('http://localhost/p'), stublocationat/p#x, assertfetchwas never called.onPopState: a same-url popstate still navigates (#1437). Same tracker and a stubbedlocationat exactly/p, assertfetchWAS called. This is the narrowness proof, and the one that guards the same-URL form re-render.onPopState: an absorbed fragment traversal records the new url (#1437). After case 1, assert_currentPageUrl() === 'http://localhost/p#x'. This is the regression test for the failure mode that the first attempted patch actually exhibited, so it is not a hypothetical.onPopState: the empty fragment is absorbed too (#1437). Tracker at/p, stubbedlocationat/p#, assert no fetch.Extend
eligibleAnchorHref: rejects a pure same-page hash jumpat:3707withassert.equal(_eligibleAnchorHref(mkAnchor('http://localhost/#')), null)and an assertion that'http://localhost/'is still ACCEPTED, which pins thehref=""half.Layers that do NOT apply, and why
test/e2e/*.test.mjs). The behaviour is entirely client-side and needs a real history stack and a real scroll, both of which the browser layer above gives with far better isolation. No server response changes..claude/hooks/require-bun-parity-with-runtime-src.shwill not fire and notest/bun/**file is owed.test/examples/*/smoke/*). Nothing about a rendered page changes.webjs checkrules. No new convention, no correctness rule.The counterfactual, named concretely
Reverting step 2 alone (the popstate guard) must red, in this order of directness: browser case 1 on
injectedNode.isConnectedand onfetchCount === 0, browser case 4 on both traversals, and unit cases 1, 3 and 4. Reverting step 1's recording line alone (keeping the early return) must red browser case 4's Back leg and unit case 3, which is the exact failure the first patch attempt produced.Reverting step 3 alone (the click bow-out) must red browser case 2 on
fetchCount === 0and onlocation.href, and unit case ineligibleAnchorHrefforhttp://localhost/#.Browser case 5 and unit case 2 must stay GREEN throughout. They are the proof that the guard did not swallow a real navigation.
Run
npm testfor the node layer andnpm run test:browserfor the browser layer, and report both.npm testdoes not run the browser suite. The rootweb-test-runner.config.jsruns Chromium, Firefox and WebKit concurrently, and a fast local loop narrows to one withWEBJS_BROWSERS=chromium npx wtr. Chromium there launches WITHOUT--hide-scrollbars, so it is the engine whose scrollbar takes real layout width. That does not change any assertion here, since every offset is compared against a value read in the same page, never against a hard-coded pixel.Docs
Two surfaces carry the
href="#"trap as documented behaviour, and this change REVERSES it. Both must be rewritten, not merely touched..agents/skills/webjs/references/client-router-and-streaming.md.#section, same path and query), because the router never sees one". Widen "NAMED anchor" to any fragment, empty included.#back-to-top idiom, and thathref=""is NOT a fragment link (it carries no fragment at all, so the spec reloads and the router navigates it).website/app/docs/client-router/page.ts.<p><strong>The empty fragment is the trap...</strong></p>paragraph, is deleted and replaced with the corrected statement, matching the skill's new prose.<li>Pure hash fragments on the same page (browser jumps to the anchor).</li>bullet in the auto-skipped list, is correct and stays.packages/cli/templates/.agents/skills/webjs/references/. DO NOT create or edit anything there. Verified: the path does not exist in the working tree, andscripts/sync-scaffold-skill.mjscopies the repo-root canonical skill into it atprepackand removes it again atpostpack(wired atpackages/cli/package.json:10-11). Editing the repo-root file IS the scaffold update.AGENTS.md. Add one sentence to the client-router paragraph at line 420, after thewebjs:navigation-fallbacksentence: a same-document fragment jump is the browser's, on the click and on the traversal alike, so an in-page anchor never re-fetches and never re-swaps and live DOM identity survives it.fix:prefixed (single-commit PRs squash using the COMMIT subject, so set the prefix on the commit as well).Acceptance criteria
<a href="#section">on the current path and query issues zero fetches and performs zero swapsscrollRestoration: 'auto'<a href="#">scrolls to the top natively, issues no fetch, and does so inside a<webjs-frame>too<a href="">still performs a router navigation, since it carries no fragmentwebjs:navigation-fallbackis dispatched for any same-document jumpcurrentPageUrlis up to date after an absorbed traversal, proven by the reverse traversal also bowing outnpm testandnpm run test:browserboth green, and both results reportedhref="#"trap, andAGENTS.mdstates the ruleOut of scope
navigate('#x')bow out. It is an explicit programmatic call rather than an interception decision, and nothing has reported it. LeaveperformNavigationalone apart from the new function's placement.onClickwitheligibleAnchorHref. They differ materially (onClickresolves its anchor throughfindAnchorInPathfor composed paths and SVG links, and runs after the modifier and button filtering), so folding one into the other is a refactor of the click path, not part of a bug fix. The two same-page lines are kept in lockstep with cross-referencing comments instead.history.pushStatestate payload or a boot-timereplaceState.