Skip to content

fix: leave a same-document fragment jump to the browser - #1448

Draft
vivek7405 wants to merge 8 commits into
mainfrom
fix/fragment-click-no-renav
Draft

fix: leave a same-document fragment jump to the browser#1448
vivek7405 wants to merge 8 commits into
mainfrom
fix/fragment-click-no-renav

Conversation

@vivek7405

@vivek7405 vivek7405 commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Closes #1437

Clicking an in-page fragment link re-fetched the current URL and re-swapped the whole page, so live DOM identity and any hydrated component state outside the anchor were destroyed on a jump the router should never touch at all. Chromium fires popstate for a same-document fragment navigation, and onPopState treated every popstate as back/forward, so an ordinary anchor click entered the full navigation pipeline.

What changed

onPopState now absorbs a popstate that cannot need a fetch. The necessary condition is that the destination shares the page's pathname and search, since only the fragment can then differ and the fragment never reaches the server, so both entries resolve to the same response.

That condition is not sufficient, and the gap is the interesting part. Two popstates can arrive carrying the URL the reader is already on, and they need opposite treatment:

  • a repeat click of one in-page anchor, which REPLACES its history entry rather than pushing, so it fires popstate with the URL unchanged. Nothing to fetch.
  • a Back between two distinct entries that share a URL, which is a real traversal that must re-render. The no-JS write path produces that pair: a bound <form action=${fn}> emits no action attribute, so form.action reflects the document URL and the 422 re-render pushes a duplicate entry at it.

As URLs those are identical, so no comparison can separate them. What separates them is provenance: the router saw the click it bowed out of, and it never sees a traversal. onClick leaves a mark on its way out, the next popstate consumes it, and a real navigation or submission drops it so it cannot leak. A popstate whose URL changed needs no mark, because the pathname and search test already proves it is same-document.

The click path carried the defect in a second spelling. Its bow-out tested url.hash for truthiness, so href="#", the back-to-top idiom, was intercepted rather than left to the browser. Both that line and its mirror in eligibleAnchorHref now test the href for a #. href="" keeps navigating, because it resolves to the current URL with the fragment removed, which the spec reloads rather than jumps.

The shared browser-test nav guard needed narrowing to make any of this observable: it cancelled the default of every anchor click, and preventDefault is exactly what suppresses a native fragment jump. Its own docstring already said a pure-fragment link needs no guard, so this makes the code match the contract it documented.

What the review changed

Worth recording, because two plausible rules were tried and measured wrong before the provenance one:

  1. Require the hrefs to differ. Misses the repeat click, so the second click of a back-to-top link fell through to a full navigation. The fall-through is destructive rather than merely wasteful, since cacheKey strips the fragment, so the popstate branch snapshots the live page and immediately restores the same key, re-swapping the DOM with a clone of itself.
  2. Allow an identical href when it carries a fragment. Misses the 422 Back as soon as the reader has anchored in first, because form.action returns the document URL with its fragment (measured in Chromium, for a missing action attribute and an empty one alike), so the duplicate entry carries #sec too.

Test plan

  • Unit, packages/core/test/routing/router-client.test.js: seven popstate cases plus the eligibleAnchorHref extension. 242/242 in that file. The pair that matters is two cases with byte-identical URLs and opposite expectations, differing only in whether the click was marked.
  • Browser, packages/core/test/routing/browser/fragment-jump.test.js: nine cases, the headline layer, asserting DOM survival rather than only fetch counts, including the duplicate-entry traversal that must still re-render. Plus a case in nav-guard.test.js pinning the narrowed guard.
  • Counterfactuals, each reverted in isolation with the fix committed: removing the popstate guard reds the fragment-click and traversal cases; removing only the recording line reds the reverse-traversal leg; reverting the click bow-out to the hash test reds the bare-# case; and reverting the provenance clause to the fragment-presence rule reds the duplicate-entry browser case and its unit twin.
  • Full browser suite green on all three engines, E2E green against a bundle built from this branch, and the website boot check clean with no broken modulepreloads. Re-running all of them against the final commit.
  • N/A Bun parity: browser-only client code with no runtime-sensitive server surface.
  • N/A smoke: no rendered page changes.

Docs

Two surfaces documented the href="#" trap as behaviour, and this reverses it. Both rewritten: .agents/skills/webjs/references/client-router-and-streaming.md and website/app/docs/client-router/page.ts. AGENTS.md states the rule, provenance clause included. The scaffold copy is synced from the repo root at prepack, so editing the canonical file is the scaffold update.

Clicking an in-page fragment link re-fetched the current URL and re-swapped
the page, so live DOM identity and hydrated component state outside the
anchor were destroyed on what should be a jump the router never touches.
Chromium fires popstate for a same-document fragment navigation and
onPopState treated every popstate as back/forward, so an ordinary anchor
click entered the full navigation pipeline.

A traversal whose URL differs from the current page only by fragment is
same-document by construction: the fragment never reaches the server, both
entries resolve to the same response, and the browser has already performed
the jump. onPopState now absorbs it. The bow-out also records the new URL,
which is load-bearing rather than tidy, since currentPageUrl is otherwise
written only by a completed navigation and a bow-out that skipped it left
the reverse traversal comparing two equal hrefs and re-navigating after all.

The click path had the same defect in a second spelling. Its bow-out tested
URL.hash for truthiness, and the serializer reports both a null fragment and
an empty one as '', so href="#" was intercepted rather than left alone. Both
that line and its mirror in the prefetch eligibility check now test the href
for a '#'. href="" keeps navigating, because it resolves to the current URL
with the fragment removed, which the spec reloads rather than jumps.

Closes #1437
@vivek7405 vivek7405 self-assigned this Aug 20, 2026
The decision is unit-tested, but the behaviour it exists to protect is not
observable in linkedom, which implements no layout, no scrolling and no
history traversal, so every assertion about it would pass vacuously against
the bug.

DOM survival is asserted with an injected node rather than only an expando.
Measured against the bug: an expando on a node the incoming response also
contains survives the re-swap, because the morph reconciles that node in
place and keeps its identity, so an expando alone is a test that passes with
the defect present.

The nav guard needed narrowing to make any of this observable. It cancelled
the default of every anchor click, and preventDefault is exactly what
suppresses a native fragment jump, so the suite could not tell a working
bow-out from a broken one. Its own docstring already said a pure-fragment
link needs no guard, since it never navigates the page away, so this makes
the code match the documented contract. href="" stays guarded, because it
carries no fragment and the spec reloads it.

The nav-guard fixture also restored the URL in teardown without restoring
the router's current-page tracker, so a case inherited whatever url the
previous case had navigated to. Seeded in setup and cleared in teardown.
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Design rationale: why the guard records, and why the test guard had to be narrowed

Two things about this change are not obvious from the diff, and both cost a measurement to find.

The bow-out has to write currentPageUrl, not just return. My first version of recordFragmentTraversal only returned early. It fixed the click and then failed the reverse traversal: clicking #target and pressing Back still issued a fetch and still destroyed the injected node. performNavigation's finally is the only writer of that tracker, so skipping the navigation also skipped the record, the tracker stayed at the pre-click URL, and the Back compared /repro against /repro, read equal hrefs, and re-navigated. Unit case 3 and the Back leg of browser case 4 are the regression tests for exactly that, and they are the two that red when I drop the recording line while keeping the early return.

I checked the other side of the same question before shipping it: everything performNavigation would have done on a path the guard now skips. The snapshot cache keys on pathname + search, so a fragment jump can never need a different entry than the one already there. The restore-generation bump and the anchor and height releases close a window an earlier Back left open, and pointerdown is in ANCHOR_RELEASE_EVENTS, so a real click has already closed it. The in-flight abort() currently cancels a live navigation when the reader clicks a fragment link mid-flight, and not aborting is the better behaviour there. webjs:navigate only drives a prefetch re-scan after a swap, and there is no swap. So nothing is owed.

currentPageUrl can be null, and that is safe. The guard returns false when it is, which reads like a hole: a fragment click on a page that has not navigated yet would fall through and re-fetch. It cannot happen, because enableClientRouter() seeds the tracker from location.href (navigator.js:198) and onPopState is only bound while enabled, so by the time any popstate can reach the guard the tracker is set. Returning false is also the right direction to fail, since it degrades to today's behaviour rather than swallowing a real navigation.

The nav guard was cancelling the thing under test. installNavGuard calls preventDefault() on every anchor click so a lost interception race cannot navigate the web-test-runner session away. That is exactly what suppresses a native fragment jump, so the first run of the new suite had the fetch assertions passing and every scroll assertion failing: the browser never jumped, because the guard had cancelled it. Its own docstring already said a pure-fragment link needs no guard, since it never navigates the page away, so the code was broader than the contract it documented. It now skips a same-document fragment link and keeps guarding everything else, href="" included, which carries no fragment and which the spec reloads.

While in there I found the same fixture restoring the URL in teardown without restoring the router's current-page tracker, so a case inherited whatever URL the previous case had navigated to. That is what made my first nav-guard case fetch when it should not have. Seeded in setup, cleared in teardown, which makes every case in that file deterministic rather than only the new one.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went through this one properly, and it does not hold up on the second click.

The popstate guard requires the two hrefs to DIFFER before it absorbs anything. I had reasoned that two history entries can share a URL exactly and that such a traversal is real, so the guard should leave it alone. That reasoning is wrong in the one direction that matters: a repeat click of the same in-page anchor is a history REPLACE, and it still fires popstate, with location.href identical to what the router already believes is current. I measured it in Chromium, two clicks of one #sec link give two popstates, both at the same href, and history.length never moves. So the second click of a <a href="#">Back to top</a> falls straight through to performNavigation and does exactly what this PR is supposed to stop.

It is worse than a wasted fetch. cacheKey strips the fragment, so the popstate branch snapshots the live page under pathname + search and then immediately restores that same key, which means the router re-swaps the live DOM with a serialized clone of itself. Live node identity gone, components re-upgraded, plus a background revalidation. That is the #1437 symptom, reintroduced on the anchor a reader is most likely to click twice.

The tests do not catch it because every case clicks its link exactly once, and the unit case I wrote for the identical-href popstate asserts it DOES navigate, which pins the bug in rather than finding it.

Two smaller things: the helper I added to the nav guard landed between the doc block and the function it documents, and the traversal sentence went into the skill reference but not the docs site, so the two now disagree about the popstate half.

Fixing all three.

Comment thread packages/core/src/router-client/navigator.js Outdated
Comment thread packages/core/test/routing/router-client.test.js
Comment thread packages/core/test/routing/browser/fragment-jump.test.js
Comment thread test/browser-nav-guard.js
Comment thread AGENTS.md Outdated
The guard required the two hrefs to DIFFER before it absorbed anything, on
the reasoning that two history entries can share a url and that such a
traversal is real. That reasoning was wrong in the direction that matters.

A repeat click of the same in-page anchor REPLACES its history entry rather
than pushing one, and it still fires popstate, arriving with location.href
identical to what the tracker holds. Measured in Chromium: two clicks of one
#sec link give two popstates at the same href with history.length unchanged.
So the second click of a back-to-top link fell through to a full navigation
and did exactly what this fix exists to prevent. The fall-through is also
destructive rather than merely wasteful, since cacheKey strips the fragment,
so the popstate branch snapshots the live page and immediately restores that
same key, re-swapping the DOM with a clone of itself.

Absorbing an identical-href popstate costs nothing. The case the inequality
protected cannot be told apart by the router anyway: both entries key one
snapshot, so falling through never restores the other one.

The unit case asserting the identical-href popstate navigates was pinning
the bug in, and is replaced by one asserting it is absorbed, plus separate
cases proving a changed pathname and a changed search still navigate.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The widening went too far, and this is my error rather than the previous round's.

Requiring the hrefs to differ was wrong, because a repeat anchor click replaces its entry and arrives unchanged. Dropping the requirement outright is also wrong, because a fragmentless popstate between two DISTINCT entries that share a url is a real traversal, and the framework's own no-JS write path produces exactly that pair: a bound form emits no action, so getSubmitAction falls back to location.href (form-encoder.js:33) and a 422 re-render pushes a duplicate entry (fetch-apply.js:311). Back from that used to fall through, hit the cache branch, and let the background revalidation swap the fresh render in. Under this commit it does nothing and the reader has to press Back twice.

The two cases are separable and I should have separated them the first time. A repeat anchor click always carries a #, since that is what it navigated to, and the 422 duplicate entry never does. So the guard absorbs when the pathname and search match AND either the hrefs differ or the destination carries a fragment. That keeps the repeat click fixed and leaves the fragmentless same-url traversal exactly where it was before this PR, which is the conservative side to land on for a case with no report against it.

The deleted unit case was the only thing pinning the fragmentless direction, which is why nothing went red. It comes back asserting it still navigates, alongside the fragment-bearing case asserting it is absorbed.

The stale counterfactual comments are a consequence of the same overreach: under the narrow predicate the recording IS load-bearing again for the Back leg, so those comments become true rather than needing rewriting. I will re-run that counterfactual and confirm rather than assume.

Comment thread packages/core/src/router-client/navigator.js
Comment thread packages/core/src/router-client/navigator.js Outdated
Comment thread packages/core/test/routing/router-client.test.js Outdated
Absorbing every same-path popstate went too far. A fragmentless popstate
between two DISTINCT history entries that share a url is a real traversal,
and the no-JS write path produces exactly that pair: a bound form emits no
action attribute, so getSubmitAction falls back to location.href and the 422
re-render pushes a duplicate entry at the page's own url. Back from a
validation error then did nothing at all, and the reader had to press it
twice to reach the previous page.

The repeat-anchor-click case that motivated the widening is separable from
it, because that click always CARRIES a fragment (it is what the click
navigated to) while the duplicate 422 entry never does. So the guard absorbs
when the hrefs differ, or when they match and the destination carries a
fragment. The fragmentless same-url traversal keeps its pre-#1437 behaviour,
which is the conservative side for a case nothing has reported against.

Restores the unit case pinning that direction, which was the only coverage
of it and whose removal is why the regression was silent. The recording line
is load-bearing again under this predicate, re-verified by counterfactual:
removing it reds the unit case and the Back leg of the browser traversal
case, which is what the comments on both already claim.
Two popstates can arrive carrying the url the reader is already on, and they
need opposite treatment. A repeat click of one in-page anchor replaces its
history entry rather than pushing, so it fires popstate with the url
unchanged and needs no fetch. A Back between two distinct entries sharing a
url is a real traversal that must re-render, and the no-JS write path
produces that pair, since a bound form emits no action attribute and its 422
re-render pushes a duplicate entry at the page's own url.

The urls are identical in both, so no comparison can separate them, which is
what sank the two previous attempts here. Requiring the hrefs to differ
missed the repeat click. Allowing an identical href when it carried a
fragment missed the 422 Back as soon as the reader had anchored in first:
form.action reflects the node document's URL and KEEPS its fragment, measured
in Chromium for a missing action attribute and an empty one alike, so that
duplicate entry carries #sec too.

What separates them is provenance rather than spelling. The router saw the
click it bowed out of and never sees a traversal, so onClick leaves a mark
and the next popstate consumes it. The mark is consumed whether or not it
matched, so it cannot outlive its popstate, and a real navigation or
submission drops it so it cannot leak into the 422 path.

Adds the browser-layer case that pins the traversal direction, which the
change had only at the unit layer, and which is where it belongs since
linkedom drives no history traversal at all.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, and the premise I built the last commit on is false.

I measured form.action in Chromium before changing anything: with no action attribute, and with action="", it returns the document URL WITH its fragment. So a reader who uses an in-page anchor and then submits produces a 422 duplicate entry carrying #sec, and my fragment-presence clause swallowed the Back out of the validation error. That is the same defect the commit claimed to fix, one step further along.

The real lesson is that the urls in these two cases are byte-identical, so no comparison of them can work, and both of my previous rules were guesses at a discriminator that does not exist. What does separate them is provenance: the router SAW the click it bowed out of, and it never sees a traversal. onClick now leaves a mark, the next popstate consumes it, and a real navigation or submission drops it so it cannot leak into the 422 path. A popstate whose url CHANGED still needs no mark, because the pathname and search test already proves it is same-document.

The two unit cases that pin this now have byte-identical urls and opposite expectations, differing only in whether the click was marked, which is the honest shape of the problem.

Also added the browser case for the traversal direction. You were right that it belongs there and not only at the unit layer, since linkedom drives no history traversal at all, so the unit test only ever proved a return value. Reverting the provenance clause to the fragment-presence rule reds that browser case and its unit twin.

And the PR body is rewritten. It was still describing the first commit's rule, which matters because it becomes the squash commit message.

Five gaps around the provenance mark, all reachable.

data-no-router was checked before the fragment bow-out, so a repeat click of
a data-no-router in-page anchor left no mark, arrived with an unchanged url,
and was re-navigated destructively. That attribute opts out of ROUTING, and
the bow-out routes nothing either way, but the browser still performs the
native jump and still fires the popstate that has to be recognised. Moved the
check after the bow-out, which changes nothing else: every other path through
it already returned.

disableClientRouter tore down every other pending piece of router state and
left the mark armed, so one left by a click whose popstate had not fired could
survive a disable and absorb the first same-url popstate after a re-enable.

The clear-on-miss line, and both clearFragmentNav calls, had no counterfactual:
every marked test popped the href it marked, and nothing marked one then
started real work. Reverting any of them left the suite green. Each now has a
test that reds, including one proving a stale mark cannot absorb a later real
traversal.

pendingFragmentNav was an unprefixed test-only export, which this module's own
convention says reads as public API. Renamed to _pendingFragmentNav.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All five are real and all five are fixed.

The data-no-router one is the finding I would have missed longest, because the attribute reads as "the router is not involved here" and the whole point of the mark is that the router IS involved: it saw the click. The browser still jumps and still fires the popstate whatever that attribute says. Moved the check after the bow-out, which changes nothing else, since every other path through it already returned.

The three test gaps were fair and I had not noticed the shape of them: every marked case in the suite popped the href it had marked, so the clear-on-miss line and both clearFragmentNav calls could be deleted with the suite still green. Each now has a case that reds, and the miss one goes further and proves a stale mark cannot absorb a later real traversal, which is the failure the line actually prevents.

The disable leak is the same class as the miss, over a longer window. And _pendingFragmentNav is renamed.

Counterfactuals re-run for the two behavioural ones: putting data-no-router back before the bow-out reds the repeat-click case, and removing the disable clear reds both it and the disable case.

The assertion is pure module state (mark, disable, read), with no DOM, layout
or history traversal in it, so the browser suite was the wrong home and left
the node suite CI's unit gate runs with no coverage of the line at all. The
browser file's own header states that discipline, and disableClientRouter's
sibling teardown obligation for the scroll-anchor window is already a node
test, which is where this one sits now.

Counterfactual re-run at the new layer: removing the clear reds it.
The differing-href branch was absorbing unconditionally, on the reasoning
that same pathname and search proves the two entries resolve to the same
server response. They do, but that does not prove they hold the same DOM,
and a swap in between makes them differ.

The no-JS write path reaches that shape. getSubmitAction prefers the raw
action ATTRIBUTE over form.action, and a raw attribute carries no fragment,
so a bound-submitter form declaring action="/p" pushes its 422 re-render at
/p while the reader sits at /p#sec. Back from that validation error differs
only by fragment, was absorbed, and left the reader on the error DOM with
only the url and scroll changing. The earlier form.action-keeps-its-fragment
measurement holds only on the fallback branch, for a form with no action
attribute, which is why this survived the previous round.

So a popstate the router did not cause is now left alone whatever its url,
and only the click it bowed out of is absorbed.

That deliberately drops the traversal half: an ordinary Back or Forward
between two fragment states re-renders as it does today, rather than being
absorbed. Separating it from the 422 Back needs to know whether the DOM was
replaced between the two ENTRIES, which is per-entry state the router does
not keep, since every pushState here passes null. Turbo tags its entries for
exactly this reason. Swallowing a validation-error Back is strictly worse
than re-rendering one fragment step, so this stops at the click.
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.

dogfood: an in-page fragment click re-fetches and re-swaps the whole page

1 participant