From d71ce459afde6105f6e5386ab22404fe5a3a867f Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 6 Aug 2026 22:06:13 +0530 Subject: [PATCH 01/20] fix: back-button restore survives late layout growth A snapshot's scrollY is recorded against the page at its settled height. The restore replays that number onto a document that has only just been swapped in and is still shorter, because the components in the restored markup have not upgraded and re-rendered yet. When they do, content grows above the viewport, and the browser's scroll anchoring holds the visual position by adding that growth to scrollY. The recorded offset is counted twice, so the reader lands below where they left: 763px on /ui/button, exactly the settled-minus-swapped height delta. Suppress anchoring for the duration of the restore instead of re-asserting the scroll afterwards. The number being replayed already accounts for the growth, so withholding the browser's correction fixes the double count at its source. Suppression never moves the viewport, so it cannot yank a reader who has started scrolling, and it needs no settle detection, which a re-assert would (and which cannot be answered without fighting a streaming boundary). The window closes on the first real input, on that restore's own revalidation settling plus two frames, or on a 2s ceiling. --- .../references/client-router-and-streaming.md | 8 + .../webjs/references/muscle-memory-gotchas.md | 6 + packages/core/src/router-client.js | 122 ++++++++++- .../browser/nav-scroll-anchor-restore.test.js | 202 ++++++++++++++++++ .../core/test/routing/router-client.test.js | 101 +++++++++ website/app/docs/client-router/page.ts | 1 + 6 files changed, 439 insertions(+), 1 deletion(-) create mode 100644 packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js diff --git a/.agents/skills/webjs/references/client-router-and-streaming.md b/.agents/skills/webjs/references/client-router-and-streaming.md index c9d111ef1..6ddd7a7a4 100644 --- a/.agents/skills/webjs/references/client-router-and-streaming.md +++ b/.agents/skills/webjs/references/client-router-and-streaming.md @@ -56,6 +56,14 @@ revalidate(); // clear the entire snapshot cache The router keeps a URL-keyed snapshot cache (LRU, cap 16) so Back/Forward restores instantly, then refetches in the background. Call `revalidate(path)` after a server action mutates data a cached page depends on. Wire bytes are minimized by an `X-Webjs-Have` header, so the server returns only the divergent layout fragment. Concurrent navigations abort the prior in-flight fetch, and scroll is restored on Back/Forward. +**Back/Forward scroll restore vs late layout growth.** The router SUPPRESSES the browser's scroll anchoring (`overflow-anchor`) for the duration of a Back/Forward restore, then puts it back. The saved offset was recorded against the page at its SETTLED height, while the DOM the restore swaps in is still shorter until its components upgrade and render. Without the suppression the browser treats that late growth as content appearing above a reader and adds it to the offset the router just replayed, so the reader lands BELOW where they left (the reported case was 763px, exactly the height a page gained after its swap). Three things follow for an app. + +- **Do not write your own scroll restore.** A `popstate` listener that calls `scrollTo`, a saved offset in `sessionStorage`, a `scrollIntoView` on a remembered element: all of them fight the router, which already set `history.scrollRestoration = 'manual'` and is the sole authority on scroll during a navigation. If Back lands in the wrong place, that is a framework bug to report, not something to patch in app code. +- **An app that sets `overflow-anchor` on `` itself sees it overridden during a restore and restored afterwards**, including a value set inline by your own script. Setting it in a stylesheet is unaffected between restores. Nothing else on the page is touched, and the router never sets `overflow-anchor` anywhere but the root element. +- **The window closes on the first real input** (`wheel`, `touchmove`, `keydown`, `pointerdown`), on the restore's own background revalidation settling, or on a 2s ceiling, whichever comes first. So a reader who starts scrolling mid-restore immediately gets normal browser anchoring back. Suppression only ever WITHHOLDS a browser correction, it never moves the viewport, so it cannot yank someone who has taken over. + +Components that reach their final size only after they render (a chart, a media embed with no intrinsic dimensions, anything sized from measured content) are exactly the shape that triggers this, and they need no special handling: give them a placeholder height where you can, and let the router own the restore. + **Error recovery.** A 2xx/3xx swap applies in place, and an HTML error body of any status (a 422 re-rendered form, a 5xx error page) is ALSO applied in place with no reload. For a non-HTML error or a transport failure the router dispatches a cancelable `webjs:navigation-error` on `document` (detail `{ url, status, error }`). Call `preventDefault()` to own recovery, otherwise the router renders a minimal in-place alert into the layout slot. ```ts diff --git a/.agents/skills/webjs/references/muscle-memory-gotchas.md b/.agents/skills/webjs/references/muscle-memory-gotchas.md index 2d8f11419..4390bf5d9 100644 --- a/.agents/skills/webjs/references/muscle-memory-gotchas.md +++ b/.agents/skills/webjs/references/muscle-memory-gotchas.md @@ -170,6 +170,12 @@ The file stays `middleware.ts`, NOT Next 16's renamed `proxy.ts`. WebJs middlewa Navigation is automatic. The client router auto-enables when `@webjsdev/core` loads (any page with a component), so a plain `` gets soft navigation for free. There is no `` to import and no `useRouter`. For programmatic navigation import `navigate()` / `revalidate()` from `@webjsdev/core`. There is no `next/image`, `next/font`, `next/script`, or `next/dynamic`. WebJs is no-build: use a plain ``, a `` / `@font-face`, a component's `static lazy = true` for viewport lazy-loading, and a dynamic `import()` where code should load lazily. +### No ``, and no scroll restore of your own + +Remix ships a `` component, Next has a `scrollRestoration` flag and a pile of community `useEffect` + `scrollTo` recipes, and every one of them is a thing to NOT port. WebJs restores scroll on Back/Forward automatically: the router sets `history.scrollRestoration = 'manual'` on boot and is the sole authority on scroll for the whole navigation. There is no component to render and no option to enable. An app-level `popstate` listener that calls `scrollTo`, a remembered offset in `sessionStorage`, or a `scrollIntoView` on a saved element all race the router and win sometimes, which is worse than losing consistently. + +This includes the case that most tempts a hand-rolled fix: Back landing BELOW where the reader left, on a page whose components size themselves after they render. The router already handles it, by suppressing the browser's scroll anchoring across the restore so late growth above the viewport is not added to the offset it just replayed (see `client-router-and-streaming.md`). If a restore still lands wrong, report it rather than patching around it in app code. + ### Server-only code: the `.server.ts` boundary, not a `server-only` package Next poisons a client-imported module with the `server-only` package. WebJs uses the file extension: `*.server.ts` is the path-level boundary (the file router refuses to serve the source). A `'use server'` file's exports are RPC-callable; a `.server.ts` file WITHOUT `'use server'` is a server-only utility whose browser import throws at load. Reach a no-`'use server'` utility through a `'use server'` action, `route.ts`, or `middleware`, never by direct import into a shipping page or component. diff --git a/packages/core/src/router-client.js b/packages/core/src/router-client.js index a03c1cff4..a33dd8618 100644 --- a/packages/core/src/router-client.js +++ b/packages/core/src/router-client.js @@ -301,6 +301,110 @@ let currentPageUrl = null; */ let prevScrollRestoration = null; +/** + * Hard ceiling on the restore window (#1310). The revalidation is a + * same-origin GET of a page the browser rendered moments ago, so this is + * generously past its p99. It exists only so a hung or never-settling fetch + * can never leave scroll anchoring suppressed for the life of the page. + */ +const ANCHOR_SUPPRESS_CEILING_MS = 2000; + +/** + * Inputs that mean the reader has taken over the viewport, so the restore is + * over and the browser's own anchoring should resume. + * + * NOT `scroll`. The router's own `scrollTo` and anchoring itself both fire + * `scroll`, so it cannot tell a reader apart from the restore it is guarding, + * and no threshold makes it able to. These are input events, so there is + * nothing to threshold out and the FIRST one closes the window. `keydown` is + * deliberately not narrowed to scrolling keys: any keypress means interaction, + * and closing early only restores the browser default, which is the safe + * direction to err in. + * + * @type {string[]} + */ +const ANCHOR_RELEASE_EVENTS = ['wheel', 'touchmove', 'keydown', 'pointerdown']; + +/** + * Closes the currently open restore window, or null when none is open. + * @type {(() => void) | null} + */ +let releaseScrollAnchor = null; + +/** + * Suppress the browser's scroll anchoring for the duration of a back/forward + * scroll restore (#1310). + * + * A snapshot's `scrollY` is recorded against the page at its SETTLED height. + * The restore replays that number onto a document that has only just been + * swapped in and is still shorter, because the components in the restored + * markup have not upgraded and re-rendered yet. When they do, content grows + * ABOVE the viewport, and scroll anchoring (`overflow-anchor: auto`, the UA + * default) holds the VISUAL position by adding that growth to `scrollY`. The + * offset is counted twice. On webjs.dev's `/ui/button` that lands the reader + * 763px too low, exactly the settled-minus-swapped height delta. + * + * Anchoring is right for a reader on a live page and wrong for exactly this + * window, where the restored number already accounts for the growth. So the + * window suppresses it rather than re-scrolling afterwards. A re-assert would + * have to fire on every growth, and a settling restore cannot be told apart + * from a `` boundary streaming in (#471 / #473). Suppression + * never MOVES the viewport, it only withholds a correction, so it also cannot + * yank a reader who has already started scrolling. + * + * Chromium, Firefox, and WebKit all implement scroll anchoring and all three + * honour `overflow-anchor: none` on the root scroller, so there is no + * engine-specific path here. + * + * @returns {() => void} Idempotent release. Safe to call after the window has + * already closed on user input or the ceiling. + */ +function suppressScrollAnchoring() { + if (typeof document === 'undefined' || !document.documentElement) return () => {}; + // A second restore inside an open window supersedes the first. + if (releaseScrollAnchor) releaseScrollAnchor(); + const root = document.documentElement; + // Save and restore the author's own inline value rather than blanking it, + // the same contract `prevScrollRestoration` keeps above. + const prev = root.style.getPropertyValue('overflow-anchor'); + root.style.setProperty('overflow-anchor', 'none'); + /** @type {ReturnType | null} */ + let timer = null; + const release = () => { + // Only the window that installed this release may close it. + if (releaseScrollAnchor !== release) return; + releaseScrollAnchor = null; + if (timer) { clearTimeout(timer); timer = null; } + if (typeof window !== 'undefined') { + for (const ev of ANCHOR_RELEASE_EVENTS) { + window.removeEventListener(ev, release, /** @type {any} */ ({ capture: true })); + } + } + if (prev) root.style.setProperty('overflow-anchor', prev); + else root.style.removeProperty('overflow-anchor'); + }; + releaseScrollAnchor = release; + timer = setTimeout(release, ANCHOR_SUPPRESS_CEILING_MS); + if (typeof window !== 'undefined') { + for (const ev of ANCHOR_RELEASE_EVENTS) { + window.addEventListener(ev, release, { capture: true, passive: true }); + } + } + return release; +} + +/** + * Run `fn` after two animation frames, so a just-applied DOM has laid out + * before it reads or acts. Falls back to a macrotask where + * `requestAnimationFrame` is absent (the linkedom-backed node test harness). + * + * @param {() => void} fn + */ +function afterTwoFrames(fn) { + if (typeof requestAnimationFrame !== 'function') { setTimeout(fn, 0); return; } + requestAnimationFrame(() => requestAnimationFrame(fn)); +} + /** Enable the client router. Idempotent. */ export function enableClientRouter() { if (enabled || typeof document === 'undefined') return; @@ -371,6 +475,8 @@ export function disableClientRouter() { history.scrollRestoration = prevScrollRestoration; prevScrollRestoration = null; } + // Never leave a restore window open on (#1310). + if (releaseScrollAnchor) releaseScrollAnchor(); currentPageUrl = null; } @@ -1369,14 +1475,28 @@ async function performNavigation(href, isPopState, frameId) { // Restore window scroll to where the user left it. Use // behavior:'instant' so an app-level `scroll-behavior: smooth` // stylesheet does not animate the restore (native nav jumps). + // + // `cached.scrollY` was recorded at the page's SETTLED height, and the + // DOM just swapped in is still shorter until its components upgrade + // and re-render. Suppress scroll anchoring across the restore, or the + // browser adds that late growth to the restored offset and the reader + // lands below where they left (#1310). + let releaseAnchor = () => {}; if (typeof window !== 'undefined') { + releaseAnchor = suppressScrollAnchoring(); window.scrollTo({ left: cached.scrollX, top: cached.scrollY, behavior: 'instant' }); } // Fire-and-forget revalidation. Uses a fresh AbortController // since this background fetch is allowed to overlap with the // next foreground nav (it'll get aborted if a new nav lands). + // + // Closing the anchoring window on THIS revalidation's settle (plus two + // frames for the re-applied DOM to lay out) is what keeps the window + // tied to one restore. A height observer could not tell a settling + // restore from a streaming boundary (#471 / #473). fetchAndApply(href, frameId, /* recordHistory */ false, optimisticState, 'GET', null, signal, myToken, /* revalidating */ true) - .catch(() => {}); + .catch(() => {}) + .then(() => afterTwoFrames(releaseAnchor)); return; } } diff --git a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js new file mode 100644 index 000000000..5a518537d --- /dev/null +++ b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js @@ -0,0 +1,202 @@ +/** + * Real-browser test for #1310: a Back/Forward scroll restore must survive the + * restored page growing after the swap. + * + * The router records a snapshot's `scrollY` against the page at its SETTLED + * height. On restore it replays that number onto a document that has only just + * been swapped in and is still shorter, because the components in the restored + * markup have not upgraded and re-rendered yet. When they do, content grows + * ABOVE the viewport, and the browser's scroll anchoring (`overflow-anchor: + * auto`, the UA default) holds the VISUAL position by adding that growth to + * `scrollY`. The recorded offset is counted twice, and the reader lands below + * where they left (763px on webjs.dev's `/ui/button`). + * + * This MUST run in a real browser. linkedom implements neither scroll anchoring + * nor real scrolling, so only a live engine can prove the offset survives. All + * three engines in the matrix implement anchoring and honour `overflow-anchor: + * none` on the root scroller, so there is no engine skip here. + * + * The fixture has to GROW AFTER THE SWAP, since the growth is the whole + * mechanism. A custom element that takes its height one frame after it is + * connected models the real cause: as raw parsed markup it is 0px tall, and it + * reaches its real size only once its own render has run. + */ +import { enableClientRouter, disableClientRouter, _snapshotCache, _setCurrentPageUrl } from '../../../src/router-client.js'; + +import { assert } from '../../../../../test/browser-assert.js'; +import { installNavGuard } from '../../../../../test/browser-nav-guard.js'; + +/** Height the restored content gains after the swap, matching the live defect. */ +const GROWTH = 763; +/** Where the reader was when they navigated away. */ +const RESTORED_Y = 800; + +/** + * Grows one frame AFTER connection, never during it. The delay is load-bearing: + * a synchronous height would land in the same layout as the swap, so the + * browser would have nothing to anchor against and the defect would not + * reproduce at all. The real page grows ~25ms after its swap. + */ +class GrowLate extends HTMLElement { + connectedCallback() { + this.style.display = 'block'; + this.style.height = '0px'; + requestAnimationFrame(() => { this.style.height = GROWTH + 'px'; }); + } +} +customElements.define('wj-grow-late-1310', GrowLate); + +const frame = () => new Promise((r) => requestAnimationFrame(() => r())); +/** Long enough for the grower to connect, lay out, and take its height. */ +async function afterGrowth() { for (let i = 0; i < 4; i++) await frame(); } + +/** + * The restored page: a grower that is 0px in markup and 763px once it renders, + * followed by enough filler to make the recorded offset reachable. The grower + * sits entirely above the viewport at `RESTORED_Y`, which is where anchoring + * acts. + */ +const RESTORED_BODY = + '' + + '' + + '
restored
' + + ''; + +const RESTORED_HTML = + '' + RESTORED_BODY + ''; + +suite('Client router: a Back restore survives late layout growth (#1310)', () => { + let navGuard, container, origFetch, origScrollBehavior, entriesPushed; + /** Resolves the in-flight revalidation, so a case controls the window's close. */ + let releaseFetch; + + async function setup() { + navGuard = installNavGuard(); + enableClientRouter(); + origScrollBehavior = document.documentElement.style.scrollBehavior; + // A restore under `scroll-behavior: smooth` is what #601 made instant; keep + // the default here so the assertions are about position, not animation. + document.documentElement.style.scrollBehavior = ''; + + // The page being navigated AWAY from. Its route-key differs from the + // restored page's, so the swap replaces rather than morphs and the restored + // grower is a genuinely new element that upgrades. That is the real shape: + // Back goes from one page to another, not to itself. + container = document.createElement('div'); + container.innerHTML = + '' + + '
outgoing
' + + ''; + document.body.appendChild(container); + + // The revalidation is held open by default, so a case can assert inside the + // restore window. Its response repeats the restored markup, which is what + // the server would send. + origFetch = window.fetch; + window.fetch = () => new Promise((resolve) => { + releaseFetch = () => resolve(new Response(RESTORED_HTML, { + headers: { 'content-type': 'text/html', 'x-webjs-build': '' }, + })); + }); + + // Two real same-document history entries, so `history.back()` drives a REAL + // popstate. Reassigning `location` is impossible in a browser, and a + // synthetic popstate event would not exercise the browser's own restore. + history.pushState(null, '', location.pathname + '?wj=anchor-a'); + history.pushState(null, '', location.pathname + '?wj=anchor-b'); + entriesPushed = true; + _snapshotCache.set(location.pathname + '?wj=anchor-a', { + html: RESTORED_HTML, scrollX: 0, scrollY: RESTORED_Y, + }); + _setCurrentPageUrl(location.origin + location.pathname + '?wj=anchor-b'); + // Start where the reader was, so the restore is a real scroll rather than + // a no-op from 0. + window.scrollTo({ top: 0, left: 0, behavior: 'instant' }); + } + + /** Drive a real Back and wait for the router's synchronous restore to land. */ + async function goBack() { + const popped = new Promise((r) => window.addEventListener('popstate', r, { once: true })); + history.back(); + await popped; + // The router's popstate handler runs on the same event, and its cache-hit + // branch is synchronous through the restore. One task is enough to be past + // it without letting a frame paint. + await new Promise((r) => setTimeout(r, 0)); + } + + async function teardown() { + if (releaseFetch) releaseFetch(); + releaseFetch = null; + window.fetch = origFetch; + // Let the released revalidation finish so it cannot swap during a later case. + for (let i = 0; i < 6; i++) await frame(); + disableClientRouter(); + _snapshotCache.delete(location.pathname + '?wj=anchor-a'); + _setCurrentPageUrl(null); + if (entriesPushed) { + history.replaceState(null, '', location.pathname); + entriesPushed = false; + } + container.remove(); + document.documentElement.style.removeProperty('overflow-anchor'); + document.documentElement.style.scrollBehavior = origScrollBehavior; + window.scrollTo({ top: 0, left: 0, behavior: 'instant' }); + navGuard.remove(); + enableClientRouter(); + } + + test('the restore opens a scroll-anchoring window', async () => { + await setup(); + try { + await goBack(); + assert.equal(document.documentElement.style.getPropertyValue('overflow-anchor'), 'none', + 'the restore suppresses anchoring, so the browser cannot add the ' + + 'restored page\'s late growth to the offset it just replayed'); + } finally { await teardown(); } + }); + + test('late growth above the viewport does not push the reader down', async () => { + await setup(); + try { + await goBack(); + const restored = window.scrollY; + assert.ok(Math.abs(restored - RESTORED_Y) < 5, + `the restore lands on the recorded offset (got ${restored})`); + await afterGrowth(); + const grown = document.querySelector('wj-grow-late-1310'); + assert.ok(grown && grown.getBoundingClientRect().height > GROWTH - 5, + 'the fixture actually grew after the swap, so the case is live'); + assert.ok(Math.abs(window.scrollY - RESTORED_Y) < 5, + `${GROWTH}px of growth above the viewport must not move the reader ` + + `(expected ~${RESTORED_Y}, got ${window.scrollY})`); + } finally { await teardown(); } + }); + + test('the window closes once the revalidation settles, leaving no residue', async () => { + await setup(); + try { + await goBack(); + assert.equal(document.documentElement.style.getPropertyValue('overflow-anchor'), 'none'); + releaseFetch(); + releaseFetch = null; + // The close is the revalidation settling plus two frames for the + // re-applied DOM to lay out. + for (let i = 0; i < 6; i++) await frame(); + assert.equal(document.documentElement.style.getPropertyValue('overflow-anchor'), '', + 'the router leaves nothing of its own on after the restore'); + } finally { await teardown(); } + }); + + test('a reader taking over closes the window immediately', async () => { + await setup(); + try { + await goBack(); + assert.equal(document.documentElement.style.getPropertyValue('overflow-anchor'), 'none'); + window.dispatchEvent(new WheelEvent('wheel', { bubbles: true })); + assert.equal(document.documentElement.style.getPropertyValue('overflow-anchor'), '', + 'the first real input hands the viewport back to the browser, so a ' + + 'reader who has started scrolling keeps normal anchoring'); + } finally { await teardown(); } + }); +}); diff --git a/packages/core/test/routing/router-client.test.js b/packages/core/test/routing/router-client.test.js index 2ebcf3047..f8df361ce 100644 --- a/packages/core/test/routing/router-client.test.js +++ b/packages/core/test/routing/router-client.test.js @@ -2047,6 +2047,107 @@ test('popstate cache restore scrolls instantly, not animated (#601)', async () = } }); +test('popstate cache restore suppresses scroll anchoring across the window (#1310)', async () => { + // The saved scrollY was recorded at the page's SETTLED height. The restored + // DOM lays out shorter until its components upgrade, and the browser's + // scroll anchoring then adds that late growth to the restored offset, so + // the reader lands below where they left. The restore suppresses anchoring + // for its duration instead of re-scrolling afterwards. + const origLoc = globalThis.location; + const origFetch = globalThis.fetch; + const prevPageUrl = _currentPageUrl(); + const root = document.documentElement; + _snapshotCache.set('/anchor-here', { + html: 'cached', + scrollX: 0, + scrollY: 800, + }); + globalThis.location = /** @type any */ ({ + href: 'http://localhost/anchor-here', + pathname: '/anchor-here', origin: 'http://localhost', search: '', hash: '', + }); + _setCurrentPageUrl('http://localhost/elsewhere'); + globalThis.fetch = async () => new Response('', { + status: 200, headers: { 'content-type': 'text/html' }, + }); + const origWinScrollTo = globalThis.window?.scrollTo; + const origGlobalScrollTo = globalThis.scrollTo; + globalThis.scrollTo = /** @type any */ (() => {}); + if (globalThis.window) globalThis.window.scrollTo = /** @type any */ (() => {}); + document.head.innerHTML = ''; + document.body.innerHTML = 'before-pop'; + try { + // The cache-hit popstate branch runs synchronously through the restore, + // so the window is already open when _onPopState returns. + _onPopState({}); + assert.equal(root.style.getPropertyValue('overflow-anchor'), 'none', + 'the restore opens the window, so the browser cannot add late growth ' + + 'to the offset it just replayed'); + // The window closes on THIS revalidation settling plus two frames. + await new Promise((r) => setTimeout(r, 20)); + assert.ok(!root.style.getPropertyValue('overflow-anchor'), + 'the window closes once the revalidation settles, leaving no residue ' + + 'on '); + } finally { + _snapshotCache.delete('/anchor-here'); + _setCurrentPageUrl(prevPageUrl); + globalThis.location = origLoc; + globalThis.fetch = origFetch; + globalThis.scrollTo = origGlobalScrollTo; + if (globalThis.window) globalThis.window.scrollTo = origWinScrollTo; + root.style.removeProperty('overflow-anchor'); + document.head.innerHTML = ''; + document.body.innerHTML = ''; + } +}); + +test('disableClientRouter closes an open scroll-anchor window (#1310)', async () => { + // The router must leave nothing of its own on after it is disabled. + const origLoc = globalThis.location; + const origFetch = globalThis.fetch; + const prevPageUrl = _currentPageUrl(); + const root = document.documentElement; + _snapshotCache.set('/anchor-disable', { + html: 'cached', + scrollX: 0, + scrollY: 800, + }); + globalThis.location = /** @type any */ ({ + href: 'http://localhost/anchor-disable', + pathname: '/anchor-disable', origin: 'http://localhost', search: '', hash: '', + }); + _setCurrentPageUrl('http://localhost/elsewhere'); + globalThis.fetch = async () => new Response('', { + status: 200, headers: { 'content-type': 'text/html' }, + }); + const origWinScrollTo = globalThis.window?.scrollTo; + const origGlobalScrollTo = globalThis.scrollTo; + globalThis.scrollTo = /** @type any */ (() => {}); + if (globalThis.window) globalThis.window.scrollTo = /** @type any */ (() => {}); + document.head.innerHTML = ''; + document.body.innerHTML = 'before-pop'; + try { + _onPopState({}); + assert.equal(root.style.getPropertyValue('overflow-anchor'), 'none'); + disableClientRouter(); + assert.ok(!root.style.getPropertyValue('overflow-anchor'), + 'disabling the router closes any window it left open'); + // Let the background revalidation settle (avoid an unhandled rejection). + await new Promise((r) => setTimeout(r, 20)); + } finally { + _snapshotCache.delete('/anchor-disable'); + _setCurrentPageUrl(prevPageUrl); + globalThis.location = origLoc; + globalThis.fetch = origFetch; + globalThis.scrollTo = origGlobalScrollTo; + if (globalThis.window) globalThis.window.scrollTo = origWinScrollTo; + root.style.removeProperty('overflow-anchor'); + document.head.innerHTML = ''; + document.body.innerHTML = ''; + enableClientRouter(); // re-enable for subsequent tests + } +}); + test('navigate: forward-nav scroll-to-top is instant, not animated (#601)', async () => { document.body.innerHTML = 'before'; const { restore } = installNavigationMocks({ diff --git a/website/app/docs/client-router/page.ts b/website/app/docs/client-router/page.ts index ac6f766a9..84c634fc4 100644 --- a/website/app/docs/client-router/page.ts +++ b/website/app/docs/client-router/page.ts @@ -222,6 +222,7 @@ connectWS('/posts/' + id + '/feed', { onMessage: (m) => renderStream(m) });Snapshot cache + back/forward

The router maintains a URL-keyed LRU cache of page snapshots (capacity 16). On back/forward via popstate, the cached DOM is applied instantly and the captured window-scroll position is restored. A background refetch then revalidates the snapshot quietly.

+

A back/forward restore also suppresses the browser's scroll anchoring (overflow-anchor) for its duration, then puts it back. The saved offset was recorded against the page at its settled height, while the DOM the restore swaps in is still shorter until its components upgrade and render. Without the suppression the browser reads that late growth as content appearing above the reader and adds it to the offset the router just replayed, landing them below where they left. The window closes on the first real input (wheel, touchmove, keydown, pointerdown), on that restore's background revalidation settling, or on a 2s ceiling, whichever comes first, so a reader who starts scrolling mid-restore gets normal anchoring back immediately. Suppression only withholds a browser correction, it never moves the viewport. If your app sets overflow-anchor on <html> inline itself, it is overridden during a restore and restored afterwards.

Nav scroll restoration (both the back/forward restore and the scroll-to-top on a forward nav) is forced behavior: 'instant', so setting html { scroll-behavior: smooth } in your app does not make navigation visibly animate the scroll. 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.

After a server action mutates data that a cached page depends on, call revalidate():

import { revalidate } from '@webjsdev/core'; From c61cec3aec94fd0844c6bea2b1b090774cf2faa0 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 6 Aug 2026 22:11:00 +0530 Subject: [PATCH 02/20] test: point the back-scroll e2e back at the gallery The block was moved to /docs/routing because /ui/button reproducibly restored to 1563 instead of 800, and that was recorded as unrelated live website behaviour. It was this bug. A docs page never grows after its swap, so asserting there could not see the defect at all. It also waits long enough now for the page to finish growing and revalidating. The old 80ms landed before the growth, so the restore was correct at 80ms and wrong at 1200ms, which is exactly the failure. --- test/e2e/form-submission-and-race.test.mjs | 35 ++++++++-------------- 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/test/e2e/form-submission-and-race.test.mjs b/test/e2e/form-submission-and-race.test.mjs index e03a84665..dabd75492 100644 --- a/test/e2e/form-submission-and-race.test.mjs +++ b/test/e2e/form-submission-and-race.test.mjs @@ -375,24 +375,12 @@ test('scroll restoration: back-button restores window scroll position', async () const browser = await chromium.launch(); const page = await (await browser.newContext()).newPage(); try { - // This one block runs against /docs rather than /ui, and the reason is a - // real finding rather than convenience. The router restores window scroll - // correctly on /docs/* (set 800, back returns 800), but on a /ui/ - // gallery page it consistently lands on 1563 instead, reproducibly and - // independently of timing. That is a live website behaviour, unrelated to - // the router assertion this test exists to make, so the test makes its - // assertion on the page where nothing else is moving the scroll. - await page.goto(`${BASE}/docs/routing`); + // A /ui/ gallery page is the harder case on purpose: its component + // previews settle taller AFTER the swap, which is what used to carry the + // restore 763px past where the reader left (#1310). A /docs page does not + // grow, so asserting there proves much less. + await page.goto(`${BASE}/ui/button`); await page.waitForLoadState('domcontentloaded'); - // Make sure the page is tall enough to actually scroll. - await page.evaluate(() => { - // The docs pages are typically tall; nudge with a spacer if not. - if (document.documentElement.scrollHeight < window.innerHeight + 500) { - const sp = document.createElement('div'); - sp.style.height = '2000px'; - document.body.appendChild(sp); - } - }); // Scroll partway down. await page.evaluate(() => window.scrollTo(0, 800)); @@ -406,17 +394,20 @@ test('scroll restoration: back-button restores window scroll position', async () // so a link below the fold would move the window before the router // recorded its position, and the router would then be asserted against a // scroll it restored correctly. - await page.locator('.docs-sidebar a:has-text("Components")').first() + await page.locator('a[href="/ui/card"]').first() .evaluate((el) => /** @type {HTMLElement} */ (el).click()); - await page.waitForFunction(() => location.pathname.endsWith('/components'), + await page.waitForFunction(() => location.pathname.endsWith('/ui/card'), { timeout: 4000 }); // Back. Scroll should restore. await page.goBack(); - await page.waitForFunction(() => location.pathname.endsWith('/routing'), + await page.waitForFunction(() => location.pathname.endsWith('/ui/button'), { timeout: 4000 }); - // Give the cached-restore path a frame to run. - await page.waitForTimeout(80); + // Let the restored page finish growing AND revalidating. A frame is not + // enough: the growth lands ~65ms after the swap and the revalidation's own + // swap ~300ms after it, and a restore that is correct at 80ms and wrong at + // 1200ms is exactly the defect (#1310). + await page.waitForTimeout(1200); const afterBackScroll = await page.evaluate(() => window.scrollY); // Allow a small tolerance: browser may round, sub-pixel layout etc. From ba79b923d5fe8aedc38fa836cfe09508e923e096 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 6 Aug 2026 22:17:42 +0530 Subject: [PATCH 03/20] test: keep the anchor-restore case inside its test-runner session The case needs real history entries so it can drive a real popstate, and it built them from location.pathname. The test page's own query string identifies the web-test-runner session, so dropping it rewrote the page out of its session: every test still passed and the whole run exited 1 with no failure to point at, which reads as an infrastructure blip rather than a test problem. Build the entries from the live url instead, and put the exact original url back in teardown. --- .../browser/nav-scroll-anchor-restore.test.js | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js index 5a518537d..09d8b0872 100644 --- a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js +++ b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js @@ -65,8 +65,24 @@ const RESTORED_BODY = const RESTORED_HTML = '' + RESTORED_BODY + ''; +/** + * A same-document history entry for this test page, carrying one extra query + * param. Built from the LIVE url rather than `location.pathname`, because the + * test page's own query string identifies the web-test-runner session: dropping + * it here rewrites the page out of its session and takes down the whole run + * (with every test still passing, so it reads as an infrastructure blip). + * + * @param {string} tag + * @returns {string} pathname + search, which is also the router's cache key + */ +function entryUrl(tag) { + const u = new URL(location.href); + u.searchParams.set('wj', tag); + return u.pathname + u.search; +} + suite('Client router: a Back restore survives late layout growth (#1310)', () => { - let navGuard, container, origFetch, origScrollBehavior, entriesPushed; + let navGuard, container, origFetch, origScrollBehavior, origUrl, entriesPushed; /** Resolves the in-flight revalidation, so a case controls the window's close. */ let releaseFetch; @@ -102,13 +118,14 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => // Two real same-document history entries, so `history.back()` drives a REAL // popstate. Reassigning `location` is impossible in a browser, and a // synthetic popstate event would not exercise the browser's own restore. - history.pushState(null, '', location.pathname + '?wj=anchor-a'); - history.pushState(null, '', location.pathname + '?wj=anchor-b'); + origUrl = location.href; + history.pushState(null, '', entryUrl('anchor-a')); + history.pushState(null, '', entryUrl('anchor-b')); entriesPushed = true; - _snapshotCache.set(location.pathname + '?wj=anchor-a', { + _snapshotCache.set(entryUrl('anchor-a'), { html: RESTORED_HTML, scrollX: 0, scrollY: RESTORED_Y, }); - _setCurrentPageUrl(location.origin + location.pathname + '?wj=anchor-b'); + _setCurrentPageUrl(location.href); // Start where the reader was, so the restore is a real scroll rather than // a no-op from 0. window.scrollTo({ top: 0, left: 0, behavior: 'instant' }); @@ -132,10 +149,12 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => // Let the released revalidation finish so it cannot swap during a later case. for (let i = 0; i < 6; i++) await frame(); disableClientRouter(); - _snapshotCache.delete(location.pathname + '?wj=anchor-a'); + _snapshotCache.delete(entryUrl('anchor-a')); _setCurrentPageUrl(null); if (entriesPushed) { - history.replaceState(null, '', location.pathname); + // Restore the EXACT url the page was served at, session query string + // included. + history.replaceState(null, '', origUrl); entriesPushed = false; } container.remove(); From 41b251da245a4e70fefa47ae5570b368c41c5812 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 6 Aug 2026 22:28:50 +0530 Subject: [PATCH 04/20] fix: floor the restore window so a fast revalidation cannot close it early The window's close was scheduled off the revalidation settling, which made its length network latency plus two frames. That is only long enough while the revalidation is slower than the restored page's own upgrade and render. It is on a deployed site, where growth lands ~65ms after the swap and the revalidation's swap ~300ms after that, but that ordering is a property of one deployment: a local server, a 304, or a warm cache answers in single-digit milliseconds and closes the window before the growth it exists to absorb, restoring the bug in full. Close on the later of the revalidation and a floor instead. A real user input still closes it immediately, which is the case that actually matters for not holding anchoring off longer than a reader wants. The browser suite now covers the inverted ordering directly: an instant revalidation with content that grows several frames later. That case reproduces at 1563 against the previous commit on all three engines. --- .../references/client-router-and-streaming.md | 2 +- packages/core/src/router-client.js | 30 +++++- .../browser/nav-scroll-anchor-restore.test.js | 95 +++++++++++++++---- .../core/test/routing/router-client.test.js | 12 ++- website/app/docs/client-router/page.ts | 2 +- 5 files changed, 117 insertions(+), 24 deletions(-) diff --git a/.agents/skills/webjs/references/client-router-and-streaming.md b/.agents/skills/webjs/references/client-router-and-streaming.md index 6ddd7a7a4..a55eebdb5 100644 --- a/.agents/skills/webjs/references/client-router-and-streaming.md +++ b/.agents/skills/webjs/references/client-router-and-streaming.md @@ -60,7 +60,7 @@ The router keeps a URL-keyed snapshot cache (LRU, cap 16) so Back/Forward restor - **Do not write your own scroll restore.** A `popstate` listener that calls `scrollTo`, a saved offset in `sessionStorage`, a `scrollIntoView` on a remembered element: all of them fight the router, which already set `history.scrollRestoration = 'manual'` and is the sole authority on scroll during a navigation. If Back lands in the wrong place, that is a framework bug to report, not something to patch in app code. - **An app that sets `overflow-anchor` on `` itself sees it overridden during a restore and restored afterwards**, including a value set inline by your own script. Setting it in a stylesheet is unaffected between restores. Nothing else on the page is touched, and the router never sets `overflow-anchor` anywhere but the root element. -- **The window closes on the first real input** (`wheel`, `touchmove`, `keydown`, `pointerdown`), on the restore's own background revalidation settling, or on a 2s ceiling, whichever comes first. So a reader who starts scrolling mid-restore immediately gets normal browser anchoring back. Suppression only ever WITHHOLDS a browser correction, it never moves the viewport, so it cannot yank someone who has taken over. +- **The window closes on the first real input** (`wheel`, `touchmove`, `keydown`, `pointerdown`), so a reader who starts scrolling mid-restore immediately gets normal browser anchoring back. Absent that it closes once the restore is over, which is the LATER of the restore's own background revalidation settling and a short floor, and at the latest on a 2s ceiling. The floor is load-bearing: waiting on the revalidation alone ties the window's length to network latency rather than to the growth it guards, so a server answering faster than the page renders would close it early and the reader would land low again. Suppression only ever WITHHOLDS a browser correction, it never moves the viewport, so it cannot yank someone who has taken over. Components that reach their final size only after they render (a chart, a media embed with no intrinsic dimensions, anything sized from measured content) are exactly the shape that triggers this, and they need no special handling: give them a placeholder height where you can, and let the router own the restore. diff --git a/packages/core/src/router-client.js b/packages/core/src/router-client.js index a33dd8618..dbf809599 100644 --- a/packages/core/src/router-client.js +++ b/packages/core/src/router-client.js @@ -309,6 +309,23 @@ let prevScrollRestoration = null; */ const ANCHOR_SUPPRESS_CEILING_MS = 2000; +/** + * Floor on the restore window (#1310). The window's other closer is the + * revalidation settling, which is only long enough while the revalidation is + * SLOWER than the restored page's own upgrade-and-render. That holds on a + * deployed site (measured: growth ~65ms after the swap, the revalidation's swap + * ~300ms after that) but it is a property of one deployment, not a guarantee: a + * local server, a 304, or a warm cache answers in single-digit milliseconds and + * would otherwise close the window before the growth it exists to absorb. + * + * So the window lasts at least this long whatever the network does. The value + * clears the measured revalidation swap with margin and stays well under the + * ceiling. It is a floor, not a delay: a real user input still closes the + * window immediately, which is the case that actually matters for not holding + * anchoring off longer than a reader would want. + */ +const ANCHOR_SUPPRESS_FLOOR_MS = 500; + /** * Inputs that mean the reader has taken over the viewport, so the restore is * over and the browser's own anchoring should resume. @@ -1494,9 +1511,16 @@ async function performNavigation(href, isPopState, frameId) { // frames for the re-applied DOM to lay out) is what keeps the window // tied to one restore. A height observer could not tell a settling // restore from a streaming boundary (#471 / #473). - fetchAndApply(href, frameId, /* recordHistory */ false, optimisticState, 'GET', null, signal, myToken, /* revalidating */ true) - .catch(() => {}) - .then(() => afterTwoFrames(releaseAnchor)); + // + // The floor is what makes that safe. Waiting on the revalidation ALONE + // ties the window's length to network latency rather than to the + // growth it guards, so a server that answers faster than the restored + // page renders closes it early and the reader lands low again, which + // is the whole defect. + const revalidated = fetchAndApply(href, frameId, /* recordHistory */ false, optimisticState, 'GET', null, signal, myToken, /* revalidating */ true) + .catch(() => {}); + const floor = new Promise((r) => setTimeout(r, ANCHOR_SUPPRESS_FLOOR_MS)); + Promise.all([revalidated, floor]).then(() => afterTwoFrames(releaseAnchor)); return; } } diff --git a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js index 09d8b0872..0c3564e29 100644 --- a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js +++ b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js @@ -46,6 +46,28 @@ class GrowLate extends HTMLElement { } customElements.define('wj-grow-late-1310', GrowLate); +/** + * The same grower, but slow enough that a revalidation answering instantly + * settles BEFORE it. On the deployed site the growth lands ~65ms after the swap + * and the revalidation's own swap ~300ms after that, so the revalidation is + * comfortably the slower of the two. That ordering is a property of one + * deployment, not a guarantee: a local server, a 304, or a warm cache can answer + * in single-digit milliseconds. This models the inverted case. + */ +class GrowVeryLate extends HTMLElement { + connectedCallback() { + this.style.display = 'block'; + this.style.height = '0px'; + let n = 0; + const tick = () => { + if (++n >= 8) { this.style.height = GROWTH + 'px'; return; } + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + } +} +customElements.define('wj-grow-very-late-1310', GrowVeryLate); + const frame = () => new Promise((r) => requestAnimationFrame(() => r())); /** Long enough for the grower to connect, lay out, and take its height. */ async function afterGrowth() { for (let i = 0; i < 4; i++) await frame(); } @@ -56,14 +78,19 @@ async function afterGrowth() { for (let i = 0; i < 4; i++) await frame(); } * sits entirely above the viewport at `RESTORED_Y`, which is where anchoring * acts. */ -const RESTORED_BODY = - '' - + '' - + '
restored
' - + ''; +function restoredBody(tag) { + return '' + + `<${tag}>` + + '
restored
' + + ''; +} + +function restoredHtml(tag) { + return '' + restoredBody(tag) + ''; +} -const RESTORED_HTML = - '' + RESTORED_BODY + ''; +const RESTORED_HTML = restoredHtml('wj-grow-late-1310'); +const RESTORED_HTML_SLOW = restoredHtml('wj-grow-very-late-1310'); /** * A same-document history entry for this test page, carrying one extra query @@ -86,7 +113,15 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => /** Resolves the in-flight revalidation, so a case controls the window's close. */ let releaseFetch; - async function setup() { + /** + * @param {{ instantRevalidation?: boolean }} [opts] By default the + * revalidation is held open so a case can assert inside the restore window. + * `instantRevalidation` answers it immediately instead, which is the + * ordering a fast server produces. + */ + async function setup(opts) { + const instant = Boolean(opts && opts.instantRevalidation); + const html = instant ? RESTORED_HTML_SLOW : RESTORED_HTML; navGuard = installNavGuard(); enableClientRouter(); origScrollBehavior = document.documentElement.style.scrollBehavior; @@ -109,11 +144,12 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => // restore window. Its response repeats the restored markup, which is what // the server would send. origFetch = window.fetch; - window.fetch = () => new Promise((resolve) => { - releaseFetch = () => resolve(new Response(RESTORED_HTML, { - headers: { 'content-type': 'text/html', 'x-webjs-build': '' }, - })); + const respond = () => new Response(html, { + headers: { 'content-type': 'text/html', 'x-webjs-build': '' }, }); + window.fetch = instant + ? () => Promise.resolve(respond()) + : () => new Promise((resolve) => { releaseFetch = () => resolve(respond()); }); // Two real same-document history entries, so `history.back()` drives a REAL // popstate. Reassigning `location` is impossible in a browser, and a @@ -123,7 +159,7 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => history.pushState(null, '', entryUrl('anchor-b')); entriesPushed = true; _snapshotCache.set(entryUrl('anchor-a'), { - html: RESTORED_HTML, scrollX: 0, scrollY: RESTORED_Y, + html, scrollX: 0, scrollY: RESTORED_Y, }); _setCurrentPageUrl(location.href); // Start where the reader was, so the restore is a real scroll rather than @@ -192,16 +228,43 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => } finally { await teardown(); } }); - test('the window closes once the revalidation settles, leaving no residue', async () => { + test('a revalidation that answers before the growth still holds the reader', async () => { + // The window's close is scheduled off the revalidation, so its length is + // network latency plus two frames. That is only long enough because the + // revalidation is normally the slower of the two, which is a property of a + // deployment rather than a guarantee. Here the server answers instantly and + // the content grows several frames later, which is the ordering a local + // server, a 304, or a warm cache produces. The restore has to survive it. + await setup({ instantRevalidation: true }); + try { + await goBack(); + assert.ok(Math.abs(window.scrollY - RESTORED_Y) < 5, + `the restore lands on the recorded offset (got ${window.scrollY})`); + for (let i = 0; i < 14; i++) await frame(); + const grown = document.querySelector('wj-grow-very-late-1310'); + assert.ok(grown && grown.getBoundingClientRect().height > GROWTH - 5, + 'the fixture actually grew, so the case is live'); + assert.ok(Math.abs(window.scrollY - RESTORED_Y) < 5, + `growth landing after a fast revalidation must not move the reader ` + + `(expected ~${RESTORED_Y}, got ${window.scrollY})`); + } finally { await teardown(); } + }); + + test('the window closes once the restore is over, leaving no residue', async () => { await setup(); try { await goBack(); assert.equal(document.documentElement.style.getPropertyValue('overflow-anchor'), 'none'); releaseFetch(); releaseFetch = null; - // The close is the revalidation settling plus two frames for the - // re-applied DOM to lay out. + // The close is the LATER of the revalidation settling and the floor, plus + // two frames for the re-applied DOM to lay out. Answering the fetch alone + // is deliberately not enough: that coupling is what let a fast server + // close the window before the growth landed. for (let i = 0; i < 6; i++) await frame(); + assert.equal(document.documentElement.style.getPropertyValue('overflow-anchor'), 'none', + 'a revalidation answering early does not close the window on its own'); + await new Promise((r) => setTimeout(r, 700)); assert.equal(document.documentElement.style.getPropertyValue('overflow-anchor'), '', 'the router leaves nothing of its own on after the restore'); } finally { await teardown(); } diff --git a/packages/core/test/routing/router-client.test.js b/packages/core/test/routing/router-client.test.js index f8df361ce..ee822ce17 100644 --- a/packages/core/test/routing/router-client.test.js +++ b/packages/core/test/routing/router-client.test.js @@ -2083,10 +2083,16 @@ test('popstate cache restore suppresses scroll anchoring across the window (#131 assert.equal(root.style.getPropertyValue('overflow-anchor'), 'none', 'the restore opens the window, so the browser cannot add late growth ' + 'to the offset it just replayed'); - // The window closes on THIS revalidation settling plus two frames. - await new Promise((r) => setTimeout(r, 20)); + // The revalidation settles immediately here, and that alone must NOT close + // the window: tying its length to network latency rather than to the growth + // it guards is what let a fast server close it early. + await new Promise((r) => setTimeout(r, 50)); + assert.equal(root.style.getPropertyValue('overflow-anchor'), 'none', + 'an instant revalidation does not close the window on its own'); + // The floor is the other half of the close. + await new Promise((r) => setTimeout(r, 700)); assert.ok(!root.style.getPropertyValue('overflow-anchor'), - 'the window closes once the revalidation settles, leaving no residue ' + + 'the window closes once the restore is over, leaving no residue ' + 'on '); } finally { _snapshotCache.delete('/anchor-here'); diff --git a/website/app/docs/client-router/page.ts b/website/app/docs/client-router/page.ts index 84c634fc4..1b234762b 100644 --- a/website/app/docs/client-router/page.ts +++ b/website/app/docs/client-router/page.ts @@ -222,7 +222,7 @@ connectWS('/posts/' + id + '/feed', { onMessage: (m) => renderStream(m) });Snapshot cache + back/forward

The router maintains a URL-keyed LRU cache of page snapshots (capacity 16). On back/forward via popstate, the cached DOM is applied instantly and the captured window-scroll position is restored. A background refetch then revalidates the snapshot quietly.

-

A back/forward restore also suppresses the browser's scroll anchoring (overflow-anchor) for its duration, then puts it back. The saved offset was recorded against the page at its settled height, while the DOM the restore swaps in is still shorter until its components upgrade and render. Without the suppression the browser reads that late growth as content appearing above the reader and adds it to the offset the router just replayed, landing them below where they left. The window closes on the first real input (wheel, touchmove, keydown, pointerdown), on that restore's background revalidation settling, or on a 2s ceiling, whichever comes first, so a reader who starts scrolling mid-restore gets normal anchoring back immediately. Suppression only withholds a browser correction, it never moves the viewport. If your app sets overflow-anchor on <html> inline itself, it is overridden during a restore and restored afterwards.

+

A back/forward restore also suppresses the browser's scroll anchoring (overflow-anchor) for its duration, then puts it back. The saved offset was recorded against the page at its settled height, while the DOM the restore swaps in is still shorter until its components upgrade and render. Without the suppression the browser reads that late growth as content appearing above the reader and adds it to the offset the router just replayed, landing them below where they left. The window closes on the first real input (wheel, touchmove, keydown, pointerdown), so a reader who starts scrolling mid-restore gets normal anchoring back immediately. Absent that it closes once the restore is over, which is the later of that restore's background revalidation settling and a short floor, and at the latest on a 2s ceiling. The floor matters because waiting on the revalidation alone would tie the window to network latency rather than to the growth it guards, so a server answering faster than the page renders would close it too early. Suppression only withholds a browser correction, it never moves the viewport. If your app sets overflow-anchor on <html> inline itself, it is overridden during a restore and restored afterwards.

Nav scroll restoration (both the back/forward restore and the scroll-to-top on a forward nav) is forced behavior: 'instant', so setting html { scroll-behavior: smooth } in your app does not make navigation visibly animate the scroll. 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.

After a server action mutates data that a cached page depends on, call revalidate():

import { revalidate } from '@webjsdev/core'; From a43ef15f5f78d147ce770acab032e461960522de Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 6 Aug 2026 22:39:57 +0530 Subject: [PATCH 05/20] test: prove the restore window is temporary and narrowly scoped Suppressing the browser's scroll anchoring is only safe if it is given back, and the suite asserted that by checking an inline property was gone. That would miss a release that cleared the property while leaving anchoring broken some other way, and it said nothing about the paths the fix is not supposed to touch. Three cases: anchoring holds the reader's position again once the window closes, judged by behaviour rather than by the property; a forward navigation opens no window at all; and a revalidation that never answers still releases on the ceiling rather than leaving anchoring off for the life of the page. Breaking the release reds all three plus the two that already covered it. --- .../browser/nav-scroll-anchor-restore.test.js | 61 ++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js index 0c3564e29..878f7d187 100644 --- a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js +++ b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js @@ -21,7 +21,7 @@ * connected models the real cause: as raw parsed markup it is 0px tall, and it * reaches its real size only once its own render has run. */ -import { enableClientRouter, disableClientRouter, _snapshotCache, _setCurrentPageUrl } from '../../../src/router-client.js'; +import { enableClientRouter, disableClientRouter, navigate, _snapshotCache, _setCurrentPageUrl } from '../../../src/router-client.js'; import { assert } from '../../../../../test/browser-assert.js'; import { installNavGuard } from '../../../../../test/browser-nav-guard.js'; @@ -201,6 +201,65 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => enableClientRouter(); } + test('anchoring WORKS again once the window has closed', async () => { + // The inverse of the headline, and the regression that would matter most if + // this fix were wrong: suppression is temporary, so once the restore is over + // the browser must be holding the reader's position again exactly as it does + // on any other page. Asserting only that the inline property is gone would + // not catch a release that cleared the property while leaving anchoring + // broken some other way, so this asserts the BEHAVIOUR: growth above the + // viewport moves `scrollY` again. + await setup({ instantRevalidation: true }); + try { + await goBack(); + await new Promise((r) => setTimeout(r, 900)); // past the floor + assert.equal(document.documentElement.style.getPropertyValue('overflow-anchor'), '', + 'precondition: the window is closed'); + + const before = window.scrollY; + const grower = document.createElement('div'); + grower.style.height = GROWTH + 'px'; + const region = document.querySelector('wj-grow-very-late-1310'); + region.parentNode.insertBefore(grower, region); + // Anchoring acts at layout, so give it a frame to compensate. + await frame(); + await frame(); + assert.ok(Math.abs((window.scrollY - before) - GROWTH) < 5, + 'with the window closed the browser holds the visual position again, ' + + `so ${GROWTH}px inserted above the viewport moves scrollY by that much ` + + `(moved ${window.scrollY - before})`); + grower.remove(); + } finally { await teardown(); } + }); + + test('a forward navigation opens no window', async () => { + // The fix is scoped to the popstate cache-hit branch. Every other scroll + // path lands at offset 0 or targets an element, so anchoring is either inert + // or correct there, and touching it would be a regression rather than a fix. + // The instant stub, since a forward nav AWAITS its fetch (the popstate path + // does not, which is why the other cases can hold it open). + await setup({ instantRevalidation: true }); + try { + await navigate(location.origin + entryUrl('forward-target')); + assert.equal(document.documentElement.style.getPropertyValue('overflow-anchor'), '', + 'a forward nav never suppresses anchoring'); + } finally { await teardown(); } + }); + + test('a revalidation that never settles still releases on the ceiling', async () => { + // The ceiling exists so a hung fetch cannot leave anchoring off for the life + // of the page. Nothing else would ever release this window: the floor has + // passed and the fetch never answers. + await setup(); + try { + await goBack(); + assert.equal(document.documentElement.style.getPropertyValue('overflow-anchor'), 'none'); + await new Promise((r) => setTimeout(r, 2400)); // past the 2s ceiling + assert.equal(document.documentElement.style.getPropertyValue('overflow-anchor'), '', + 'the ceiling releases a window whose revalidation never came back'); + } finally { await teardown(); } + }); + test('the restore opens a scroll-anchoring window', async () => { await setup(); try { From 132883664544150dfbae39ae192723d382b99f19 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 6 Aug 2026 22:48:37 +0530 Subject: [PATCH 06/20] fix: leave a clamped restore alone rather than freezing it Suppressing anchoring unconditionally introduced this bug's mirror image. A document that has not grown yet can be too short to scroll to the recorded offset at all, so the browser clamps to its current maximum. There the shortfall is exactly the growth still to come, and anchoring adding that growth is what carries the reader back down. Suppressing froze the clamp instead. Measured on /ui/button: a reader who left at the page bottom, 2002, restored to 2002 before this fix series and to 1239 after it, stranded a full 763px page-growth ABOVE where they left. The same error as the bug, pointing the other way, and deterministic rather than intermittent. Suppress only when the recorded offset was actually reached. The two situations want opposite things and are told apart by the one question that separates them: did the scroll land. Reading it back is safe because nothing can grow between the write and the read. An offset the un-grown page cannot reach still lands wherever anchoring carries it rather than on the exact number, which is unchanged behaviour rather than anything this series introduces. --- .../references/client-router-and-streaming.md | 1 + packages/core/src/router-client.js | 18 +++++++- .../browser/nav-scroll-anchor-restore.test.js | 45 ++++++++++++++++--- .../core/test/routing/router-client.test.js | 22 +++++++-- website/app/docs/client-router/page.ts | 2 +- 5 files changed, 76 insertions(+), 12 deletions(-) diff --git a/.agents/skills/webjs/references/client-router-and-streaming.md b/.agents/skills/webjs/references/client-router-and-streaming.md index a55eebdb5..46aac1169 100644 --- a/.agents/skills/webjs/references/client-router-and-streaming.md +++ b/.agents/skills/webjs/references/client-router-and-streaming.md @@ -60,6 +60,7 @@ The router keeps a URL-keyed snapshot cache (LRU, cap 16) so Back/Forward restor - **Do not write your own scroll restore.** A `popstate` listener that calls `scrollTo`, a saved offset in `sessionStorage`, a `scrollIntoView` on a remembered element: all of them fight the router, which already set `history.scrollRestoration = 'manual'` and is the sole authority on scroll during a navigation. If Back lands in the wrong place, that is a framework bug to report, not something to patch in app code. - **An app that sets `overflow-anchor` on `` itself sees it overridden during a restore and restored afterwards**, including a value set inline by your own script. Setting it in a stylesheet is unaffected between restores. Nothing else on the page is touched, and the router never sets `overflow-anchor` anywhere but the root element. +- **Suppression is conditional, and only applies when the recorded offset was actually reached.** A page that has not grown yet can be too short to scroll that far, so the browser clamps to its current maximum. There the shortfall IS the growth still to come, and anchoring adding it is what carries the reader back down, so the router leaves anchoring alone. Suppressing in that case would freeze the clamp and strand the reader a full page-growth above where they left, which is this same defect pointing the other way. One consequence to know: for an offset the un-grown page cannot reach, the restore lands wherever anchoring carries it rather than exactly on the recorded number, which for a reader at the bottom is the bottom. - **The window closes on the first real input** (`wheel`, `touchmove`, `keydown`, `pointerdown`), so a reader who starts scrolling mid-restore immediately gets normal browser anchoring back. Absent that it closes once the restore is over, which is the LATER of the restore's own background revalidation settling and a short floor, and at the latest on a 2s ceiling. The floor is load-bearing: waiting on the revalidation alone ties the window's length to network latency rather than to the growth it guards, so a server answering faster than the page renders would close it early and the reader would land low again. Suppression only ever WITHHOLDS a browser correction, it never moves the viewport, so it cannot yank someone who has taken over. Components that reach their final size only after they render (a chart, a media embed with no intrinsic dimensions, anything sized from measured content) are exactly the shape that triggers this, and they need no special handling: give them a placeholder height where you can, and let the router own the restore. diff --git a/packages/core/src/router-client.js b/packages/core/src/router-client.js index dbf809599..3529784cf 100644 --- a/packages/core/src/router-client.js +++ b/packages/core/src/router-client.js @@ -1500,8 +1500,24 @@ async function performNavigation(href, isPopState, frameId) { // lands below where they left (#1310). let releaseAnchor = () => {}; if (typeof window !== 'undefined') { - releaseAnchor = suppressScrollAnchoring(); window.scrollTo({ left: cached.scrollX, top: cached.scrollY, behavior: 'instant' }); + // Suppress ONLY when the recorded offset was actually reached. + // + // A document that has not grown yet can be too SHORT to scroll that + // far, and the browser clamps to its current maximum. A reader at + // the bottom of the settled page is the clear case: the shortfall is + // then exactly the growth still to come, and anchoring ADDING that + // growth is what carries them back to the bottom. Suppressing there + // freezes the clamp instead and strands them a full page-growth + // ABOVE where they left, which is this bug's own mirror image. + // + // So the two situations want opposite things and are told apart by + // the one question that separates them: did the scroll land. Reading + // it here is safe because nothing can grow between the write and the + // read, both being in this synchronous block. + if (window.scrollY >= cached.scrollY - 1) { + releaseAnchor = suppressScrollAnchoring(); + } } // Fire-and-forget revalidation. Uses a fresh AbortController // since this background fetch is allowed to overlap with the diff --git a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js index 878f7d187..f5b8beea3 100644 --- a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js +++ b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js @@ -114,13 +114,15 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => let releaseFetch; /** - * @param {{ instantRevalidation?: boolean }} [opts] By default the - * revalidation is held open so a case can assert inside the restore window. - * `instantRevalidation` answers it immediately instead, which is the - * ordering a fast server produces. + * @param {{ instantRevalidation?: boolean, restoredY?: number }} [opts] By + * default the revalidation is held open so a case can assert inside the + * restore window. `instantRevalidation` answers it immediately instead, + * which is the ordering a fast server produces. `restoredY` overrides the + * recorded offset, so a case can force the clamped path. */ async function setup(opts) { const instant = Boolean(opts && opts.instantRevalidation); + const restoredY = (opts && opts.restoredY) != null ? opts.restoredY : RESTORED_Y; const html = instant ? RESTORED_HTML_SLOW : RESTORED_HTML; navGuard = installNavGuard(); enableClientRouter(); @@ -159,7 +161,7 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => history.pushState(null, '', entryUrl('anchor-b')); entriesPushed = true; _snapshotCache.set(entryUrl('anchor-a'), { - html, scrollX: 0, scrollY: RESTORED_Y, + html, scrollX: 0, scrollY: restoredY, }); _setCurrentPageUrl(location.href); // Start where the reader was, so the restore is a real scroll rather than @@ -201,6 +203,30 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => enableClientRouter(); } + test('a CLAMPED restore is left alone, so the reader is not stranded high', async () => { + // The mirror image of this bug, and the reason suppression is conditional. + // + // A document that has not grown yet can be too short to scroll to the + // recorded offset at all, so the browser clamps to its current maximum. The + // shortfall is then the growth still to come, and anchoring adding that + // growth is what carries the reader back down. Suppressing there would + // freeze the clamp and strand them a full page-growth ABOVE where they + // left, measured at 763px on the reported page: the same error as the bug, + // pointing the other way. + // + // The recorded offset here is far past anything the un-grown document can + // reach, so the clamp is certain whatever the runner's viewport height is. + await setup({ restoredY: 50000 }); + try { + await goBack(); + assert.ok(window.scrollY < 50000, + 'precondition: the restore was clamped, so this is the case under test'); + assert.equal(document.documentElement.style.getPropertyValue('overflow-anchor'), '', + 'a clamped restore installs no window, leaving the browser to heal the ' + + 'clamp as the page grows'); + } finally { await teardown(); } + }); + test('anchoring WORKS again once the window has closed', async () => { // The inverse of the headline, and the regression that would matter most if // this fix were wrong: suppression is temporary, so once the restore is over @@ -320,7 +346,14 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => // two frames for the re-applied DOM to lay out. Answering the fetch alone // is deliberately not enough: that coupling is what let a fast server // close the window before the growth landed. - for (let i = 0; i < 6; i++) await frame(); + // + // Wall clock, not a frame count. This is the one assertion here that + // needs a wait SHORTER than the floor, and the runner puts test files in + // concurrent pages where a non-visible page has rAF throttled, so a fixed + // number of frames could outlast the floor and fail while nothing is + // broken. Every other wait in this file only wants "enough time", so + // slower frames make those assertions stronger rather than flakier. + await new Promise((r) => setTimeout(r, 100)); assert.equal(document.documentElement.style.getPropertyValue('overflow-anchor'), 'none', 'a revalidation answering early does not close the window on its own'); await new Promise((r) => setTimeout(r, 700)); diff --git a/packages/core/test/routing/router-client.test.js b/packages/core/test/routing/router-client.test.js index ee822ce17..87a7016d4 100644 --- a/packages/core/test/routing/router-client.test.js +++ b/packages/core/test/routing/router-client.test.js @@ -2072,8 +2072,14 @@ test('popstate cache restore suppresses scroll anchoring across the window (#131 }); const origWinScrollTo = globalThis.window?.scrollTo; const origGlobalScrollTo = globalThis.scrollTo; - globalThis.scrollTo = /** @type any */ (() => {}); - if (globalThis.window) globalThis.window.scrollTo = /** @type any */ (() => {}); + const origScrollY = globalThis.window?.scrollY; + // linkedom has no layout, so the stub has to move `scrollY` itself. The + // restore READS it back to tell a landed scroll from one the browser clamped + // against a document that has not grown yet, and only the landed case + // suppresses anchoring. + const land = /** @type any */ ((o) => { if (globalThis.window) globalThis.window.scrollY = o && o.top; }); + globalThis.scrollTo = land; + if (globalThis.window) globalThis.window.scrollTo = land; document.head.innerHTML = ''; document.body.innerHTML = 'before-pop'; try { @@ -2101,6 +2107,7 @@ test('popstate cache restore suppresses scroll anchoring across the window (#131 globalThis.fetch = origFetch; globalThis.scrollTo = origGlobalScrollTo; if (globalThis.window) globalThis.window.scrollTo = origWinScrollTo; + if (globalThis.window) globalThis.window.scrollY = origScrollY; root.style.removeProperty('overflow-anchor'); document.head.innerHTML = ''; document.body.innerHTML = ''; @@ -2128,8 +2135,14 @@ test('disableClientRouter closes an open scroll-anchor window (#1310)', async () }); const origWinScrollTo = globalThis.window?.scrollTo; const origGlobalScrollTo = globalThis.scrollTo; - globalThis.scrollTo = /** @type any */ (() => {}); - if (globalThis.window) globalThis.window.scrollTo = /** @type any */ (() => {}); + const origScrollY = globalThis.window?.scrollY; + // linkedom has no layout, so the stub has to move `scrollY` itself. The + // restore READS it back to tell a landed scroll from one the browser clamped + // against a document that has not grown yet, and only the landed case + // suppresses anchoring. + const land = /** @type any */ ((o) => { if (globalThis.window) globalThis.window.scrollY = o && o.top; }); + globalThis.scrollTo = land; + if (globalThis.window) globalThis.window.scrollTo = land; document.head.innerHTML = ''; document.body.innerHTML = 'before-pop'; try { @@ -2147,6 +2160,7 @@ test('disableClientRouter closes an open scroll-anchor window (#1310)', async () globalThis.fetch = origFetch; globalThis.scrollTo = origGlobalScrollTo; if (globalThis.window) globalThis.window.scrollTo = origWinScrollTo; + if (globalThis.window) globalThis.window.scrollY = origScrollY; root.style.removeProperty('overflow-anchor'); document.head.innerHTML = ''; document.body.innerHTML = ''; diff --git a/website/app/docs/client-router/page.ts b/website/app/docs/client-router/page.ts index 1b234762b..30854f83a 100644 --- a/website/app/docs/client-router/page.ts +++ b/website/app/docs/client-router/page.ts @@ -222,7 +222,7 @@ connectWS('/posts/' + id + '/feed', { onMessage: (m) => renderStream(m) });Snapshot cache + back/forward

The router maintains a URL-keyed LRU cache of page snapshots (capacity 16). On back/forward via popstate, the cached DOM is applied instantly and the captured window-scroll position is restored. A background refetch then revalidates the snapshot quietly.

-

A back/forward restore also suppresses the browser's scroll anchoring (overflow-anchor) for its duration, then puts it back. The saved offset was recorded against the page at its settled height, while the DOM the restore swaps in is still shorter until its components upgrade and render. Without the suppression the browser reads that late growth as content appearing above the reader and adds it to the offset the router just replayed, landing them below where they left. The window closes on the first real input (wheel, touchmove, keydown, pointerdown), so a reader who starts scrolling mid-restore gets normal anchoring back immediately. Absent that it closes once the restore is over, which is the later of that restore's background revalidation settling and a short floor, and at the latest on a 2s ceiling. The floor matters because waiting on the revalidation alone would tie the window to network latency rather than to the growth it guards, so a server answering faster than the page renders would close it too early. Suppression only withholds a browser correction, it never moves the viewport. If your app sets overflow-anchor on <html> inline itself, it is overridden during a restore and restored afterwards.

+

A back/forward restore also suppresses the browser's scroll anchoring (overflow-anchor) for its duration, then puts it back. The saved offset was recorded against the page at its settled height, while the DOM the restore swaps in is still shorter until its components upgrade and render. Without the suppression the browser reads that late growth as content appearing above the reader and adds it to the offset the router just replayed, landing them below where they left. The window closes on the first real input (wheel, touchmove, keydown, pointerdown), so a reader who starts scrolling mid-restore gets normal anchoring back immediately. Absent that it closes once the restore is over, which is the later of that restore's background revalidation settling and a short floor, and at the latest on a 2s ceiling. The floor matters because waiting on the revalidation alone would tie the window to network latency rather than to the growth it guards, so a server answering faster than the page renders would close it too early. Suppression only withholds a browser correction, it never moves the viewport. If your app sets overflow-anchor on <html> inline itself, it is overridden during a restore and restored afterwards. The suppression is conditional: when the page has not grown enough to scroll to the recorded offset at all, the browser clamps the scroll, and there the shortfall is exactly the growth still to come, so anchoring adding it is what carries the reader back down. The router leaves anchoring alone in that case rather than freezing the clamp.

Nav scroll restoration (both the back/forward restore and the scroll-to-top on a forward nav) is forced behavior: 'instant', so setting html { scroll-behavior: smooth } in your app does not make navigation visibly animate the scroll. 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.

After a server action mutates data that a cached page depends on, call revalidate():

import { revalidate } from '@webjsdev/core'; From 7b17bcf95b1548a0822c769da9de9bd0b67c1634 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 6 Aug 2026 23:14:29 +0530 Subject: [PATCH 07/20] fix: close the restore window when a new navigation starts The window outlives its own restore on purpose, so a navigation starting inside that span used to inherit it. A second Back that CLAMPS opens no window of its own, so it ran its whole growth under the previous restore's suppression and froze its clamp; a forward navigation carried the suppression onto an unrelated page. Every navigation now closes an open window first and reopens only if it earns one. Also records why the clamp probe must stay synchronous, which cost a round trip to learn. Deferring it even by a microtask breaks the fix: the restored components' renders have been applied by then, and reading scrollY forces the layout that flushes them, so anchoring runs during the read and hands back the already-shifted offset. Measured on /ui/button, the suppression landed 19ms late with scrollY already 800 to 1563. What makes the synchronous read correct is not which document it sees but that it sees the same layout the scroll just landed in. --- .../references/client-router-and-streaming.md | 3 +- packages/core/src/router-client.js | 60 ++++++++++++++++--- .../browser/nav-scroll-anchor-restore.test.js | 22 +++++++ 3 files changed, 76 insertions(+), 9 deletions(-) diff --git a/.agents/skills/webjs/references/client-router-and-streaming.md b/.agents/skills/webjs/references/client-router-and-streaming.md index 46aac1169..f6876f206 100644 --- a/.agents/skills/webjs/references/client-router-and-streaming.md +++ b/.agents/skills/webjs/references/client-router-and-streaming.md @@ -56,10 +56,11 @@ revalidate(); // clear the entire snapshot cache The router keeps a URL-keyed snapshot cache (LRU, cap 16) so Back/Forward restores instantly, then refetches in the background. Call `revalidate(path)` after a server action mutates data a cached page depends on. Wire bytes are minimized by an `X-Webjs-Have` header, so the server returns only the divergent layout fragment. Concurrent navigations abort the prior in-flight fetch, and scroll is restored on Back/Forward. -**Back/Forward scroll restore vs late layout growth.** The router SUPPRESSES the browser's scroll anchoring (`overflow-anchor`) for the duration of a Back/Forward restore, then puts it back. The saved offset was recorded against the page at its SETTLED height, while the DOM the restore swaps in is still shorter until its components upgrade and render. Without the suppression the browser treats that late growth as content appearing above a reader and adds it to the offset the router just replayed, so the reader lands BELOW where they left (the reported case was 763px, exactly the height a page gained after its swap). Three things follow for an app. +**Back/Forward scroll restore vs late layout growth.** The router SUPPRESSES the browser's scroll anchoring (`overflow-anchor`) for the duration of a Back/Forward restore, then puts it back. The saved offset was recorded against the page at its SETTLED height, while the DOM the restore swaps in is still shorter until its components upgrade and render. Without the suppression the browser treats that late growth as content appearing above a reader and adds it to the offset the router just replayed, so the reader lands BELOW where they left (the reported case was 763px, exactly the height a page gained after its swap). What follows for an app: - **Do not write your own scroll restore.** A `popstate` listener that calls `scrollTo`, a saved offset in `sessionStorage`, a `scrollIntoView` on a remembered element: all of them fight the router, which already set `history.scrollRestoration = 'manual'` and is the sole authority on scroll during a navigation. If Back lands in the wrong place, that is a framework bug to report, not something to patch in app code. - **An app that sets `overflow-anchor` on `` itself sees it overridden during a restore and restored afterwards**, including a value set inline by your own script. Setting it in a stylesheet is unaffected between restores. Nothing else on the page is touched, and the router never sets `overflow-anchor` anywhere but the root element. +- **A new navigation ends an open window.** The window outlives its own restore on purpose (a floor, then a ceiling), so any navigation starting inside that span closes it first, and reopens only if it earns one. Otherwise a second Back, or a click, would inherit suppressed anchoring on a page it was never meant for. - **Suppression is conditional, and only applies when the recorded offset was actually reached.** A page that has not grown yet can be too short to scroll that far, so the browser clamps to its current maximum. There the shortfall IS the growth still to come, and anchoring adding it is what carries the reader back down, so the router leaves anchoring alone. Suppressing in that case would freeze the clamp and strand the reader a full page-growth above where they left, which is this same defect pointing the other way. One consequence to know: for an offset the un-grown page cannot reach, the restore lands wherever anchoring carries it rather than exactly on the recorded number, which for a reader at the bottom is the bottom. - **The window closes on the first real input** (`wheel`, `touchmove`, `keydown`, `pointerdown`), so a reader who starts scrolling mid-restore immediately gets normal browser anchoring back. Absent that it closes once the restore is over, which is the LATER of the restore's own background revalidation settling and a short floor, and at the latest on a 2s ceiling. The floor is load-bearing: waiting on the revalidation alone ties the window's length to network latency rather than to the growth it guards, so a server answering faster than the page renders would close it early and the reader would land low again. Suppression only ever WITHHOLDS a browser correction, it never moves the viewport, so it cannot yank someone who has taken over. diff --git a/packages/core/src/router-client.js b/packages/core/src/router-client.js index 3529784cf..8fa56582f 100644 --- a/packages/core/src/router-client.js +++ b/packages/core/src/router-client.js @@ -369,9 +369,28 @@ let releaseScrollAnchor = null; * never MOVES the viewport, it only withholds a correction, so it also cannot * yank a reader who has already started scrolling. * - * Chromium, Firefox, and WebKit all implement scroll anchoring and all three - * honour `overflow-anchor: none` on the root scroller, so there is no - * engine-specific path here. + * Chromium, Firefox, and WebKit all implement scroll anchoring, and all three + * honour `overflow-anchor: none` identically whether it sits on the root + * scroller or on ``, so there is no engine-specific path here. + * + * It goes on the ROOT, and `` is not an alternative even though it looks + * like the tidier one. Suppressing on `` works identically on all three + * engines (the property excludes an element and its subtree from being chosen + * as the anchor, and every candidate lives under ``), and it would avoid + * writing to the root at all, which is worth wanting: toggling something on the + * root re-runs global style resolution, and on WebKit that re-resolves + * `oklch()` token values and repaints them for a frame, which is the #610 flash + * that made `data-navigating` opt-in. + * + * It is disqualified by the RELEASE, not the suppression. On WebKit, anchoring + * never resumes once it has been suppressed on ``: removing the property, + * setting it back to `auto`, and both in sequence were each measured, and after + * every one the next growth above the viewport still failed to move `scrollY`. + * Suppressing on the root resumes correctly on all three. Since the whole point + * is that suppression is TEMPORARY, a placement that cannot be undone would + * leave every WebKit reader, so every iOS browser, with scroll anchoring off + * for the life of the page after their first Back. That is a far worse trade + * than one repaint, so the root it is. * * @returns {() => void} Idempotent release. Safe to call after the window has * already closed on user input or the ceiling. @@ -1458,6 +1477,15 @@ async function performNavigation(href, isPopState, frameId) { // Bump nav generation. Captured below + by anything we await into. const myToken = ++currentNavigationToken; + // A new navigation ends any restore window still open from an earlier one + // (#1310). The window outlives its own restore by design (a floor, then a + // ceiling), so without this a second navigation inside that span inherits + // suppressed anchoring: a Back that CLAMPS opens no window of its own, so it + // would run the whole growth under the previous restore's suppression and + // freeze its clamp, and a forward nav would carry it onto a different page + // entirely. Reopening for this navigation, if it earns one, happens below. + if (releaseScrollAnchor) releaseScrollAnchor(); + // Snapshot the page the user is LEAVING (with its scroll position) // so back/forward navigation can restore it. We key under // `currentPageUrl` rather than `location.href` because on popstate @@ -1511,10 +1539,21 @@ async function performNavigation(href, isPopState, frameId) { // freezes the clamp instead and strands them a full page-growth // ABOVE where they left, which is this bug's own mirror image. // - // So the two situations want opposite things and are told apart by - // the one question that separates them: did the scroll land. Reading - // it here is safe because nothing can grow between the write and the - // read, both being in this synchronous block. + // So the two situations want opposite things, and they are told + // apart by the one question that separates them: did the scroll + // land. Reading it here is safe because nothing can grow between + // the write and the read, both being in this synchronous block. + // + // The read MUST stay synchronous, and that is the subtle part. + // Deferring it even by a microtask breaks the fix outright: by then + // the restored components' renders have been applied, and reading + // `scrollY` forces the layout that flushes them, so anchoring runs + // DURING the read and hands back the already-shifted offset. + // Measured on /ui/button, the suppression then landed 19ms after + // the scroll with `scrollY` already 800 -> 1563, which is the bug + // it exists to prevent. What makes the synchronous read correct is + // not which document it sees but that it sees the SAME layout the + // scroll just landed in, so the two are consistent by construction. if (window.scrollY >= cached.scrollY - 1) { releaseAnchor = suppressScrollAnchoring(); } @@ -1536,7 +1575,9 @@ async function performNavigation(href, isPopState, frameId) { const revalidated = fetchAndApply(href, frameId, /* recordHistory */ false, optimisticState, 'GET', null, signal, myToken, /* revalidating */ true) .catch(() => {}); const floor = new Promise((r) => setTimeout(r, ANCHOR_SUPPRESS_FLOOR_MS)); - Promise.all([revalidated, floor]).then(() => afterTwoFrames(releaseAnchor)); + // Read `releaseAnchor` when this fires, not now: the decision above is + // asynchronous, so capturing it here would always capture the no-op. + Promise.all([revalidated, floor]).then(() => afterTwoFrames(() => releaseAnchor())); return; } } @@ -1602,6 +1643,9 @@ async function performSubmission(href, method, body, frameId, form) { activeAbortController = new AbortController(); const signal = activeAbortController.signal; const myToken = ++currentNavigationToken; + // Same reasoning as performNavigation: a submission is a navigation, so it + // ends any restore window a recent Back left open (#1310). + if (releaseScrollAnchor) releaseScrollAnchor(); const isSafe = method === 'get' || method === 'head'; let url = new URL(href, location.href); diff --git a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js index f5b8beea3..a840923c3 100644 --- a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js +++ b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js @@ -227,6 +227,28 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => } finally { await teardown(); } }); + test('a second navigation inside the window closes it', async () => { + // The window deliberately outlives its own restore (a floor, then a + // ceiling), so a navigation starting inside that span must end it. Without + // this, a Back that CLAMPS opens no window of its own and would run its + // whole growth under the PREVIOUS restore's suppression, freezing its + // clamp; a forward nav would carry the suppression onto another page. + await setup(); + try { + await goBack(); + assert.equal(document.documentElement.style.getPropertyValue('overflow-anchor'), 'none', + 'precondition: a window is open'); + // Well inside the floor, so nothing else could have closed it. NOT + // awaited: the close is the point and it happens as the navigation + // STARTS, while this setup holds the fetch open so the navigation itself + // never settles. + navigate(location.origin + entryUrl('second-nav')).catch(() => {}); + await new Promise((r) => setTimeout(r, 0)); + assert.equal(document.documentElement.style.getPropertyValue('overflow-anchor'), '', + 'starting another navigation ends the previous restore\'s window'); + } finally { await teardown(); } + }); + test('anchoring WORKS again once the window has closed', async () => { // The inverse of the headline, and the regression that would matter most if // this fix were wrong: suppression is temporary, so once the restore is over From 6f143c22bfabd3d8dc002d1317f5e903c6309016 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 6 Aug 2026 23:26:37 +0530 Subject: [PATCH 08/20] fix: correct a stale comment and cover the nav close at the unit layer Three review findings, none of them behavioural. The comment on the release chain claimed the suppression decision was asynchronous, which was true only of a version that got reverted. The decision is synchronous, so the wrapper lambda it justified bought nothing, and the claim also contradicted the comment twenty lines above saying the read must stay synchronous. Dropped both. The docs site enumerates every close condition for the window, so it needed the new one too; only the skill reference had it. The nav-closes-window behaviour had a browser test but nothing at the unit layer, where deleting either call site left the suite green. --- packages/core/src/router-client.js | 4 +- .../core/test/routing/router-client.test.js | 56 +++++++++++++++++++ website/app/docs/client-router/page.ts | 2 +- 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/packages/core/src/router-client.js b/packages/core/src/router-client.js index 8fa56582f..c784ba80f 100644 --- a/packages/core/src/router-client.js +++ b/packages/core/src/router-client.js @@ -1575,9 +1575,7 @@ async function performNavigation(href, isPopState, frameId) { const revalidated = fetchAndApply(href, frameId, /* recordHistory */ false, optimisticState, 'GET', null, signal, myToken, /* revalidating */ true) .catch(() => {}); const floor = new Promise((r) => setTimeout(r, ANCHOR_SUPPRESS_FLOOR_MS)); - // Read `releaseAnchor` when this fires, not now: the decision above is - // asynchronous, so capturing it here would always capture the no-op. - Promise.all([revalidated, floor]).then(() => afterTwoFrames(() => releaseAnchor())); + Promise.all([revalidated, floor]).then(() => afterTwoFrames(releaseAnchor)); return; } } diff --git a/packages/core/test/routing/router-client.test.js b/packages/core/test/routing/router-client.test.js index 87a7016d4..1f41e32d2 100644 --- a/packages/core/test/routing/router-client.test.js +++ b/packages/core/test/routing/router-client.test.js @@ -2114,6 +2114,62 @@ test('popstate cache restore suppresses scroll anchoring across the window (#131 } }); +test('a second navigation closes an open scroll-anchor window (#1310)', async () => { + // The window outlives its own restore on purpose (a floor, then a ceiling), + // so a navigation starting inside that span has to end it. Otherwise a Back + // that CLAMPS, which opens no window of its own, would run its whole growth + // under the previous restore's suppression and freeze its clamp, and a + // forward nav would carry the suppression onto an unrelated page. + const origLoc = globalThis.location; + const origFetch = globalThis.fetch; + const prevPageUrl = _currentPageUrl(); + const root = document.documentElement; + _snapshotCache.set('/anchor-second-nav', { + html: 'cached', + scrollX: 0, + scrollY: 800, + }); + globalThis.location = /** @type any */ ({ + href: 'http://localhost/anchor-second-nav', + pathname: '/anchor-second-nav', origin: 'http://localhost', search: '', hash: '', + }); + _setCurrentPageUrl('http://localhost/elsewhere'); + globalThis.fetch = async () => new Response('', { + status: 200, headers: { 'content-type': 'text/html' }, + }); + const origWinScrollTo = globalThis.window?.scrollTo; + const origGlobalScrollTo = globalThis.scrollTo; + const origScrollY = globalThis.window?.scrollY; + const land = /** @type any */ ((o) => { if (globalThis.window) globalThis.window.scrollY = o && o.top; }); + globalThis.scrollTo = land; + if (globalThis.window) globalThis.window.scrollTo = land; + document.head.innerHTML = ''; + document.body.innerHTML = 'before-pop'; + try { + _onPopState({}); + assert.equal(root.style.getPropertyValue('overflow-anchor'), 'none', + 'precondition: the restore opened a window'); + // A forward navigation, started well inside the floor. Not awaited: the + // close happens as the navigation STARTS, and awaiting would also run the + // whole fetch-and-apply pipeline, which is not what this asserts. + navigate('http://localhost/somewhere-else').catch(() => {}); + assert.ok(!root.style.getPropertyValue('overflow-anchor'), + 'starting another navigation ends the previous restore\'s window'); + await new Promise((r) => setTimeout(r, 20)); + } finally { + _snapshotCache.delete('/anchor-second-nav'); + _setCurrentPageUrl(prevPageUrl); + globalThis.location = origLoc; + globalThis.fetch = origFetch; + globalThis.scrollTo = origGlobalScrollTo; + if (globalThis.window) globalThis.window.scrollTo = origWinScrollTo; + if (globalThis.window) globalThis.window.scrollY = origScrollY; + root.style.removeProperty('overflow-anchor'); + document.head.innerHTML = ''; + document.body.innerHTML = ''; + } +}); + test('disableClientRouter closes an open scroll-anchor window (#1310)', async () => { // The router must leave nothing of its own on after it is disabled. const origLoc = globalThis.location; diff --git a/website/app/docs/client-router/page.ts b/website/app/docs/client-router/page.ts index 30854f83a..c40f4e766 100644 --- a/website/app/docs/client-router/page.ts +++ b/website/app/docs/client-router/page.ts @@ -222,7 +222,7 @@ connectWS('/posts/' + id + '/feed', { onMessage: (m) => renderStream(m) });Snapshot cache + back/forward

The router maintains a URL-keyed LRU cache of page snapshots (capacity 16). On back/forward via popstate, the cached DOM is applied instantly and the captured window-scroll position is restored. A background refetch then revalidates the snapshot quietly.

-

A back/forward restore also suppresses the browser's scroll anchoring (overflow-anchor) for its duration, then puts it back. The saved offset was recorded against the page at its settled height, while the DOM the restore swaps in is still shorter until its components upgrade and render. Without the suppression the browser reads that late growth as content appearing above the reader and adds it to the offset the router just replayed, landing them below where they left. The window closes on the first real input (wheel, touchmove, keydown, pointerdown), so a reader who starts scrolling mid-restore gets normal anchoring back immediately. Absent that it closes once the restore is over, which is the later of that restore's background revalidation settling and a short floor, and at the latest on a 2s ceiling. The floor matters because waiting on the revalidation alone would tie the window to network latency rather than to the growth it guards, so a server answering faster than the page renders would close it too early. Suppression only withholds a browser correction, it never moves the viewport. If your app sets overflow-anchor on <html> inline itself, it is overridden during a restore and restored afterwards. The suppression is conditional: when the page has not grown enough to scroll to the recorded offset at all, the browser clamps the scroll, and there the shortfall is exactly the growth still to come, so anchoring adding it is what carries the reader back down. The router leaves anchoring alone in that case rather than freezing the clamp.

+

A back/forward restore also suppresses the browser's scroll anchoring (overflow-anchor) for its duration, then puts it back. The saved offset was recorded against the page at its settled height, while the DOM the restore swaps in is still shorter until its components upgrade and render. Without the suppression the browser reads that late growth as content appearing above the reader and adds it to the offset the router just replayed, landing them below where they left. The window closes on the first real input (wheel, touchmove, keydown, pointerdown), so a reader who starts scrolling mid-restore gets normal anchoring back immediately. It also closes as soon as another navigation starts, since the window outlives its own restore and must not be inherited by a page it was never meant for. Absent both it closes once the restore is over, which is the later of that restore's background revalidation settling and a short floor, and at the latest on a 2s ceiling. The floor matters because waiting on the revalidation alone would tie the window to network latency rather than to the growth it guards, so a server answering faster than the page renders would close it too early. Suppression only withholds a browser correction, it never moves the viewport. If your app sets overflow-anchor on <html> inline itself, it is overridden during a restore and restored afterwards. The suppression is conditional: when the page has not grown enough to scroll to the recorded offset at all, the browser clamps the scroll, and there the shortfall is exactly the growth still to come, so anchoring adding it is what carries the reader back down. The router leaves anchoring alone in that case rather than freezing the clamp.

Nav scroll restoration (both the back/forward restore and the scroll-to-top on a forward nav) is forced behavior: 'instant', so setting html { scroll-behavior: smooth } in your app does not make navigation visibly animate the scroll. 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.

After a server action mutates data that a cached page depends on, call revalidate():

import { revalidate } from '@webjsdev/core'; From e5d05f9fb2f57228039930573829b3efbbe161ea Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 6 Aug 2026 23:37:24 +0530 Subject: [PATCH 09/20] test: cover the submission call site, and finish the docs sentence The nav-close landed at two call sites but only one was covered, so deleting the one in performSubmission left every suite green. The gap was not just a missing case: a form appended to the test's container never reaches the router at all, because the restore swaps the body wholesale and leaves that container detached, and an action pointing at the page's own url is skipped as a non-HTML extension since the runner serves test files from a .js path. Both are now spelled out where the next person will hit them. The docs sentence also dropped the half that matters. It said a navigation closes the window and stopped there, which reads as a second Back getting no suppression at all, the opposite of what the router does. --- .../browser/nav-scroll-anchor-restore.test.js | 33 +++++++++++++++++++ website/app/docs/client-router/page.ts | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js index a840923c3..a8fe35055 100644 --- a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js +++ b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js @@ -249,6 +249,39 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => } finally { await teardown(); } }); + test('a form SUBMISSION inside the window closes it too', async () => { + // A submission is a navigation and runs its own pipeline + // (`performSubmission`), so it needs the same close as `performNavigation`. + // Covered separately because a test that only drives `navigate()` leaves + // that second call site free to be deleted with every suite still green. + await setup(); + try { + await goBack(); + assert.equal(document.documentElement.style.getPropertyValue('overflow-anchor'), 'none', + 'precondition: a window is open'); + // Appended to the LIVE body, not to `container`: the restore swaps the + // body wholesale, so `container` is detached by now and a form inside it + // would never reach the router's document-level submit listener. + // + // The action must also not be this page's own url. The runner serves test + // files at a `.js` path, and the router skips a submission whose action + // carries a non-HTML extension, so that form would never reach + // `performSubmission` at all. + const holder = document.createElement('div'); + holder.innerHTML = '
'; + document.body.appendChild(holder); + const form = holder.querySelector('#wj-anchor-form'); + // Well inside the floor. The router intercepts this and the nav guard + // cancels the browser's own submission, so nothing leaves the page. + form.requestSubmit(form.querySelector('button')); + await new Promise((r) => setTimeout(r, 0)); + assert.equal(document.documentElement.style.getPropertyValue('overflow-anchor'), '', + 'a submission ends the previous restore\'s window, same as a link nav'); + holder.remove(); + } finally { await teardown(); } + }); + test('anchoring WORKS again once the window has closed', async () => { // The inverse of the headline, and the regression that would matter most if // this fix were wrong: suppression is temporary, so once the restore is over diff --git a/website/app/docs/client-router/page.ts b/website/app/docs/client-router/page.ts index c40f4e766..e433b7e4b 100644 --- a/website/app/docs/client-router/page.ts +++ b/website/app/docs/client-router/page.ts @@ -222,7 +222,7 @@ connectWS('/posts/' + id + '/feed', { onMessage: (m) => renderStream(m) });Snapshot cache + back/forward

The router maintains a URL-keyed LRU cache of page snapshots (capacity 16). On back/forward via popstate, the cached DOM is applied instantly and the captured window-scroll position is restored. A background refetch then revalidates the snapshot quietly.

-

A back/forward restore also suppresses the browser's scroll anchoring (overflow-anchor) for its duration, then puts it back. The saved offset was recorded against the page at its settled height, while the DOM the restore swaps in is still shorter until its components upgrade and render. Without the suppression the browser reads that late growth as content appearing above the reader and adds it to the offset the router just replayed, landing them below where they left. The window closes on the first real input (wheel, touchmove, keydown, pointerdown), so a reader who starts scrolling mid-restore gets normal anchoring back immediately. It also closes as soon as another navigation starts, since the window outlives its own restore and must not be inherited by a page it was never meant for. Absent both it closes once the restore is over, which is the later of that restore's background revalidation settling and a short floor, and at the latest on a 2s ceiling. The floor matters because waiting on the revalidation alone would tie the window to network latency rather than to the growth it guards, so a server answering faster than the page renders would close it too early. Suppression only withholds a browser correction, it never moves the viewport. If your app sets overflow-anchor on <html> inline itself, it is overridden during a restore and restored afterwards. The suppression is conditional: when the page has not grown enough to scroll to the recorded offset at all, the browser clamps the scroll, and there the shortfall is exactly the growth still to come, so anchoring adding it is what carries the reader back down. The router leaves anchoring alone in that case rather than freezing the clamp.

+

A back/forward restore also suppresses the browser's scroll anchoring (overflow-anchor) for its duration, then puts it back. The saved offset was recorded against the page at its settled height, while the DOM the restore swaps in is still shorter until its components upgrade and render. Without the suppression the browser reads that late growth as content appearing above the reader and adds it to the offset the router just replayed, landing them below where they left. The window closes on the first real input (wheel, touchmove, keydown, pointerdown), so a reader who starts scrolling mid-restore gets normal anchoring back immediately. It also closes as soon as another navigation starts, since the window outlives its own restore and must not be inherited by a page it was never meant for; that navigation then opens a window of its own if it earns one. Absent both it closes once the restore is over, which is the later of that restore's background revalidation settling and a short floor, and at the latest on a 2s ceiling. The floor matters because waiting on the revalidation alone would tie the window to network latency rather than to the growth it guards, so a server answering faster than the page renders would close it too early. Suppression only withholds a browser correction, it never moves the viewport. If your app sets overflow-anchor on <html> inline itself, it is overridden during a restore and restored afterwards. The suppression is conditional: when the page has not grown enough to scroll to the recorded offset at all, the browser clamps the scroll, and there the shortfall is exactly the growth still to come, so anchoring adding it is what carries the reader back down. The router leaves anchoring alone in that case rather than freezing the clamp.

Nav scroll restoration (both the back/forward restore and the scroll-to-top on a forward nav) is forced behavior: 'instant', so setting html { scroll-behavior: smooth } in your app does not make navigation visibly animate the scroll. 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.

After a server action mutates data that a cached page depends on, call revalidate():

import { revalidate } from '@webjsdev/core'; From faac312ddb8e3401c35198cc70ee448d0a10e56b Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 6 Aug 2026 23:46:33 +0530 Subject: [PATCH 10/20] test: drop a wrong explanation and release the form in finally The comment added with the submission case explained the form action with a mechanism that does not exist. The runner serves the test page at `/`, not at a `.js` path, and `.js` is not in the router's non-HTML extension list anyway, so an action pointing at the page's own url would have reached performSubmission fine. Verified by using that url, which passes on all three engines, and it is the more realistic fixture since a bound form posts to its own page. The only real cause was the one the neighbouring comment already gave: the restore swaps the body wholesale, so a form inside the test container is detached before it can be submitted. The form also outlived a failing assertion, since its removal sat after the assert rather than in the finally that releases everything else. --- .../browser/nav-scroll-anchor-restore.test.js | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js index a8fe35055..a4ffb6f01 100644 --- a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js +++ b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js @@ -255,6 +255,10 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => // Covered separately because a test that only drives `navigate()` leaves // that second call site free to be deleted with every suite still green. await setup(); + // Declared out here so the `finally` can release it whatever the assertion + // does. Everything else this file creates is released from `teardown()`, + // and a live form left attached to the body would outlast the whole run. + let holder = null; try { await goBack(); assert.equal(document.documentElement.style.getPropertyValue('overflow-anchor'), 'none', @@ -262,14 +266,9 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => // Appended to the LIVE body, not to `container`: the restore swaps the // body wholesale, so `container` is detached by now and a form inside it // would never reach the router's document-level submit listener. - // - // The action must also not be this page's own url. The runner serves test - // files at a `.js` path, and the router skips a submission whose action - // carries a non-HTML extension, so that form would never reach - // `performSubmission` at all. - const holder = document.createElement('div'); + holder = document.createElement('div'); holder.innerHTML = '
'; + + `action="${entryUrl('submit-target')}">`; document.body.appendChild(holder); const form = holder.querySelector('#wj-anchor-form'); // Well inside the floor. The router intercepts this and the nav guard @@ -278,8 +277,10 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => await new Promise((r) => setTimeout(r, 0)); assert.equal(document.documentElement.style.getPropertyValue('overflow-anchor'), '', 'a submission ends the previous restore\'s window, same as a link nav'); - holder.remove(); - } finally { await teardown(); } + } finally { + if (holder) holder.remove(); + await teardown(); + } }); test('anchoring WORKS again once the window has closed', async () => { From 5d1b22898531753750a3dcc273e811f1949fc973 Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 7 Aug 2026 00:05:54 +0530 Subject: [PATCH 11/20] fix: chase a clamped restore to the exact recorded offset The clamped path was left to scroll anchoring, which carries a reader back down as the page grows. That is right only at the very bottom, where the shortfall and the growth are the same number. Anchoring adds the FULL growth however far short the clamp fell, so everyone above the bottom overshot: leaving at 1902 on /ui/button came back at 2002, and the whole 1240 to 2002 band landed at the bottom regardless of where it started. Re-assert the recorded offset once the document can hold it. #1310 rejected re-asserting the scroll in the general case and that reasoning still holds; the difference here is that this knows exactly where it is going and can tell when it has arrived. It runs only on the clamped path, only while the offset is out of reach, writes once, and stops on the same inputs that close a suppression window, so it cannot fight a reader who has taken over. There is no settling-versus-streaming question to answer, which is what sank the general version. The whole range is now exact: 0, 400, 800, 1200, 1500, 1800, 1902 and 2002 all restore to themselves. Also wires the e2e that covers this into CI. It ran against the website rather than the blog, so it was outside the e2e job entirely, which meant the one assertion exercising a Back restore on a real growing page never ran on a PR. --- .../references/client-router-and-streaming.md | 2 +- .github/workflows/ci.yml | 21 +++++ packages/core/src/router-client.js | 84 ++++++++++++++++++- .../browser/nav-scroll-anchor-restore.test.js | 45 ++++++++++ website/app/docs/client-router/page.ts | 2 +- 5 files changed, 150 insertions(+), 4 deletions(-) diff --git a/.agents/skills/webjs/references/client-router-and-streaming.md b/.agents/skills/webjs/references/client-router-and-streaming.md index f6876f206..47e79837c 100644 --- a/.agents/skills/webjs/references/client-router-and-streaming.md +++ b/.agents/skills/webjs/references/client-router-and-streaming.md @@ -61,7 +61,7 @@ The router keeps a URL-keyed snapshot cache (LRU, cap 16) so Back/Forward restor - **Do not write your own scroll restore.** A `popstate` listener that calls `scrollTo`, a saved offset in `sessionStorage`, a `scrollIntoView` on a remembered element: all of them fight the router, which already set `history.scrollRestoration = 'manual'` and is the sole authority on scroll during a navigation. If Back lands in the wrong place, that is a framework bug to report, not something to patch in app code. - **An app that sets `overflow-anchor` on `` itself sees it overridden during a restore and restored afterwards**, including a value set inline by your own script. Setting it in a stylesheet is unaffected between restores. Nothing else on the page is touched, and the router never sets `overflow-anchor` anywhere but the root element. - **A new navigation ends an open window.** The window outlives its own restore on purpose (a floor, then a ceiling), so any navigation starting inside that span closes it first, and reopens only if it earns one. Otherwise a second Back, or a click, would inherit suppressed anchoring on a page it was never meant for. -- **Suppression is conditional, and only applies when the recorded offset was actually reached.** A page that has not grown yet can be too short to scroll that far, so the browser clamps to its current maximum. There the shortfall IS the growth still to come, and anchoring adding it is what carries the reader back down, so the router leaves anchoring alone. Suppressing in that case would freeze the clamp and strand the reader a full page-growth above where they left, which is this same defect pointing the other way. One consequence to know: for an offset the un-grown page cannot reach, the restore lands wherever anchoring carries it rather than exactly on the recorded number, which for a reader at the bottom is the bottom. +- **Suppression is conditional, and only applies when the recorded offset was actually reached.** A page that has not grown yet can be too short to scroll that far, so the browser clamps to its current maximum. There the shortfall IS the growth still to come, and anchoring adding it is what carries the reader back down, so the router leaves anchoring alone. Suppressing in that case would freeze the clamp and strand the reader a full page-growth above where they left, which is this same defect pointing the other way. That case is not left to anchoring alone, though, because anchoring adds the FULL growth however far short the clamp fell, so by itself it only lands a reader who left at the very bottom. The router also CHASES the recorded offset there, re-asserting it the moment the page is tall enough to hold it, and then stopping. That is the one place the router writes scroll after the initial restore, it is scoped to the clamped path, and it stops on the same inputs that close a suppression window, so it cannot fight a reader who has taken over. - **The window closes on the first real input** (`wheel`, `touchmove`, `keydown`, `pointerdown`), so a reader who starts scrolling mid-restore immediately gets normal browser anchoring back. Absent that it closes once the restore is over, which is the LATER of the restore's own background revalidation settling and a short floor, and at the latest on a 2s ceiling. The floor is load-bearing: waiting on the revalidation alone ties the window's length to network latency rather than to the growth it guards, so a server answering faster than the page renders would close it early and the reader would land low again. Suppression only ever WITHHOLDS a browser correction, it never moves the viewport, so it cannot yank someone who has taken over. Components that reach their final size only after they render (a chart, a media embed with no intrinsic dimensions, anything sized from measured content) are exactly the shape that triggers this, and they need no special handling: give them a placeholder height where you can, and let the router own the restore. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 553db7d34..e3b0dcee4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -388,6 +388,27 @@ jobs: env: WEBJS_E2E: '1' run: node --test test/e2e/dev-overlay-nav.test.mjs + # Form-submission, concurrent-nav and scroll-restoration e2e (#1310). This + # one runs against the WEBSITE rather than the blog, because the scroll + # case needs a page whose content settles taller after the swap, which is + # what a /ui/ gallery page does and a blog page does not. It was + # outside CI entirely until now, so the one assertion covering a Back + # restore on a real growing page never ran on a PR. + # + # `webjs dev` runs the website's own `webjs.dev.before` tasks (the registry + # copy and the Tailwind build), so no extra setup step is needed here. + - name: Run form-submission + scroll-restoration e2e (#1310) + env: + WEBJS_E2E: '1' + run: | + npm run dev --workspace=@webjsdev/website & + for i in $(seq 1 60); do + curl -sf -o /dev/null http://localhost:5001/ui/button && break + sleep 2 + done + curl -sf -o /dev/null http://localhost:5001/ui/button \ + || { echo "website dev server never came up"; exit 1; } + node --test test/e2e/form-submission-and-race.test.mjs # Touch-emulation e2e for interactive Tier-2 ui components (#745/#747): # boots the site serving the gallery and taps hover-card / dropdown-submenu / sonner # under a Chromium iPhone context (faithful touch events, no real device). diff --git a/packages/core/src/router-client.js b/packages/core/src/router-client.js index c784ba80f..679c7ec61 100644 --- a/packages/core/src/router-client.js +++ b/packages/core/src/router-client.js @@ -429,6 +429,72 @@ function suppressScrollAnchoring() { return release; } +/** + * Cancels an in-flight catch-up, or null when none is running. + * @type {(() => void) | null} + */ +let cancelScrollCatchUp = null; + +/** + * Chase a restored scroll offset the document was too SHORT to reach (#1310). + * + * The sibling of `suppressScrollAnchoring`, for the case that one deliberately + * declines. When the recorded offset is past the un-grown document's maximum, + * the browser clamps, and anchoring then adds the growth back as the page + * settles. That lands a reader who left at the very bottom back at the bottom, + * because there the shortfall and the growth are the same number. It is wrong + * for everyone else: anchoring adds the FULL growth whatever the shortfall was, + * so a reader who left 100px above the bottom is carried 100px too far. + * + * This re-asserts the recorded offset once the document can actually hold it, + * which is the only moment the number becomes reachable, and then stops. + * + * It is deliberately narrow, because #1310 rejected re-asserting the scroll in + * the general case and that reasoning still holds. The difference is that this + * knows exactly where it is going and can tell when it has arrived: it runs + * ONLY on the clamped path, only while the offset is still out of reach, and it + * stops on the first real input, so it cannot fight a reader who has taken over. + * A settling restore does not have to be told apart from a streaming boundary + * here, which is the question that sank the general version. + * + * @param {number} targetY The recorded offset to reach. + * @param {number} targetX + */ +function catchUpToRestoredScroll(targetY, targetX) { + if (typeof window === 'undefined' || typeof requestAnimationFrame !== 'function') return; + if (cancelScrollCatchUp) cancelScrollCatchUp(); + let rafId = 0; + /** @type {ReturnType | null} */ + let timer = null; + const stop = () => { + if (cancelScrollCatchUp !== stop) return; + cancelScrollCatchUp = null; + if (rafId) cancelAnimationFrame(rafId); + if (timer) { clearTimeout(timer); timer = null; } + for (const ev of ANCHOR_RELEASE_EVENTS) { + window.removeEventListener(ev, stop, /** @type {any} */ ({ capture: true })); + } + }; + const tick = () => { + if (cancelScrollCatchUp !== stop) return; + const maxY = document.documentElement.scrollHeight - window.innerHeight; + if (maxY >= targetY) { + // Reachable at last. One write, then done. + window.scrollTo({ left: targetX, top: targetY, behavior: 'instant' }); + stop(); + return; + } + rafId = requestAnimationFrame(tick); + }; + cancelScrollCatchUp = stop; + // Same inputs that close a suppression window: the reader has taken over. + for (const ev of ANCHOR_RELEASE_EVENTS) { + window.addEventListener(ev, stop, { capture: true, passive: true }); + } + timer = setTimeout(stop, ANCHOR_SUPPRESS_CEILING_MS); + rafId = requestAnimationFrame(tick); +} + /** * Run `fn` after two animation frames, so a just-applied DOM has laid out * before it reads or acts. Falls back to a macrotask where @@ -511,8 +577,10 @@ export function disableClientRouter() { history.scrollRestoration = prevScrollRestoration; prevScrollRestoration = null; } - // Never leave a restore window open on (#1310). + // Never leave a restore window open on , nor a catch-up chasing a + // scroll offset after the router is gone (#1310). if (releaseScrollAnchor) releaseScrollAnchor(); + if (cancelScrollCatchUp) cancelScrollCatchUp(); currentPageUrl = null; } @@ -1484,7 +1552,10 @@ async function performNavigation(href, isPopState, frameId) { // would run the whole growth under the previous restore's suppression and // freeze its clamp, and a forward nav would carry it onto a different page // entirely. Reopening for this navigation, if it earns one, happens below. + // The clamped path's catch-up is cancelled for the same reason: it chases an + // offset recorded for the page being navigated away from. if (releaseScrollAnchor) releaseScrollAnchor(); + if (cancelScrollCatchUp) cancelScrollCatchUp(); // Snapshot the page the user is LEAVING (with its scroll position) // so back/forward navigation can restore it. We key under @@ -1556,6 +1627,13 @@ async function performNavigation(href, isPopState, frameId) { // scroll just landed in, so the two are consistent by construction. if (window.scrollY >= cached.scrollY - 1) { releaseAnchor = suppressScrollAnchoring(); + } else { + // Clamped. Anchoring is left on, since it is what carries the + // reader back down, but it adds the FULL growth regardless of how + // far short the clamp fell, so on its own it only lands a reader + // who left at the very bottom. Chase the recorded offset instead, + // once the page is tall enough to hold it. + catchUpToRestoredScroll(cached.scrollY, cached.scrollX); } } // Fire-and-forget revalidation. Uses a fresh AbortController @@ -1642,8 +1720,10 @@ async function performSubmission(href, method, body, frameId, form) { const signal = activeAbortController.signal; const myToken = ++currentNavigationToken; // Same reasoning as performNavigation: a submission is a navigation, so it - // ends any restore window a recent Back left open (#1310). + // ends any restore window a recent Back left open (#1310), and cancels a + // clamped restore's catch-up. if (releaseScrollAnchor) releaseScrollAnchor(); + if (cancelScrollCatchUp) cancelScrollCatchUp(); const isSafe = method === 'get' || method === 'head'; let url = new URL(href, location.href); diff --git a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js index a4ffb6f01..62e4cb0e7 100644 --- a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js +++ b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js @@ -283,6 +283,51 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => } }); + test('a clamped restore is CHASED to the exact recorded offset', async () => { + // Leaving anchoring on is not enough on its own. It adds the FULL growth + // whatever the shortfall was, so it only lands a reader who left at the very + // bottom, where those two numbers coincide; anyone above that is carried too + // far (1902 came back as 2002 on /ui/button). The catch-up re-asserts the + // recorded offset the moment the page is tall enough to hold it. + // + // The target is picked from the live viewport so it sits INSIDE the band + // that only the grown page can reach: past the un-grown document's maximum + // (3000px of fixture), and 300px into the 763px the grower adds. + const restoredY = Math.max(0, 3000 - window.innerHeight + 300); + await setup({ restoredY }); + try { + await goBack(); + assert.ok(window.scrollY < restoredY - 1, + `precondition: the restore was clamped (got ${window.scrollY} for ${restoredY})`); + for (let i = 0; i < 12; i++) await frame(); + assert.ok(Math.abs(window.scrollY - restoredY) < 5, + `the catch-up lands on the recorded offset once it is reachable ` + + `(expected ~${restoredY}, got ${window.scrollY})`); + } finally { await teardown(); } + }); + + test('a reader taking over cancels the catch-up', async () => { + // The catch-up WRITES scroll, unlike suppression, so it is the one part of + // this that could yank someone. It stops on the same inputs a suppression + // window closes on, before the offset becomes reachable. + const restoredY = Math.max(0, 3000 - window.innerHeight + 300); + await setup({ restoredY }); + try { + await goBack(); + const clamped = window.scrollY; + assert.ok(clamped < restoredY - 1, 'precondition: the restore was clamped'); + window.dispatchEvent(new WheelEvent('wheel', { bubbles: true })); + for (let i = 0; i < 12; i++) await frame(); + // Asserted as "the catch-up never wrote", not as "nothing moved". The + // clamped path deliberately leaves anchoring ON, so the browser still + // carries the position as the page grows, exactly as it does on main. + // What must not happen is this code adding a write of its own on top. + assert.ok(Math.abs(window.scrollY - restoredY) >= 5, + 'a reader who has taken over is never scrolled onto the recorded ' + + `offset (landed exactly on ${restoredY}, so the catch-up wrote)`); + } finally { await teardown(); } + }); + test('anchoring WORKS again once the window has closed', async () => { // The inverse of the headline, and the regression that would matter most if // this fix were wrong: suppression is temporary, so once the restore is over diff --git a/website/app/docs/client-router/page.ts b/website/app/docs/client-router/page.ts index e433b7e4b..f6b58bb0d 100644 --- a/website/app/docs/client-router/page.ts +++ b/website/app/docs/client-router/page.ts @@ -222,7 +222,7 @@ connectWS('/posts/' + id + '/feed', { onMessage: (m) => renderStream(m) });Snapshot cache + back/forward

The router maintains a URL-keyed LRU cache of page snapshots (capacity 16). On back/forward via popstate, the cached DOM is applied instantly and the captured window-scroll position is restored. A background refetch then revalidates the snapshot quietly.

-

A back/forward restore also suppresses the browser's scroll anchoring (overflow-anchor) for its duration, then puts it back. The saved offset was recorded against the page at its settled height, while the DOM the restore swaps in is still shorter until its components upgrade and render. Without the suppression the browser reads that late growth as content appearing above the reader and adds it to the offset the router just replayed, landing them below where they left. The window closes on the first real input (wheel, touchmove, keydown, pointerdown), so a reader who starts scrolling mid-restore gets normal anchoring back immediately. It also closes as soon as another navigation starts, since the window outlives its own restore and must not be inherited by a page it was never meant for; that navigation then opens a window of its own if it earns one. Absent both it closes once the restore is over, which is the later of that restore's background revalidation settling and a short floor, and at the latest on a 2s ceiling. The floor matters because waiting on the revalidation alone would tie the window to network latency rather than to the growth it guards, so a server answering faster than the page renders would close it too early. Suppression only withholds a browser correction, it never moves the viewport. If your app sets overflow-anchor on <html> inline itself, it is overridden during a restore and restored afterwards. The suppression is conditional: when the page has not grown enough to scroll to the recorded offset at all, the browser clamps the scroll, and there the shortfall is exactly the growth still to come, so anchoring adding it is what carries the reader back down. The router leaves anchoring alone in that case rather than freezing the clamp.

+

A back/forward restore also suppresses the browser's scroll anchoring (overflow-anchor) for its duration, then puts it back. The saved offset was recorded against the page at its settled height, while the DOM the restore swaps in is still shorter until its components upgrade and render. Without the suppression the browser reads that late growth as content appearing above the reader and adds it to the offset the router just replayed, landing them below where they left. The window closes on the first real input (wheel, touchmove, keydown, pointerdown), so a reader who starts scrolling mid-restore gets normal anchoring back immediately. It also closes as soon as another navigation starts, since the window outlives its own restore and must not be inherited by a page it was never meant for; that navigation then opens a window of its own if it earns one. Absent both it closes once the restore is over, which is the later of that restore's background revalidation settling and a short floor, and at the latest on a 2s ceiling. The floor matters because waiting on the revalidation alone would tie the window to network latency rather than to the growth it guards, so a server answering faster than the page renders would close it too early. Suppression only withholds a browser correction, it never moves the viewport. If your app sets overflow-anchor on <html> inline itself, it is overridden during a restore and restored afterwards. The suppression is conditional: when the page has not grown enough to scroll to the recorded offset at all, the browser clamps the scroll, and there the shortfall is exactly the growth still to come, so anchoring adding it is what carries the reader back down. The router leaves anchoring alone in that case rather than freezing the clamp, and additionally chases the saved offset, re-asserting it once the page is tall enough to hold it. That is the only scroll the router writes after the initial restore, and it stops on the first real input, so it never fights a reader who has started scrolling.

Nav scroll restoration (both the back/forward restore and the scroll-to-top on a forward nav) is forced behavior: 'instant', so setting html { scroll-behavior: smooth } in your app does not make navigation visibly animate the scroll. 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.

After a server action mutates data that a cached page depends on, call revalidate():

import { revalidate } from '@webjsdev/core'; From 99e068f3885519af342680ac291db84737633ebb Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 7 Aug 2026 00:19:55 +0530 Subject: [PATCH 12/20] test: drive the clamped-restore growth from the test, not a timer The two catch-up cases were flaky on Firefox, failing their clamped precondition about one run in three: the restored page was already tall enough to reach the offset, so there was no clamp to chase. Both attempts to control that from the outside failed. Removing the leftover fixture in setup as well as teardown did not fix it, because a revalidation swap can land after teardown has run, and keying the grower per run made it worse. The precondition does not need to be a race at all. A grower that never grows on its own is 0px whatever else happened, so the test asserts the clamp and only then adds the height, which is the moment the catch-up is waiting for. Six consecutive Firefox runs clean, and the full browser suite is green on all three engines. The self-growing fixtures stay where late growth arriving by itself is the thing under test. --- .../browser/nav-scroll-anchor-restore.test.js | 84 ++++++++++++++----- 1 file changed, 64 insertions(+), 20 deletions(-) diff --git a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js index 62e4cb0e7..27a6a8f39 100644 --- a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js +++ b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js @@ -68,6 +68,31 @@ class GrowVeryLate extends HTMLElement { } customElements.define('wj-grow-very-late-1310', GrowVeryLate); +/** + * Never grows on its own; the test grows it. The clamped cases need the restored + * page to be SHORT at the moment of the restore and TALL a moment later, and + * driving that from the test removes every timing race from the precondition: + * whether a leftover element is reused no longer matters, since this one is 0px + * either way. The self-growing fixtures above still carry the headline cases, + * where late growth arriving on its own IS the thing under test. + */ +class GrowOnCommand extends HTMLElement { + connectedCallback() { + this.style.display = 'block'; + this.style.height = '0px'; + } +} +customElements.define('wj-grow-on-command-1310', GrowOnCommand); + +/** + * Offset the un-grown fixture cannot reach (its filler is 3000px, so its maximum + * is under that whatever the viewport) but the grown one can, once the test adds + * 3000px more. + */ +const CLAMPED_TARGET = 4000; +/** How much the test grows the on-command fixture by. */ +const COMMANDED_GROWTH = 3000; + const frame = () => new Promise((r) => requestAnimationFrame(() => r())); /** Long enough for the grower to connect, lay out, and take its height. */ async function afterGrowth() { for (let i = 0; i < 4; i++) await frame(); } @@ -79,9 +104,14 @@ async function afterGrowth() { for (let i = 0; i < 4; i++) await frame(); } * acts. */ function restoredBody(tag) { + // The wrapper id lets `teardown` remove the fixture: the restore REPLACES the + // whole body and nothing puts the original back, so each case would otherwise + // leave its markup in the document. return '' + + '
' + `<${tag}>` + '
restored
' + + '
' + ''; } @@ -89,8 +119,7 @@ function restoredHtml(tag) { return '' + restoredBody(tag) + ''; } -const RESTORED_HTML = restoredHtml('wj-grow-late-1310'); -const RESTORED_HTML_SLOW = restoredHtml('wj-grow-very-late-1310'); + /** * A same-document history entry for this test page, carrying one extra query @@ -118,12 +147,22 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => * default the revalidation is held open so a case can assert inside the * restore window. `instantRevalidation` answers it immediately instead, * which is the ordering a fast server produces. `restoredY` overrides the - * recorded offset, so a case can force the clamped path. + * recorded offset, so a case can force the clamped path, and `manualGrowth` + * swaps in a grower the test drives by hand. */ async function setup(opts) { const instant = Boolean(opts && opts.instantRevalidation); const restoredY = (opts && opts.restoredY) != null ? opts.restoredY : RESTORED_Y; - const html = instant ? RESTORED_HTML_SLOW : RESTORED_HTML; + const html = restoredHtml((opts && opts.manualGrowth) ? 'wj-grow-on-command-1310' + : instant ? 'wj-grow-very-late-1310' : 'wj-grow-late-1310'); + // Clear any fixture a previous case left in the document BEFORE starting. + // Teardown removes it, but a revalidation swap can land after teardown has + // run and put it back, and a leftover grower is already at full height, so + // the swap reuses it and the next restore is no longer clamped. Removing it + // here as well is what makes the clamped precondition hold every time; on + // Firefox it failed roughly one run in three without this. + const stale = document.getElementById('wj-restored-1310'); + if (stale) stale.remove(); navGuard = installNavGuard(); enableClientRouter(); origScrollBehavior = document.documentElement.style.scrollBehavior; @@ -196,6 +235,9 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => entriesPushed = false; } container.remove(); + // The swapped-in restored page, which replaced the body wholesale. + const restored = document.getElementById('wj-restored-1310'); + if (restored) restored.remove(); document.documentElement.style.removeProperty('overflow-anchor'); document.documentElement.style.scrollBehavior = origScrollBehavior; window.scrollTo({ top: 0, left: 0, behavior: 'instant' }); @@ -290,19 +332,20 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => // far (1902 came back as 2002 on /ui/button). The catch-up re-asserts the // recorded offset the moment the page is tall enough to hold it. // - // The target is picked from the live viewport so it sits INSIDE the band - // that only the grown page can reach: past the un-grown document's maximum - // (3000px of fixture), and 300px into the 763px the grower adds. - const restoredY = Math.max(0, 3000 - window.innerHeight + 300); - await setup({ restoredY }); + // The target sits inside the band only the grown page can reach, and the + // fixture grows by 3000px so that band does not depend on the runner's + // viewport height. + await setup({ restoredY: CLAMPED_TARGET, manualGrowth: true }); try { await goBack(); - assert.ok(window.scrollY < restoredY - 1, - `precondition: the restore was clamped (got ${window.scrollY} for ${restoredY})`); + assert.ok(window.scrollY < CLAMPED_TARGET - 1, + `precondition: the restore was clamped (got ${window.scrollY} for ${CLAMPED_TARGET})`); + // Now make the offset reachable, which is what the catch-up waits for. + document.querySelector('wj-grow-on-command-1310').style.height = COMMANDED_GROWTH + 'px'; for (let i = 0; i < 12; i++) await frame(); - assert.ok(Math.abs(window.scrollY - restoredY) < 5, - `the catch-up lands on the recorded offset once it is reachable ` - + `(expected ~${restoredY}, got ${window.scrollY})`); + assert.ok(Math.abs(window.scrollY - CLAMPED_TARGET) < 5, + 'the catch-up lands on the recorded offset once it is reachable ' + + `(expected ~${CLAMPED_TARGET}, got ${window.scrollY})`); } finally { await teardown(); } }); @@ -310,21 +353,22 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => // The catch-up WRITES scroll, unlike suppression, so it is the one part of // this that could yank someone. It stops on the same inputs a suppression // window closes on, before the offset becomes reachable. - const restoredY = Math.max(0, 3000 - window.innerHeight + 300); - await setup({ restoredY }); + await setup({ restoredY: CLAMPED_TARGET, manualGrowth: true }); try { await goBack(); - const clamped = window.scrollY; - assert.ok(clamped < restoredY - 1, 'precondition: the restore was clamped'); + assert.ok(window.scrollY < CLAMPED_TARGET - 1, + 'precondition: the restore was clamped'); + // The reader takes over BEFORE the offset becomes reachable. window.dispatchEvent(new WheelEvent('wheel', { bubbles: true })); + document.querySelector('wj-grow-on-command-1310').style.height = COMMANDED_GROWTH + 'px'; for (let i = 0; i < 12; i++) await frame(); // Asserted as "the catch-up never wrote", not as "nothing moved". The // clamped path deliberately leaves anchoring ON, so the browser still // carries the position as the page grows, exactly as it does on main. // What must not happen is this code adding a write of its own on top. - assert.ok(Math.abs(window.scrollY - restoredY) >= 5, + assert.ok(Math.abs(window.scrollY - CLAMPED_TARGET) >= 5, 'a reader who has taken over is never scrolled onto the recorded ' - + `offset (landed exactly on ${restoredY}, so the catch-up wrote)`); + + `offset (landed exactly on ${CLAMPED_TARGET}, so the catch-up wrote)`); } finally { await teardown(); } }); From 0e586f3bd206dd57fb74454eed4d4cfac2b8b710 Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 7 Aug 2026 00:52:44 +0530 Subject: [PATCH 13/20] fix: order the restore correctly under a view transition Three review findings, one of them a real defect on a real app shape. Under a view transition applySwap defers its DOM mutation a frame, so the restore wrote and measured the scroll against the OUTGOING page. That was not reachable from the earlier fixture, whose snapshot head was empty: the full-body restore merges the incoming head BEFORE deciding whether to run a transition, so the opt-in was stripped and the transition never engaged. A real snapshot carries it, since it is serialized from the live document. With the meta in the snapshot head it reproduces: a 60000px outgoing page made the scroll "land" at 20000, suppression opened, and the restored page then clamped to 2416 with anchoring held off, which is the stranding the clamped path exists to prevent. The decision now waits for the swap commit on that path only; the synchronous path is untouched, since deferring it there breaks the fix outright. The catch-up is bounded by the floor rather than the ceiling. Any growth past the target fires it, and growth is not exclusively the restore settling, so a two second window could scroll a reader who had landed and started reading, generating no input to cancel it. Its docstring no longer claims to escape the settling-versus-streaming question; the window is what makes it safe, not an ability to tell the two apart. The CI step no longer stalls or leaks. Left on the step's stdout, the background server held the log pipe open and outlived the script: run locally the identical script hung for ten minutes after the tests had passed. Redirecting to a file fixes that, and setsid plus a process-group kill stops the watcher child holding :5001, which a plain kill did not. Both paths measured: pass exits 0 in 14s, failure exits 1, and the port is free afterwards either way. --- .github/workflows/ci.yml | 18 +++- packages/core/src/router-client.js | 94 ++++++++++++------- .../browser/nav-scroll-anchor-restore.test.js | 67 +++++++++++-- 3 files changed, 139 insertions(+), 40 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3b0dcee4..7fb2bb10e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -401,7 +401,23 @@ jobs: env: WEBJS_E2E: '1' run: | - npm run dev --workspace=@webjsdev/website & + # Redirected to a FILE, not left on the step's stdout. A background + # child that keeps the log pipe open outlives the script and stalls + # the step: run locally, the identical script hung for ten minutes + # after the tests had already passed, with the server still listening. + setsid npm run dev --workspace=@webjsdev/website > /tmp/website-dev.log 2>&1 & + server_pid=$! + # Stop the server however this step ends. `trap ... EXIT` keeps the + # test's exit code, which a trailing `kill` would not: the step runs + # under `bash -e`, so a failing test aborts the script before any + # cleanup line placed after it could run. The pid is captured into a + # variable rather than read as `$!` inside the trap, which would + # resolve at FIRE time against whatever job was most recent then. + # `setsid` puts the server in its own process GROUP and the trap + # signals the whole group. `webjs dev` spawns a watcher which spawns + # the listener, so killing only the npm pid leaves the port held; that + # was measured, the step exited cleanly and :5001 stayed up. + trap 'kill -- -"$server_pid" 2>/dev/null || true' EXIT for i in $(seq 1 60); do curl -sf -o /dev/null http://localhost:5001/ui/button && break sleep 2 diff --git a/packages/core/src/router-client.js b/packages/core/src/router-client.js index 679c7ec61..5e7443604 100644 --- a/packages/core/src/router-client.js +++ b/packages/core/src/router-client.js @@ -452,10 +452,15 @@ let cancelScrollCatchUp = null; * It is deliberately narrow, because #1310 rejected re-asserting the scroll in * the general case and that reasoning still holds. The difference is that this * knows exactly where it is going and can tell when it has arrived: it runs - * ONLY on the clamped path, only while the offset is still out of reach, and it - * stops on the first real input, so it cannot fight a reader who has taken over. - * A settling restore does not have to be told apart from a streaming boundary - * here, which is the question that sank the general version. + * ONLY on the clamped path, only while the offset is still out of reach, writes + * once, and stops on the first real input. + * + * It does NOT escape the settling-versus-streaming question, and it is worth + * being exact about that rather than claiming otherwise. It cannot tell the + * restore settling apart from any other growth, so the guard is its WINDOW: it + * lives only as long as the floor, the same span the restore's own suppression + * covers. Outside that it is gone, so late-resolving content cannot move a + * reader who is simply reading and generating no input to cancel it. * * @param {number} targetY The recorded offset to reach. * @param {number} targetX @@ -491,7 +496,17 @@ function catchUpToRestoredScroll(targetY, targetX) { for (const ev of ANCHOR_RELEASE_EVENTS) { window.addEventListener(ev, stop, { capture: true, passive: true }); } - timer = setTimeout(stop, ANCHOR_SUPPRESS_CEILING_MS); + // Bounded by the FLOOR, not the ceiling. The ceiling is a backstop for a hung + // fetch; this is a scroll WRITE, so its window is the one thing that decides + // whether a reader can be moved without asking. Any growth past the target + // fires it, and growth is not exclusively the restore settling: a + // boundary resolving, a lazy component entering, or a late + // image would all qualify. Holding it open for the full ceiling would mean a + // reader who landed and started READING, and so generates no input to cancel + // it, could be scrolled up to two seconds after pressing Back. The floor + // covers the restore's own settling, which is what it is for, and is measured + // in a few hundred milliseconds rather than seconds. + timer = setTimeout(stop, ANCHOR_SUPPRESS_FLOOR_MS); rafId = requestAnimationFrame(tick); } @@ -1599,41 +1614,56 @@ async function performNavigation(href, isPopState, frameId) { // lands below where they left (#1310). let releaseAnchor = () => {}; if (typeof window !== 'undefined') { - window.scrollTo({ left: cached.scrollX, top: cached.scrollY, behavior: 'instant' }); - // Suppress ONLY when the recorded offset was actually reached. + // Restore the scroll, then decide whether to suppress anchoring. // - // A document that has not grown yet can be too SHORT to scroll that + // Suppress ONLY when the recorded offset was actually reached. A + // document that has not grown yet can be too SHORT to scroll that // far, and the browser clamps to its current maximum. A reader at // the bottom of the settled page is the clear case: the shortfall is // then exactly the growth still to come, and anchoring ADDING that // growth is what carries them back to the bottom. Suppressing there // freezes the clamp instead and strands them a full page-growth - // ABOVE where they left, which is this bug's own mirror image. - // - // So the two situations want opposite things, and they are told - // apart by the one question that separates them: did the scroll - // land. Reading it here is safe because nothing can grow between - // the write and the read, both being in this synchronous block. + // ABOVE where they left, which is this bug's own mirror image. The + // two situations want opposite things and are told apart by the one + // question that separates them: did the scroll land. // - // The read MUST stay synchronous, and that is the subtle part. - // Deferring it even by a microtask breaks the fix outright: by then - // the restored components' renders have been applied, and reading - // `scrollY` forces the layout that flushes them, so anchoring runs - // DURING the read and hands back the already-shifted offset. - // Measured on /ui/button, the suppression then landed 19ms after - // the scroll with `scrollY` already 800 -> 1563, which is the bug - // it exists to prevent. What makes the synchronous read correct is - // not which document it sees but that it sees the SAME layout the - // scroll just landed in, so the two are consistent by construction. - if (window.scrollY >= cached.scrollY - 1) { - releaseAnchor = suppressScrollAnchoring(); + // Both halves must read the SAME layout, and the scroll must be + // written against the page being restored. That is why this is + // ordered rather than simply inlined, and why the ordering differs + // by path. + const restoreScroll = () => { + window.scrollTo({ left: cached.scrollX, top: cached.scrollY, behavior: 'instant' }); + if (window.scrollY >= cached.scrollY - 1) { + releaseAnchor = suppressScrollAnchoring(); + } else { + // Clamped. Anchoring is left on, since it is what carries the + // reader back down, but it adds the FULL growth regardless of + // how far short the clamp fell, so on its own it only lands a + // reader who left at the very bottom. Chase the recorded offset + // instead, once the page is tall enough to hold it. + catchUpToRestoredScroll(cached.scrollY, cached.scrollX); + } + }; + if (viewTransitionsEnabled() && typeof (/** @type any */ (document)).startViewTransition === 'function') { + // Under a view transition `applySwap` defers its DOM mutation a + // frame, so running now would write and measure against the + // OUTGOING page. Measured with a 60000px outgoing page and a + // 3000px restored one: the scroll "landed" at 20000, suppression + // opened, and the restored page then clamped to 2416 with + // anchoring held off, which is precisely the stranding the + // conditional exists to prevent. Wait for the swap to commit. + _swapCommit.then(restoreScroll).catch(() => {}); } else { - // Clamped. Anchoring is left on, since it is what carries the - // reader back down, but it adds the FULL growth regardless of how - // far short the clamp fell, so on its own it only lands a reader - // who left at the very bottom. Chase the recorded offset instead, - // once the page is tall enough to hold it. - catchUpToRestoredScroll(cached.scrollY, cached.scrollX); + // The synchronous path, and the read must STAY synchronous here. + // Deferring it even by a microtask breaks the fix outright: by + // then the restored components' renders have been applied, and + // reading `scrollY` forces the layout that flushes them, so + // anchoring runs DURING the read and hands back the + // already-shifted offset. Measured on /ui/button, the suppression + // landed 19ms late with `scrollY` already 800 -> 1563. What makes + // it correct is not which document it sees but that it sees the + // same layout the scroll just landed in. + restoreScroll(); } } // Fire-and-forget revalidation. Uses a fresh AbortController diff --git a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js index 27a6a8f39..0e41bebfc 100644 --- a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js +++ b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js @@ -115,10 +115,21 @@ function restoredBody(tag) { + ''; } -function restoredHtml(tag) { - return '' + restoredBody(tag) + ''; +function restoredHtml(tag, head) { + return `${head || ''}` + + restoredBody(tag) + ''; } +/** + * The view-transition opt-in, in the SNAPSHOT's head rather than the live + * document's. That placement is load-bearing: the full-body restore merges the + * incoming head BEFORE it decides whether to run a transition, so a meta added + * only to the live document is gone by the time that check runs and the + * transition never engages. A real snapshot carries it, since it is serialized + * from the live document. + */ +const VT_META = ''; + /** @@ -147,14 +158,18 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => * default the revalidation is held open so a case can assert inside the * restore window. `instantRevalidation` answers it immediately instead, * which is the ordering a fast server produces. `restoredY` overrides the - * recorded offset, so a case can force the clamped path, and `manualGrowth` - * swaps in a grower the test drives by hand. + * recorded offset, so a case can force the clamped path, `manualGrowth` + * swaps in a grower the test drives by hand, and `viewTransition` puts the + * view-transition opt-in in the snapshot's head. */ async function setup(opts) { const instant = Boolean(opts && opts.instantRevalidation); const restoredY = (opts && opts.restoredY) != null ? opts.restoredY : RESTORED_Y; - const html = restoredHtml((opts && opts.manualGrowth) ? 'wj-grow-on-command-1310' - : instant ? 'wj-grow-very-late-1310' : 'wj-grow-late-1310'); + const outgoingHeight = (opts && opts.tallOutgoing) ? 60000 : 3000; + const html = restoredHtml( + (opts && opts.manualGrowth) ? 'wj-grow-on-command-1310' + : instant ? 'wj-grow-very-late-1310' : 'wj-grow-late-1310', + (opts && opts.viewTransition) ? VT_META : ''); // Clear any fixture a previous case left in the document BEFORE starting. // Teardown removes it, but a revalidation swap can land after teardown has // run and put it back, and a leftover grower is already at full height, so @@ -177,7 +192,7 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => container = document.createElement('div'); container.innerHTML = '' - + '
outgoing
' + + `
outgoing
` + ''; document.body.appendChild(container); @@ -238,6 +253,8 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => // The swapped-in restored page, which replaced the body wholesale. const restored = document.getElementById('wj-restored-1310'); if (restored) restored.remove(); + // The head merge can bring the opt-in into the live document. + document.querySelectorAll('meta[name="view-transition"]').forEach((m) => m.remove()); document.documentElement.style.removeProperty('overflow-anchor'); document.documentElement.style.scrollBehavior = origScrollBehavior; window.scrollTo({ top: 0, left: 0, behavior: 'instant' }); @@ -291,6 +308,42 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => } finally { await teardown(); } }); + test('under a view transition the decision waits for the swap to commit', async () => { + // `applySwap` defers its DOM mutation a frame when a transition is running, + // so writing and measuring the scroll straight through would act on the + // OUTGOING page. Here that page is far taller than the restored one, so a + // decision taken against it says "landed" and suppresses anchoring, and the + // restored page then clamps with anchoring held off. That is the stranding + // the clamped path exists to avoid, arriving by a different route. + // + // The transition is SIMULATED. A hidden document skips a real one, and the + // runner puts test files in concurrent pages, so the deferred path is not + // otherwise reachable from this suite on any engine. The stub defers the + // callback exactly as the spec does. + const origSVT = (/** @type any */ (document)).startViewTransition; + let transitions = 0; + (/** @type any */ (document)).startViewTransition = (cb) => { + transitions += 1; + const done = new Promise((resolve) => { + requestAnimationFrame(() => { cb(); resolve(); }); + }); + return { updateCallbackDone: done, finished: done, ready: done, skipTransition() {} }; + }; + await setup({ restoredY: CLAMPED_TARGET, manualGrowth: true, viewTransition: true, tallOutgoing: true }); + try { + await goBack(); + assert.ok(transitions > 0, + 'precondition: the swap actually ran through a view transition'); + for (let i = 0; i < 4; i++) await frame(); + assert.equal(document.documentElement.style.getPropertyValue('overflow-anchor'), '', + 'the clamp is judged against the restored page, so a restore that ' + + 'clamps leaves anchoring alone rather than freezing it'); + } finally { + await teardown(); + (/** @type any */ (document)).startViewTransition = origSVT; + } + }); + test('a form SUBMISSION inside the window closes it too', async () => { // A submission is a navigation and runs its own pipeline // (`performSubmission`), so it needs the same close as `performNavigation`. From 3c0d656a0338583fc410256f7bd98cc92233f664 Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 7 Aug 2026 01:06:16 +0530 Subject: [PATCH 14/20] fix: guard the deferred restore, bound its chase, and keep the CI log Four review findings, one a real defect in the view-transition path added last commit. That path is the only place the restore outlives the call that scheduled it, and every cancel site in this feature runs at the START of the next thing. So a navigation, submission, or disableClientRouter arriving inside the deferred frame closed the window and then had the stale restore reopen it, keyed to the previous history entry, scrolling a page it was never meant for. It is token-guarded now, the same mechanism the rest of the file uses; the synchronous branch cannot outlive anything and needs none. The catch-up's window was a live behaviour constant with nothing pinning it: both existing cases grow the fixture immediately, so they passed at any bound. A case now drives growth AFTER the window with no input at any point, which is the reason the bound exists. Both doc surfaces named user input as the only thing that stops the chase and never mentioned the time box, so a component settling later than it would strand a clamped reader with nothing saying so. The source comment was wrong in the other direction, claiming the chase covers the same span as the suppression beside it; it is deliberately the shorter of the two, because it writes scroll. The CI step sent the server log to a file and never read it back, on a required check that boots the website and runs its Tailwind build. It now dumps the log on any failure and preserves the exit code exactly. --- .../references/client-router-and-streaming.md | 2 +- .github/workflows/ci.yml | 8 ++- packages/core/src/router-client.js | 28 ++++++++-- .../browser/nav-scroll-anchor-restore.test.js | 51 +++++++++++++++++++ website/app/docs/client-router/page.ts | 2 +- 5 files changed, 84 insertions(+), 7 deletions(-) diff --git a/.agents/skills/webjs/references/client-router-and-streaming.md b/.agents/skills/webjs/references/client-router-and-streaming.md index 47e79837c..e839105fa 100644 --- a/.agents/skills/webjs/references/client-router-and-streaming.md +++ b/.agents/skills/webjs/references/client-router-and-streaming.md @@ -61,7 +61,7 @@ The router keeps a URL-keyed snapshot cache (LRU, cap 16) so Back/Forward restor - **Do not write your own scroll restore.** A `popstate` listener that calls `scrollTo`, a saved offset in `sessionStorage`, a `scrollIntoView` on a remembered element: all of them fight the router, which already set `history.scrollRestoration = 'manual'` and is the sole authority on scroll during a navigation. If Back lands in the wrong place, that is a framework bug to report, not something to patch in app code. - **An app that sets `overflow-anchor` on `` itself sees it overridden during a restore and restored afterwards**, including a value set inline by your own script. Setting it in a stylesheet is unaffected between restores. Nothing else on the page is touched, and the router never sets `overflow-anchor` anywhere but the root element. - **A new navigation ends an open window.** The window outlives its own restore on purpose (a floor, then a ceiling), so any navigation starting inside that span closes it first, and reopens only if it earns one. Otherwise a second Back, or a click, would inherit suppressed anchoring on a page it was never meant for. -- **Suppression is conditional, and only applies when the recorded offset was actually reached.** A page that has not grown yet can be too short to scroll that far, so the browser clamps to its current maximum. There the shortfall IS the growth still to come, and anchoring adding it is what carries the reader back down, so the router leaves anchoring alone. Suppressing in that case would freeze the clamp and strand the reader a full page-growth above where they left, which is this same defect pointing the other way. That case is not left to anchoring alone, though, because anchoring adds the FULL growth however far short the clamp fell, so by itself it only lands a reader who left at the very bottom. The router also CHASES the recorded offset there, re-asserting it the moment the page is tall enough to hold it, and then stopping. That is the one place the router writes scroll after the initial restore, it is scoped to the clamped path, and it stops on the same inputs that close a suppression window, so it cannot fight a reader who has taken over. +- **Suppression is conditional, and only applies when the recorded offset was actually reached.** A page that has not grown yet can be too short to scroll that far, so the browser clamps to its current maximum. There the shortfall IS the growth still to come, and anchoring adding it is what carries the reader back down, so the router leaves anchoring alone. Suppressing in that case would freeze the clamp and strand the reader a full page-growth above where they left, which is this same defect pointing the other way. That case is not left to anchoring alone, though, because anchoring adds the FULL growth however far short the clamp fell, so by itself it only lands a reader who left at the very bottom. The router also CHASES the recorded offset there, re-asserting it the moment the page is tall enough to hold it, and then stopping. That is the one place the router writes scroll after the initial restore, it is scoped to the clamped path, and it stops on the same inputs that close a suppression window. It is also time-boxed, to a window SHORTER than the suppression one: a few hundred milliseconds, not the 2s ceiling. That bound is what keeps it from moving a reader who has landed and started reading, since such a reader generates no input to cancel it and the chase cannot tell the restore settling apart from any other growth. The cost is the other side of that trade: a component that reaches its final height later than the bound (a chart, an embed measured from its content) leaves a clamped reader at the clamp rather than at the offset they left. - **The window closes on the first real input** (`wheel`, `touchmove`, `keydown`, `pointerdown`), so a reader who starts scrolling mid-restore immediately gets normal browser anchoring back. Absent that it closes once the restore is over, which is the LATER of the restore's own background revalidation settling and a short floor, and at the latest on a 2s ceiling. The floor is load-bearing: waiting on the revalidation alone ties the window's length to network latency rather than to the growth it guards, so a server answering faster than the page renders would close it early and the reader would land low again. Suppression only ever WITHHOLDS a browser correction, it never moves the viewport, so it cannot yank someone who has taken over. Components that reach their final size only after they render (a chart, a media embed with no intrinsic dimensions, anything sized from measured content) are exactly the shape that triggers this, and they need no special handling: give them a placeholder height where you can, and let the router own the restore. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fb2bb10e..98ea8b70a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -417,7 +417,13 @@ jobs: # signals the whole group. `webjs dev` spawns a watcher which spawns # the listener, so killing only the npm pid leaves the port held; that # was measured, the step exited cleanly and :5001 stayed up. - trap 'kill -- -"$server_pid" 2>/dev/null || true' EXIT + # On any failure the server log goes BACK to the step log. This is a + # required check and the server runs the website's own before-tasks + # (registry copy, Tailwind build), so a boot failure, a build failure + # or a 500 on /ui/button must not fail the check with its cause in a + # file nobody reads. `$?` is captured first, since the trap body would + # otherwise clobber it. + trap 'rc=$?; if [ "$rc" -ne 0 ]; then echo "--- website dev server log ---"; tail -n 200 /tmp/website-dev.log || true; fi; kill -- -"$server_pid" 2>/dev/null || true; exit "$rc"' EXIT for i in $(seq 1 60); do curl -sf -o /dev/null http://localhost:5001/ui/button && break sleep 2 diff --git a/packages/core/src/router-client.js b/packages/core/src/router-client.js index 5e7443604..dcaa17998 100644 --- a/packages/core/src/router-client.js +++ b/packages/core/src/router-client.js @@ -458,9 +458,16 @@ let cancelScrollCatchUp = null; * It does NOT escape the settling-versus-streaming question, and it is worth * being exact about that rather than claiming otherwise. It cannot tell the * restore settling apart from any other growth, so the guard is its WINDOW: it - * lives only as long as the floor, the same span the restore's own suppression - * covers. Outside that it is gone, so late-resolving content cannot move a - * reader who is simply reading and generating no input to cancel it. + * lives for `ANCHOR_SUPPRESS_FLOOR_MS` and no longer. That is SHORTER than the + * suppression window beside it, which runs to the later of the floor and the + * revalidation, capped by the ceiling; the two are not the same span and this + * is deliberately the tighter of them, because this one WRITES scroll. Outside + * it, late-resolving content cannot move a reader who is simply reading and + * generating no input to cancel it. + * + * The cost is the other side of that trade, and it is real: content settling + * later than the floor leaves a clamped reader at the clamp rather than at the + * offset they left. Both doc surfaces say so. * * @param {number} targetY The recorded offset to reach. * @param {number} targetX @@ -1652,7 +1659,20 @@ async function performNavigation(href, isPopState, frameId) { // opened, and the restored page then clamped to 2416 with // anchoring held off, which is precisely the stranding the // conditional exists to prevent. Wait for the swap to commit. - _swapCommit.then(restoreScroll).catch(() => {}); + // + // Token-guarded, because this is the one path where the restore + // outlives the call that scheduled it. Every cancel site in this + // feature (performNavigation, performSubmission, + // disableClientRouter) runs at the START of the next thing, so a + // navigation, submission, or disable arriving inside the deferred + // frame would close the window and then have this reopen it, + // scrolling a page it was never meant for to an offset recorded + // for the previous history entry. The synchronous branch below + // cannot outlive anything and so needs no guard. + _swapCommit.then(() => { + if (myToken !== currentNavigationToken || !enabled) return; + restoreScroll(); + }).catch(() => {}); } else { // The synchronous path, and the read must STAY synchronous here. // Deferring it even by a microtask breaks the fix outright: by diff --git a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js index 0e41bebfc..6c436eb47 100644 --- a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js +++ b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js @@ -344,6 +344,35 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => } }); + test('a navigation during a deferred restore cancels it, not the other way round', async () => { + // The view-transition path is the one place the restore OUTLIVES the call + // that scheduled it, and every cancel site in this feature runs at the start + // of the next thing. So a navigation arriving inside the deferred frame + // would close the window and then have the stale restore reopen it, keyed to + // the previous history entry, scrolling a page it was never meant for. + const origSVT = (/** @type any */ (document)).startViewTransition; + (/** @type any */ (document)).startViewTransition = (cb) => { + const done = new Promise((resolve) => { + requestAnimationFrame(() => { cb(); resolve(); }); + }); + return { updateCallbackDone: done, finished: done, ready: done, skipTransition() {} }; + }; + await setup({ viewTransition: true, tallOutgoing: true }); + try { + await goBack(); + // Inside the deferred frame: the swap has not committed, so the restore + // is still pending. Not awaited, since the point is that it STARTS. + navigate(location.origin + entryUrl('during-deferred')).catch(() => {}); + for (let i = 0; i < 6; i++) await frame(); + assert.equal(document.documentElement.style.getPropertyValue('overflow-anchor'), '', + 'the superseded restore does not reopen a window on the page that ' + + 'replaced it'); + } finally { + await teardown(); + (/** @type any */ (document)).startViewTransition = origSVT; + } + }); + test('a form SUBMISSION inside the window closes it too', async () => { // A submission is a navigation and runs its own pipeline // (`performSubmission`), so it needs the same close as `performNavigation`. @@ -402,6 +431,28 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => } finally { await teardown(); } }); + test('the catch-up gives up after its window, and does not move a settled reader', async () => { + // The bound is a live behaviour constant: past it a clamped restore stops + // chasing. The other two catch-up cases grow the fixture immediately, so + // they pass at any bound and none of them would notice it being widened + // back to the ceiling. This is the case that pins it, and it is the reason + // the bound exists: a reader who landed and started READING generates no + // input to cancel the chase, so late-arriving growth must not scroll them. + await setup({ restoredY: CLAMPED_TARGET, manualGrowth: true }); + try { + await goBack(); + const clamped = window.scrollY; + assert.ok(clamped < CLAMPED_TARGET - 1, 'precondition: the restore was clamped'); + // Past the window, with no input at any point. + await new Promise((r) => setTimeout(r, 900)); + document.querySelector('wj-grow-on-command-1310').style.height = COMMANDED_GROWTH + 'px'; + for (let i = 0; i < 12; i++) await frame(); + assert.ok(Math.abs(window.scrollY - CLAMPED_TARGET) >= 5, + 'growth arriving after the window must not scroll a reader who never ' + + `asked (landed on ${CLAMPED_TARGET}, so the chase was still live)`); + } finally { await teardown(); } + }); + test('a reader taking over cancels the catch-up', async () => { // The catch-up WRITES scroll, unlike suppression, so it is the one part of // this that could yank someone. It stops on the same inputs a suppression diff --git a/website/app/docs/client-router/page.ts b/website/app/docs/client-router/page.ts index f6b58bb0d..d40202035 100644 --- a/website/app/docs/client-router/page.ts +++ b/website/app/docs/client-router/page.ts @@ -222,7 +222,7 @@ connectWS('/posts/' + id + '/feed', { onMessage: (m) => renderStream(m) });Snapshot cache + back/forward

The router maintains a URL-keyed LRU cache of page snapshots (capacity 16). On back/forward via popstate, the cached DOM is applied instantly and the captured window-scroll position is restored. A background refetch then revalidates the snapshot quietly.

-

A back/forward restore also suppresses the browser's scroll anchoring (overflow-anchor) for its duration, then puts it back. The saved offset was recorded against the page at its settled height, while the DOM the restore swaps in is still shorter until its components upgrade and render. Without the suppression the browser reads that late growth as content appearing above the reader and adds it to the offset the router just replayed, landing them below where they left. The window closes on the first real input (wheel, touchmove, keydown, pointerdown), so a reader who starts scrolling mid-restore gets normal anchoring back immediately. It also closes as soon as another navigation starts, since the window outlives its own restore and must not be inherited by a page it was never meant for; that navigation then opens a window of its own if it earns one. Absent both it closes once the restore is over, which is the later of that restore's background revalidation settling and a short floor, and at the latest on a 2s ceiling. The floor matters because waiting on the revalidation alone would tie the window to network latency rather than to the growth it guards, so a server answering faster than the page renders would close it too early. Suppression only withholds a browser correction, it never moves the viewport. If your app sets overflow-anchor on <html> inline itself, it is overridden during a restore and restored afterwards. The suppression is conditional: when the page has not grown enough to scroll to the recorded offset at all, the browser clamps the scroll, and there the shortfall is exactly the growth still to come, so anchoring adding it is what carries the reader back down. The router leaves anchoring alone in that case rather than freezing the clamp, and additionally chases the saved offset, re-asserting it once the page is tall enough to hold it. That is the only scroll the router writes after the initial restore, and it stops on the first real input, so it never fights a reader who has started scrolling.

+

A back/forward restore also suppresses the browser's scroll anchoring (overflow-anchor) for its duration, then puts it back. The saved offset was recorded against the page at its settled height, while the DOM the restore swaps in is still shorter until its components upgrade and render. Without the suppression the browser reads that late growth as content appearing above the reader and adds it to the offset the router just replayed, landing them below where they left. The window closes on the first real input (wheel, touchmove, keydown, pointerdown), so a reader who starts scrolling mid-restore gets normal anchoring back immediately. It also closes as soon as another navigation starts, since the window outlives its own restore and must not be inherited by a page it was never meant for; that navigation then opens a window of its own if it earns one. Absent both it closes once the restore is over, which is the later of that restore's background revalidation settling and a short floor, and at the latest on a 2s ceiling. The floor matters because waiting on the revalidation alone would tie the window to network latency rather than to the growth it guards, so a server answering faster than the page renders would close it too early. Suppression only withholds a browser correction, it never moves the viewport. If your app sets overflow-anchor on <html> inline itself, it is overridden during a restore and restored afterwards. The suppression is conditional: when the page has not grown enough to scroll to the recorded offset at all, the browser clamps the scroll, and there the shortfall is exactly the growth still to come, so anchoring adding it is what carries the reader back down. The router leaves anchoring alone in that case rather than freezing the clamp, and additionally chases the saved offset, re-asserting it once the page is tall enough to hold it. That is the only scroll the router writes after the initial restore. It stops on the first real input, so it never fights a reader who has started scrolling, and it is time-boxed to a few hundred milliseconds, which is shorter than the suppression window. The bound is what protects a reader who has landed and started reading, since they generate no input to cancel it. The trade is that a component reaching its final height after that bound leaves a clamped reader at the clamp rather than at the offset they left.

Nav scroll restoration (both the back/forward restore and the scroll-to-top on a forward nav) is forced behavior: 'instant', so setting html { scroll-behavior: smooth } in your app does not make navigation visibly animate the scroll. 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.

After a server action mutates data that a cached page depends on, call revalidate():

import { revalidate } from '@webjsdev/core'; From 6b7e36233bf839abc25d41e0e36317f8ff2bd097 Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 7 Aug 2026 01:20:13 +0530 Subject: [PATCH 15/20] fix: key the deferred restore to its own supersede, not the nav token The guard added last commit used currentNavigationToken, which is the obvious choice and the wrong one. loadFrame bumps that token too, and its own contract says a frame self-load is NOT a page navigation. An eager inside a restored snapshot loads as part of the swap, so the guard could read a routine frame load as a supersede and drop the whole restore, leaving the reader at the outgoing page's offset. That is worse than the defect this change exists to fix. A counter that moves only for the three things which really do end a restore replaces it. Being straight about the test: the new case puts a self-loading frame in the restored page and asserts the restore still runs, and the frame does load, but it does NOT red under the token version. The bump lands after the guard reads in this environment, so the race resolves the safe way and the substitution is invisible to it. The case is worth keeping as a regression guard on the invariant; the reason for the change is the semantic one, that a frame load is not a navigation, not a red test. Removing the guard outright still reds the superseding-navigation case. --- packages/core/src/router-client.js | 23 +++++++++- .../browser/nav-scroll-anchor-restore.test.js | 44 ++++++++++++++++--- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/packages/core/src/router-client.js b/packages/core/src/router-client.js index dcaa17998..eef1e2e24 100644 --- a/packages/core/src/router-client.js +++ b/packages/core/src/router-client.js @@ -435,6 +435,21 @@ function suppressScrollAnchoring() { */ let cancelScrollCatchUp = null; +/** + * Bumped wherever a restore is superseded (#1310), and read by the one restore + * path that outlives the call scheduling it. + * + * Deliberately NOT `currentNavigationToken`, which is the obvious choice and the + * wrong one: `loadFrame` bumps that too, and its own contract says a frame + * self-load is not a page navigation. An eager `` inside a + * RESTORED snapshot loads during the swap, so keying on the nav token would + * read a routine frame load as a supersede and drop the entire restore, leaving + * the reader at the outgoing page's offset. That is worse than the defect this + * whole change fixes. This counter moves only for the three things that really + * do end a restore. + */ +let restoreGeneration = 0; + /** * Chase a restored scroll offset the document was too SHORT to reach (#1310). * @@ -601,6 +616,7 @@ export function disableClientRouter() { } // Never leave a restore window open on , nor a catch-up chasing a // scroll offset after the router is gone (#1310). + restoreGeneration += 1; if (releaseScrollAnchor) releaseScrollAnchor(); if (cancelScrollCatchUp) cancelScrollCatchUp(); currentPageUrl = null; @@ -1576,6 +1592,7 @@ async function performNavigation(href, isPopState, frameId) { // entirely. Reopening for this navigation, if it earns one, happens below. // The clamped path's catch-up is cancelled for the same reason: it chases an // offset recorded for the page being navigated away from. + restoreGeneration += 1; if (releaseScrollAnchor) releaseScrollAnchor(); if (cancelScrollCatchUp) cancelScrollCatchUp(); @@ -1660,7 +1677,7 @@ async function performNavigation(href, isPopState, frameId) { // anchoring held off, which is precisely the stranding the // conditional exists to prevent. Wait for the swap to commit. // - // Token-guarded, because this is the one path where the restore + // Guarded, because this is the one path where the restore // outlives the call that scheduled it. Every cancel site in this // feature (performNavigation, performSubmission, // disableClientRouter) runs at the START of the next thing, so a @@ -1669,8 +1686,9 @@ async function performNavigation(href, isPopState, frameId) { // scrolling a page it was never meant for to an offset recorded // for the previous history entry. The synchronous branch below // cannot outlive anything and so needs no guard. + const myRestore = restoreGeneration; _swapCommit.then(() => { - if (myToken !== currentNavigationToken || !enabled) return; + if (myRestore !== restoreGeneration || !enabled) return; restoreScroll(); }).catch(() => {}); } else { @@ -1772,6 +1790,7 @@ async function performSubmission(href, method, body, frameId, form) { // Same reasoning as performNavigation: a submission is a navigation, so it // ends any restore window a recent Back left open (#1310), and cancels a // clamped restore's catch-up. + restoreGeneration += 1; if (releaseScrollAnchor) releaseScrollAnchor(); if (cancelScrollCatchUp) cancelScrollCatchUp(); diff --git a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js index 6c436eb47..3e3e8c2fd 100644 --- a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js +++ b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js @@ -103,21 +103,22 @@ async function afterGrowth() { for (let i = 0; i < 4; i++) await frame(); } * sits entirely above the viewport at `RESTORED_Y`, which is where anchoring * acts. */ -function restoredBody(tag) { +function restoredBody(tag, extra) { // The wrapper id lets `teardown` remove the fixture: the restore REPLACES the // whole body and nothing puts the original back, so each case would otherwise // leave its markup in the document. return '' + '
' + + (extra || '') + `<${tag}>` + '
restored
' + '
' + ''; } -function restoredHtml(tag, head) { +function restoredHtml(tag, head, extra) { return `${head || ''}` - + restoredBody(tag) + ''; + + restoredBody(tag, extra) + ''; } /** @@ -159,8 +160,9 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => * restore window. `instantRevalidation` answers it immediately instead, * which is the ordering a fast server produces. `restoredY` overrides the * recorded offset, so a case can force the clamped path, `manualGrowth` - * swaps in a grower the test drives by hand, and `viewTransition` puts the - * view-transition opt-in in the snapshot's head. + * swaps in a grower the test drives by hand, `viewTransition` puts the + * view-transition opt-in in the snapshot's head, and `withFrame` puts an + * eager `` in the restored page. */ async function setup(opts) { const instant = Boolean(opts && opts.instantRevalidation); @@ -169,7 +171,10 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => const html = restoredHtml( (opts && opts.manualGrowth) ? 'wj-grow-on-command-1310' : instant ? 'wj-grow-very-late-1310' : 'wj-grow-late-1310', - (opts && opts.viewTransition) ? VT_META : ''); + (opts && opts.viewTransition) ? VT_META : '', + (opts && opts.withFrame) + ? '' + : ''); // Clear any fixture a previous case left in the document BEFORE starting. // Teardown removes it, but a revalidation swap can land after teardown has // run and put it back, and a leftover grower is already at full height, so @@ -373,6 +378,33 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => } }); + test('a frame self-load in the restored page does not cancel the restore', async () => { + // `loadFrame` shares the global navigation token, and an eager + // `` inside a restored snapshot loads as part of the swap. + // Keying the deferred restore on that token therefore reads a routine frame + // load as a supersede and drops the restore entirely, leaving the reader at + // the outgoing page's offset, which is worse than the defect being fixed. + // The restore is keyed to its own supersede counter instead. + const origSVT = (/** @type any */ (document)).startViewTransition; + (/** @type any */ (document)).startViewTransition = (cb) => { + const done = new Promise((resolve) => { + requestAnimationFrame(() => { cb(); resolve(); }); + }); + return { updateCallbackDone: done, finished: done, ready: done, skipTransition() {} }; + }; + await setup({ viewTransition: true, withFrame: true }); + try { + await goBack(); + for (let i = 0; i < 6; i++) await frame(); + assert.ok(Math.abs(window.scrollY - RESTORED_Y) < 5, + 'the restore still runs with a self-loading frame in the page ' + + `(expected ~${RESTORED_Y}, got ${window.scrollY})`); + } finally { + await teardown(); + (/** @type any */ (document)).startViewTransition = origSVT; + } + }); + test('a form SUBMISSION inside the window closes it too', async () => { // A submission is a navigation and runs its own pipeline // (`performSubmission`), so it needs the same close as `performNavigation`. From 7f336a58c025b174e59ca0c8a1401e3093c6979b Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 7 Aug 2026 01:33:22 +0530 Subject: [PATCH 16/20] test: pin the restore keying, and make the code match its own rule The token to counter change shipped with nothing that reds when reverted, which I said at the time. There was a way to build it after all: every other case supersedes with navigate(), which moves BOTH counters, so none of them can tell the implementations apart. A case that moves only the nav token, which is exactly what a frame self-load does, separates them: under the old keying the restore is dropped and the reader is left at the outgoing offset, measured as 0 against an expected 800. The frame case asserted nothing about the frame, so it could not tell "the frame loaded and the restore survived" from "the frame never loaded", which is the failure mode its own commit message flagged. It counts the self-load now, like every other precondition in the file. The counter's rule also did not match the code. It claimed to move only for things that end a restore, while a frame-TARGETED nav or submission bumped it unconditionally, though the codebase already declares those equivalent to the src self-load the rule exempts. They swap one region and leave the page, so they are excluded too, and the comment is now true. --- packages/core/src/router-client.js | 18 ++++++-- .../browser/nav-scroll-anchor-restore.test.js | 43 +++++++++++++++++-- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/packages/core/src/router-client.js b/packages/core/src/router-client.js index eef1e2e24..9842e9215 100644 --- a/packages/core/src/router-client.js +++ b/packages/core/src/router-client.js @@ -437,7 +437,10 @@ let cancelScrollCatchUp = null; /** * Bumped wherever a restore is superseded (#1310), and read by the one restore - * path that outlives the call scheduling it. + * path that outlives the call scheduling it. That means a PAGE navigation, a + * PAGE-level submission, and disabling the router. A frame-targeted nav or + * submission swaps one region and leaves the page, so it is excluded, exactly + * like the `loadFrame` case below. * * Deliberately NOT `currentNavigationToken`, which is the obvious choice and the * wrong one: `loadFrame` bumps that too, and its own contract says a frame @@ -1592,7 +1595,13 @@ async function performNavigation(href, isPopState, frameId) { // entirely. Reopening for this navigation, if it earns one, happens below. // The clamped path's catch-up is cancelled for the same reason: it chases an // offset recorded for the page being navigated away from. - restoreGeneration += 1; + // + // A FRAME-targeted nav is excluded, for the same reason `loadFrame` is: it + // swaps one region and leaves the page, and so the restored scroll offset, + // intact. The codebase already treats a click-driven frame nav and a `src` + // self-load as the same thing, so exempting one and not the other would be + // the split this rule exists to avoid. + if (!frameId) restoreGeneration += 1; if (releaseScrollAnchor) releaseScrollAnchor(); if (cancelScrollCatchUp) cancelScrollCatchUp(); @@ -1789,8 +1798,9 @@ async function performSubmission(href, method, body, frameId, form) { const myToken = ++currentNavigationToken; // Same reasoning as performNavigation: a submission is a navigation, so it // ends any restore window a recent Back left open (#1310), and cancels a - // clamped restore's catch-up. - restoreGeneration += 1; + // clamped restore's catch-up. Frame-targeted submissions are excluded on the + // same reasoning as the frame navs above. + if (!frameId) restoreGeneration += 1; if (releaseScrollAnchor) releaseScrollAnchor(); if (cancelScrollCatchUp) cancelScrollCatchUp(); diff --git a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js index 3e3e8c2fd..0d46a6023 100644 --- a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js +++ b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js @@ -21,7 +21,7 @@ * connected models the real cause: as raw parsed markup it is 0px tall, and it * reaches its real size only once its own render has run. */ -import { enableClientRouter, disableClientRouter, navigate, _snapshotCache, _setCurrentPageUrl } from '../../../src/router-client.js'; +import { enableClientRouter, disableClientRouter, navigate, _snapshotCache, _setCurrentPageUrl, _bumpNavToken } from '../../../src/router-client.js'; import { assert } from '../../../../../test/browser-assert.js'; import { installNavGuard } from '../../../../../test/browser-nav-guard.js'; @@ -153,6 +153,8 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => let navGuard, container, origFetch, origScrollBehavior, origUrl, entriesPushed; /** Resolves the in-flight revalidation, so a case controls the window's close. */ let releaseFetch; + /** Frame self-loads seen, so a case can prove its fixture actually loaded. */ + let frameLoads = 0; /** * @param {{ instantRevalidation?: boolean, restoredY?: number }} [opts] By @@ -208,9 +210,11 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => const respond = () => new Response(html, { headers: { 'content-type': 'text/html', 'x-webjs-build': '' }, }); + frameLoads = 0; + const count = (u) => { if (String(u).includes('wj-frame-target')) frameLoads += 1; }; window.fetch = instant - ? () => Promise.resolve(respond()) - : () => new Promise((resolve) => { releaseFetch = () => resolve(respond()); }); + ? (u) => { count(u); return Promise.resolve(respond()); } + : (u) => { count(u); return new Promise((resolve) => { releaseFetch = () => resolve(respond()); }); }; // Two real same-document history entries, so `history.back()` drives a REAL // popstate. Reassigning `location` is impossible in a browser, and a @@ -396,6 +400,9 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => try { await goBack(); for (let i = 0; i < 6; i++) await frame(); + assert.ok(frameLoads > 0, + 'precondition: the frame actually self-loaded, so this is not silently ' + + 'a duplicate of the plain restore case'); assert.ok(Math.abs(window.scrollY - RESTORED_Y) < 5, 'the restore still runs with a self-loading frame in the page ' + `(expected ~${RESTORED_Y}, got ${window.scrollY})`); @@ -405,6 +412,36 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => } }); + test('a bare nav-token bump does not cancel a deferred restore', async () => { + // The counterfactual for keying the deferred restore to its own supersede + // counter rather than to `currentNavigationToken`. Every other case here + // supersedes with `navigate()`, which moves BOTH, so none of them can tell + // the two implementations apart. This moves only the nav token, which is + // what a frame self-load does: under the old keying the restore is dropped + // and the reader is left at the outgoing offset, under the current one it + // runs. + const origSVT = (/** @type any */ (document)).startViewTransition; + (/** @type any */ (document)).startViewTransition = (cb) => { + const done = new Promise((resolve) => { + requestAnimationFrame(() => { cb(); resolve(); }); + }); + return { updateCallbackDone: done, finished: done, ready: done, skipTransition() {} }; + }; + await setup({ viewTransition: true }); + try { + await goBack(); + // Inside the deferred frame, before the swap commits. + _bumpNavToken(); + for (let i = 0; i < 6; i++) await frame(); + assert.ok(Math.abs(window.scrollY - RESTORED_Y) < 5, + 'a nav-token bump that is not a page navigation must not drop the ' + + `restore (expected ~${RESTORED_Y}, got ${window.scrollY})`); + } finally { + await teardown(); + (/** @type any */ (document)).startViewTransition = origSVT; + } + }); + test('a form SUBMISSION inside the window closes it too', async () => { // A submission is a navigation and runs its own pipeline // (`performSubmission`), so it needs the same close as `performNavigation`. From 8af4eb8d0355fa002986f6d8f125588c217498eb Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 7 Aug 2026 01:42:20 +0530 Subject: [PATCH 17/20] fix: exempt a frame nav from all three cancels, not just the counter The exemption landed on one of three sibling operations. The counter was guarded by !frameId while releaseScrollAnchor and cancelScrollCatchUp on the next two lines still ran unconditionally, so a frame-targeted nav or submission still closed the suppression window and aborted the catch-up. That is the same split the comment claims to avoid, moved one line down, and it brings the full double-count back. It needs no user input either: a component upgrading in the just-restored page can navigate or submit a frame on its own, and resolveTargetFrameId picks up the enclosing frame. All three move together now, with a case that clicks a link inside a frame while a window is open. Reverting to the split form reds it. Two test-quality fixes alongside. The bare-token case and the frame case both depend on the swap actually being deferred, and neither said so, so either would have passed vacuously if the simulated transition stopped engaging. They assert it now, the way the sibling case already did. --- packages/core/src/router-client.js | 22 ++++++++--- .../browser/nav-scroll-anchor-restore.test.js | 39 +++++++++++++++++-- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/packages/core/src/router-client.js b/packages/core/src/router-client.js index 9842e9215..85eba1ef5 100644 --- a/packages/core/src/router-client.js +++ b/packages/core/src/router-client.js @@ -1601,9 +1601,17 @@ async function performNavigation(href, isPopState, frameId) { // intact. The codebase already treats a click-driven frame nav and a `src` // self-load as the same thing, so exempting one and not the other would be // the split this rule exists to avoid. - if (!frameId) restoreGeneration += 1; - if (releaseScrollAnchor) releaseScrollAnchor(); - if (cancelScrollCatchUp) cancelScrollCatchUp(); + // + // All THREE move together. Exempting only the counter while still closing + // the window and aborting the catch-up would leave the split exactly where + // it was, one line further down: a form inside a frame, submitted by a + // component upgrading in the just-restored page, would hand anchoring back + // mid-restore and bring the whole double-count back. + if (!frameId) { + restoreGeneration += 1; + if (releaseScrollAnchor) releaseScrollAnchor(); + if (cancelScrollCatchUp) cancelScrollCatchUp(); + } // Snapshot the page the user is LEAVING (with its scroll position) // so back/forward navigation can restore it. We key under @@ -1800,9 +1808,11 @@ async function performSubmission(href, method, body, frameId, form) { // ends any restore window a recent Back left open (#1310), and cancels a // clamped restore's catch-up. Frame-targeted submissions are excluded on the // same reasoning as the frame navs above. - if (!frameId) restoreGeneration += 1; - if (releaseScrollAnchor) releaseScrollAnchor(); - if (cancelScrollCatchUp) cancelScrollCatchUp(); + if (!frameId) { + restoreGeneration += 1; + if (releaseScrollAnchor) releaseScrollAnchor(); + if (cancelScrollCatchUp) cancelScrollCatchUp(); + } const isSafe = method === 'get' || method === 'head'; let url = new URL(href, location.href); diff --git a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js index 0d46a6023..4618706ab 100644 --- a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js +++ b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js @@ -390,7 +390,9 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => // the outgoing page's offset, which is worse than the defect being fixed. // The restore is keyed to its own supersede counter instead. const origSVT = (/** @type any */ (document)).startViewTransition; + let transitions = 0; (/** @type any */ (document)).startViewTransition = (cb) => { + transitions += 1; const done = new Promise((resolve) => { requestAnimationFrame(() => { cb(); resolve(); }); }); @@ -400,9 +402,10 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => try { await goBack(); for (let i = 0; i < 6; i++) await frame(); - assert.ok(frameLoads > 0, - 'precondition: the frame actually self-loaded, so this is not silently ' - + 'a duplicate of the plain restore case'); + assert.ok(transitions > 0 && frameLoads > 0, + 'precondition: the swap was deferred AND the frame actually ' + + 'self-loaded, so this is not silently a duplicate of the plain ' + + 'restore case'); assert.ok(Math.abs(window.scrollY - RESTORED_Y) < 5, 'the restore still runs with a self-loading frame in the page ' + `(expected ~${RESTORED_Y}, got ${window.scrollY})`); @@ -421,7 +424,9 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => // and the reader is left at the outgoing offset, under the current one it // runs. const origSVT = (/** @type any */ (document)).startViewTransition; + let transitions = 0; (/** @type any */ (document)).startViewTransition = (cb) => { + transitions += 1; const done = new Promise((resolve) => { requestAnimationFrame(() => { cb(); resolve(); }); }); @@ -430,6 +435,9 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => await setup({ viewTransition: true }); try { await goBack(); + assert.ok(transitions > 0, + 'precondition: the swap really was deferred, or the bump below lands ' + + 'after the restore has already run and proves nothing'); // Inside the deferred frame, before the swap commits. _bumpNavToken(); for (let i = 0; i < 6; i++) await frame(); @@ -442,6 +450,31 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => } }); + test('a FRAME-targeted navigation leaves an open window alone', async () => { + // A frame nav swaps one region and leaves the page, so it must not end a + // restore. The exemption has to cover all three of the counter, the + // suppression window and the catch-up: closing the window here would hand + // anchoring back mid-restore and bring the whole double-count back, and it + // needs no user input to happen (a component upgrading in the just-restored + // page can submit or navigate a frame on its own). + await setup(); + try { + await goBack(); + assert.equal(document.documentElement.style.getPropertyValue('overflow-anchor'), 'none', + 'precondition: a window is open'); + const holder = document.createElement('div'); + holder.innerHTML = '' + + '
go'; + document.body.appendChild(holder); + try { + holder.querySelector('#wj-frame-link').click(); + await new Promise((r) => setTimeout(r, 0)); + assert.equal(document.documentElement.style.getPropertyValue('overflow-anchor'), 'none', + 'a frame-targeted navigation must leave the restore window open'); + } finally { holder.remove(); } + } finally { await teardown(); } + }); + test('a form SUBMISSION inside the window closes it too', async () => { // A submission is a navigation and runs its own pipeline // (`performSubmission`), so it needs the same close as `performNavigation`. From f9d937ab63bbe63ba405021f84174822436079c6 Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 7 Aug 2026 01:52:23 +0530 Subject: [PATCH 18/20] docs: record the frame carve-out, and prove two cases are not vacuous The frame exemption contradicted both doc surfaces, which still said any navigation ends the window and named a click as the example. A click inside a frame is now precisely the case they got wrong, and both files were already open in this PR, so it was drift this change introduced rather than a pre-existing gap. The frame-nav case asserted only that the window stayed open, which is also true when the click never reaches the router at all, so it caught just one of the two failure directions. It counts the frame navigation now, using the same stubbed-fetch idiom the file already had. The third view-transition case had no precondition either. On the synchronous path the restore has already run and the navigation simply closes its window, so it would pass green while exercising none of the deferred supersede guard, which is the mechanism the frame carve-out reasons about and the one case least able to afford a quiet pass. --- .../references/client-router-and-streaming.md | 2 +- .../browser/nav-scroll-anchor-restore.test.js | 19 ++++++++++++++++++- website/app/docs/client-router/page.ts | 2 +- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/.agents/skills/webjs/references/client-router-and-streaming.md b/.agents/skills/webjs/references/client-router-and-streaming.md index e839105fa..e6023ec65 100644 --- a/.agents/skills/webjs/references/client-router-and-streaming.md +++ b/.agents/skills/webjs/references/client-router-and-streaming.md @@ -60,7 +60,7 @@ The router keeps a URL-keyed snapshot cache (LRU, cap 16) so Back/Forward restor - **Do not write your own scroll restore.** A `popstate` listener that calls `scrollTo`, a saved offset in `sessionStorage`, a `scrollIntoView` on a remembered element: all of them fight the router, which already set `history.scrollRestoration = 'manual'` and is the sole authority on scroll during a navigation. If Back lands in the wrong place, that is a framework bug to report, not something to patch in app code. - **An app that sets `overflow-anchor` on `` itself sees it overridden during a restore and restored afterwards**, including a value set inline by your own script. Setting it in a stylesheet is unaffected between restores. Nothing else on the page is touched, and the router never sets `overflow-anchor` anywhere but the root element. -- **A new navigation ends an open window.** The window outlives its own restore on purpose (a floor, then a ceiling), so any navigation starting inside that span closes it first, and reopens only if it earns one. Otherwise a second Back, or a click, would inherit suppressed anchoring on a page it was never meant for. +- **A new PAGE navigation ends an open window.** The window outlives its own restore on purpose (a floor, then a ceiling), so a page navigation or a page-level form submission starting inside that span closes it first, and reopens only if it earns one. Otherwise a second Back, or a click, would inherit suppressed anchoring on a page it was never meant for. A `` navigation is the exception, whether it comes from a click inside the frame, a form inside it, or the frame's own `src`: it swaps one region and leaves the page, and so the restored offset, intact, so it leaves the restore running. Closing there would hand anchoring back mid-restore and bring the double count straight back, and it needs no user input to happen, since a component upgrading in the just-restored page can drive a frame on its own. - **Suppression is conditional, and only applies when the recorded offset was actually reached.** A page that has not grown yet can be too short to scroll that far, so the browser clamps to its current maximum. There the shortfall IS the growth still to come, and anchoring adding it is what carries the reader back down, so the router leaves anchoring alone. Suppressing in that case would freeze the clamp and strand the reader a full page-growth above where they left, which is this same defect pointing the other way. That case is not left to anchoring alone, though, because anchoring adds the FULL growth however far short the clamp fell, so by itself it only lands a reader who left at the very bottom. The router also CHASES the recorded offset there, re-asserting it the moment the page is tall enough to hold it, and then stopping. That is the one place the router writes scroll after the initial restore, it is scoped to the clamped path, and it stops on the same inputs that close a suppression window. It is also time-boxed, to a window SHORTER than the suppression one: a few hundred milliseconds, not the 2s ceiling. That bound is what keeps it from moving a reader who has landed and started reading, since such a reader generates no input to cancel it and the chase cannot tell the restore settling apart from any other growth. The cost is the other side of that trade: a component that reaches its final height later than the bound (a chart, an embed measured from its content) leaves a clamped reader at the clamp rather than at the offset they left. - **The window closes on the first real input** (`wheel`, `touchmove`, `keydown`, `pointerdown`), so a reader who starts scrolling mid-restore immediately gets normal browser anchoring back. Absent that it closes once the restore is over, which is the LATER of the restore's own background revalidation settling and a short floor, and at the latest on a 2s ceiling. The floor is load-bearing: waiting on the revalidation alone ties the window's length to network latency rather than to the growth it guards, so a server answering faster than the page renders would close it early and the reader would land low again. Suppression only ever WITHHOLDS a browser correction, it never moves the viewport, so it cannot yank someone who has taken over. diff --git a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js index 4618706ab..2057dac5b 100644 --- a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js +++ b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js @@ -155,6 +155,8 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => let releaseFetch; /** Frame self-loads seen, so a case can prove its fixture actually loaded. */ let frameLoads = 0; + /** Frame-targeted navigations seen, so a case can prove the click routed. */ + let frameNavs = 0; /** * @param {{ instantRevalidation?: boolean, restoredY?: number }} [opts] By @@ -211,7 +213,11 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => headers: { 'content-type': 'text/html', 'x-webjs-build': '' }, }); frameLoads = 0; - const count = (u) => { if (String(u).includes('wj-frame-target')) frameLoads += 1; }; + frameNavs = 0; + const count = (u) => { + if (String(u).includes('wj-frame-target')) frameLoads += 1; + if (String(u).includes('wj-frame-nav-1310')) frameNavs += 1; + }; window.fetch = instant ? (u) => { count(u); return Promise.resolve(respond()); } : (u) => { count(u); return new Promise((resolve) => { releaseFetch = () => resolve(respond()); }); }; @@ -360,7 +366,9 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => // would close the window and then have the stale restore reopen it, keyed to // the previous history entry, scrolling a page it was never meant for. const origSVT = (/** @type any */ (document)).startViewTransition; + let transitions = 0; (/** @type any */ (document)).startViewTransition = (cb) => { + transitions += 1; const done = new Promise((resolve) => { requestAnimationFrame(() => { cb(); resolve(); }); }); @@ -369,6 +377,10 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => await setup({ viewTransition: true, tallOutgoing: true }); try { await goBack(); + assert.ok(transitions > 0, + 'precondition: the swap really was deferred. On the synchronous path ' + + 'the restore has already run and the navigation below simply closes ' + + 'its window, which passes while exercising none of the guard'); // Inside the deferred frame: the swap has not committed, so the restore // is still pending. Not awaited, since the point is that it STARTS. navigate(location.origin + entryUrl('during-deferred')).catch(() => {}); @@ -469,6 +481,11 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => try { holder.querySelector('#wj-frame-link').click(); await new Promise((r) => setTimeout(r, 0)); + // Positive proof the click actually routed. Without this the assertion + // below also passes when the router never saw the click at all, which + // is the only other way the window stays open. + assert.ok(frameNavs > 0, + 'precondition: the click reached the router as a frame-targeted nav'); assert.equal(document.documentElement.style.getPropertyValue('overflow-anchor'), 'none', 'a frame-targeted navigation must leave the restore window open'); } finally { holder.remove(); } diff --git a/website/app/docs/client-router/page.ts b/website/app/docs/client-router/page.ts index d40202035..d407aef8d 100644 --- a/website/app/docs/client-router/page.ts +++ b/website/app/docs/client-router/page.ts @@ -222,7 +222,7 @@ connectWS('/posts/' + id + '/feed', { onMessage: (m) => renderStream(m) });Snapshot cache + back/forward

The router maintains a URL-keyed LRU cache of page snapshots (capacity 16). On back/forward via popstate, the cached DOM is applied instantly and the captured window-scroll position is restored. A background refetch then revalidates the snapshot quietly.

-

A back/forward restore also suppresses the browser's scroll anchoring (overflow-anchor) for its duration, then puts it back. The saved offset was recorded against the page at its settled height, while the DOM the restore swaps in is still shorter until its components upgrade and render. Without the suppression the browser reads that late growth as content appearing above the reader and adds it to the offset the router just replayed, landing them below where they left. The window closes on the first real input (wheel, touchmove, keydown, pointerdown), so a reader who starts scrolling mid-restore gets normal anchoring back immediately. It also closes as soon as another navigation starts, since the window outlives its own restore and must not be inherited by a page it was never meant for; that navigation then opens a window of its own if it earns one. Absent both it closes once the restore is over, which is the later of that restore's background revalidation settling and a short floor, and at the latest on a 2s ceiling. The floor matters because waiting on the revalidation alone would tie the window to network latency rather than to the growth it guards, so a server answering faster than the page renders would close it too early. Suppression only withholds a browser correction, it never moves the viewport. If your app sets overflow-anchor on <html> inline itself, it is overridden during a restore and restored afterwards. The suppression is conditional: when the page has not grown enough to scroll to the recorded offset at all, the browser clamps the scroll, and there the shortfall is exactly the growth still to come, so anchoring adding it is what carries the reader back down. The router leaves anchoring alone in that case rather than freezing the clamp, and additionally chases the saved offset, re-asserting it once the page is tall enough to hold it. That is the only scroll the router writes after the initial restore. It stops on the first real input, so it never fights a reader who has started scrolling, and it is time-boxed to a few hundred milliseconds, which is shorter than the suppression window. The bound is what protects a reader who has landed and started reading, since they generate no input to cancel it. The trade is that a component reaching its final height after that bound leaves a clamped reader at the clamp rather than at the offset they left.

+

A back/forward restore also suppresses the browser's scroll anchoring (overflow-anchor) for its duration, then puts it back. The saved offset was recorded against the page at its settled height, while the DOM the restore swaps in is still shorter until its components upgrade and render. Without the suppression the browser reads that late growth as content appearing above the reader and adds it to the offset the router just replayed, landing them below where they left. The window closes on the first real input (wheel, touchmove, keydown, pointerdown), so a reader who starts scrolling mid-restore gets normal anchoring back immediately. It also closes as soon as another page navigation starts, since the window outlives its own restore and must not be inherited by a page it was never meant for; that navigation then opens a window of its own if it earns one. A <webjs-frame> navigation is the exception and leaves the restore running, because it swaps one region rather than the page, so the restored offset is still the right one. Absent both it closes once the restore is over, which is the later of that restore's background revalidation settling and a short floor, and at the latest on a 2s ceiling. The floor matters because waiting on the revalidation alone would tie the window to network latency rather than to the growth it guards, so a server answering faster than the page renders would close it too early. Suppression only withholds a browser correction, it never moves the viewport. If your app sets overflow-anchor on <html> inline itself, it is overridden during a restore and restored afterwards. The suppression is conditional: when the page has not grown enough to scroll to the recorded offset at all, the browser clamps the scroll, and there the shortfall is exactly the growth still to come, so anchoring adding it is what carries the reader back down. The router leaves anchoring alone in that case rather than freezing the clamp, and additionally chases the saved offset, re-asserting it once the page is tall enough to hold it. That is the only scroll the router writes after the initial restore. It stops on the first real input, so it never fights a reader who has started scrolling, and it is time-boxed to a few hundred milliseconds, which is shorter than the suppression window. The bound is what protects a reader who has landed and started reading, since they generate no input to cancel it. The trade is that a component reaching its final height after that bound leaves a clamped reader at the clamp rather than at the offset they left.

Nav scroll restoration (both the back/forward restore and the scroll-to-top on a forward nav) is forced behavior: 'instant', so setting html { scroll-behavior: smooth } in your app does not make navigation visibly animate the scroll. 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.

After a server action mutates data that a cached page depends on, call revalidate():

import { revalidate } from '@webjsdev/core'; From ed59998ca557ec13c831b52b2552d09c8503e677 Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 7 Aug 2026 02:01:37 +0530 Subject: [PATCH 19/20] fix: state the frame carve-out correctly, and pin its submission half The sentence added last commit enumerated the exempt cases and got the enumeration wrong in both directions. A click inside a frame carrying data-webjs-frame="_top" breaks out and IS a page navigation, as is one inside a frame with no id, while an external trigger naming a frame id is exempt without being "inside" anything. The source comments said "frame-targeted" and were right; the prose over-specified. It now defers to the same rule that decides frame targeting everywhere else, and names the two cases that are page navigations. The carve-out also claimed a frame-targeted submission as exempt with nothing exercising it: the page-level case uses a bare form, the frame case uses a link, so deleting frameId from performSubmission's guard left every suite green. There is a case for it now, and removing that guard reds it. Two more surfaces carried the old universal claim, both introduced by this PR: the unit test's comment and the PR body. Also moved the docs-site sentence, which had been inserted between "Absent both" and the two things "both" refers to. --- .../references/client-router-and-streaming.md | 2 +- .../browser/nav-scroll-anchor-restore.test.js | 27 +++++++++++++++++++ .../core/test/routing/router-client.test.js | 10 ++++--- website/app/docs/client-router/page.ts | 3 ++- 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/.agents/skills/webjs/references/client-router-and-streaming.md b/.agents/skills/webjs/references/client-router-and-streaming.md index e6023ec65..d50789127 100644 --- a/.agents/skills/webjs/references/client-router-and-streaming.md +++ b/.agents/skills/webjs/references/client-router-and-streaming.md @@ -60,7 +60,7 @@ The router keeps a URL-keyed snapshot cache (LRU, cap 16) so Back/Forward restor - **Do not write your own scroll restore.** A `popstate` listener that calls `scrollTo`, a saved offset in `sessionStorage`, a `scrollIntoView` on a remembered element: all of them fight the router, which already set `history.scrollRestoration = 'manual'` and is the sole authority on scroll during a navigation. If Back lands in the wrong place, that is a framework bug to report, not something to patch in app code. - **An app that sets `overflow-anchor` on `` itself sees it overridden during a restore and restored afterwards**, including a value set inline by your own script. Setting it in a stylesheet is unaffected between restores. Nothing else on the page is touched, and the router never sets `overflow-anchor` anywhere but the root element. -- **A new PAGE navigation ends an open window.** The window outlives its own restore on purpose (a floor, then a ceiling), so a page navigation or a page-level form submission starting inside that span closes it first, and reopens only if it earns one. Otherwise a second Back, or a click, would inherit suppressed anchoring on a page it was never meant for. A `` navigation is the exception, whether it comes from a click inside the frame, a form inside it, or the frame's own `src`: it swaps one region and leaves the page, and so the restored offset, intact, so it leaves the restore running. Closing there would hand anchoring back mid-restore and bring the double count straight back, and it needs no user input to happen, since a component upgrading in the just-restored page can drive a frame on its own. +- **A new PAGE navigation ends an open window.** The window outlives its own restore on purpose (a floor, then a ceiling), so a page navigation or a page-level form submission starting inside that span closes it first, and reopens only if it earns one. Otherwise a second Back, or a click, would inherit suppressed anchoring on a page it was never meant for. A FRAME-TARGETED navigation or submission is the exception, on exactly the rule that decides frame targeting everywhere else (the enclosing frame, an explicit `data-webjs-frame=""` from anywhere, or the frame's own `src`; `_top` and an unresolvable id are page navigations and do close the window). It swaps one region and leaves the page, and so the restored offset, intact, so it leaves the restore running. Closing there would hand anchoring back mid-restore and bring the double count straight back, and it needs no user input to happen, since a component upgrading in the just-restored page can drive a frame on its own. - **Suppression is conditional, and only applies when the recorded offset was actually reached.** A page that has not grown yet can be too short to scroll that far, so the browser clamps to its current maximum. There the shortfall IS the growth still to come, and anchoring adding it is what carries the reader back down, so the router leaves anchoring alone. Suppressing in that case would freeze the clamp and strand the reader a full page-growth above where they left, which is this same defect pointing the other way. That case is not left to anchoring alone, though, because anchoring adds the FULL growth however far short the clamp fell, so by itself it only lands a reader who left at the very bottom. The router also CHASES the recorded offset there, re-asserting it the moment the page is tall enough to hold it, and then stopping. That is the one place the router writes scroll after the initial restore, it is scoped to the clamped path, and it stops on the same inputs that close a suppression window. It is also time-boxed, to a window SHORTER than the suppression one: a few hundred milliseconds, not the 2s ceiling. That bound is what keeps it from moving a reader who has landed and started reading, since such a reader generates no input to cancel it and the chase cannot tell the restore settling apart from any other growth. The cost is the other side of that trade: a component that reaches its final height later than the bound (a chart, an embed measured from its content) leaves a clamped reader at the clamp rather than at the offset they left. - **The window closes on the first real input** (`wheel`, `touchmove`, `keydown`, `pointerdown`), so a reader who starts scrolling mid-restore immediately gets normal browser anchoring back. Absent that it closes once the restore is over, which is the LATER of the restore's own background revalidation settling and a short floor, and at the latest on a 2s ceiling. The floor is load-bearing: waiting on the revalidation alone ties the window's length to network latency rather than to the growth it guards, so a server answering faster than the page renders would close it early and the reader would land low again. Suppression only ever WITHHOLDS a browser correction, it never moves the viewport, so it cannot yank someone who has taken over. diff --git a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js index 2057dac5b..912a79ddd 100644 --- a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js +++ b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js @@ -492,6 +492,33 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => } finally { await teardown(); } }); + test('a FRAME-targeted submission leaves an open window alone', async () => { + // The submission half of the frame exemption. `performSubmission` has its + // own `!frameId` guard, and nothing exercised it: the page-level submission + // case below uses a bare form, and the frame case above uses a link, so + // deleting `frameId` from that guard left every suite green. + await setup(); + try { + await goBack(); + assert.equal(document.documentElement.style.getPropertyValue('overflow-anchor'), 'none', + 'precondition: a window is open'); + const holder = document.createElement('div'); + holder.innerHTML = '' + + '
' + + '
'; + document.body.appendChild(holder); + try { + const f = holder.querySelector('#wj-frame-form'); + f.requestSubmit(f.querySelector('button')); + await new Promise((r) => setTimeout(r, 0)); + assert.ok(frameNavs > 0, + 'precondition: the submission reached the router as frame-targeted'); + assert.equal(document.documentElement.style.getPropertyValue('overflow-anchor'), 'none', + 'a frame-targeted submission must leave the restore window open'); + } finally { holder.remove(); } + } finally { await teardown(); } + }); + test('a form SUBMISSION inside the window closes it too', async () => { // A submission is a navigation and runs its own pipeline // (`performSubmission`), so it needs the same close as `performNavigation`. diff --git a/packages/core/test/routing/router-client.test.js b/packages/core/test/routing/router-client.test.js index 1f41e32d2..75edfab4f 100644 --- a/packages/core/test/routing/router-client.test.js +++ b/packages/core/test/routing/router-client.test.js @@ -2116,10 +2116,12 @@ test('popstate cache restore suppresses scroll anchoring across the window (#131 test('a second navigation closes an open scroll-anchor window (#1310)', async () => { // The window outlives its own restore on purpose (a floor, then a ceiling), - // so a navigation starting inside that span has to end it. Otherwise a Back - // that CLAMPS, which opens no window of its own, would run its whole growth - // under the previous restore's suppression and freeze its clamp, and a - // forward nav would carry the suppression onto an unrelated page. + // so a PAGE navigation starting inside that span has to end it. Otherwise a + // Back that CLAMPS, which opens no window of its own, would run its whole + // growth under the previous restore's suppression and freeze its clamp, and a + // forward nav would carry the suppression onto an unrelated page. A + // frame-targeted navigation is exempt, since it swaps one region and leaves + // the restored offset meaningful; the browser suite covers that side. const origLoc = globalThis.location; const origFetch = globalThis.fetch; const prevPageUrl = _currentPageUrl(); diff --git a/website/app/docs/client-router/page.ts b/website/app/docs/client-router/page.ts index d407aef8d..47484e95a 100644 --- a/website/app/docs/client-router/page.ts +++ b/website/app/docs/client-router/page.ts @@ -222,7 +222,8 @@ connectWS('/posts/' + id + '/feed', { onMessage: (m) => renderStream(m) });Snapshot cache + back/forward

The router maintains a URL-keyed LRU cache of page snapshots (capacity 16). On back/forward via popstate, the cached DOM is applied instantly and the captured window-scroll position is restored. A background refetch then revalidates the snapshot quietly.

-

A back/forward restore also suppresses the browser's scroll anchoring (overflow-anchor) for its duration, then puts it back. The saved offset was recorded against the page at its settled height, while the DOM the restore swaps in is still shorter until its components upgrade and render. Without the suppression the browser reads that late growth as content appearing above the reader and adds it to the offset the router just replayed, landing them below where they left. The window closes on the first real input (wheel, touchmove, keydown, pointerdown), so a reader who starts scrolling mid-restore gets normal anchoring back immediately. It also closes as soon as another page navigation starts, since the window outlives its own restore and must not be inherited by a page it was never meant for; that navigation then opens a window of its own if it earns one. A <webjs-frame> navigation is the exception and leaves the restore running, because it swaps one region rather than the page, so the restored offset is still the right one. Absent both it closes once the restore is over, which is the later of that restore's background revalidation settling and a short floor, and at the latest on a 2s ceiling. The floor matters because waiting on the revalidation alone would tie the window to network latency rather than to the growth it guards, so a server answering faster than the page renders would close it too early. Suppression only withholds a browser correction, it never moves the viewport. If your app sets overflow-anchor on <html> inline itself, it is overridden during a restore and restored afterwards. The suppression is conditional: when the page has not grown enough to scroll to the recorded offset at all, the browser clamps the scroll, and there the shortfall is exactly the growth still to come, so anchoring adding it is what carries the reader back down. The router leaves anchoring alone in that case rather than freezing the clamp, and additionally chases the saved offset, re-asserting it once the page is tall enough to hold it. That is the only scroll the router writes after the initial restore. It stops on the first real input, so it never fights a reader who has started scrolling, and it is time-boxed to a few hundred milliseconds, which is shorter than the suppression window. The bound is what protects a reader who has landed and started reading, since they generate no input to cancel it. The trade is that a component reaching its final height after that bound leaves a clamped reader at the clamp rather than at the offset they left.

+

A back/forward restore also suppresses the browser's scroll anchoring (overflow-anchor) for its duration, then puts it back. The saved offset was recorded against the page at its settled height, while the DOM the restore swaps in is still shorter until its components upgrade and render. Without the suppression the browser reads that late growth as content appearing above the reader and adds it to the offset the router just replayed, landing them below where they left. The window closes on the first real input (wheel, touchmove, keydown, pointerdown), so a reader who starts scrolling mid-restore gets normal anchoring back immediately. It also closes as soon as another page navigation starts, since the window outlives its own restore and must not be inherited by a page it was never meant for; that navigation then opens a window of its own if it earns one. Absent either of those it closes once the restore is over, which is the later of that restore's background revalidation settling and a short floor, and at the latest on a 2s ceiling. The floor matters because waiting on the revalidation alone would tie the window to network latency rather than to the growth it guards, so a server answering faster than the page renders would close it too early. Suppression only withholds a browser correction, it never moves the viewport. If your app sets overflow-anchor on <html> inline itself, it is overridden during a restore and restored afterwards. The suppression is conditional: when the page has not grown enough to scroll to the recorded offset at all, the browser clamps the scroll, and there the shortfall is exactly the growth still to come, so anchoring adding it is what carries the reader back down. The router leaves anchoring alone in that case rather than freezing the clamp, and additionally chases the saved offset, re-asserting it once the page is tall enough to hold it. That is the only scroll the router writes after the initial restore. It stops on the first real input, so it never fights a reader who has started scrolling, and it is time-boxed to a few hundred milliseconds, which is shorter than the suppression window. The bound is what protects a reader who has landed and started reading, since they generate no input to cancel it. The trade is that a component reaching its final height after that bound leaves a clamped reader at the clamp rather than at the offset they left.

+

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.

Nav scroll restoration (both the back/forward restore and the scroll-to-top on a forward nav) is forced behavior: 'instant', so setting html { scroll-behavior: smooth } in your app does not make navigation visibly animate the scroll. 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.

After a server action mutates data that a cached page depends on, call revalidate():

import { revalidate } from '@webjsdev/core'; From 0d7d707b99b0b019001fd41a3f20646e3ffcf00a Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 7 Aug 2026 02:28:33 +0530 Subject: [PATCH 20/20] fix: hold the offset the catch-up landed on, and state its cost correctly The chase stopped on reachability: it wrote the recorded offset on the first frame the document could hold it, then tore itself down. Anchoring is deliberately left on for the clamped path, so every later stage of growth was added on top of that write and carried the reader back below the offset. Real growth arrives in stages, since its cause is components upgrading one at a time, while every fixture here grew in one assignment, so nothing caught it. A two-stage fixture that lands stage one exactly on the reachability threshold reproduces it: an offset of 4000 ends at 5000. Once the reader is ON the recorded offset the situation is identical to a restore that landed first time, so it now gets that case's protection for what remains, bounded by the same floor and closing on the same inputs. The cost of the bound was also documented backwards in three places. The bound stops the ROUTER writing scroll; it does not stop the browser. Anchoring stays on, so growth after the window is still added and the reader drifts BELOW the offset, which is main's behaviour, rather than sitting at the clamp as all three surfaces claimed. --- .../references/client-router-and-streaming.md | 2 +- packages/core/src/router-client.js | 30 +++++++++++++---- .../browser/nav-scroll-anchor-restore.test.js | 32 +++++++++++++++++++ website/app/docs/client-router/page.ts | 2 +- 4 files changed, 57 insertions(+), 9 deletions(-) diff --git a/.agents/skills/webjs/references/client-router-and-streaming.md b/.agents/skills/webjs/references/client-router-and-streaming.md index d50789127..dd457e37a 100644 --- a/.agents/skills/webjs/references/client-router-and-streaming.md +++ b/.agents/skills/webjs/references/client-router-and-streaming.md @@ -61,7 +61,7 @@ The router keeps a URL-keyed snapshot cache (LRU, cap 16) so Back/Forward restor - **Do not write your own scroll restore.** A `popstate` listener that calls `scrollTo`, a saved offset in `sessionStorage`, a `scrollIntoView` on a remembered element: all of them fight the router, which already set `history.scrollRestoration = 'manual'` and is the sole authority on scroll during a navigation. If Back lands in the wrong place, that is a framework bug to report, not something to patch in app code. - **An app that sets `overflow-anchor` on `` itself sees it overridden during a restore and restored afterwards**, including a value set inline by your own script. Setting it in a stylesheet is unaffected between restores. Nothing else on the page is touched, and the router never sets `overflow-anchor` anywhere but the root element. - **A new PAGE navigation ends an open window.** The window outlives its own restore on purpose (a floor, then a ceiling), so a page navigation or a page-level form submission starting inside that span closes it first, and reopens only if it earns one. Otherwise a second Back, or a click, would inherit suppressed anchoring on a page it was never meant for. A FRAME-TARGETED navigation or submission is the exception, on exactly the rule that decides frame targeting everywhere else (the enclosing frame, an explicit `data-webjs-frame=""` from anywhere, or the frame's own `src`; `_top` and an unresolvable id are page navigations and do close the window). It swaps one region and leaves the page, and so the restored offset, intact, so it leaves the restore running. Closing there would hand anchoring back mid-restore and bring the double count straight back, and it needs no user input to happen, since a component upgrading in the just-restored page can drive a frame on its own. -- **Suppression is conditional, and only applies when the recorded offset was actually reached.** A page that has not grown yet can be too short to scroll that far, so the browser clamps to its current maximum. There the shortfall IS the growth still to come, and anchoring adding it is what carries the reader back down, so the router leaves anchoring alone. Suppressing in that case would freeze the clamp and strand the reader a full page-growth above where they left, which is this same defect pointing the other way. That case is not left to anchoring alone, though, because anchoring adds the FULL growth however far short the clamp fell, so by itself it only lands a reader who left at the very bottom. The router also CHASES the recorded offset there, re-asserting it the moment the page is tall enough to hold it, and then stopping. That is the one place the router writes scroll after the initial restore, it is scoped to the clamped path, and it stops on the same inputs that close a suppression window. It is also time-boxed, to a window SHORTER than the suppression one: a few hundred milliseconds, not the 2s ceiling. That bound is what keeps it from moving a reader who has landed and started reading, since such a reader generates no input to cancel it and the chase cannot tell the restore settling apart from any other growth. The cost is the other side of that trade: a component that reaches its final height later than the bound (a chart, an embed measured from its content) leaves a clamped reader at the clamp rather than at the offset they left. +- **Suppression is conditional, and only applies when the recorded offset was actually reached.** A page that has not grown yet can be too short to scroll that far, so the browser clamps to its current maximum. There the shortfall IS the growth still to come, and anchoring adding it is what carries the reader back down, so the router leaves anchoring alone. Suppressing in that case would freeze the clamp and strand the reader a full page-growth above where they left, which is this same defect pointing the other way. That case is not left to anchoring alone, though, because anchoring adds the FULL growth however far short the clamp fell, so by itself it only lands a reader who left at the very bottom. The router also CHASES the recorded offset there, re-asserting it the moment the page is tall enough to hold it, and then stopping. That is the one place the router writes scroll after the initial restore, it is scoped to the clamped path, and it stops on the same inputs that close a suppression window. It is also time-boxed, to a window SHORTER than the suppression one: a few hundred milliseconds, not the 2s ceiling. That bound is what keeps it from moving a reader who has landed and started reading, since such a reader generates no input to cancel it and the chase cannot tell the restore settling apart from any other growth. Be precise about what the bound buys, because it is easy to read it as more than it is: it stops the ROUTER writing scroll, not the browser. Anchoring stays on for this path, so growth arriving after the window is still added to `scrollY`. The cost of a component that reaches its final height later than the bound (a chart, an embed measured from its content) is therefore that the reader drifts BELOW the offset, the same way they would without this fix at all, not that they sit at the clamp. - **The window closes on the first real input** (`wheel`, `touchmove`, `keydown`, `pointerdown`), so a reader who starts scrolling mid-restore immediately gets normal browser anchoring back. Absent that it closes once the restore is over, which is the LATER of the restore's own background revalidation settling and a short floor, and at the latest on a 2s ceiling. The floor is load-bearing: waiting on the revalidation alone ties the window's length to network latency rather than to the growth it guards, so a server answering faster than the page renders would close it early and the reader would land low again. Suppression only ever WITHHOLDS a browser correction, it never moves the viewport, so it cannot yank someone who has taken over. Components that reach their final size only after they render (a chart, a media embed with no intrinsic dimensions, anything sized from measured content) are exactly the shape that triggers this, and they need no special handling: give them a placeholder height where you can, and let the router own the restore. diff --git a/packages/core/src/router-client.js b/packages/core/src/router-client.js index 85eba1ef5..68f12ab1f 100644 --- a/packages/core/src/router-client.js +++ b/packages/core/src/router-client.js @@ -479,13 +479,15 @@ let restoreGeneration = 0; * lives for `ANCHOR_SUPPRESS_FLOOR_MS` and no longer. That is SHORTER than the * suppression window beside it, which runs to the later of the floor and the * revalidation, capped by the ceiling; the two are not the same span and this - * is deliberately the tighter of them, because this one WRITES scroll. Outside - * it, late-resolving content cannot move a reader who is simply reading and - * generating no input to cancel it. + * is deliberately the tighter of them, because this one WRITES scroll. * - * The cost is the other side of that trade, and it is real: content settling - * later than the floor leaves a clamped reader at the clamp rather than at the - * offset they left. Both doc surfaces say so. + * Be precise about what the bound does and does not buy. It stops the ROUTER + * writing scroll after it expires. It does not stop the BROWSER: anchoring is + * deliberately left on for this path, so growth arriving after the window is + * still added to `scrollY` and still carries the reader down toward the bottom, + * which is main's behaviour. So the cost of a component that settles later than + * the window is that the reader drifts below the offset, NOT that they sit at + * the clamp. * * @param {number} targetY The recorded offset to reach. * @param {number} targetX @@ -509,8 +511,22 @@ function catchUpToRestoredScroll(targetY, targetX) { if (cancelScrollCatchUp !== stop) return; const maxY = document.documentElement.scrollHeight - window.innerHeight; if (maxY >= targetY) { - // Reachable at last. One write, then done. + // Reachable at last. Land the reader on the recorded offset. window.scrollTo({ left: targetX, top: targetY, behavior: 'instant' }); + // And then protect it, because landing is not the end of the story. The + // growth that made the offset reachable is rarely all of it: the real + // cause is components upgrading one at a time, so more arrives after + // this. Anchoring is still on here, deliberately, so every later stage + // would be added on top of the offset just written and carry the reader + // below it again. Measured on a two-stage fixture, an offset of 4000 + // ended at 5000. + // + // Once the reader IS on the recorded offset the situation is identical to + // a restore that landed on its first try, so it gets that case's + // protection for what remains: suppression, bounded by the same floor + // this chase runs under, and closing on the same inputs. + const releaseLanded = suppressScrollAnchoring(); + setTimeout(releaseLanded, ANCHOR_SUPPRESS_FLOOR_MS); stop(); return; } diff --git a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js index 912a79ddd..064531f62 100644 --- a/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js +++ b/packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js @@ -577,6 +577,38 @@ suite('Client router: a Back restore survives late layout growth (#1310)', () => } finally { await teardown(); } }); + test('a clamped restore survives growth that arrives in STAGES', async () => { + // The real cause of the growth is components upgrading and rendering one at + // a time, so it arrives in pieces. Every other clamped case grows in a + // single assignment, which is the easy shape: the catch-up writes the offset + // once and the page never moves again. With staged growth the page keeps + // growing after that write, and anchoring is deliberately left ON here, so + // each later stage is added on top of the offset and carries the reader + // below it. + await setup({ restoredY: CLAMPED_TARGET, manualGrowth: true }); + try { + await goBack(); + assert.ok(window.scrollY < CLAMPED_TARGET - 1, 'precondition: clamped'); + const grower = document.querySelector('wj-grow-on-command-1310'); + // Stage one makes the offset EXACTLY reachable, computed from the live + // viewport so it lands on the threshold rather than near it: the fixture + // filler is 3000px, so a grower of `target + innerHeight - 3000` puts the + // document's maximum scroll precisely at the target. That is the frame + // the catch-up writes and stops on. Stage two then adds more above the + // viewport, which is the growth that must not be counted on top. + const stageOne = CLAMPED_TARGET + window.innerHeight - 3000; + grower.style.height = stageOne + 'px'; + for (let i = 0; i < 6; i++) await frame(); + assert.ok(Math.abs(window.scrollY - CLAMPED_TARGET) < 5, + `precondition: stage one let the catch-up land (got ${window.scrollY})`); + grower.style.height = (stageOne + 1000) + 'px'; + for (let i = 0; i < 12; i++) await frame(); + assert.ok(Math.abs(window.scrollY - CLAMPED_TARGET) < 5, + 'staged growth must still land on the recorded offset ' + + `(expected ~${CLAMPED_TARGET}, got ${window.scrollY})`); + } finally { await teardown(); } + }); + test('the catch-up gives up after its window, and does not move a settled reader', async () => { // The bound is a live behaviour constant: past it a clamped restore stops // chasing. The other two catch-up cases grow the fixture immediately, so diff --git a/website/app/docs/client-router/page.ts b/website/app/docs/client-router/page.ts index 47484e95a..d1097ec18 100644 --- a/website/app/docs/client-router/page.ts +++ b/website/app/docs/client-router/page.ts @@ -222,7 +222,7 @@ connectWS('/posts/' + id + '/feed', { onMessage: (m) => renderStream(m) });Snapshot cache + back/forward

The router maintains a URL-keyed LRU cache of page snapshots (capacity 16). On back/forward via popstate, the cached DOM is applied instantly and the captured window-scroll position is restored. A background refetch then revalidates the snapshot quietly.

-

A back/forward restore also suppresses the browser's scroll anchoring (overflow-anchor) for its duration, then puts it back. The saved offset was recorded against the page at its settled height, while the DOM the restore swaps in is still shorter until its components upgrade and render. Without the suppression the browser reads that late growth as content appearing above the reader and adds it to the offset the router just replayed, landing them below where they left. The window closes on the first real input (wheel, touchmove, keydown, pointerdown), so a reader who starts scrolling mid-restore gets normal anchoring back immediately. It also closes as soon as another page navigation starts, since the window outlives its own restore and must not be inherited by a page it was never meant for; that navigation then opens a window of its own if it earns one. Absent either of those it closes once the restore is over, which is the later of that restore's background revalidation settling and a short floor, and at the latest on a 2s ceiling. The floor matters because waiting on the revalidation alone would tie the window to network latency rather than to the growth it guards, so a server answering faster than the page renders would close it too early. Suppression only withholds a browser correction, it never moves the viewport. If your app sets overflow-anchor on <html> inline itself, it is overridden during a restore and restored afterwards. The suppression is conditional: when the page has not grown enough to scroll to the recorded offset at all, the browser clamps the scroll, and there the shortfall is exactly the growth still to come, so anchoring adding it is what carries the reader back down. The router leaves anchoring alone in that case rather than freezing the clamp, and additionally chases the saved offset, re-asserting it once the page is tall enough to hold it. That is the only scroll the router writes after the initial restore. It stops on the first real input, so it never fights a reader who has started scrolling, and it is time-boxed to a few hundred milliseconds, which is shorter than the suppression window. The bound is what protects a reader who has landed and started reading, since they generate no input to cancel it. The trade is that a component reaching its final height after that bound leaves a clamped reader at the clamp rather than at the offset they left.

+

A back/forward restore also suppresses the browser's scroll anchoring (overflow-anchor) for its duration, then puts it back. The saved offset was recorded against the page at its settled height, while the DOM the restore swaps in is still shorter until its components upgrade and render. Without the suppression the browser reads that late growth as content appearing above the reader and adds it to the offset the router just replayed, landing them below where they left. The window closes on the first real input (wheel, touchmove, keydown, pointerdown), so a reader who starts scrolling mid-restore gets normal anchoring back immediately. It also closes as soon as another page navigation starts, since the window outlives its own restore and must not be inherited by a page it was never meant for; that navigation then opens a window of its own if it earns one. Absent either of those it closes once the restore is over, which is the later of that restore's background revalidation settling and a short floor, and at the latest on a 2s ceiling. The floor matters because waiting on the revalidation alone would tie the window to network latency rather than to the growth it guards, so a server answering faster than the page renders would close it too early. Suppression only withholds a browser correction, it never moves the viewport. If your app sets overflow-anchor on <html> inline itself, it is overridden during a restore and restored afterwards. The suppression is conditional: when the page has not grown enough to scroll to the recorded offset at all, the browser clamps the scroll, and there the shortfall is exactly the growth still to come, so anchoring adding it is what carries the reader back down. The router leaves anchoring alone in that case rather than freezing the clamp, and additionally chases the saved offset, re-asserting it once the page is tall enough to hold it. That is the only scroll the router writes after the initial restore. It stops on the first real input, so it never fights a reader who has started scrolling, and it is time-boxed to a few hundred milliseconds, which is shorter than the suppression window. The bound is what protects a reader who has landed and started reading, since they generate no input to cancel it. The bound stops the router writing scroll, not the browser: anchoring stays on for this path, so a component reaching its final height after the bound has its growth added to the scroll position, and the reader drifts below the offset rather than sitting at the clamp.

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.

Nav scroll restoration (both the back/forward restore and the scroll-to-top on a forward nav) is forced behavior: 'instant', so setting html { scroll-behavior: smooth } in your app does not make navigation visibly animate the scroll. 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.

After a server action mutates data that a cached page depends on, call revalidate():