From fadd07eb999c8a80a154cf1a0cacb0b770ff4573 Mon Sep 17 00:00:00 2001
From: Vivek
Date: Fri, 21 Aug 2026 03:43:28 +0530
Subject: [PATCH 01/10] feat: add data-preserve-scroll to opt out of the
forward-nav scroll
A forward navigation always scrolls to the top and an author had no way to
say otherwise. That default is right and matches Next and Remix 3, but there
was no escape hatch for a filter, sort, or tab link whose control sits below
the fold, or a form that re-renders in place with validation errors. WebJs
has an extra reason to want one: a searchParams-only navigation already
morphs the deepest shared boundary and preserves hydrated component state,
so the scroll was the only thing such a navigation still threw away.
The attribute resolves through closest(), so a wrapper marks a whole region
and a single link opts back out with ="false". A hash anchor still wins,
because the reader named a target. navigate(url, { scroll: false }) is the
programmatic twin, spelled the way Next spells it.
---
packages/core/index.d.ts | 2 +-
packages/core/src/router-client.d.ts | 2 +-
packages/core/src/router-client.js | 3 ++
packages/core/src/router-client/events.js | 13 +++++-
.../core/src/router-client/fetch-apply.js | 44 +++++++++++++------
packages/core/src/router-client/navigator.js | 42 +++++++++++++-----
packages/core/src/router-client/scroll.js | 29 ++++++++++++
7 files changed, 108 insertions(+), 27 deletions(-)
diff --git a/packages/core/index.d.ts b/packages/core/index.d.ts
index fc16dd279..59d3a0151 100644
--- a/packages/core/index.d.ts
+++ b/packages/core/index.d.ts
@@ -88,7 +88,7 @@ export { enableClientRouter, disableClientRouter, revalidate, refreshPage, loadF
// `string`, so this is non-breaking; once generated, a bogus in-app path is a
// tsserver error. The runtime is the same async function in router-client.js.
import type { Route } from './src/routes.d.ts';
-export function navigate(url: Route, opts?: { replace?: boolean }): Promise;
+export function navigate(url: Route, opts?: { replace?: boolean; scroll?: boolean }): Promise;
// The full lit-html-parity directive set (mirrors index.js); the per-directive
// declarations live in src/directives.d.ts. `repeat` is re-exported above.
export {
diff --git a/packages/core/src/router-client.d.ts b/packages/core/src/router-client.d.ts
index 11ac01e69..bcebce449 100644
--- a/packages/core/src/router-client.d.ts
+++ b/packages/core/src/router-client.d.ts
@@ -2,7 +2,7 @@ import type { Route } from './routes.js';
export function enableClientRouter(): void;
export function disableClientRouter(): void;
-export function navigate(url: Route, opts?: { replace?: boolean }): Promise;
+export function navigate(url: Route, opts?: { replace?: boolean; scroll?: boolean }): Promise;
export function loadFrame(
frameEl: Element,
url: string,
diff --git a/packages/core/src/router-client.js b/packages/core/src/router-client.js
index c090b1738..56db0ed6e 100644
--- a/packages/core/src/router-client.js
+++ b/packages/core/src/router-client.js
@@ -95,6 +95,9 @@ export {
prefetchSuppressed as _prefetchSuppressed,
prefetchTake as _prefetchTake,
} from './router-client/prefetch.js';
+export {
+ resolvePreserveScroll as _resolvePreserveScroll,
+} from './router-client/scroll.js';
export {
snapshotCache as _snapshotCache,
} from './router-client/snapshot-cache.js';
diff --git a/packages/core/src/router-client/events.js b/packages/core/src/router-client/events.js
index 0b9188eea..a44620919 100644
--- a/packages/core/src/router-client/events.js
+++ b/packages/core/src/router-client/events.js
@@ -12,6 +12,7 @@ import { enabled } from './state.js';
import { warnIfActionSubmissionCannotDeliver } from './diagnostics.js';
import { buildSubmitFormData, encodeSubmitBody, getSubmitAction, getSubmitEnctype, getSubmitMethod } from './form-encoder.js';
import { resolveTargetFrameId } from './frames.js';
+import { resolvePreserveScroll } from './scroll.js';
import { performNavigation, performSubmission } from './navigator.js';
/** @param {MouseEvent} e */
@@ -40,7 +41,11 @@ export function onClick(e) {
// external sidebar/nav link), `_top` breaks out to a full-page nav, and
// absence falls back to the closest enclosing frame (today's default).
const frameId = resolveTargetFrameId(anchor);
- performNavigation(href, false, frameId);
+ // #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) });
}
/** @param {PopStateEvent} _e */
@@ -134,6 +139,10 @@ export function onSubmit(e) {
// an explicit `data-webjs-frame` on (or above) the form or its submitter
// wins, `_top` breaks out, absence falls back to the enclosing frame.
const frameId = resolveTargetFrameId(submitter || form);
- performSubmission(url.href, method, body, frameId, 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 });
}
diff --git a/packages/core/src/router-client/fetch-apply.js b/packages/core/src/router-client/fetch-apply.js
index b014b2e52..4afce6a0b 100644
--- a/packages/core/src/router-client/fetch-apply.js
+++ b/packages/core/src/router-client/fetch-apply.js
@@ -35,13 +35,23 @@ import { _swapCommit, applySwap } from './swap.js';
* @param {boolean} [revalidating] True for the BACKGROUND refresh after a
* snapshot restore: the user is already viewing a page, so a boundary
* mismatch must degrade in place (never a jarring `location.href` load).
- * @param {'page' | 'shell'} [refresh] Same-URL in-place refresh (#1398). It
- * suppresses the `X-Webjs-Have` header and picks the swap tier; see
- * `refreshPage`.
- * @param {boolean} [noPrefetch] Never consume a speculative entry, whatever the
- * cache holds (#1407). Set by `loadFrame`: a `` 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.
+ * @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 `` 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.
* @returns {Promise<{ ok: boolean, status: number | null, aborted: boolean, applied: boolean }>}
* The fetch outcome, so a caller (the form-submission busy/event lifecycle)
* can report whether the submission settled as a success, an error, or an
@@ -62,8 +72,11 @@ import { _swapCommit, applySwap } from './swap.js';
* boundary scan). A caller deciding whether to fall back to a full page load
* wants `applied`; one reporting the submission's success wants `ok`.
*/
-export async function fetchAndApply(href, frameId, recordHistory, optimisticState, method, body, signal, token, revalidating, refresh, noPrefetch) {
+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);
const myToken = typeof token === 'number' ? token : currentNavigationToken;
let html;
// Set when the response streams Suspense boundaries (#473): holds the open
@@ -367,18 +380,23 @@ export async function fetchAndApply(href, frameId, recordHistory, optimisticStat
// one rule ("a frame swap never moves the window") beats two. `_top` and an
// unresolvable `data-webjs-frame` id both resolve to a null `frameId` in
// `resolveTargetFrameId`, so they stay page navigations and still scroll.
+ // `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);
- if (url.hash) {
- const t = document.getElementById(url.hash.slice(1));
+ 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.
- if (t) t.scrollIntoView();
- else { warnIfSmoothScrollOnHtml(); window.scrollTo({ left: 0, top: 0, behavior: 'instant' }); }
- } else {
+ 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();
diff --git a/packages/core/src/router-client/navigator.js b/packages/core/src/router-client/navigator.js
index 2a1462579..02a4650cf 100644
--- a/packages/core/src/router-client/navigator.js
+++ b/packages/core/src/router-client/navigator.js
@@ -179,8 +179,15 @@ export function disableClientRouter() {
/**
* 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 }} [opts]
+ * @param {{ replace?: boolean, scroll?: boolean }} [opts]
*/
export async function navigate(url, opts) {
const target = new URL(url, location.href);
@@ -190,7 +197,9 @@ export async function navigate(url, opts) {
hardNavigate(url);
return;
}
- await performNavigation(target.href, opts?.replace ?? false, null);
+ await performNavigation(target.href, opts?.replace ?? false, null, {
+ preserveScroll: opts?.scroll === false,
+ });
}
/**
@@ -258,14 +267,13 @@ export async function loadFrame(frameEl, url) {
signal,
myToken,
/* revalidating */ false,
- /* refresh */ undefined,
// 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,
+ { noPrefetch: true },
);
// A self-load is a freshness request, so it does BOTH halves (#1407): it
@@ -410,10 +418,15 @@ export async function refreshPage(mode) {
* @param {string} href
* @param {boolean} isPopState
* @param {string | null} frameId Active id, or null.
- * @param {{ refresh?: 'page' | 'shell' }} [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`.
+ * @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.
* @returns {Promise<{ ok: boolean, status: number | null, aborted: boolean, applied: boolean } | null>}
* The `fetchAndApply` outcome, so a caller can tell an applied navigation from
* a failed one (#1398). Read `applied` rather than `ok` for that question: an
@@ -425,6 +438,7 @@ export async function refreshPage(mode) {
*/
export async function performNavigation(href, isPopState, frameId, opts) {
const refresh = (opts && opts.refresh) || undefined;
+ const preserveScroll = !!(opts && opts.preserveScroll);
// #1008 / #936: a forward, main-document nav fired while the document is
// still parsing (`readyState === 'loading'`) races the DOM. The leaving
// page's closing layout markers at the bottom of the body may not exist yet,
@@ -643,7 +657,7 @@ export async function performNavigation(href, isPopState, frameId, opts) {
// duplicate `history.pushState` and the whole scroll block in one flag,
// which is exactly what the comment above that block already says it means.
// So Back still goes to the previous page and the reader keeps their place.
- const outcome = await fetchAndApply(href, frameId, !isPopState && !refresh, optimisticState, 'GET', null, signal, myToken, /* revalidating */ false, refresh);
+ const outcome = await fetchAndApply(href, frameId, !isPopState && !refresh, optimisticState, 'GET', null, signal, myToken, /* revalidating */ false, { refresh, preserveScroll });
// The cache-miss re-assert described above, DEFERRED two frames rather
// than written synchronously. A synchronous write here only wins when the
// fetch was slower than the UA's replay, which is most fetches but not a
@@ -711,8 +725,14 @@ export async function performNavigation(href, isPopState, frameId, opts) {
* either without an explicit header.
* @param {string | null} frameId
* @param {HTMLFormElement | null} [form] The submitted form, for busy + events.
+ * @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.
*/
-export async function performSubmission(href, method, body, frameId, form) {
+export async function performSubmission(href, method, body, frameId, form, opts) {
if (activeAbortController) activeAbortController.abort();
activeAbortController = new AbortController();
const signal = activeAbortController.signal;
@@ -771,6 +791,8 @@ export async function performSubmission(href, method, body, frameId, form) {
isSafe ? null : body,
signal,
myToken,
+ /* revalidating */ false,
+ { preserveScroll: !!(opts && opts.preserveScroll) },
);
outcomeOk = !!(outcome && outcome.ok);
// Mutating submissions invalidate cached versions of other URLs -
diff --git a/packages/core/src/router-client/scroll.js b/packages/core/src/router-client/scroll.js
index 40564881b..a1cdf8431 100644
--- a/packages/core/src/router-client/scroll.js
+++ b/packages/core/src/router-client/scroll.js
@@ -257,3 +257,32 @@ export function afterTwoFrames(fn) {
export function bumpRestoreGeneration() {
restoreGeneration += 1;
}
+
+/**
+ * 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 (`!== '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';
+}
From 9ed0b456f4a87d2330d30c40421d5033077e633c Mon Sep 17 00:00:00 2001
From: Vivek
Date: Fri, 21 Aug 2026 03:46:14 +0530
Subject: [PATCH 02/10] test: cover data-preserve-scroll at the unit and
browser layers
The browser file is the headline layer: linkedom has no layout, so a
position assertion there would pass vacuously whatever the code did. The
unit file therefore covers attribute resolution only, plus one wiring
assertion on navigate()'s option that catches a typo in the option name
faster than a browser round trip would.
---
.../browser/nav-preserve-scroll.test.js | 362 ++++++++++++++++++
.../core/test/routing/router-client.test.js | 119 +++++-
2 files changed, 480 insertions(+), 1 deletion(-)
create mode 100644 packages/core/test/routing/browser/nav-preserve-scroll.test.js
diff --git a/packages/core/test/routing/browser/nav-preserve-scroll.test.js b/packages/core/test/routing/browser/nav-preserve-scroll.test.js
new file mode 100644
index 000000000..bed39eb71
--- /dev/null
+++ b/packages/core/test/routing/browser/nav-preserve-scroll.test.js
@@ -0,0 +1,362 @@
+/**
+ * Real-browser test for #1436: `data-preserve-scroll` on a link or form keeps
+ * the reader's scroll offset across a forward navigation, while an unmarked one
+ * still scrolls to top.
+ *
+ * A forward navigation always scrolled to the top and an author had no way to
+ * say otherwise. That default is right, but it is wrong for a filter, sort, or
+ * tab link whose control sits below the fold, and for a form that re-renders in
+ * place with validation errors: the reader is thrown away from the thing they
+ * were just looking at.
+ *
+ * This MUST run in a real browser. linkedom implements no layout and no
+ * scrolling at all, so `window.scrollY` never moves there and every position
+ * assertion below would pass vacuously. The unit file covers attribute
+ * RESOLUTION only, and says so.
+ *
+ * Fixture rules, carried over from `frame-swap-scroll.test.js` (#1427). Each is
+ * load-bearing and each is easy to get wrong in a way that leaves the test green
+ * either way:
+ *
+ * - The page has to be TALL and stay tall across the swap, so the response
+ * carries its own spacer. A swap that shortens the document clamps
+ * `scrollY` to 0 on its own and looks exactly like the defect.
+ * - The starting offset has to be non-zero and asserted BEFORE the click. A
+ * fixture that never managed to scroll would report 0 afterwards for the
+ * wrong reason.
+ * - Every href keeps the page's OWN query string, which identifies the
+ * web-test-runner session. A link that replaces the search string pushes the
+ * page out of its session and takes down the entire run, with every test
+ * still passing, so it reads as an infrastructure blip.
+ * - The response repeats the live boundary KEY. A different key shares no
+ * boundary with the live DOM, so the router degrades to a full page load
+ * rather than swapping, and the case would assert scroll behaviour on a
+ * navigation that never applied.
+ * - `scroll-behavior` is forced off: the assertions are about position, and a
+ * smooth scroll would not have landed by the time they run (#601).
+ *
+ * COUNTERFACTUALS, each proven to red the cases it names (at `fadd07eb`):
+ *
+ * - delete the `!preserveScroll` guard in `fetch-apply.js`: cases 2, 3, 6, 7
+ * - gate the WHOLE `if (recordHistory && !frameId)` block on `!preserveScroll`
+ * instead: case 5 (the hash carve-out is what that breaks)
+ * - drop the `closest()` walk to a bare `hasAttribute`: case 3
+ * - drop the `!== 'false'` test so presence alone preserves: case 4
+ * - read the option as `!opts?.scroll` rather than `opts?.scroll === false`:
+ * case 7's optionless half
+ */
+import { enableClientRouter, disableClientRouter, navigate } from '../../../src/router-client.js';
+
+import { assert } from '../../../../../test/browser-assert.js';
+import { installNavGuard } from '../../../../../test/browser-nav-guard.js';
+
+/** Where the reader is when they click. Comfortably clear of 0. */
+const START_Y = 500;
+/** Tall enough that no swap can shorten the document below `START_Y`. */
+const SPACER = 3000;
+
+const frame = () => new Promise((r) => requestAnimationFrame(() => r()));
+const tick = () => new Promise((r) => setTimeout(r, 0));
+/** Past the fetch, the swap, and the layout it produces. */
+async function settle() { for (let i = 0; i < 3; i++) { await tick(); } await frame(); }
+
+/**
+ * A same-page url carrying one extra param, built from the LIVE url so the
+ * session query string survives (see the header note).
+ *
+ * @param {string} kind
+ * @returns {string} pathname + search
+ */
+function href(kind) {
+ const u = new URL(location.href);
+ u.searchParams.set('wj1436', kind);
+ return u.pathname + u.search;
+}
+
+/**
+ * The live page: an anchor target and a tall spacer, then every trigger shape
+ * the attribute has to answer for. The boundary comments let the swap happen
+ * INSIDE this container rather than replacing the whole body, so the
+ * web-test-runner harness DOM is never touched.
+ */
+function liveHtml() {
+ return ''
+ // Above the spacer, so scrolling to it moves the window UP from `START_Y`
+ // by an unmistakable margin (the hash case asserts that gap).
+ + 'anchor'
+ + `
spacer
`
+ + `plain`
+ + `marked`
+ + `anchored`
+ + ''
+ + `'
+ + `'
+ + ''
+ + `filter`
+ + 'ORIGINAL'
+ + ''
+ + '';
+}
+
+/** A frame-scoped response: only the frame subtree changes. */
+const FRAME_RESPONSE =
+ ''
+ + 'UPDATED'
+ + '';
+
+/**
+ * A page response, with its OWN tall spacer and its own copy of the anchor
+ * target. Without the spacer the swap would shorten the document, the browser
+ * would clamp `scrollY` to 0 by itself, and the scroll-to-top cases would pass
+ * even with the router's scroll write removed. The anchor target has to survive
+ * too, because the hash case resolves it against the document the swap
+ * produced, not the one it left.
+ *
+ * The live head is echoed back as insurance rather than out of need, for the
+ * reason `frame-swap-scroll.test.js` documents at length: losing
+ * web-test-runner's session scripts would not fail THIS file, which has already
+ * loaded, but would destabilize the session and surface as unrelated later files
+ * failing to start.
+ */
+function pageResponse() {
+ return '' + document.head.innerHTML + ''
+ + ''
+ + 'anchor'
+ + `
swapped
`
+ + ''
+ + '';
+}
+
+suite('Client router: data-preserve-scroll keeps the reader in place (#1436)', () => {
+ let navGuard, container, origFetch, origScrollBehavior, origUrl;
+ /** Every url the router fetched, tagged with the shape it asked for. */
+ let fetched;
+
+ function setup() {
+ navGuard = installNavGuard();
+ enableClientRouter();
+ origUrl = location.href;
+ origScrollBehavior = document.documentElement.style.scrollBehavior;
+ document.documentElement.style.scrollBehavior = '';
+
+ container = document.createElement('div');
+ container.innerHTML = liveHtml();
+ document.body.appendChild(container);
+
+ fetched = [];
+ origFetch = window.fetch;
+ // Answer the SHAPE the router asked for, read off its own `x-webjs-frame`
+ // request header rather than guessed from the url: a page request answered
+ // with a bare frame body would carry no boundary comments, so the router
+ // would swap the whole body and take the harness DOM with it.
+ window.fetch = (u, init) => {
+ const url = String(typeof u === 'string' ? u : (u && u.url) || u);
+ const headers = (init && init.headers) || {};
+ const framed = Boolean(headers['x-webjs-frame']);
+ fetched.push((framed ? 'frame:' : 'page:') + url);
+ return Promise.resolve(new Response(framed ? FRAME_RESPONSE : pageResponse(), {
+ headers: { 'content-type': 'text/html', 'x-webjs-build': '' },
+ }));
+ };
+
+ window.scrollTo({ left: 0, top: START_Y, behavior: 'instant' });
+ assert.equal(window.scrollY, START_Y,
+ 'the fixture is tall enough to scroll: without this the test proves nothing');
+ }
+
+ /**
+ * Undo everything `setup` installed. Every step is guarded, because `setup`
+ * ASSERTS its precondition and so can throw partway through, and a teardown
+ * that threw on the first missing field would leave the fetch stub and the nav
+ * guard installed for the rest of the RUN.
+ */
+ function teardown() {
+ if (origFetch) { window.fetch = origFetch; origFetch = null; }
+ // A page swap replaces the container's contents and nothing puts them back.
+ const swapped = document.getElementById('wj-swapped-1436');
+ if (swapped) swapped.remove();
+ if (container) { container.remove(); container = null; }
+ if (origUrl) { history.replaceState(null, '', origUrl); origUrl = null; }
+ if (origScrollBehavior != null) {
+ document.documentElement.style.scrollBehavior = origScrollBehavior;
+ origScrollBehavior = null;
+ }
+ window.scrollTo({ left: 0, top: 0, behavior: 'instant' });
+ if (navGuard) { navGuard.remove(); navGuard = null; }
+ // Re-arm cleanly for the next case; every other suite in the run expects
+ // the router enabled.
+ disableClientRouter();
+ enableClientRouter();
+ }
+
+ /**
+ * The swap applied AND the document it produced is still tall enough to hold
+ * `START_Y`. Both halves matter: without the first the case would be asserting
+ * scroll behaviour on a navigation that never happened, and without the second
+ * a `scrollY` of 0 could be the browser clamping rather than the router
+ * scrolling.
+ */
+ function assertPageSwapped() {
+ assert.ok(document.getElementById('wj-swapped-1436'),
+ 'the page swap applied, so this is a genuine forward navigation');
+ assert.ok(document.documentElement.scrollHeight - window.innerHeight > START_Y,
+ 'the swapped page still holds the old offset, so a 0 here is the router '
+ + 'scrolling and not the browser clamping');
+ }
+
+ test('1. an UNMARKED link still scrolls to top (the default is unchanged)', async () => {
+ try {
+ setup();
+ document.getElementById('wj-plain').click();
+ await settle();
+
+ assertPageSwapped();
+ assert.equal(window.scrollY, 0,
+ 'a link with no attribute scrolls to top exactly as before');
+ } finally { teardown(); }
+ });
+
+ test('2. a link carrying data-preserve-scroll holds the offset', async () => {
+ try {
+ setup();
+ document.getElementById('wj-marked').click();
+ await settle();
+
+ assertPageSwapped();
+ assert.equal(window.scrollY, START_Y,
+ 'the marked link left the reader where they were');
+ } finally { teardown(); }
+ });
+
+ test('3. the attribute on an ANCESTOR covers a link inside it', async () => {
+ try {
+ setup();
+ // Resolved through closest(), following `data-webjs-frame` rather than
+ // `data-no-router`: one mark on a filter bar covers every link in it.
+ document.getElementById('wj-inherits').click();
+ await settle();
+
+ assertPageSwapped();
+ assert.equal(window.scrollY, START_Y,
+ 'a link inside a marked wrapper inherits the preference');
+ } finally { teardown(); }
+ });
+
+ test('4. ="false" inside a marked wrapper scrolls to top (nearest carrier wins)', async () => {
+ try {
+ setup();
+ document.getElementById('wj-opted-out').click();
+ await settle();
+
+ assertPageSwapped();
+ assert.equal(window.scrollY, 0,
+ 'the nearest carrier decides, so one link can opt back into the default');
+ } finally { teardown(); }
+ });
+
+ test('5. a marked HASH link still scrolls to its anchor', async () => {
+ try {
+ setup();
+ // The reader named a target, and a named target beats a blanket
+ // preference. This is the case that catches a guard placed on the whole
+ // scroll block rather than on the scroll-to-top arms.
+ const target = document.getElementById('wj-hash-target');
+ const targetY = Math.round(window.scrollY + target.getBoundingClientRect().top);
+ assert.ok(Math.abs(targetY - START_Y) > 100,
+ `the anchor sits at ${targetY}, which must be clear of ${START_Y}: if the two `
+ + 'coincided, scrolling to the anchor would be indistinguishable from holding '
+ + 'the offset and this case could not fail');
+
+ document.getElementById('wj-hash').click();
+ await settle();
+
+ assertPageSwapped();
+ const landed = window.scrollY;
+ assert.ok(Math.abs(landed - START_Y) > 100,
+ `the window moved off ${START_Y} (landed at ${landed}), so the hash won over `
+ + 'the attribute');
+ const after = document.getElementById('wj-hash-target');
+ const offset = Math.round(landed + after.getBoundingClientRect().top);
+ assert.ok(Math.abs(landed - offset) < 5,
+ `the window landed ON the anchor (${landed} vs the target's ${offset}), rather `
+ + 'than merely leaving the old offset');
+ } finally { teardown(); }
+ });
+
+ test('6. a marked FORM holds the offset, and an unmarked one scrolls to top', async () => {
+ try {
+ setup();
+ // The submit path reaches the scroll block through its own caller, so a
+ // fix proven only on the click path would leave this one scrolling. This
+ // is also the case the feature most exists for: a long form failing
+ // validation re-renders in place at 422, and scrolling to top moves the
+ // reader away from the field that failed.
+ document.getElementById('wj-marked-submit').click();
+ await settle();
+
+ assertPageSwapped();
+ assert.ok(fetched.some((u) => u.startsWith('page:') && u.includes('wj1436=marked-form')),
+ 'the router handled the submission, not the browser');
+ assert.equal(window.scrollY, START_Y,
+ 'a marked submission left the reader where they were');
+ } finally { teardown(); }
+
+ try {
+ setup();
+ document.getElementById('wj-plain-submit').click();
+ await settle();
+
+ assertPageSwapped();
+ assert.equal(window.scrollY, 0,
+ 'an unmarked submission still scrolls to top');
+ } finally { teardown(); }
+ });
+
+ test('7. navigate(url, { scroll: false }) holds the offset, navigate(url) does not', async () => {
+ try {
+ setup();
+ await navigate(href('programmatic'), { scroll: false });
+ await settle();
+
+ assertPageSwapped();
+ assert.equal(window.scrollY, START_Y,
+ 'the programmatic option is the twin of the attribute');
+ } finally { teardown(); }
+
+ try {
+ setup();
+ // Read as `opts?.scroll === false`, so an optionless call keeps today's
+ // behaviour. A `!opts?.scroll` regression would silently preserve here.
+ await navigate(href('programmatic-default'));
+ await settle();
+
+ assertPageSwapped();
+ assert.equal(window.scrollY, 0,
+ 'an optionless navigate still scrolls to top');
+ } finally { teardown(); }
+ });
+
+ test('8. on a FRAME-targeted link the attribute is inert (the #1427 rule holds)', async () => {
+ try {
+ setup();
+ // A frame swap already writes no scroll, so the attribute asks for
+ // something already true and no branch was added for it. Marking a
`;
}
diff --git a/gallery/app/features/metadata/page.ts b/gallery/app/features/metadata/page.ts
index c76148691..6e2ad0030 100644
--- a/gallery/app/features/metadata/page.ts
+++ b/gallery/app/features/metadata/page.ts
@@ -42,7 +42,13 @@ export default function MetadataExample({
Current title source:
${topic ? '?topic=' + topic : '(default, no ?topic=)'}
-
From a6f389275887077c1ef3a202ac4af38df1faefb9 Mon Sep 17 00:00:00 2001
From: Vivek
Date: Fri, 21 Aug 2026 04:06:24 +0530
Subject: [PATCH 04/10] fix: raise the router-client barrel floor for the new
export
The guard has two assertions and I read only the first. The floor test
passes at 70 against a floor of 69, but a second test asserts the floor
EQUALS the count, deliberately, so that adding an export is a conscious
act rather than something that quietly widens the tolerance.
Also promotes the new docs section to an h2 and moves it out of the
Back/Forward section. As an h3 it captured the revalidate() paragraph
that belongs to the snapshot cache, and it nested the forward-nav knob
inside the Back/Forward section, which is the exact conflation the
section's own prose warns against.
---
test/architecture/barrel-surface.test.mjs | 2 +-
website/app/docs/client-router/page.ts | 22 +++++++++++-----------
2 files changed, 12 insertions(+), 12 deletions(-)
diff --git a/test/architecture/barrel-surface.test.mjs b/test/architecture/barrel-surface.test.mjs
index 207464ae2..d77faf43f 100644
--- a/test/architecture/barrel-surface.test.mjs
+++ b/test/architecture/barrel-surface.test.mjs
@@ -2,7 +2,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
const BARREL_FLOORS = [
- { path: '../../packages/core/src/router-client.js', floor: 69 },
+ { path: '../../packages/core/src/router-client.js', floor: 70 },
{ path: '../../packages/core/src/slot.js', floor: 31 },
{ path: '../../packages/server/src/vendor.js', floor: 26 },
{ path: '../../packages/server/src/ssr.js', floor: 21 },
diff --git a/website/app/docs/client-router/page.ts b/website/app/docs/client-router/page.ts
index 9f6506fff..4c1be8397 100644
--- a/website/app/docs/client-router/page.ts
+++ b/website/app/docs/client-router/page.ts
@@ -192,7 +192,17 @@ connectWS('/posts/' + id + '/feed', { onMessage: (m) => renderStream(m) });
A frame-targeted navigation or submission is the one exception to the closing rule above: it swaps a single <webjs-frame> rather than the page, so the restored offset is still the right one and the restore is left running. Frame targeting here means what it means everywhere else, so a trigger that breaks out with data-webjs-frame="_top", or names an id that does not resolve, is a page navigation and does close the window.
The browser restores Back/Forward scroll, not the router. WebJs FORCES history.scrollRestoration to auto when the router starts, overriding an app that had set 'manual' (and putting that value back if you call disableClientRouter()), so setting it to 'manual' yourself does not take effect while the router is running. Only under auto does the browser record a scroll position per history entry, replay it on a traverse, and compose the iOS edge back-swipe gesture preview from that recording. The router writes no scroll of its own on a restore: it reserves the recorded height across the swap so the browser's replay lands on a document that can hold the offset, and that is the entire mechanism. One writer, which is the model Next and Remix 3 use as well. Taking manual control suppresses the recording, so every scrolled page previews blank for the whole duration of the gesture; that is a real bug WebJs shipped, inherited from Turbo Drive's assumeControlOfScrollRestoration, and it is why Turbo still has it (Turbo is single-writer too, but its writer is the app rather than the browser).
Navigation never animates the scroll, so setting html { scroll-behavior: smooth } in your app does not make it do so. The forward-nav scroll-to-top is the router's own write and is forced behavior: 'instant'; the back/forward restore is the browser's and is not a scrolling-API call at all, so scroll-behavior cannot reach it either. It jumps like a native page load. A hash-anchor (#section) link still scrolls smoothly when you opt into it. Because route transitions ignore scroll-behavior: smooth (it only affects in-page anchors), the router logs a one-time dev-only console hint if it detects that setting on <html>, and notes that combining it with a sticky backdrop-filter header can flash on iOS during navigation.
-
Preserving scroll on a forward navigation (data-preserve-scroll)
+
After a server action mutates data that a cached page depends on, call revalidate():
+ import { revalidate } from '@webjsdev/core';
+
+// Invalidate one cached URL, next visit refetches
+revalidate('/products/123');
+
+// Clear the entire cache, useful after broad mutations
+revalidate();
+
Mutating form submissions (POST / PUT / PATCH / DELETE) clear the cache automatically on success. You only need revalidate() when the mutation happens via JS / RPC and didn't go through a form.
+
+
Preserving scroll on a forward navigation (data-preserve-scroll)
A forward navigation scrolls to the top, the way a browser does. data-preserve-scroll is the per-link escape hatch, for a navigation that changes only part of what the reader is looking at: a filter, sort, or tab link whose control sits below the fold, a pager, or a form that re-renders in place with validation errors. WebJs wants it more than most frameworks do, because a searchParams-only navigation already morphs the deepest shared boundary and keeps the hydrated state of every component around it, so the scroll is the only thing such a navigation still throws away.
<!-- one link -->
<a href="?sort=new" data-preserve-scroll>Newest</a>
@@ -209,16 +219,6 @@ connectWS('/posts/' + id + '/feed', { onMessage: (m) => renderStream(m) });
The attribute resolves through closest(), so one mark on a wrapping element covers every link inside it, and data-preserve-scroll="false" on something nearer opts back out. A hash link still scrolls to its anchor, because the reader named a target and a named target beats a blanket preference. It is inert on a frame-targeted link, since a frame swap never writes a scroll to begin with, and inert with JS off, where the link is a plain <a>, so nothing about a page's correctness may depend on it.
It carries the reader's current offset onto the destination. It does not restore the offset they once had there, which is a different feature and not one WebJs ships, so this is the wrong tool for a "back to the list" link.
-
After a server action mutates data that a cached page depends on, call revalidate():
- import { revalidate } from '@webjsdev/core';
-
-// Invalidate one cached URL, next visit refetches
-revalidate('/products/123');
-
-// Clear the entire cache, useful after broad mutations
-revalidate();
-
Mutating form submissions (POST / PUT / PATCH / DELETE) clear the cache automatically on success. You only need revalidate() when the mutation happens via JS / RPC and didn't go through a form.
-
Link prefetch (on by default)
Same-origin in-app links are prefetched speculatively, so a click resolves from a warm cache with no round-trip. No attribute is needed; it is on for every internal <a href>, the way Next, Nuxt, and SvelteKit ship auto-prefetch, and the prefetch sends the same headers a real navigation does so the click consumes the fragment.
The default strategy is device-adaptive, because one strategy cannot serve both input modalities. On a hover-capable pointer (mouse / trackpad) the default is intent (warm on hover or focus, a real head-start before the click). On touch the default is viewport (warm as links settle on-screen), because touch has no hover and touchstart fires at tap time, too late to help. The modality is detected with matchMedia('(hover: hover) and (pointer: fine)'), not a user-agent sniff, and a per-link data-prefetch always overrides it.
From 88b579f8dceffe95b46fa96b838eba41c067aaef Mon Sep 17 00:00:00 2001
From: Vivek
Date: Fri, 21 Aug 2026 04:15:07 +0530
Subject: [PATCH 05/10] docs: correct the exempt-seam counts in the dts
coverage test
The new barrel export makes the underscore seam count 64. Two adjacent
numbers in the same comment were already stale before this branch (the
checked total drifted to 172 and the runtime total to 236), so all three
are re-derived with the test's own entryPairs and checkedNames rather
than left half-corrected and self-contradicting.
---
test/types/dts-export-coverage.test.mjs | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/test/types/dts-export-coverage.test.mjs b/test/types/dts-export-coverage.test.mjs
index cd8640d9f..dd89512e6 100644
--- a/test/types/dts-export-coverage.test.mjs
+++ b/test/types/dts-export-coverage.test.mjs
@@ -39,7 +39,7 @@ const tscBin = join(ROOT, 'node_modules', 'typescript', 'bin', 'tsc');
// `minNames` is the per-package total of CHECKED export names, which catches the
// failure the entry count cannot see: an entry that still resolves, but to a
// SMALLER module than intended, quietly shrinking the check. Today's totals are
-// 169 for core (232 runtime names minus the 63 exempt `_` seams) and 146 for
+// 172 for core (236 runtime names minus the 64 exempt `_` seams) and 146 for
// server; the floors sit just below. Raising an export count only makes both
// floors stricter, which is the same rationale recorded on the reverse guard.
const PACKAGES = [
@@ -70,7 +70,7 @@ function entryPairs(pkgDir) {
/**
* Runtime export names the overlay is REQUIRED to declare. A leading `_` marks a
* test-only seam that is deliberately NOT part of the published API (the
- * `Internal exports for unit testing` block in `src/router-client.js` is 63 such
+ * `Internal exports for unit testing` block in `src/router-client.js` is 64 such
* names), so declaring them would publish a test seam as editor autocomplete.
*
* The convention is expressed as a RULE rather than an ignore list, because a
@@ -215,7 +215,7 @@ for (const { name, dir, minEntries, minNames } of PACKAGES) {
assert.ok(
exemptTotal >= 1,
`${name}: no underscore-prefixed export was exempted anywhere, so the ` +
- `test-only-seam rule in checkedNames() is dead code (63 such names exist today)`,
+ `test-only-seam rule in checkedNames() is dead code (64 such names exist today)`,
);
}
});
From 3b31e36d781b7e9f69df13bb8196564ee2610eec Mon Sep 17 00:00:00 2001
From: Vivek
Date: Fri, 21 Aug 2026 04:25:21 +0530
Subject: [PATCH 06/10] fix: reach a form-associated submitter's form, and make
the frame case falsifiable
Two problems the final review found.
A submitter is form-associated by form="id", not by containment, so
closest() from a detached button never passes through the form it
submits and the form's own mark was dropped. Every doc surface promised
otherwise. The form is now a FALLBACK, consulted only when the trigger
resolves no carrier, so a marked form reaches its detached buttons while
data-preserve-scroll="false" on a button inside one still wins.
The frame case was a tautology: with the #1427 guard intact the block
never runs, and with it deleted preserveScroll suppresses the write, so
the offset was START_Y either way and deleting that guard left all eight
cases green. An unmarked sibling link is the discriminating half and now
carries the claim.
---
packages/core/src/router-client/events.js | 11 ++--
packages/core/src/router-client/scroll.js | 28 +++++++--
.../browser/nav-preserve-scroll.test.js | 57 ++++++++++++++++---
.../core/test/routing/router-client.test.js | 31 ++++++++++
4 files changed, 112 insertions(+), 15 deletions(-)
diff --git a/packages/core/src/router-client/events.js b/packages/core/src/router-client/events.js
index a44620919..bfc245298 100644
--- a/packages/core/src/router-client/events.js
+++ b/packages/core/src/router-client/events.js
@@ -139,10 +139,13 @@ export function onSubmit(e) {
// an explicit `data-webjs-frame` on (or above) the form or its submitter
// wins, `_top` breaks out, absence falls back to the enclosing frame.
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);
+ // Same trigger precedence as the frame line above. The form is passed as a
+ // FALLBACK rather than relied on through the submitter's ancestors, because a
+ // submitter is form-associated by `form="id"` and may sit outside the form
+ // entirely, where `closest()` would never reach it. The trigger still wins
+ // when it resolves a carrier, so `data-preserve-scroll="false"` on a button
+ // inside a marked form still opts that button out.
+ const preserveScroll = resolvePreserveScroll(submitter || form, form);
performSubmission(url.href, method, body, frameId, form, { preserveScroll });
}
diff --git a/packages/core/src/router-client/scroll.js b/packages/core/src/router-client/scroll.js
index a1cdf8431..58c01cfe9 100644
--- a/packages/core/src/router-client/scroll.js
+++ b/packages/core/src/router-client/scroll.js
@@ -276,13 +276,33 @@ export function bumpRestoreGeneration() {
* 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.
*
+ * `fallback` is consulted only when `trigger` resolves NO carrier, which is what
+ * a form submission needs. A submitter is form-associated by `form="id"` rather
+ * than by containment, so `
<a href="/" data-preserve-scroll="false">Home</a> <!-- opts back out -->
</nav>
-<!-- forms too: the lookup starts at the submitter and walks up through the form -->
+<!-- forms too: resolved from the submitter, falling back to the form itself -->
<form method="post" action="${'${saveDraft}'}" data-preserve-scroll>...</form>
-
The attribute resolves through closest(), so one mark on a wrapping element covers every link inside it, and data-preserve-scroll="false" on something nearer opts back out. A hash link still scrolls to its anchor, because the reader named a target and a named target beats a blanket preference. It is inert on a frame-targeted link, since a frame swap never writes a scroll to begin with, and inert with JS off, where the link is a plain <a>, so nothing about a page's correctness may depend on it.
+
The attribute resolves through closest(), so one mark on a wrapping element covers every link inside it, and data-preserve-scroll="false" on something nearer opts back out. On a form the lookup starts at the submitter and falls back to the form, so a marked form covers its buttons even when one is attached from elsewhere with form="id" rather than nested inside it. A hash link still scrolls to its anchor, because the reader named a target and a named target beats a blanket preference. It is inert on a frame-targeted link, since a frame swap never writes a scroll to begin with, and inert with JS off, where the link is a plain <a>, so nothing about a page's correctness may depend on it.
It carries the reader's current offset onto the destination. It does not restore the offset they once had there, which is a different feature and not one WebJs ships, so this is the wrong tool for a "back to the list" link.
Link prefetch (on by default)
diff --git a/website/app/docs/migrating-from-nextjs/page.ts b/website/app/docs/migrating-from-nextjs/page.ts
index b8f41bc70..dd94a897e 100644
--- a/website/app/docs/migrating-from-nextjs/page.ts
+++ b/website/app/docs/migrating-from-nextjs/page.ts
@@ -28,7 +28,7 @@ export default function MigratingFromNextjs() {
Client Component / 'use client'
A WebComponent. Interactivity lives in components, which hydrate. No directive: a @click or signal read requests the JavaScript.
'use server' action (in a component file)
A .server.{js,ts} file with 'use server'. It is a FILE boundary, not an in-component directive. Import it and call it; the browser import is rewritten to a typed RPC stub.
React hooks (useState, useEffect)
Signals (signal / computed from @webjsdev/core) plus the lit-style lifecycle hooks (connectedCallback, updated, ...). State lives in components.
-
next/link
A plain <a href>. The client router auto-enhances same-origin links into partial-swap navigations. Prefetch is on by default; tune it with data-prefetch.
+
next/link
A plain <a href>. The client router auto-enhances same-origin links into partial-swap navigations. Prefetch is on by default; tune it with data-prefetch. <Link scroll={false}> is data-preserve-scroll, which also works on a wrapping element and on a form.
next/image
Not provided. Use a plain <img> (with width / height / loading="lazy") and layer an image service if you need one. WebJs ships no image optimizer.
getServerSideProps / getStaticProps
An async page function: export default async function Page({ params, searchParams, url }). It runs on the server; fetch your data there (through a .server action) and return the markup.
generateStaticParams / static export
Not needed. Pages render per request. Opt a same-for-everyone page into the HTML cache with export const revalidate = N, the no-build equivalent of ISR.
From b360fb7d9967aa628a172608835d26035a3e9026 Mon Sep 17 00:00:00 2001
From: Vivek
Date: Fri, 21 Aug 2026 04:34:20 +0530
Subject: [PATCH 08/10] test: restate the decayed counterfactual and pin the
walk where the fallback cannot
The submitter-to-form fallback made two header claims false. Dropping
closest() no longer reds case 6, because the fallback catches the form's
mark, so nothing was left proving the walk on the submit path. And the
guard toggle now reds case 9 as well.
Both restated from a real toggle rather than reasoning, and the walk is
pinned by a marked fieldset inside an unmarked form, which the fallback
cannot rescue.
---
.../browser/nav-preserve-scroll.test.js | 15 ++++++---
.../core/test/routing/router-client.test.js | 33 ++++++++++++++++++-
2 files changed, 42 insertions(+), 6 deletions(-)
diff --git a/packages/core/test/routing/browser/nav-preserve-scroll.test.js b/packages/core/test/routing/browser/nav-preserve-scroll.test.js
index 34521733a..ea59b243a 100644
--- a/packages/core/test/routing/browser/nav-preserve-scroll.test.js
+++ b/packages/core/test/routing/browser/nav-preserve-scroll.test.js
@@ -40,13 +40,18 @@
* branch, so re-run the toggle and restate this list if a later commit touches
* the scroll block or the resolver:
*
- * - delete the `!preserveScroll` guard in `fetch-apply.js`: cases 2, 3, 6, 7
+ * - delete the `!preserveScroll` guard in `fetch-apply.js`: cases 2, 3, 6, 7, 9
* - gate the WHOLE `if (recordHistory && !frameId)` block on `!preserveScroll`
* instead: case 5 (the hash carve-out is what that breaks)
- * - drop the `closest()` walk to a bare `hasAttribute`: cases 3 AND 6. The
- * form half is not incidental: the mark sits on the `