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