Skip to content

dogfood: no per-link opt-out from the forward-nav scroll-to-top #1436

Description

@vivek7405

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.
    const url = new URL(finalUrl);
    if (url.hash) {
      const t = 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.

Prior art, verified by reading each source:

framework opt-out shape source read
Next <Link scroll={false}>, router.push(url, { scroll: false }) prop, scroll ?? true next.js/packages/next/src/client/link.tsx:56 and :270
Remix 3 rmx-reset-scroll="false", on links AND forms attribute, getAttribute(...) !== 'false' remix/packages/ui/src/runtime/navigation.ts:296 (links) and :314 (forms)
Turbo <meta name="turbo-refresh-scroll" content="preserve"> page-level meta, refresh visits only turbo/src/core/drive/page_view.js:62-64, turbo/src/core/drive/page_snapshot.js:94
WebJs none

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:

  1. Reader is at /blog, scrolled to the fifth post, at offset 1200. They click it.
  2. They read the post and scroll to offset 3000.
  3. 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}
 */
export function resolvePreserveScroll(trigger) {
  if (!trigger || !trigger.closest) return false;
  const carrier = trigger.closest('[data-preserve-scroll]');
  if (!carrier) return false;
  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';

Replace lines 42 to 43, which currently read:

  const frameId = resolveTargetFrameId(anchor);
  performNavigation(href, false, frameId);

with:

  const frameId = 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) });

Step 3. Read it on the submit path, same file

Replace lines 136 to 137, which currently read:

  const frameId = resolveTargetFrameId(submitter || form);
  performSubmission(url.href, method, body, frameId, form);

with:

  const frameId = 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.
  const preserveScroll = 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

Line 65 currently reads:

export async function fetchAndApply(href, frameId, recordHistory, optimisticState, method, body, signal, token, revalidating, refresh, noPrefetch) {
  method = method || 'GET';

Replace with:

export async function fetchAndApply(href, frameId, recordHistory, optimisticState, method, body, signal, token, revalidating, opts) {
  method = method || 'GET';
  const refresh = (opts && opts.refresh) || undefined;
  const noPrefetch = !!(opts && opts.noPrefetch);
  const preserveScroll = !!(opts && opts.preserveScroll);

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-navigation POLICY, as a bag rather than three more positionals. The
 *   nine parameters above are request inputs; these three decide what the
 *   pipeline does with the response, and the list was already at eleven.
 *
 *   `refresh` is a same-URL in-place refresh (#1398). It suppresses the
 *   `X-Webjs-Have` header and picks the swap tier; see `refreshPage`.
 *
 *   `noPrefetch` never consumes a speculative entry, whatever the cache holds
 *   (#1407). Set by `loadFrame`: a `<webjs-frame src>` self-load or `src`
 *   mutation asks for THIS frame's content now, which is a freshness request
 *   rather than the click-follows-hover shape the warm cache serves.
 *
 *   `preserveScroll` keeps the reader's current offset instead of scrolling to
 *   top on a forward navigation (#1436), from `data-preserve-scroll` on the
 *   link or form, or from `navigate(url, { scroll: false })`. It suppresses only
 *   the scroll-to-top writes, never the hash-anchor scroll.

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.
    const url = new URL(finalUrl);
    const target = 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();
    } else if (!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.

Line 646, the foreground navigation. Currently:

    const outcome = await fetchAndApply(href, frameId, !isPopState && !refresh, optimisticState, 'GET', null, signal, myToken, /* revalidating */ false, refresh);

becomes:

    const outcome = await fetchAndApply(href, frameId, !isPopState && !refresh, optimisticState, 'GET', null, signal, myToken, /* revalidating */ false, { refresh, preserveScroll });

Line 765, performSubmission. The call currently passes eight positionals and
omits revalidating. Fill it in explicitly and add the bag:

    const outcome = await fetchAndApply(
      url.href,
      frameId,
      /* recordHistory */ true,
      optimisticState,
      isSafe ? 'GET' : method.toUpperCase(),
      isSafe ? null : body,
      signal,
      myToken,
      /* revalidating */ false,
      { preserveScroll: !!(opts && opts.preserveScroll) },
    );

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` marks a same-URL in-place re-render (#1398). It suppresses three
 *   things a forward navigation does and a refresh must not: the outgoing
 *   snapshot, the optimistic loading skeleton, and history plus scroll. 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
 *   nothing there.

Line 427 currently reads const refresh = (opts && opts.refresh) || undefined;.
Add directly beneath it:

  const preserveScroll = !!(opts && opts.preserveScroll);

Step 8. Accept the option on performSubmission, same file

Line 715 currently reads:

export async function performSubmission(href, method, body, frameId, form) {

becomes:

export async function performSubmission(href, method, body, frameId, form, opts) {

and gains a @param in the JSDoc above (after the form param at line 713):

 * @param {{ preserveScroll?: boolean }} [opts]  `preserveScroll` keeps the
 *   reader's offset instead of scrolling to top after the response applies
 *   (#1436), from `data-preserve-scroll` on the form or its submitter. The case
 *   it exists for is a long form failing validation: the 422 re-renders in
 *   place, and scrolling to top would move the reader away from the field that
 *   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]
 */
export async function navigate(url, opts) {
  const target = new URL(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;
  }
  await performNavigation(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):

export {
  resolvePreserveScroll as _resolvePreserveScroll,
} from './router-client/scroll.js';

test/architecture/barrel-surface.test.mjs asserts a FLOOR of 69 exports for
this barrel, so adding one is fine and no number needs changing.

Step 11. Widen the two type declarations

packages/core/index.d.ts:91 and packages/core/src/router-client.d.ts:5 both
currently read:

export function navigate(url: Route, opts?: { replace?: boolean }): Promise<void>;

Both become:

export function navigate(url: Route, opts?: { replace?: boolean; scroll?: boolean }): Promise<void>;

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:

    <ul class="list-disc pl-5 mb-4">
      <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>

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 paragraph
is where the gallery teaches the router's per-link attributes and currently names
only data-no-router. Add the new member:

    <p class="text-muted-foreground text-sm mt-6">
      Opt out app-wide with <code class="font-mono">{ "webjs": { "clientRouter": false } }</code>,
      or per-link with <code class="font-mono">data-no-router</code> (use it for
      auth flows like <code class="font-mono">/logout</code> that must reset
      in-memory state). A forward navigation scrolls to top; per link (or per
      wrapping element) <code class="font-mono">data-preserve-scroll</code> keeps
      the reader where they are, for a filter or tab link that changes only part
      of what they are looking at. A hash link still scrolls to its anchor, and
      a frame-targeted link never scrolled anyway.
    </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:

          <button
            @click=${() => navigate('/features/client-router/second', { scroll: false })}
            class=${buttonClass({ variant: 'secondary' })}>navigate(..., { scroll: false })</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.

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.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.
  • document.documentElement.style.scrollBehavior forced off, since the
    assertions are about position and a smooth scroll would not have landed (dogfood: nav scroll restoration animates under scroll-behavior: smooth #601).

Cases:

  1. an UNMARKED link still scrolls to top (scrollY === 0), the default proven
    unchanged in the same fixture that proves the opt-out
  2. a link carrying data-preserve-scroll leaves scrollY at START_Y
  3. the attribute on an ANCESTOR (<nav data-preserve-scroll>) covers a link
    inside it
  4. data-preserve-scroll="false" on a link INSIDE that marked wrapper scrolls to
    top (nearest carrier wins)
  5. 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)
  6. a marked <form method="post"> submission leaves scrollY at START_Y,
    and an unmarked one scrolls to top
  7. navigate(url, { scroll: false }) preserves; bare navigate(url) scrolls to
    top
  8. a data-preserve-scroll link that TARGETS a frame leaves the offset alone and
    swaps 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:

  • delete the !preserveScroll guard in fetch-apply.js and cases 2, 3, 6, 7 red
  • gate the WHOLE if (recordHistory && !frameId) block on !preserveScroll and
    case 5 reds (the hash carve-out is what that would break)
  • drop the closest() walk to a bare hasAttribute and case 3 reds
  • drop the !== 'false' test and case 4 reds
  • drop the opts?.scroll === false test to !opts?.scroll and case 1 or 7 reds
    once navigate(url) is called with no options

Run all three engines: npm run test:browser.

Unit: extend packages/core/test/routing/router-client.test.js

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
  • The attribute is inert on a frame-targeted link (the dogfood: a <webjs-frame> swap scrolls the whole page to top #1427 rule holds, with
    no new branch added for it)
  • With JS off the attribute is inert and the link is a plain <a>, so
    nothing about the page's correctness depends on it
  • The popstate / traverse path is untouched, and the dogfood: back-button scroll restores ~763px too low on pages that grow after swap #1310 / fix: back-button restore survives late layout growth #1313 / dogfood: iOS back-swipe still blank after #1410; A/B the snapshot timing on-device #1428
    restore guards in
    packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js are
    all green, unmodified
  • packages/core/test/routing/browser/nav-scroll-instant.test.js (dogfood: nav scroll restoration animates under scroll-behavior: smooth #601) and
    frame-swap-scroll.test.js (dogfood: a <webjs-frame> swap scrolls the whole page to top #1427) are green, unmodified, proving the
    scroll-block restructure in Step 5 changed no default behaviour
  • 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

Out of scope

  • The popstate / traverse path. Back/Forward restore is snapshot-driven and
    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.js beyond the new resolver
    is touched, and onPopState (events.js:47) is not touched at all. Changing
    scroll 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.
  • 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.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions