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
Line anchors in this body were re-derived at HEAD e0abcf96 (2026-08-21). The
previous body was written against 776a0ba9 on a branch that has since merged,
and several of its anchors had moved. Corrections are called out inline.
Problem
A forward navigation always scrolls to the top of the page, and an author has no
way to say otherwise. That is the right default and matches every comparable
framework, but there is no per-link escape hatch for the cases where an author
knows better, and every framework WebJs is measured against ships one.
The single scroll-to-top site is packages/core/src/router-client/fetch-apply.js
lines 370 to 387, inside fetchAndApply(...), gated on recordHistory && !frameId:
if(recordHistory&&!frameId){// Use the final URL (after any server-side redirect) so hash// anchors point at the document we actually rendered.consturl=newURL(finalUrl);if(url.hash){constt=document.getElementById(url.hash.slice(1));// A hash anchor is the one nav scroll we DON'T force instant: a// `#section` link is exactly where an app's `scroll-behavior: smooth`// is wanted, and native browsers animate it too.if(t)t.scrollIntoView();else{warnIfSmoothScrollOnHtml();window.scrollTo({left: 0,top: 0,behavior: 'instant'});}}else{// Scroll-to-top on a forward nav. behavior:'instant' so an app-level// `scroll-behavior: smooth` does not animate it (match native nav).warnIfSmoothScrollOnHtml();window.scrollTo({left: 0,top: 0,behavior: 'instant'});}}
There is no author-facing control over it. The router already reads per-link
opt-outs from packages/core/src/router-client/events.js (download at line 25, data-no-router at line 26) and per-link prefetch strategy from packages/core/src/router-client/prefetch.js (prefetchSuppressed line 247, prefetchMode line 285), so the precedent and the plumbing both exist. Scroll is
the one navigation policy with no per-link knob.
Correction 1: the Turbo row in the previous body was wrong
The old body listed Turbo's refreshScroll === "preserve" as a per-link opt-out.
It is not. shouldPreserveScrollPosition(visit) is isPageRefresh(visit) && (visit?.refresh?.scroll || this.snapshot.refreshScroll) === "preserve",
and isPageRefresh requires the same pathname AND visit.action === "replace".
So it applies only to a same-URL refresh visit, driven by a page-level <meta>,
never by a per-link attribute. Turbo has no per-link scroll opt-out at all. Next
and Remix 3 are the two real precedents.
Correction 2: the motivating example does not work, and neither named consumer is correct
The old body and its comment both propose the "back to list" link as the case
this fixes, naming website/app/blog/[slug]/page.ts:75 and :93 ("All posts",
both anchors verified present at HEAD) and gallery/app/features/client-router/second/page.ts:18 ("Back to page one",
anchor verified at HEAD). Marking those is wrong, and the arithmetic says why.
Preserving scroll keeps the reader's CURRENT window offset and carries it onto
the destination. It does not restore the destination's remembered offset. Those
are different features. Walk the blog journey:
Reader is at /blog, scrolled to the fifth post, at offset 1200. They click it.
They read the post and scroll to offset 3000.
They click the footer "All posts" link.
With data-preserve-scroll on that link the reader lands at offset 3000 in the
index, which corresponds to nothing they were looking at. Today they land at 0.
Neither is offset 1200. Clicking the HEADER copy of the same link instead means
the reader is at offset 0 when they click, so preserving 0 and scrolling to 0
produce the identical result and the attribute changes nothing at all. So the
attribute is either a no-op or worse than the default on exactly the link the
issue was filed about.
The gallery link is the same shape, and additionally has no scroll to preserve:
both gallery/app/features/client-router/page.ts and its second/page.ts are
short enough not to scroll on a desktop viewport, so the demo would demonstrate
nothing.
Returning the reader to where they were in the list is a forward-navigation
snapshot restore, keyed on the DESTINATION url. No framework in the comparison
ships that per link, WebJs's snapshot cache is popstate-only today, and the
back/forward path it would have to share was reworked three times in #1310 / #1313 / #1428. It is named under Out of scope below and is not part of
this change.
The feature this issue ships is the standard one, and its real use is a
navigation that changes part of what the reader is already looking at: a filter,
sort, or tab link whose control sits below the fold, a pager, and a form that
re-renders in place with validation errors. WebJs has an extra reason to want it
that the other frameworks do not: a searchParams-only navigation already MORPHS
the deepest shared boundary and preserves hydrated component state (AGENTS.md,
client navigation section). Scrolling to top is then the only thing such a
navigation still throws away.
Correction 3: scroll.js exists and is NOT where the forward scroll lives
The previous body predates the packages/core/src/router-client/scroll.js
module. It exists at HEAD (259 lines) and owns the BACK/FORWARD restore
machinery only: suppressScrollAnchoring (line 70), reserveRestoredHeight
(line 191), restoreGeneration (line 234), afterTwoFrames (line 243), bumpRestoreGeneration (line 257). The forward-nav scroll-to-top did not move
and is still in fetch-apply.js. The new attribute resolver is added to scroll.js (it is scroll policy and the module is a leaf, importing only constants.js), and the scroll WRITE stays in fetch-apply.js.
Correction 4: there is no second, mirrored copy of the skill reference
packages/cli/templates/.agents/skills/webjs/ does not exist in the working
tree. packages/cli/package.json line 10 runs scripts/sync-scaffold-skill.mjs and scripts/sync-scaffold-gallery.mjs at prepack, which copy the canonical repo-root .agents/skills/webjs/ and gallery/ into packages/cli/templates/, and postpack deletes them again.
Both surfaces are single-sourced, so editing the repo-root copy is the whole
edit and there is no mirror to keep in step.
Design / approach
The attribute is data-preserve-scroll, presence-on, ="false" off
Settled against the in-repo family and against Remix 3.
The router already reads two naming shapes. Author-intent knobs an app writes on
its own markup drop the vendor prefix when the term is unambiguous
(data-no-router in events.js:26 and :83, data-prefetch and data-no-prefetch in prefetch.js:248 and :287, data-key in the reconciler). data-webjs-* is reserved for names that mark framework machinery or would
collide with a generic word (data-webjs-frame, data-webjs-permanent, data-webjs-track, data-webjs-src, data-webjs-build). "preserve-scroll" is
unambiguous author intent, so it takes the unprefixed form and joins data-no-router and data-prefetch, which is also where a reader will look for
it.
Named for what is preserved, not for what is disabled. data-no-scroll reads as
"never scroll", which would wrongly imply the hash-anchor scroll is suppressed
too, and the hash branch is explicitly kept (below).
It takes a VALUE, and the only value that means anything is the literal false.
Presence with any other value (including empty) preserves. This is exactly Remix
3's rmx-reset-scroll test at remix/packages/ui/src/runtime/navigation.ts:296
(getAttribute('rmx-reset-scroll') !== 'false'), and it matches the value
vocabulary prefetchMode already accepts here, where false and none are
recognized words (prefetch.js:289-291). Matching is case-insensitive and
trimmed, the same normalization prefetch.js:287 applies.
Answering the question the old body left open: data-preserve-scroll="false"
means "scroll to top", the default. It is not a no-op and it is not an error. It
exists so a single link inside a marked wrapper can opt back out, which the
ancestor walk below makes necessary.
It resolves through closest(), nearest carrier wins
data-no-router is read on the clicked element only (events.js:26). data-webjs-frame walks ancestors (frames.js:66, trigger.closest('[data-webjs-frame]')). This attribute follows data-webjs-frame.
The reason the two precedents differ is the size of the hammer. data-no-router turns the router OFF for a link, and an ancestor silently
disabling soft navigation for a whole subtree is a bigger surprise than it is
worth, so scoping it to the exact element is the conservative call there. This
attribute is a soft preference whose natural authoring unit is a region: a filter
bar, a tab strip, a segmented control, a breadcrumb. Marking the wrapper once
beats repeating the attribute on every link in it, which is the same argument resolveTargetFrameId makes for data-webjs-frame, and the docs site already
teaches that wrapper shape at website/app/docs/client-router/page.ts:122-125.
closest() returns the NEAREST carrier, so the ="false" escape needs no extra
logic: a link carrying data-preserve-scroll="false" inside a <nav data-preserve-scroll> resolves to its own attribute and scrolls to top.
It applies to form submissions too
Remix 3 covers both surfaces from one attribute
(navigation.ts:296 for links, :314 for forms). WebJs's onSubmit already
mirrors onClick for every other policy decision (data-no-router on the form
and on the submitter at events.js:83 and :86, and resolveTargetFrameId(submitter || form) at events.js:136), so a form-only gap
would be the odd one out. It is also the case with the clearest payoff: a long
form that fails validation re-renders at 422 IN PLACE today and then throws the
reader to the top of the page, away from the field that failed.
Resolution reads from submitter || form, one call, matching the frame line
directly above it. closest() from the submitter passes through the form on its
way up, so a marked form covers its own buttons with no second lookup.
Hash links still scroll to their anchor
/blog#section carrying the attribute scrolls to #section. The reader named a
target, and a named target beats a blanket preference.
The mechanism is explicit rather than incidental. preserveScroll suppresses the
two window.scrollTo({ top: 0 }) writes and never the t.scrollIntoView() call,
which includes the case where the hash names an element the response does not
contain (that arm scrolls to top today, and under the attribute it stays put,
which is the same rule applied consistently).
Note the pure same-page hash jump never reaches this code at all: events.js:34 returns early when pathname and search both match and a hash is
present, leaving it to the browser.
On a frame-targeted link the attribute is inert, by construction
recordHistory is not a synonym for forward navigation. A click-driven frame nav
reaches fetchAndApply with recordHistory: true (it advances the URL
deliberately), while loadFrame passes false (navigator.js:251-255). That is
why the scroll block is gated on recordHistory && !frameId rather than recordHistory alone (#1427, commit 1ccb6441 in #1430).
So a frame swap already writes no scroll, and data-preserve-scroll on a
frame-targeted link asks for something already true. Nothing is added for it: no
branch, no warning. A warning was rejected because the ancestor walk makes an
inert hit ordinary rather than suspicious (a marked <nav> above a mixed set of
links is correct authoring), and warning on correct authoring is noise. It is
documented as inert instead.
The programmatic option is navigate(url, { scroll: false })
Next parity, and the reflex a reader arrives with: router.push(url, { scroll: false }), resolved as scroll ?? true at next.js/packages/next/src/client/link.tsx:270. Default is unchanged.
The two spellings differ deliberately, and both prior-art frameworks do the same
thing. Remix 3 has attribute rmx-reset-scroll and internal state resetScroll;
Next has prop scroll and internal routerScroll. An HTML attribute is
presence-shaped, so data-preserve-scroll states the intent positively and needs
no value in the common case, while a JS options bag reads naturally as a boolean
knob on the default behaviour.
One existing quirk the implementer must know and must NOT change: navigate(url, { replace: true }) passes replace into the isPopState slot
(navigator.js:193), which makes recordHistory false, which today ALREADY
suppresses the scroll as a side effect. Since there is no replaceState call
anywhere in packages/core/src (verified by grep), that option also leaves the
URL unchanged. That is a separate latent defect. Do not fix it here and do not
lean on it: { scroll: false } is the supported spelling.
Threading: fetchAndApply's last two positionals become one options bag
fetchAndApply(href, frameId, recordHistory, optimisticState, method, body, signal, token, revalidating, refresh, noPrefetch) at fetch-apply.js:65 is
already eleven positionals, and a twelfth would be unreadable. It has exactly
four call sites, all in navigator.js (lines 251, 607, 646, 765), so the clean
change is cheap.
The last two positionals (refresh, noPrefetch) collapse into a trailing opts object that also carries preserveScroll. The signature goes from eleven
positionals to nine plus a bag, and the three fields that end up in the bag are
exactly the per-navigation POLICY flags, while the nine that stay positional are
the request inputs. performNavigation already carries its own opts bag with refresh in it (navigator.js:426-427), so this becomes a straight pass-through
rather than an unpack-and-repack.
WebJs has no users yet, so a clean signature beats an additive one (AGENTS.md).
Alternatives considered and rejected
A twelfth positional parameter. Rejected: eleven is already past the point
where a call site is readable, and three of the eleven are policy flags that
belong together.
Refactoring the whole signature to a single options object. Rejected: it
would rewrite four call sites in the middle of code reworked three times for #1310 / #1313 / #1428, for a cosmetic gain this change does not need.
Threading the flag through module state in state.js. Rejected: a
per-navigation value in module scope is readable by paths that should not see it
(the background revalidation, a frame self-load), and the file's history is full
of bugs caused by one path inheriting another's state.
Making a searchParams-only navigation preserve scroll by DEFAULT. Tempting,
because the router already morphs rather than replaces for that case. Rejected:
Next, Remix 3 and Turbo all scroll to top on any forward navigation regardless of
whether only the query changed, and a default that differs from all three is a
surprise an author cannot discover. Opt-in keeps the default bit-identical.
One attribute that restores the DESTINATION's remembered offset when the
snapshot cache holds one, and preserves the current offset otherwise. This is
the design that would have made the two originally named consumers work.
Rejected: no comparable framework does it, the behaviour of a single attribute
would then depend on invisible cache state (a cold tab and a warm tab behave
differently on the same click), and it would put a new writer on the scroll path
that #1428 deliberately reduced to a single writer, the browser. See Out of scope.
data-no-scroll as the spelling. Rejected: it reads as "never scroll", which
contradicts the hash-anchor carve-out that is kept.
Presence-only, with no ="false" meaning. Rejected: it makes data-preserve-scroll="false" a silent trap that preserves scroll, and it leaves
the ancestor walk with no per-link escape.
Implementation plan
Cut a worktree first (git worktree add -b fix/preserve-scroll-opt-out ../webjs-preserve-scroll origin/main, then npm run worktree:link inside it). packages/ is plain .js with JSDoc; do not add a .ts file there.
Step 1. Add the resolver to packages/core/src/router-client/scroll.js
Append after bumpRestoreGeneration (currently ends at line 259). scroll.js
imports only constants.js, so nothing here can create an import cycle
(test/architecture/import-cycles.test.mjs asserts the graph holds at exactly
two known components).
/** * Whether this navigation should keep the reader's current scroll offset rather * than scrolling to top (#1436). * * Resolved from `data-preserve-scroll` on the trigger OR the nearest ancestor * carrying it, so one filter bar / tab strip / breadcrumb marks every link in it * at once. That is `resolveTargetFrameId`'s precedent (`frames.js`), not * `data-no-router`'s element-only read: `data-no-router` turns the router OFF * for a link, a big enough hammer that an ancestor doing it silently would * surprise, while this is a soft preference whose natural authoring unit is a * region. * * VALUE-aware, and the only value that means anything is the literal `false`. * That is Remix 3's `rmx-reset-scroll` test * (`remix/packages/ui/src/runtime/navigation.ts`, `!== 'false'`) and the value * vocabulary `prefetchMode` already accepts here. Because `closest()` returns * the NEAREST carrier, `data-preserve-scroll="false"` on one link inside a * marked wrapper opts that link back into the default with no extra logic. * * @param {Element | null} trigger the clicked anchor, or a submitted form's * submitter (falling back to the form). * @returns {boolean} */exportfunctionresolvePreserveScroll(trigger){if(!trigger||!trigger.closest)returnfalse;constcarrier=trigger.closest('[data-preserve-scroll]');if(!carrier)returnfalse;return(carrier.getAttribute('data-preserve-scroll')||'').toLowerCase().trim()!=='false';}
Step 2. Read it on the click path, packages/core/src/router-client/events.js
Add to the imports (line 9 block): import { resolvePreserveScroll } from './scroll.js';
constframeId=resolveTargetFrameId(anchor);// #1436: `data-preserve-scroll` on the anchor or an ancestor keeps the reader// where they are instead of scrolling to top. Read here, beside the other// per-link opt-outs, and carried on the navigation's opts bag. Inert on a// frame-targeted link, which already writes no scroll (#1427).performNavigation(href,false,frameId,{preserveScroll: resolvePreserveScroll(anchor)});
constframeId=resolveTargetFrameId(submitter||form);// Same trigger precedence as the frame line above, and one lookup covers// both: `closest()` from the submitter passes through the form on its way up,// so a marked form covers its own buttons.constpreserveScroll=resolvePreserveScroll(submitter||form);performSubmission(url.href,method,body,frameId,form,{ preserveScroll });
Step 4. Change the fetchAndApply signature, packages/core/src/router-client/fetch-apply.js
In the JSDoc above it, delete the @param {'page' | 'shell'} [refresh] block
(lines 38 to 40) and the @param {boolean} [noPrefetch] block (lines 41 to 44),
and put one opts block in their place, keeping the existing prose for each
field verbatim so nothing is lost:
* @param{{refresh?: 'page'|'shell',noPrefetch?: boolean,preserveScroll?: boolean}}[opts]*Per-navigationPOLICY,asabagratherthanthreemorepositionals.The*nineparametersabovearerequestinputs;thesethreedecidewhatthe*pipelinedoeswiththeresponse,andthelistwasalreadyateleven.**`refresh`isasame-URLin-placerefresh(#1398).Itsuppressesthe*`X-Webjs-Have`headerandpickstheswaptier;see`refreshPage`.**`noPrefetch`neverconsumesaspeculativeentry,whateverthecacheholds*(#1407).Setby`loadFrame`: a`<webjs-frame src>`self-loador`src`*mutationasksforTHISframe's content now, which is a freshness request
*ratherthantheclick-follows-hovershapethewarmcacheserves.**`preserveScroll`keepsthereader's current offset instead of scrolling to
*toponaforwardnavigation(#1436),from`data-preserve-scroll`onthe*linkorform,orfrom`navigate(url, { scroll: false })`.Itsuppressesonly*thescroll-to-topwrites,neverthehash-anchorscroll.
Step 5. Honour it in the scroll block, same file
Lines 370 to 387 currently read as quoted in Problem above. Replace the BODY of
the if (recordHistory && !frameId) block (leaving the long comment at lines
340 to 369 exactly as it is, and appending one paragraph to it) with:
// `preserveScroll` (#1436) suppresses the scroll-to-TOP writes and nothing// else. A hash anchor still wins, because the reader named a target and a// named target beats a blanket preference; that is the one arm below that is// not guarded. The arm where the hash names an element the response does not// contain scrolls to top today and is guarded with the rest, so "preserve"// means one thing on every path.if(recordHistory&&!frameId){// Use the final URL (after any server-side redirect) so hash// anchors point at the document we actually rendered.consturl=newURL(finalUrl);consttarget=url.hash ? document.getElementById(url.hash.slice(1)) : null;if(target){// A hash anchor is the one nav scroll we DON'T force instant: a// `#section` link is exactly where an app's `scroll-behavior: smooth`// is wanted, and native browsers animate it too.target.scrollIntoView();}elseif(!preserveScroll){// Scroll-to-top on a forward nav. behavior:'instant' so an app-level// `scroll-behavior: smooth` does not animate it (match native nav).warnIfSmoothScrollOnHtml();window.scrollTo({left: 0,top: 0,behavior: 'instant'});}}
This flattens the original nested if (url.hash) { if (t) ... else ... } else ...
into if (hash && target) ... else if (!preserveScroll) .... With preserveScroll false the two are behaviour-identical: both call scrollIntoView() exactly when a hash resolves to a live element, and both call warnIfSmoothScrollOnHtml() then scrollTo in every other case. Verify that
claim rather than trusting it, by running nav-scroll-instant.test.js and frame-swap-scroll.test.js unchanged.
Step 6. Update the four call sites, packages/core/src/router-client/navigator.js
Line 251, inside loadFrame. The call currently ends:
/* revalidating */false,/* refresh */undefined,// A self-load never consumes a speculative entry (#1407). .../* noPrefetch */true,);
becomes:
/* revalidating */false,// A self-load never consumes a speculative entry (#1407). Dropping the// blanket `!frameId` guard from the consume check made this path eligible// for the first time, and it should not be: a `src` self-load or a `src`// mutation is the app asking for THIS frame's content now, which is a// freshness request, not the click-follows-hover shape the warm cache// exists to serve. Deliberately out of scope for that change.{noPrefetch: true},);
Keep the existing comment text; only the value it annotates moves into the bag.
Line 607, the background revalidation. It passes nine positionals and stops,
so the tenth is undefined and it needs no edit. Leave it byte for byte. That is
correct on its own terms: a revalidation passes recordHistory: false, so it
never reaches the scroll block at all.
Step 7. Accept the option on performNavigation, same file
Line 413's @param currently reads @param {{ refresh?: 'page' | 'shell' }} [opts]. Widen it and add prose:
* @param{{refresh?: 'page'|'shell',preserveScroll?: boolean}}[opts]*`refresh`marksasame-URLin-placere-render(#1398).Itsuppressesthree*thingsaforwardnavigationdoesandarefreshmustnot: theoutgoing*snapshot,theoptimisticloadingskeleton,andhistoryplusscroll.See*`refreshPage`. `preserveScroll` keeps the reader's current offset on a * forward navigation that WOULD otherwise scroll to top (#1436), from * `data-preserve-scroll` or `navigate(url,{scroll: false})`. The two are * independent: a refresh already writes no scroll, so `preserveScroll` adds*nothingthere.
Line 427 currently reads const refresh = (opts && opts.refresh) || undefined;.
Add directly beneath it:
and gains a @param in the JSDoc above (after the form param at line 713):
* @param{{preserveScroll?: boolean}}[opts]`preserveScroll`keepsthe*reader's offset instead of scrolling to top after the response applies
*(#1436),from`data-preserve-scroll`ontheformoritssubmitter.Thecase*itexistsforisalongformfailingvalidation: the422re-rendersin*place,andscrollingtotopwouldmovethereaderawayfromthefieldthat*failed.
Step 9. Expose it on navigate, same file
Lines 180 to 194 become:
/** * Programmatic navigation (replaces `location.href = url`). * * `scroll: false` is the programmatic twin of `data-preserve-scroll` on a link * (#1436), spelled the way Next spells it on its own programmatic entry * (`router.push(url, { scroll: false })`), which is the reflex a reader arrives * with. Default is `true`, so an omitted option scrolls to top exactly as * before. * * @param {string} url * @param {{ replace?: boolean, scroll?: boolean }} [opts] */exportasyncfunctionnavigate(url,opts){consttarget=newURL(url,location.href);if(target.origin!==location.origin){// Cross-origin: an intentional full-page nav, not a degradation, but it// ends the session in a test just the same, so it rides the same seam.hardNavigate(url);return;}awaitperformNavigation(target.href,opts?.replace??false,null,{preserveScroll: opts?.scroll===false,});}
Read the test as opts?.scroll === false, not as !opts?.scroll: an omitted
option and an explicit true must both keep today's behaviour.
Step 10. Export the resolver from the barrel, packages/core/src/router-client.js
Insert a block between the prefetch.js block (ends line 97) and the snapshot-cache.js block (starts line 98):
Both files, not one. test/repo-health/core-subpath-types.test.mjs reads the
subpath declarations.
Step 12. Rebuild the core browser bundle
packages/core/dist is BUILT and is what @webjsdev/core resolves to for the
in-repo apps, e2e, and Bun. A src-only edit is invisible to them until it is
rebuilt, and a stale dist makes a manual gallery check pass or fail for the
wrong reason:
npm --workspace @webjsdev/core run build:dist
The browser suite imports ../../../src/router-client.js directly, so it does
NOT need the rebuild. Everything else does.
Step 13. First consumers, in the gallery (a SCAFFOLD change)
Invoke the webjs-scaffold-sync skill BEFORE editing gallery/**. The gallery
is bundled into every generated app at prepack
(packages/cli/package.json:10 runs scripts/sync-scaffold-gallery.mjs, which
copies repo-root gallery/ into packages/cli/templates/gallery/; postpack
deletes it again), so a gallery edit ships to every scaffolded app.
Surfaces the skill walks, with what each needs here:
Generator (packages/cli/lib/create.js, copyGallery at line 130):
no change. No new demo directory is added, so the copy filter and GALLERY_APP_SHELL_FILES are untouched.
Template (packages/cli/templates/gallery/): no change and no such
directory in the working tree. It is derived at prepack.
Scaffold tests (test/scaffolds/scaffold-gallery.test.js): no change. It
asserts demo FILES exist (client-router/second/page.ts at line 76, the
feature list at line 31), and no file is added or removed.
Coverage manifest (test/scaffolds/gallery-coverage.json): no change.
It gates @webjsdev/core EXPORTS, and this change adds none. navigate is already classified at line 212 with "demo": "modules/client-router/components/router-controls.ts", which is one
of the files edited below, so the entry stays honest.
Intent parity (test/repo-health/skill-gallery-intent-parity.test.mjs):
no change. It reconciles the SET of demos in gallery/modules/gallery/nav.ts against SKILL.md's primitive table, and no
demo is added or removed.
Generate, boot, check (mandatory, the skill's last step): generate an app
into a temp dir, boot it, and run webjs check from INSIDE it. The generators
emit strings, so an escaping bug only shows in a freshly generated app.
The edits:
gallery/app/features/metadata/page.ts, lines 45 to 49. These three ?topic= links re-render the SAME page with a different query string, which is
the canonical case for this attribute and the one the framework already morphs
rather than replaces. Wrap the list, so one mark covers all three and the
ancestor walk is demonstrated rather than described. Currently:
gallery/app/features/client-router/page.ts, lines 38 to 43. This paragraph
is where the gallery teaches the router's per-link attributes and currently names
only data-no-router. Add the new member:
<pclass="text-muted-foreground text-sm mt-6">Optoutapp-widewith<codeclass="font-mono">{"webjs": {"clientRouter": false}}</code>,orper-linkwith<codeclass="font-mono">data-no-router</code>(useitforauthflowslike<codeclass="font-mono">/logout</code>thatmustresetin-memorystate).Aforwardnavigationscrollstotop;perlink(orperwrappingelement)<codeclass="font-mono">data-preserve-scroll</code>keepsthereaderwheretheyare,forafilterortablinkthatchangesonlypartofwhattheyarelookingat.Ahashlinkstillscrollstoitsanchor,andaframe-targetedlink never scrolledanyway.</p>
gallery/modules/client-router/components/router-controls.ts. Add the
programmatic twin next to the existing navigate() button (lines 37 to 39), and
extend the file's header comment. New button:
Header comment addition, after the existing navigate(url) sentence at line 1:
// `navigate(url, { scroll: false })` is the programmatic twin of// `data-preserve-scroll` on a link: the same soft swap, without the// scroll-to-top. Reach for it after an in-page action that changes the URL but// should not move the reader.
The three gallery pages are short, so the effect is most visible on a narrow
viewport. That is a property of the demo pages, not of the feature.
Step 14. Consumers deliberately NOT changed
website/app/blog/[slug]/page.ts:75 and :93, and gallery/app/features/client-router/second/page.ts:18. Leave all three exactly
as they are, for the arithmetic in Correction 2. Marking them would teach a wrong
idiom in the framework's own showcase, which is the harm the issue was filed
about. Anyone re-reading this issue's history should read Correction 2 before
re-proposing them.
Tests
.claude/hooks/require-tests-with-src.sh blocks a commit that stages packages/*/src with no test alongside, and .claude/hooks/require-docs-with-src.sh blocks one with no doc surface. Both are
satisfied by the work below. .claude/hooks/require-scaffold-with-src.sh is
satisfied by the staged gallery/** and .agents/skills/webjs/** edits.
A new sibling rather than an extension of an existing file. nav-scroll-instant.test.js is scoped to #601 and stubs window.scrollTo to
inspect the CALL SHAPE, which cannot prove a position was preserved. nav-scroll-anchor-restore.test.js is the Back/Forward restore suite and must
not grow a forward-navigation concern. frame-swap-scroll.test.js (#1427) is the
right MODEL to copy: real click, real window.scrollY, installNavGuard, a tall
fixture. Read all three before writing.
Fixture rules carried over from frame-swap-scroll.test.js, each load-bearing:
The page must be TALL and STAY tall across the swap (SPACER = 3000), so the
response carries its own spacer. A swap that shortens the document clamps scrollY to 0 and looks exactly like the defect.
The starting offset must be non-zero and asserted BEFORE the click
(START_Y = 500). A fixture that never scrolled would report 0 afterwards for
the wrong reason.
Every href must keep the page's OWN query string, built from location.href.
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
passes.
The response must repeat the live boundary KEY, or the router degrades to a
full page load and the case asserts scroll behaviour on a navigation that
never applied.
an UNMARKED link still scrolls to top (scrollY === 0), the default proven
unchanged in the same fixture that proves the opt-out
a link carrying data-preserve-scroll leaves scrollY at START_Y
the attribute on an ANCESTOR (<nav data-preserve-scroll>) covers a link
inside it
data-preserve-scroll="false" on a link INSIDE that marked wrapper scrolls to
top (nearest carrier wins)
a link to a different path carrying #wj-target AND data-preserve-scroll
still scrolls to the anchor (assert the window moved to the target's offset,
not that it merely left START_Y)
a marked <form method="post"> submission leaves scrollY at START_Y,
and an unmarked one scrolls to top
navigate(url, { scroll: false }) preserves; bare navigate(url) scrolls to
top
The linkedom harness implements no layout and no scrolling, so window.scrollY
never moves there and any position assertion would pass vacuously. This layer
covers ATTRIBUTE RESOLUTION only, and the file header should say so.
Add _resolvePreserveScroll to the destructured import list (the block at lines
88 to 135), then add tests beside the existing resolveTargetFrameId group
(lines 380 to 449), reusing that group's frameFixture helper shape:
absent attribute, false
present with an empty value, true
="true", true
="false", false
="FALSE" and =" false ", both false (case-insensitive, trimmed, matching prefetchMode's normalization)
on an ANCESTOR of the trigger, true
="false" on a link inside a marked wrapper, false (nearest carrier wins)
a null trigger, false, no throw
Add one more beside them asserting the wiring rather than the resolver: navigate('/x', { scroll: false }) resolves without throwing and still fetches /x, using the file's existing fetch-stub pattern. That catches a typo in the
option name, which the browser layer would also catch but more slowly.
Run: node --test packages/core/test/routing/router-client.test.js, then the
full npm test.
e2e: not applicable, and why
No new file under test/e2e/. The change ships no server-side code: the
attribute is inert HTML the server passes through untouched, and there is no
header, no route, and no render path involved. The browser suite already exercises
the real click, the real router, and a real window.scrollY across Chromium,
Firefox and WebKit, which is strictly more coverage of the thing that can break
than a single-engine Playwright run would add.
Two existing e2e files must stay green as regression checks and must not be
edited: test/e2e/nested-layout-partial-swap.test.mjs (asserts the sidenav scrollTop survives a navigation, lines 90 to 126) and test/e2e/form-submission-and-race.test.mjs. Run with WEBJS_E2E=1 AFTER the dist rebuild in Step 12, or they test the old bundle.
Smoke: not applicable
test/examples/blog/smoke/ covers examples/blog, which this change does not
touch. website/ is not touched either, beyond its docs page (Step 15 below),
and test/repo-health/site-pages-well-formed.test.mjs covers that.
Bun parity: does not apply
The verdict, stated explicitly. Every file changed here is browser-only client
router code that never executes under a server runtime, and none of it is on
AGENTS.md's runtime-sensitive list (the serializer, the node:http versus Bun.serve listener and request path, SSR / action / CSRF dispatch, streams, node:crypto, the TS stripper, auth / session / cors). No test/bun/** file is
added.
.claude/hooks/require-bun-parity-with-runtime-src.sh will not fire either: its
filename regex matches serialize|/json\.js|file-storage|listener|ts-strip|action|render-server|/ssr[./]|conditional-get|websocket|node-version|csrf|/auth\.js|/session\.js|/cors\.js|crypto|compression|body-limit|/dev[./]|stream,
and none of events.js, navigator.js, fetch-apply.js, scroll.js, or router-client.js matches any of those. If a later edit widens the change into a
file that does match, WEBJS_BUN_VERIFIED=1 is the named escape hatch, but the
correct answer is to re-check whether the change really is browser-only.
Convention validation
webjs check refuses to run from the workspace root (#1301), so run it from
inside each app: ( cd gallery && npx webjs check ), ( cd website && npx webjs check ), ( cd examples/blog && npx webjs check ).
Run webjs doctor in gallery and website too, since the required conventions CI job runs it over all three and gates on each app's webjs.doctor.gate.
Docs
Invoke the webjs-doc-sync skill. This adds new author-facing API surface (an
HTML attribute plus an option on an exported function), which is exactly the
change type that skill exists for, and it carries the authoritative map so no
surface is silently skipped. .claude/hooks/require-docs-with-src.sh blocks the
commit until at least one doc surface is staged, and WEBJS_NO_DOC_GATE=1 is NOT
justified here.
Five surfaces, all of them real.
1. .agents/skills/webjs/references/client-router-and-streaming.md. The
canonical agent-facing reference, and the ONLY copy (see Correction 4).
After the data-no-router paragraph at line 45, add a paragraph for data-preserve-scroll: what it does, that it resolves through closest() so a
wrapper covers a region, that ="false" is the per-link escape, that a hash
link still scrolls to its anchor, that it is inert on a frame-targeted link
because a frame swap never scrolls, and that it is inert with JS off.
In the programmatic block at lines 49 to 55, add await navigate('/products?sort=new', { scroll: false }); with the same
one-line comment style the neighbours use.
In the scroll prose that begins at line 73, add one sentence separating the two
concerns: the Back/Forward restore is the BROWSER's and has no per-link knob,
while the forward-nav scroll-to-top is the router's own write and this is its
knob.
2. website/app/docs/client-router/page.ts. Three edits.
A new <h3> after the scroll paragraph at line 194 (inside the
"Snapshot cache + back/forward" section, where the scroll prose already lives),
titled Preserving scroll on a forward navigation (data-preserve-scroll), with
a <code-block> showing the link form, the wrapper form, the ="false"
escape, and the hash carve-out.
A line in the "Opt-out per link / form" section at lines 268 to 277 pointing at
it, so a reader who lands on the opt-out heading finds the scroll knob.
The navigate option in "Programmatic navigation" at line 246.
The site is markup in a template literal, so test/repo-health/site-pages-well-formed.test.mjs
gates the edit. /llms.txt and the per-page /docs/client-router/llms.txt are
generated from this page, so they need no separate edit.
3. .agents/skills/webjs/references/muscle-memory-gotchas.md. Worth naming,
and the section already exists: "No <ScrollRestoration>, and no scroll restore
of your own" at lines 211 to 217. That section currently tells a Next or Remix
reader that every scroll reflex they have is a thing to NOT port, which after
this change is one sentence too absolute. Add a short paragraph: Next's <Link scroll={false}> IS the one scroll reflex that ports, as data-preserve-scroll on the link (or a wrapper) and navigate(url, { scroll: false }) programmatically. Everything else in that
section stands unchanged, and say so, so the paragraph does not read as a
loosening of the "do not hand-roll a restore" rule.
4. AGENTS.md, the "Client navigation" section. Line 420 is the router
paragraph and line 422 lists the advanced surface with its attribute family. Add data-preserve-scroll to line 422's list beside data-prefetch and data-webjs-permanent, in one clause, since that line is an index into the
reference rather than the explanation itself.
5. The gallery, in Step 13 above. The scaffold gallery is a teaching surface
in its own right, and its client-router page is where an agent reads the router's
per-link attributes.
Not changed and not needed: blog/client-router-turbo-drive-style.md and blog/works-without-javascript.md both mention data-no-router, but a published
post is a dated record and is not retro-edited. README.md needs nothing: this
is not a headline capability. CONVENTIONS.md needs nothing: no new convention. test/docs/doc-source-consistency.test.mjs gates named imports from @webjsdev/core in doc fences, and navigate already exists, so no fence breaks.
Acceptance criteria
A link carrying data-preserve-scroll keeps the reader's scroll offset
across a forward navigation, proven with a real window.scrollY in
Chromium, Firefox and WebKit
A link WITHOUT the attribute still scrolls to top, asserted in the same
fixture, so the default is proven bit-identical
The attribute on an ancestor covers every link inside it
data-preserve-scroll="false" on a link inside a marked wrapper scrolls to
top
A hash link carrying the attribute still scrolls to its anchor
A marked <form> submission preserves the offset, and an unmarked one
still scrolls to top
navigate(url, { scroll: false }) preserves the offset, and navigate(url)
with no options still scrolls to top
Each counterfactual listed under Tests reds the case it names when applied,
and the new test file's header records them
fetchAndApply has nine positional parameters plus one opts bag, and all
four call sites in navigator.js are updated
resolvePreserveScroll is exported from the barrel as _resolvePreserveScroll, and test/architecture/import-cycles.test.mjs
and barrel-surface.test.mjs are green
Both navigate declarations (packages/core/index.d.ts and packages/core/src/router-client.d.ts) accept scroll, and webjs typecheck passes
packages/core/dist is rebuilt before e2e and before any manual app check
The gallery consumers ship with a comment saying why the attribute is
there, the webjs-scaffold-sync skill was invoked, and a freshly generated
app boots and passes webjs check
webjs check and webjs doctor are clean in gallery, website and examples/blog
All five doc surfaces updated, via the webjs-doc-sync skill
npm test, npm run test:browser, and WEBJS_E2E=1 e2e all green
A forward-navigation snapshot restore ("back to list returns me to the
entry I was reading"). That is the feature the old body's blog example actually
described, it is a different mechanism keyed on the DESTINATION url, and it
would add a second writer to the scroll path that dogfood: iOS back-swipe still blank after #1410; A/B the snapshot timing on-device #1428 deliberately reduced to
one. Do not fold it in here.
Marking the blog or the second/page.ts gallery link. See Correction 2 and
Step 14. Leaving them alone is a decision, not an omission.
Fixing navigate(url, { replace: true }). It passes replace into the isPopState slot and there is no replaceState call anywhere in packages/core/src, so it currently records no history entry, leaves the URL
unchanged, and suppresses the scroll as a side effect. Real, adjacent, and a
separate change with its own history semantics. Do not "tidy" it while
threading the new flag.
Changing the DEFAULT for a searchParams-only navigation. Rejected in
Design. The default stays scroll-to-top for every forward navigation.
Refactoring fetchAndApply's remaining nine positionals into the options
bag. The two that move are the ones that must, to make room; the rest stay.
Any warning, dev hint, or webjs check rule for the attribute appearing
where it is inert (a frame-targeted link, a page with JS off). The ancestor
walk makes an inert hit ordinary rather than suspicious.
Problem
A forward navigation always scrolls to the top of the page, and an author has no
way to say otherwise. That is the right default and matches every comparable
framework, but there is no per-link escape hatch for the cases where an author
knows better, and every framework WebJs is measured against ships one.
The single scroll-to-top site is
packages/core/src/router-client/fetch-apply.jslines 370 to 387, inside
fetchAndApply(...), gated onrecordHistory && !frameId:There is no author-facing control over it. The router already reads per-link
opt-outs from
packages/core/src/router-client/events.js(downloadat line 25,data-no-routerat line 26) and per-link prefetch strategy frompackages/core/src/router-client/prefetch.js(prefetchSuppressedline 247,prefetchModeline 285), so the precedent and the plumbing both exist. Scroll isthe one navigation policy with no per-link knob.
Prior art, verified by reading each source:
<Link scroll={false}>,router.push(url, { scroll: false })scroll ?? truenext.js/packages/next/src/client/link.tsx:56and:270rmx-reset-scroll="false", on links AND formsgetAttribute(...) !== 'false'remix/packages/ui/src/runtime/navigation.ts:296(links) and:314(forms)<meta name="turbo-refresh-scroll" content="preserve">turbo/src/core/drive/page_view.js:62-64,turbo/src/core/drive/page_snapshot.js:94Correction 1: the Turbo row in the previous body was wrong
The old body listed Turbo's
refreshScroll === "preserve"as a per-link opt-out.It is not.
shouldPreserveScrollPosition(visit)isisPageRefresh(visit) && (visit?.refresh?.scroll || this.snapshot.refreshScroll) === "preserve",and
isPageRefreshrequires the same pathname ANDvisit.action === "replace".So it applies only to a same-URL refresh visit, driven by a page-level
<meta>,never by a per-link attribute. Turbo has no per-link scroll opt-out at all. Next
and Remix 3 are the two real precedents.
Correction 2: the motivating example does not work, and neither named consumer is correct
The old body and its comment both propose the "back to list" link as the case
this fixes, naming
website/app/blog/[slug]/page.ts:75and:93("All posts",both anchors verified present at HEAD) and
gallery/app/features/client-router/second/page.ts:18("Back to page one",anchor verified at HEAD). Marking those is wrong, and the arithmetic says why.
Preserving scroll keeps the reader's CURRENT window offset and carries it onto
the destination. It does not restore the destination's remembered offset. Those
are different features. Walk the blog journey:
/blog, scrolled to the fifth post, at offset 1200. They click it.With
data-preserve-scrollon that link the reader lands at offset 3000 in theindex, which corresponds to nothing they were looking at. Today they land at 0.
Neither is offset 1200. Clicking the HEADER copy of the same link instead means
the reader is at offset 0 when they click, so preserving 0 and scrolling to 0
produce the identical result and the attribute changes nothing at all. So the
attribute is either a no-op or worse than the default on exactly the link the
issue was filed about.
The gallery link is the same shape, and additionally has no scroll to preserve:
both
gallery/app/features/client-router/page.tsand itssecond/page.tsareshort enough not to scroll on a desktop viewport, so the demo would demonstrate
nothing.
Returning the reader to where they were in the list is a forward-navigation
snapshot restore, keyed on the DESTINATION url. No framework in the comparison
ships that per link, WebJs's snapshot cache is popstate-only today, and the
back/forward path it would have to share was reworked three times in
#1310 / #1313 / #1428. It is named under Out of scope below and is not part of
this change.
The feature this issue ships is the standard one, and its real use is a
navigation that changes part of what the reader is already looking at: a filter,
sort, or tab link whose control sits below the fold, a pager, and a form that
re-renders in place with validation errors. WebJs has an extra reason to want it
that the other frameworks do not: a searchParams-only navigation already MORPHS
the deepest shared boundary and preserves hydrated component state (AGENTS.md,
client navigation section). Scrolling to top is then the only thing such a
navigation still throws away.
Correction 3:
scroll.jsexists and is NOT where the forward scroll livesThe previous body predates the
packages/core/src/router-client/scroll.jsmodule. It exists at HEAD (259 lines) and owns the BACK/FORWARD restore
machinery only:
suppressScrollAnchoring(line 70),reserveRestoredHeight(line 191),
restoreGeneration(line 234),afterTwoFrames(line 243),bumpRestoreGeneration(line 257). The forward-nav scroll-to-top did not moveand is still in
fetch-apply.js. The new attribute resolver is added toscroll.js(it is scroll policy and the module is a leaf, importing onlyconstants.js), and the scroll WRITE stays infetch-apply.js.Correction 4: there is no second, mirrored copy of the skill reference
packages/cli/templates/.agents/skills/webjs/does not exist in the workingtree.
packages/cli/package.jsonline 10 runsscripts/sync-scaffold-skill.mjsandscripts/sync-scaffold-gallery.mjsatprepack, which copy the canonical repo-root.agents/skills/webjs/andgallery/intopackages/cli/templates/, andpostpackdeletes them again.Both surfaces are single-sourced, so editing the repo-root copy is the whole
edit and there is no mirror to keep in step.
Design / approach
The attribute is
data-preserve-scroll, presence-on,="false"offSettled against the in-repo family and against Remix 3.
The router already reads two naming shapes. Author-intent knobs an app writes on
its own markup drop the vendor prefix when the term is unambiguous
(
data-no-routerinevents.js:26and:83,data-prefetchanddata-no-prefetchinprefetch.js:248and:287,data-keyin the reconciler).data-webjs-*is reserved for names that mark framework machinery or wouldcollide with a generic word (
data-webjs-frame,data-webjs-permanent,data-webjs-track,data-webjs-src,data-webjs-build). "preserve-scroll" isunambiguous author intent, so it takes the unprefixed form and joins
data-no-routeranddata-prefetch, which is also where a reader will look forit.
Named for what is preserved, not for what is disabled.
data-no-scrollreads as"never scroll", which would wrongly imply the hash-anchor scroll is suppressed
too, and the hash branch is explicitly kept (below).
It takes a VALUE, and the only value that means anything is the literal
false.Presence with any other value (including empty) preserves. This is exactly Remix
3's
rmx-reset-scrolltest atremix/packages/ui/src/runtime/navigation.ts:296(
getAttribute('rmx-reset-scroll') !== 'false'), and it matches the valuevocabulary
prefetchModealready accepts here, wherefalseandnonearerecognized words (
prefetch.js:289-291). Matching is case-insensitive andtrimmed, the same normalization
prefetch.js:287applies.Answering the question the old body left open:
data-preserve-scroll="false"means "scroll to top", the default. It is not a no-op and it is not an error. It
exists so a single link inside a marked wrapper can opt back out, which the
ancestor walk below makes necessary.
It resolves through
closest(), nearest carrier winsdata-no-routeris read on the clicked element only (events.js:26).data-webjs-framewalks ancestors (frames.js:66,trigger.closest('[data-webjs-frame]')). This attribute followsdata-webjs-frame.The reason the two precedents differ is the size of the hammer.
data-no-routerturns the router OFF for a link, and an ancestor silentlydisabling soft navigation for a whole subtree is a bigger surprise than it is
worth, so scoping it to the exact element is the conservative call there. This
attribute is a soft preference whose natural authoring unit is a region: a filter
bar, a tab strip, a segmented control, a breadcrumb. Marking the wrapper once
beats repeating the attribute on every link in it, which is the same argument
resolveTargetFrameIdmakes fordata-webjs-frame, and the docs site alreadyteaches that wrapper shape at
website/app/docs/client-router/page.ts:122-125.closest()returns the NEAREST carrier, so the="false"escape needs no extralogic: a link carrying
data-preserve-scroll="false"inside a<nav data-preserve-scroll>resolves to its own attribute and scrolls to top.It applies to form submissions too
Remix 3 covers both surfaces from one attribute
(
navigation.ts:296for links,:314for forms). WebJs'sonSubmitalreadymirrors
onClickfor every other policy decision (data-no-routeron the formand on the submitter at
events.js:83and:86, andresolveTargetFrameId(submitter || form)atevents.js:136), so a form-only gapwould be the odd one out. It is also the case with the clearest payoff: a long
form that fails validation re-renders at 422 IN PLACE today and then throws the
reader to the top of the page, away from the field that failed.
Resolution reads from
submitter || form, one call, matching the frame linedirectly above it.
closest()from the submitter passes through the form on itsway up, so a marked form covers its own buttons with no second lookup.
Hash links still scroll to their anchor
/blog#sectioncarrying the attribute scrolls to#section. The reader named atarget, and a named target beats a blanket preference.
The mechanism is explicit rather than incidental.
preserveScrollsuppresses thetwo
window.scrollTo({ top: 0 })writes and never thet.scrollIntoView()call,which includes the case where the hash names an element the response does not
contain (that arm scrolls to top today, and under the attribute it stays put,
which is the same rule applied consistently).
Note the pure same-page hash jump never reaches this code at all:
events.js:34returns early when pathname and search both match and a hash ispresent, leaving it to the browser.
On a frame-targeted link the attribute is inert, by construction
recordHistoryis not a synonym for forward navigation. A click-driven frame navreaches
fetchAndApplywithrecordHistory: true(it advances the URLdeliberately), while
loadFramepassesfalse(navigator.js:251-255). That iswhy the scroll block is gated on
recordHistory && !frameIdrather thanrecordHistoryalone (#1427, commit1ccb6441in #1430).So a frame swap already writes no scroll, and
data-preserve-scrollon aframe-targeted link asks for something already true. Nothing is added for it: no
branch, no warning. A warning was rejected because the ancestor walk makes an
inert hit ordinary rather than suspicious (a marked
<nav>above a mixed set oflinks is correct authoring), and warning on correct authoring is noise. It is
documented as inert instead.
The programmatic option is
navigate(url, { scroll: false })Next parity, and the reflex a reader arrives with:
router.push(url, { scroll: false }), resolved asscroll ?? trueatnext.js/packages/next/src/client/link.tsx:270. Default is unchanged.The two spellings differ deliberately, and both prior-art frameworks do the same
thing. Remix 3 has attribute
rmx-reset-scrolland internal stateresetScroll;Next has prop
scrolland internalrouterScroll. An HTML attribute ispresence-shaped, so
data-preserve-scrollstates the intent positively and needsno value in the common case, while a JS options bag reads naturally as a boolean
knob on the default behaviour.
One existing quirk the implementer must know and must NOT change:
navigate(url, { replace: true })passesreplaceinto theisPopStateslot(
navigator.js:193), which makesrecordHistoryfalse, which today ALREADYsuppresses the scroll as a side effect. Since there is no
replaceStatecallanywhere in
packages/core/src(verified by grep), that option also leaves theURL unchanged. That is a separate latent defect. Do not fix it here and do not
lean on it:
{ scroll: false }is the supported spelling.Threading:
fetchAndApply's last two positionals become one options bagfetchAndApply(href, frameId, recordHistory, optimisticState, method, body, signal, token, revalidating, refresh, noPrefetch)atfetch-apply.js:65isalready eleven positionals, and a twelfth would be unreadable. It has exactly
four call sites, all in
navigator.js(lines 251, 607, 646, 765), so the cleanchange is cheap.
The last two positionals (
refresh,noPrefetch) collapse into a trailingoptsobject that also carriespreserveScroll. The signature goes from elevenpositionals to nine plus a bag, and the three fields that end up in the bag are
exactly the per-navigation POLICY flags, while the nine that stay positional are
the request inputs.
performNavigationalready carries its ownoptsbag withrefreshin it (navigator.js:426-427), so this becomes a straight pass-throughrather than an unpack-and-repack.
WebJs has no users yet, so a clean signature beats an additive one (AGENTS.md).
Alternatives considered and rejected
A twelfth positional parameter. Rejected: eleven is already past the point
where a call site is readable, and three of the eleven are policy flags that
belong together.
Refactoring the whole signature to a single options object. Rejected: it
would rewrite four call sites in the middle of code reworked three times for
#1310 / #1313 / #1428, for a cosmetic gain this change does not need.
Threading the flag through module state in
state.js. Rejected: aper-navigation value in module scope is readable by paths that should not see it
(the background revalidation, a frame self-load), and the file's history is full
of bugs caused by one path inheriting another's state.
Making a searchParams-only navigation preserve scroll by DEFAULT. Tempting,
because the router already morphs rather than replaces for that case. Rejected:
Next, Remix 3 and Turbo all scroll to top on any forward navigation regardless of
whether only the query changed, and a default that differs from all three is a
surprise an author cannot discover. Opt-in keeps the default bit-identical.
One attribute that restores the DESTINATION's remembered offset when the
snapshot cache holds one, and preserves the current offset otherwise. This is
the design that would have made the two originally named consumers work.
Rejected: no comparable framework does it, the behaviour of a single attribute
would then depend on invisible cache state (a cold tab and a warm tab behave
differently on the same click), and it would put a new writer on the scroll path
that #1428 deliberately reduced to a single writer, the browser. See Out of scope.
data-no-scrollas the spelling. Rejected: it reads as "never scroll", whichcontradicts the hash-anchor carve-out that is kept.
Presence-only, with no
="false"meaning. Rejected: it makesdata-preserve-scroll="false"a silent trap that preserves scroll, and it leavesthe ancestor walk with no per-link escape.
Implementation plan
Cut a worktree first (
git worktree add -b fix/preserve-scroll-opt-out ../webjs-preserve-scroll origin/main, thennpm run worktree:linkinside it).packages/is plain.jswith JSDoc; do not add a.tsfile there.Step 1. Add the resolver to
packages/core/src/router-client/scroll.jsAppend after
bumpRestoreGeneration(currently ends at line 259).scroll.jsimports only
constants.js, so nothing here can create an import cycle(
test/architecture/import-cycles.test.mjsasserts the graph holds at exactlytwo known components).
Step 2. Read it on the click path,
packages/core/src/router-client/events.jsAdd to the imports (line 9 block):
import { resolvePreserveScroll } from './scroll.js';Replace lines 42 to 43, which currently read:
with:
Step 3. Read it on the submit path, same file
Replace lines 136 to 137, which currently read:
with:
Step 4. Change the
fetchAndApplysignature,packages/core/src/router-client/fetch-apply.jsLine 65 currently reads:
Replace with:
In the JSDoc above it, delete the
@param {'page' | 'shell'} [refresh]block(lines 38 to 40) and the
@param {boolean} [noPrefetch]block (lines 41 to 44),and put one
optsblock in their place, keeping the existing prose for eachfield verbatim so nothing is lost:
Step 5. Honour it in the scroll block, same file
Lines 370 to 387 currently read as quoted in Problem above. Replace the BODY of
the
if (recordHistory && !frameId)block (leaving the long comment at lines340 to 369 exactly as it is, and appending one paragraph to it) with:
This flattens the original nested
if (url.hash) { if (t) ... else ... } else ...into
if (hash && target) ... else if (!preserveScroll) .... WithpreserveScrollfalse the two are behaviour-identical: both callscrollIntoView()exactly when a hash resolves to a live element, and both callwarnIfSmoothScrollOnHtml()thenscrollToin every other case. Verify thatclaim rather than trusting it, by running
nav-scroll-instant.test.jsandframe-swap-scroll.test.jsunchanged.Step 6. Update the four call sites,
packages/core/src/router-client/navigator.jsLine 251, inside
loadFrame. The call currently ends:becomes:
Keep the existing comment text; only the value it annotates moves into the bag.
Line 607, the background revalidation. It passes nine positionals and stops,
so the tenth is
undefinedand it needs no edit. Leave it byte for byte. That iscorrect on its own terms: a revalidation passes
recordHistory: false, so itnever reaches the scroll block at all.
Line 646, the foreground navigation. Currently:
becomes:
Line 765,
performSubmission. The call currently passes eight positionals andomits
revalidating. Fill it in explicitly and add the bag:Step 7. Accept the option on
performNavigation, same fileLine 413's
@paramcurrently reads@param {{ refresh?: 'page' | 'shell' }} [opts]. Widen it and add prose:Line 427 currently reads
const refresh = (opts && opts.refresh) || undefined;.Add directly beneath it:
Step 8. Accept the option on
performSubmission, same fileLine 715 currently reads:
becomes:
and gains a
@paramin the JSDoc above (after theformparam at line 713):Step 9. Expose it on
navigate, same fileLines 180 to 194 become:
Read the test as
opts?.scroll === false, not as!opts?.scroll: an omittedoption and an explicit
truemust both keep today's behaviour.Step 10. Export the resolver from the barrel,
packages/core/src/router-client.jsInsert a block between the
prefetch.jsblock (ends line 97) and thesnapshot-cache.jsblock (starts line 98):test/architecture/barrel-surface.test.mjsasserts a FLOOR of 69 exports forthis barrel, so adding one is fine and no number needs changing.
Step 11. Widen the two type declarations
packages/core/index.d.ts:91andpackages/core/src/router-client.d.ts:5bothcurrently read:
Both become:
Both files, not one.
test/repo-health/core-subpath-types.test.mjsreads thesubpath declarations.
Step 12. Rebuild the core browser bundle
packages/core/distis BUILT and is what@webjsdev/coreresolves to for thein-repo apps, e2e, and Bun. A
src-only edit is invisible to them until it isrebuilt, and a stale
distmakes a manual gallery check pass or fail for thewrong reason:
The browser suite imports
../../../src/router-client.jsdirectly, so it doesNOT need the rebuild. Everything else does.
Step 13. First consumers, in the gallery (a SCAFFOLD change)
Invoke the
webjs-scaffold-syncskill BEFORE editinggallery/**. The galleryis bundled into every generated app at
prepack(
packages/cli/package.json:10runsscripts/sync-scaffold-gallery.mjs, whichcopies repo-root
gallery/intopackages/cli/templates/gallery/;postpackdeletes it again), so a gallery edit ships to every scaffolded app.
Surfaces the skill walks, with what each needs here:
packages/cli/lib/create.js,copyGalleryat line 130):no change. No new demo directory is added, so the copy filter and
GALLERY_APP_SHELL_FILESare untouched.packages/cli/templates/gallery/): no change and no suchdirectory in the working tree. It is derived at
prepack.test/scaffolds/scaffold-gallery.test.js): no change. Itasserts demo FILES exist (
client-router/second/page.tsat line 76, thefeature list at line 31), and no file is added or removed.
test/scaffolds/gallery-coverage.json): no change.It gates
@webjsdev/coreEXPORTS, and this change adds none.navigateis already classified at line 212 with"demo": "modules/client-router/components/router-controls.ts", which is oneof the files edited below, so the entry stays honest.
test/repo-health/skill-gallery-intent-parity.test.mjs):no change. It reconciles the SET of demos in
gallery/modules/gallery/nav.tsagainst SKILL.md's primitive table, and nodemo is added or removed.
into a temp dir, boot it, and run
webjs checkfrom INSIDE it. The generatorsemit strings, so an escaping bug only shows in a freshly generated app.
The edits:
gallery/app/features/metadata/page.ts, lines 45 to 49. These three?topic=links re-render the SAME page with a different query string, which isthe canonical case for this attribute and the one the framework already morphs
rather than replaces. Wrap the list, so one mark covers all three and the
ancestor walk is demonstrated rather than described. Currently:
becomes:
<!-- data-preserve-scroll keeps the reader's scroll offset instead of jumping to the top on click. These links change only the query string of the page you are already on, so the control you just used would otherwise scroll out from under you. It sits on the <ul> and the router resolves it with closest(), so one mark covers every link inside; a single link can opt back out with data-preserve-scroll="false". --> <ul class="list-disc pl-5 mb-4" data-preserve-scroll> <li><a class="text-primary underline underline-offset-2" href="/features/metadata?topic=webjs">?topic=webjs</a></li> <li><a class="text-primary underline underline-offset-2" href="/features/metadata?topic=Routing">?topic=Routing</a></li> <li><a class="text-primary underline underline-offset-2" href="/features/metadata">clear the param</a></li> </ul>gallery/app/features/client-router/page.ts, lines 38 to 43. This paragraphis where the gallery teaches the router's per-link attributes and currently names
only
data-no-router. Add the new member:gallery/modules/client-router/components/router-controls.ts. Add theprogrammatic twin next to the existing
navigate()button (lines 37 to 39), andextend the file's header comment. New button:
Header comment addition, after the existing
navigate(url)sentence at line 1:The three gallery pages are short, so the effect is most visible on a narrow
viewport. That is a property of the demo pages, not of the feature.
Step 14. Consumers deliberately NOT changed
website/app/blog/[slug]/page.ts:75and:93, andgallery/app/features/client-router/second/page.ts:18. Leave all three exactlyas they are, for the arithmetic in Correction 2. Marking them would teach a wrong
idiom in the framework's own showcase, which is the harm the issue was filed
about. Anyone re-reading this issue's history should read Correction 2 before
re-proposing them.
Tests
.claude/hooks/require-tests-with-src.shblocks a commit that stagespackages/*/srcwith no test alongside, and.claude/hooks/require-docs-with-src.shblocks one with no doc surface. Both aresatisfied by the work below.
.claude/hooks/require-scaffold-with-src.shissatisfied by the staged
gallery/**and.agents/skills/webjs/**edits.Browser (the headline layer): new file
packages/core/test/routing/browser/nav-preserve-scroll.test.js.A new sibling rather than an extension of an existing file.
nav-scroll-instant.test.jsis scoped to #601 and stubswindow.scrollTotoinspect the CALL SHAPE, which cannot prove a position was preserved.
nav-scroll-anchor-restore.test.jsis the Back/Forward restore suite and mustnot grow a forward-navigation concern.
frame-swap-scroll.test.js(#1427) is theright MODEL to copy: real click, real
window.scrollY,installNavGuard, a tallfixture. Read all three before writing.
Fixture rules carried over from
frame-swap-scroll.test.js, each load-bearing:SPACER = 3000), so theresponse carries its own spacer. A swap that shortens the document clamps
scrollYto 0 and looks exactly like the defect.(
START_Y = 500). A fixture that never scrolled would report 0 afterwards forthe wrong reason.
location.href.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
passes.
full page load and the case asserts scroll behaviour on a navigation that
never applied.
document.documentElement.style.scrollBehaviorforced off, since theassertions are about position and a smooth scroll would not have landed (dogfood: nav scroll restoration animates under
scroll-behavior: smooth#601).Cases:
scrollY === 0), the default provenunchanged in the same fixture that proves the opt-out
data-preserve-scrollleavesscrollYatSTART_Y<nav data-preserve-scroll>) covers a linkinside it
data-preserve-scroll="false"on a link INSIDE that marked wrapper scrolls totop (nearest carrier wins)
#wj-targetANDdata-preserve-scrollstill scrolls to the anchor (assert the window moved to the target's offset,
not that it merely left
START_Y)<form method="post">submission leavesscrollYatSTART_Y,and an unmarked one scrolls to top
navigate(url, { scroll: false })preserves; barenavigate(url)scrolls totop
data-preserve-scrolllink that TARGETS a frame leaves the offset alone andswaps only the frame, which is the pre-existing dogfood: a <webjs-frame> swap scrolls the whole page to top #1427 behaviour holding rather
than a new branch
Counterfactuals, stated in the file header so a later reader can re-run them:
!preserveScrollguard infetch-apply.jsand cases 2, 3, 6, 7 redif (recordHistory && !frameId)block on!preserveScrollandcase 5 reds (the hash carve-out is what that would break)
closest()walk to a barehasAttributeand case 3 reds!== 'false'test and case 4 redsopts?.scroll === falsetest to!opts?.scrolland case 1 or 7 redsonce
navigate(url)is called with no optionsRun all three engines:
npm run test:browser.Unit: extend
packages/core/test/routing/router-client.test.jsThe linkedom harness implements no layout and no scrolling, so
window.scrollYnever moves there and any position assertion would pass vacuously. This layer
covers ATTRIBUTE RESOLUTION only, and the file header should say so.
Add
_resolvePreserveScrollto the destructured import list (the block at lines88 to 135), then add tests beside the existing
resolveTargetFrameIdgroup(lines 380 to 449), reusing that group's
frameFixturehelper shape:falsetrue="true",true="false",false="FALSE"and=" false ", bothfalse(case-insensitive, trimmed, matchingprefetchMode's normalization)true="false"on a link inside a marked wrapper,false(nearest carrier wins)nulltrigger,false, no throwAdd one more beside them asserting the wiring rather than the resolver:
navigate('/x', { scroll: false })resolves without throwing and still fetches/x, using the file's existing fetch-stub pattern. That catches a typo in theoption name, which the browser layer would also catch but more slowly.
Run:
node --test packages/core/test/routing/router-client.test.js, then thefull
npm test.e2e: not applicable, and why
No new file under
test/e2e/. The change ships no server-side code: theattribute is inert HTML the server passes through untouched, and there is no
header, no route, and no render path involved. The browser suite already exercises
the real click, the real router, and a real
window.scrollYacross Chromium,Firefox and WebKit, which is strictly more coverage of the thing that can break
than a single-engine Playwright run would add.
Two existing e2e files must stay green as regression checks and must not be
edited:
test/e2e/nested-layout-partial-swap.test.mjs(asserts the sidenavscrollTopsurvives a navigation, lines 90 to 126) andtest/e2e/form-submission-and-race.test.mjs. Run withWEBJS_E2E=1AFTER thedistrebuild in Step 12, or they test the old bundle.Smoke: not applicable
test/examples/blog/smoke/coversexamples/blog, which this change does nottouch.
website/is not touched either, beyond its docs page (Step 15 below),and
test/repo-health/site-pages-well-formed.test.mjscovers that.Bun parity: does not apply
The verdict, stated explicitly. Every file changed here is browser-only client
router code that never executes under a server runtime, and none of it is on
AGENTS.md's runtime-sensitive list (the serializer, the
node:httpversusBun.servelistener and request path, SSR / action / CSRF dispatch, streams,node:crypto, the TS stripper, auth / session / cors). Notest/bun/**file isadded.
.claude/hooks/require-bun-parity-with-runtime-src.shwill not fire either: itsfilename regex matches
serialize|/json\.js|file-storage|listener|ts-strip|action|render-server|/ssr[./]|conditional-get|websocket|node-version|csrf|/auth\.js|/session\.js|/cors\.js|crypto|compression|body-limit|/dev[./]|stream,and none of
events.js,navigator.js,fetch-apply.js,scroll.js, orrouter-client.jsmatches any of those. If a later edit widens the change into afile that does match,
WEBJS_BUN_VERIFIED=1is the named escape hatch, but thecorrect answer is to re-check whether the change really is browser-only.
Convention validation
webjs checkrefuses to run from the workspace root (#1301), so run it frominside each app:
( cd gallery && npx webjs check ),( cd website && npx webjs check ),( cd examples/blog && npx webjs check ).Run
webjs doctoringalleryandwebsitetoo, since the requiredconventionsCI job runs it over all three and gates on each app'swebjs.doctor.gate.Docs
Invoke the
webjs-doc-syncskill. This adds new author-facing API surface (anHTML attribute plus an option on an exported function), which is exactly the
change type that skill exists for, and it carries the authoritative map so no
surface is silently skipped.
.claude/hooks/require-docs-with-src.shblocks thecommit until at least one doc surface is staged, and
WEBJS_NO_DOC_GATE=1is NOTjustified here.
Five surfaces, all of them real.
1.
.agents/skills/webjs/references/client-router-and-streaming.md. Thecanonical agent-facing reference, and the ONLY copy (see Correction 4).
data-no-routerparagraph at line 45, add a paragraph fordata-preserve-scroll: what it does, that it resolves throughclosest()so awrapper covers a region, that
="false"is the per-link escape, that a hashlink still scrolls to its anchor, that it is inert on a frame-targeted link
because a frame swap never scrolls, and that it is inert with JS off.
await navigate('/products?sort=new', { scroll: false });with the sameone-line comment style the neighbours use.
concerns: the Back/Forward restore is the BROWSER's and has no per-link knob,
while the forward-nav scroll-to-top is the router's own write and this is its
knob.
2.
website/app/docs/client-router/page.ts. Three edits.<h3>after the scroll paragraph at line 194 (inside the"Snapshot cache + back/forward" section, where the scroll prose already lives),
titled
Preserving scroll on a forward navigation (data-preserve-scroll), witha
<code-block>showing the link form, the wrapper form, the="false"escape, and the hash carve-out.
it, so a reader who lands on the opt-out heading finds the scroll knob.
navigateoption in "Programmatic navigation" at line 246.The site is markup in a template literal, so
test/repo-health/site-pages-well-formed.test.mjsgates the edit.
/llms.txtand the per-page/docs/client-router/llms.txtaregenerated from this page, so they need no separate edit.
3.
.agents/skills/webjs/references/muscle-memory-gotchas.md. Worth naming,and the section already exists: "No
<ScrollRestoration>, and no scroll restoreof your own" at lines 211 to 217. That section currently tells a Next or Remix
reader that every scroll reflex they have is a thing to NOT port, which after
this change is one sentence too absolute. Add a short paragraph: Next's
<Link scroll={false}>IS the one scroll reflex that ports, asdata-preserve-scrollon the link (or a wrapper) andnavigate(url, { scroll: false })programmatically. Everything else in thatsection stands unchanged, and say so, so the paragraph does not read as a
loosening of the "do not hand-roll a restore" rule.
4.
AGENTS.md, the "Client navigation" section. Line 420 is the routerparagraph and line 422 lists the advanced surface with its attribute family. Add
data-preserve-scrollto line 422's list besidedata-prefetchanddata-webjs-permanent, in one clause, since that line is an index into thereference rather than the explanation itself.
5. The gallery, in Step 13 above. The scaffold gallery is a teaching surface
in its own right, and its client-router page is where an agent reads the router's
per-link attributes.
Not changed and not needed:
blog/client-router-turbo-drive-style.mdandblog/works-without-javascript.mdboth mentiondata-no-router, but a publishedpost is a dated record and is not retro-edited.
README.mdneeds nothing: thisis not a headline capability.
CONVENTIONS.mdneeds nothing: no new convention.test/docs/doc-source-consistency.test.mjsgates named imports from@webjsdev/corein doc fences, andnavigatealready exists, so no fence breaks.Acceptance criteria
data-preserve-scrollkeeps the reader's scroll offsetacross a forward navigation, proven with a real
window.scrollYinChromium, Firefox and WebKit
fixture, so the default is proven bit-identical
data-preserve-scroll="false"on a link inside a marked wrapper scrolls totop
<form>submission preserves the offset, and an unmarked onestill scrolls to top
navigate(url, { scroll: false })preserves the offset, andnavigate(url)with no options still scrolls to top
no new branch added for it)
<a>, sonothing about the page's correctness depends on it
restore guards in
packages/core/test/routing/browser/nav-scroll-anchor-restore.test.jsareall green, unmodified
packages/core/test/routing/browser/nav-scroll-instant.test.js(dogfood: nav scroll restoration animates underscroll-behavior: smooth#601) andframe-swap-scroll.test.js(dogfood: a <webjs-frame> swap scrolls the whole page to top #1427) are green, unmodified, proving thescroll-block restructure in Step 5 changed no default behaviour
and the new test file's header records them
fetchAndApplyhas nine positional parameters plus oneoptsbag, and allfour call sites in
navigator.jsare updatedresolvePreserveScrollis exported from the barrel as_resolvePreserveScroll, andtest/architecture/import-cycles.test.mjsand
barrel-surface.test.mjsare greennavigatedeclarations (packages/core/index.d.tsandpackages/core/src/router-client.d.ts) acceptscroll, andwebjs typecheckpassespackages/core/distis rebuilt before e2e and before any manual app checkthere, the
webjs-scaffold-syncskill was invoked, and a freshly generatedapp boots and passes
webjs checkwebjs checkandwebjs doctorare clean ingallery,websiteandexamples/blogwebjs-doc-syncskillnpm test,npm run test:browser, andWEBJS_E2E=1e2e all greenOut of scope
was reworked in fix: leave scroll restoration to the browser so iOS previews the back-swipe #1430 (height reservation, anchoring window, write-back).
Nothing in
packages/core/src/router-client/scroll.jsbeyond the new resolveris touched, and
onPopState(events.js:47) is not touched at all. Changingscroll there risks regressing dogfood: back-button scroll restores ~763px too low on pages that grow after swap #1310, fix: back-button restore survives late layout growth #1313 and dogfood: iOS back-swipe still blank after #1410; A/B the snapshot timing on-device #1428.
entry I was reading"). That is the feature the old body's blog example actually
described, it is a different mechanism keyed on the DESTINATION url, and it
would add a second writer to the scroll path that dogfood: iOS back-swipe still blank after #1410; A/B the snapshot timing on-device #1428 deliberately reduced to
one. Do not fold it in here.
second/page.tsgallery link. See Correction 2 andStep 14. Leaving them alone is a decision, not an omission.
navigate(url, { replace: true }). It passesreplaceinto theisPopStateslot and there is noreplaceStatecall anywhere inpackages/core/src, so it currently records no history entry, leaves the URLunchanged, and suppresses the scroll as a side effect. Real, adjacent, and a
separate change with its own history semantics. Do not "tidy" it while
threading the new flag.
Design. The default stays scroll-to-top for every forward navigation.
fetchAndApply's remaining nine positionals into the optionsbag. The two that move are the ones that must, to make room; the rest stay.
webjs checkrule for the attribute appearingwhere it is inert (a frame-targeted link, a page with JS off). The ancestor
walk makes an inert hit ordinary rather than suspicious.