You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Pressing Back lands the reader roughly 763px too far down the page. They were reading one section, they come back, and they are one or two sections below where they left.
Reproduce on the live site (or locally at localhost:5001):
Expected: back on Sizes. Actual: on Props, about two sections lower.
Two conditions matter. It only shows past ~300px of scroll (near the top the restore is correct), and it needs a page whose content settles taller AFTER the swap. /ui/<name> gallery pages do. /docs/* pages do not, so testing there shows nothing wrong.
The router is not restoring the wrong number. It restores the right number too early. Exactly one window.scrollTo happens during the popstate, and it carries the correct stored value:
Sweeping the starting scroll on /ui/button (re-measured 2026-08-06 against the live site, headless Chromium, viewport 1280x900):
scrolled to
restored to
delta
0
0
0
200
200
0
400
734
+334
800
1563
+763
1200
1963
+763
The two large cases land on exactly the full height delta. The small ones sit above or partly above the growth region. The offset tracks the growth rather than scaling with the starting position, which is the signature of scroll anchoring rather than a bad stored value.
The measured timeline (this corrects the previous diagnosis)
Per-frame sampling with window.scrollTo and window.fetch both instrumented, timestamps relative to the first sample after history.back():
Two claims in the earlier write-up were wrong and are corrected here.
The growth is not caused by the background revalidation. It completes at +65ms, 141ms before the revalidation response even arrives and 229ms before its swap. The growth is intrinsic to the restore itself. The snapshot HTML swaps in at 2139px and grows to 2902px on its own within ~25ms, as the components in the restored markup upgrade and re-render. The revalidation's own swap then repeats a shrink-and-regrow cycle (1563 to 1239 to 1563) which happens to be net neutral here only because the two heights match.
There is an observable window at the correct position. The earlier note said per-frame sampling showed no intermediate 800 because the growth landed in the same blocked frame as the swap. It does not. scrollY sits at 800 for roughly 27ms. That window is what makes a fix possible at all.
So the corrected sequence is:
applySwap() swaps in the cached HTML, which lays out at 2139px
scrollTo({top: 800}) fires immediately and lands correctly
The restored components upgrade and re-render, growing content ABOVE the viewport by 763px
The browser's scroll anchoring (overflow-anchor: auto, the UA default) holds the visual position by adding that growth to scrollY, carrying 800 to 1563
The stored value was never in doubt. snapshotCurrent() records the scroll offset against the page at its SETTLED height. The restore replays that post-growth number onto a pre-growth document, and anchoring then counts the growth a second time.
Verified NOT to be the alternative explanations. The stored value is correct (logged as 800). The final document height equals the height at record time (2902 both). Forcing scrollTo(0, 800) after the restore puts Sizes back at the top, so page layout is stable and reproducible.
Not gallery-specific. Any page whose restored content settles taller after the swap hits this. Custom elements that take their final size on upgrade, images with no intrinsic dimensions, late web fonts. The gallery makes it reproducible because its component previews are exactly that shape.
Line anchors below are against HEAD ddfc5547 and were each re-verified.
Design / approach
Settled decision: suppress scroll anchoring across the restore window. Do not re-assert the scroll afterwards.
The window opens when the popstate cache-hit branch writes the restored scroll, and closes on the first of three things. The revalidation for THAT restore settling plus two animation frames, a user input that means the reader has taken over, or a hard ceiling so a hung revalidation can never leave anchoring off.
Why suppression is right
Scroll anchoring exists to hold a reader's visual position when content grows above them. That is correct for a reader sitting on a live page and wrong for exactly this window, because the number being restored ALREADY accounts for the growth. Suppressing anchoring stops the double count at its source. Re-asserting the scroll patches the symptom after the fact.
Three properties follow, and each of them is why the alternative loses.
Suppression never moves the viewport. It withholds a browser correction, it does not perform an action. So it structurally cannot yank a reader who has started scrolling, which is the failure mode that makes the re-assert direction dangerous.
It is correct for streaming content too. A boundary that resolves inside the restore window is content the recorded offset already accounted for, so suppressing anchoring for it is right rather than merely tolerable.
Verified, not assumed
Two probes were run before this was written down.
Probe 1, synthetic. A 4000px page, scrolled to 800, then 763px of content inserted above the viewport, run in all three Playwright engines:
chromium: CSS.supports(overflow-anchor:none)=true
anchoring ON -> scrollY 800 => 1563 (moved 763)
anchor:none -> scrollY 800 => 800 (moved 0)
firefox: same
webkit: same
All three engines implement scroll anchoring and all three honour overflow-anchor: none on the root scroller. There is no engine to carve out and no engine-specific code path needed.
Probe 2, the real fix on the live site. An init script suppressed anchoring on the restore's instant scroll write and released it after 1200ms or on user input, run against the unmodified production site at https://webjs.dev.
Exact restoration at every offset, including through the revalidation's later shrink-and-regrow cycle.
Alternatives rejected
Re-assert the scroll after the revalidation settles. Rejected on the measurement. The position is already wrong at +65ms and the revalidation does not settle until roughly +300ms, so the reader would watch the wrong position for a quarter second and then be jumped. Worse, a single re-assert is not even a fix, because anchoring keeps acting afterwards. The measured revalidation swap at +294ms moves scrollY from 1563 to 1239 and then back, so a re-assert landing between those two events would itself be undone. Making it correct would mean re-asserting on every height change, which is precisely the thing that cannot be distinguished from a <webjs-suspense> boundary streaming in. It also requires a correctness-critical user-scroll cancellation, where a bug yanks the viewport out from under someone.
Defer the restore until a settle signal. This is what the one framework that gets this right actually does. Remix v3 (remix/packages/ui/src/runtime/navigation.ts:81-94) hands the traverse case to the Navigation API's intercept() and passes no scroll option, so the UA default after-transition holds its own scroll restoration until await frame.reload() resolves. WebJs cannot copy it. The router is on the popstate path, not the Navigation API, and it already took control with history.scrollRestoration = 'manual' (packages/core/src/router-client.js:343), so there is no UA restoration left to defer. Deferring the router's own write instead would paint the destination at the outgoing page's scroll offset for two frames, which is the flash that Turbo, Next, and Astro all avoid by scrolling synchronously right after the swap.
Record the snapshot's document height and re-scroll once the live document reaches it. Rejected because a revalidated page legitimately differs in height from its snapshot, which is the entire reason for revalidating. The condition would either never fire or fire by coincidence. It also violates the router's degradation philosophy, which is to fall back rather than guess.
Prior art, and why none of it already solved this
None of the four frameworks read handles late layout growth on a restore. Every one of them scrolls exactly once, synchronously after the swap, and lets the position drift if content grows.
Turbo is the closest model, and WebJs's snapshot cache is explicitly built on it (see the comments at packages/core/src/router-client.js:1235 and :1248). turbo/src/core/drive/visit.js:333-343 is the only scroll-restoration site in the drive layer, and its sole call site is visit.js:402-407, immediately after renderPage resolves. A one-shot this.scrolled latch (visit.js:52, set at :341) means a second render never re-scrolls. grep -rni "overflow.anchor" over the whole Turbo repo returns nothing, and there is no user-scroll detection anywhere in the drive layer. Turbo has this bug.
Next.js Pages Router saves {x, y} into sessionStorage (next.js/packages/next/src/shared/lib/router/router.ts:948-966) and applies it once in the render commit (next.js/packages/next/src/client/index.tsx:758), with no deferral and no second pass. The App Router never assigns history.scrollRestoration, leaving it auto and handing the whole job to the browser (next.js/packages/next/src/client/components/app-router.tsx:379-398, plus restore-reducer.ts:52-53 explicitly declining to force a scroll). Neither uses overflow-anchor.
Remix v2 restores from a blocking inline <script> during HTML parsing (remix-v2/packages/remix-react/scroll-restoration.tsx:59-71), which is deliberately EARLIER than settle, plus a react-router layout effect gated on the navigation reaching idle. That waits for data to settle, never for layout.
Astro stores scroll in history state and calls scrollTo immediately after a synchronous full-document swap (astro/packages/astro/src/transitions/router.ts:213-214). No re-measure, no re-assert, no overflow-anchor.
The repo sets overflow-anchor nowhere. grep -rn "overflow-anchor\|overflowAnchor" packages/ website/ examples/ returns nothing, so the behaviour in play is the UA default auto and there is no existing app CSS for the fix to collide with.
Scope: which navigation paths change
Only the popstate cache-hit branch. The reasoning for each other path, so nobody widens this for symmetry:
Popstate cache-miss (router-client.js:1389) scrolls to top: 0. At offset 0 there is no content above the viewport, so anchoring has nothing to compensate and cannot move the position. No change.
Forward navigation scroll-to-top (router-client.js:2470, and the no-target hash branch at :2465) scrolls to top: 0. Same argument. No change.
Forward navigation hash anchor (router-client.js:2464, t.scrollIntoView()) does land at a non-zero offset, but the target is an ELEMENT, not a replayed number. Anchoring holding the visual position of content around that element is the correct behaviour there. No change.
The defect is specific to replaying a recorded post-growth offset onto a pre-growth document, and that happens in exactly one branch.
Implementation plan
All line anchors are against HEAD ddfc5547.
Step 1. Add the suppression helper to packages/core/src/router-client.js
Place it immediately after the prevScrollRestoration declaration, which ends at L302, and before export function enableClientRouter() at L305. That block is the existing house pattern for saving and restoring a browser scroll setting, so the new one belongs beside it.
Current, L292-L302:
/** * Previous value of `history.scrollRestoration` (so we can restore it * when the router is disabled). The browser's default behavior of * auto-restoring scroll on popstate races with the SPA's own scroll * restoration: disabled here so WebJs is the sole authority on scroll * during navigation. Same pattern as Turbo Drive's * `assumeControlOfScrollRestoration()` (turbo/src/core/drive/history.js). * * @type {ScrollRestoration | null} */letprevScrollRestoration=null;
Insert after it:
/** * 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. */constANCHOR_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[]} */constANCHOR_RELEASE_EVENTS=['wheel','touchmove','keydown','pointerdown'];/** * Closes the currently open restore window, or null when none is open. * @type {(() => void) | null} */letreleaseScrollAnchor=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 `<webjs-suspense>` 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. */functionsuppressScrollAnchoring(){if(typeofdocument==='undefined'||!document.documentElement)return()=>{};// A second restore inside an open window supersedes the first.if(releaseScrollAnchor)releaseScrollAnchor();constroot=document.documentElement;// Save and restore the author's own inline value rather than blanking it,// the same contract `prevScrollRestoration` keeps above.constprev=root.style.getPropertyValue('overflow-anchor');root.style.setProperty('overflow-anchor','none');/** @type {ReturnType<typeof setTimeout> | null} */lettimer=null;constrelease=()=>{// Only the window that installed this release may close it.if(releaseScrollAnchor!==release)return;releaseScrollAnchor=null;if(timer){clearTimeout(timer);timer=null;}if(typeofwindow!=='undefined'){for(constevofANCHOR_RELEASE_EVENTS){window.removeEventListener(ev,release,/** @type {any} */({capture: true}));}}if(prev)root.style.setProperty('overflow-anchor',prev);elseroot.style.removeProperty('overflow-anchor');};releaseScrollAnchor=release;timer=setTimeout(release,ANCHOR_SUPPRESS_CEILING_MS);if(typeofwindow!=='undefined'){for(constevofANCHOR_RELEASE_EVENTS){window.addEventListener(ev,release,{capture: true,passive: true});}}returnrelease;}/** * 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 */functionafterTwoFrames(fn){if(typeofrequestAnimationFrame!=='function'){setTimeout(fn,0);return;}requestAnimationFrame(()=>requestAnimationFrame(fn));}
Use setProperty / getPropertyValue / removeProperty rather than the style.overflowAnchor camelCase accessor. The camelCase form is not reliably modelled by linkedom, which backs the node test harness, and the property form is what the unit test in step 4 reads.
Step 2. Open the window at the restore site
packages/core/src/router-client.js, the popstate cache-hit branch inside performNavigation, currently L1367-L1381.
Current:
if(cachedDoc){applySwap(cachedDoc,frameId,/* revalidating */true,/* href */null);// 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).if(typeofwindow!=='undefined'){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).fetchAndApply(href,frameId,/* recordHistory */false,optimisticState,'GET',null,signal,myToken,/* revalidating */true).catch(()=>{});return;}
After:
if(cachedDoc){applySwap(cachedDoc,frameId,/* revalidating */true,/* href */null);// 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).letreleaseAnchor=()=>{};if(typeofwindow!=='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 <webjs-suspense> boundary (#471 / #473).fetchAndApply(href,frameId,/* recordHistory */false,optimisticState,'GET',null,signal,myToken,/* revalidating */true).catch(()=>{}).then(()=>afterTwoFrames(releaseAnchor));return;}
behavior: 'instant' is unchanged and must stay. It is load-bearing from #601, where an app-level html { scroll-behavior: smooth } otherwise animates the restore, and packages/core/test/routing/browser/nav-scroll-instant.test.js guards it.
Do not touch the cache-miss window.scrollTo at L1389, and do not touch either window.scrollTo in fetchAndApply at L2465 and L2470. See the scope reasoning in Design.
Step 3. Close any open window when the router is disabled
packages/core/src/router-client.js, disableClientRouter(), currently L356-L375. The router must leave no residue on <html>.
if(prefetchViewObserver){prefetchViewObserver.disconnect();prefetchViewObserver=null;}if(typeofhistory!=='undefined'&&prevScrollRestoration!==null){history.scrollRestoration=prevScrollRestoration;prevScrollRestoration=null;}// Never leave a restore window open on <html> (#1310).if(releaseScrollAnchor)releaseScrollAnchor();currentPageUrl=null;
Step 4. Node unit test
packages/core/test/routing/router-client.test.js. Add directly after the existing popstate cache restore scrolls instantly, not animated (#601) test, which begins at L1998 and follows the same _snapshotCache.set plus globalThis.location stub plus _onPopState({}) shape already used at L1982, L2029, L2717, L3129, and L3185.
Assert three things:
Synchronously after _onPopState({}), document.documentElement.style.getPropertyValue('overflow-anchor') is 'none'.
After the revalidation settles and the two frames elapse, it is back to ''.
Calling disableClientRouter() while the window is open clears it.
If linkedom does not model overflow-anchor through setProperty, do not weaken the assertion to a spy on the style object. Assert instead that the restore path called the suppression, by exporting suppressScrollAnchoring through the test-seam export block at L4660-L4713 as _suppressScrollAnchoring and asserting the release contract directly, and let the browser test in step 5 carry the DOM-level proof. Check which one linkedom supports before writing the test rather than guessing.
This is a unit test and it is necessary but NOT sufficient (AGENTS.md Code workflow item 1). The headline behaviour is a browser assertion.
Step 5. Browser test, the headline
New file: packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js, next to nav-scroll-instant.test.js. Follow the house style in packages/core/test/routing/browser/fetch-revalidates.test.js for the suite() form, the import { assert } from '../../../../../test/browser-assert.js' path, and the mandatory installNavGuard() from ../../../../../test/browser-nav-guard.js.
The fixture MUST grow after the swap. A static fixture cannot reproduce this at all, because the growth is the whole mechanism.
Shape:
Define a local custom element, for example class GrowBlock extends HTMLElement { connectedCallback() { this.style.display = 'block'; this.style.height = '763px'; } }, registered once per file with a unique tag. As raw parsed markup it is 0px tall, and it takes its 763px on upgrade. That models the real cause exactly.
Build a tall live document, with a boundary range whose content is <grow-block-…></grow-block-…><div style="height:3000px"></div> so the grower sits entirely above the viewport once scrolled.
Seed _snapshotCache (exported as _snapshotCache at L4685) with { html, scrollX: 0, scrollY: 800 } where html is the same markup, so the restore lands a 0px-tall grower that then upgrades.
Drive a REAL popstate rather than reassigning location, which is impossible in a browser. Push two same-origin entries with history.pushState(null, '', location.pathname + '?wj=a') and then ?wj=b, seed the cache under the ?wj=a key, set _setCurrentPageUrl (exported at L4748) to the ?wj=b URL, stub window.fetch to return the same HTML, then call history.back() and await the popstate.
Assertions:
A, the window opens. Right after the restore, document.documentElement.style.getPropertyValue('overflow-anchor') is 'none'.
B, the headline. After the grower has upgraded and added 763px above the viewport, window.scrollY is still the restored 800 within a small tolerance, not 1563.
C, no residue. After the stubbed revalidation settles and two frames elapse, the property is back to ''.
D, user takeover. Dispatching a wheel event on window during an open window closes it immediately, and the property is back to ''.
All three engines in the web-test-runner matrix (Chromium, Firefox, WebKit) implement scroll anchoring and honour overflow-anchor: none, verified by the probe in Design. Do NOT add an engine skip.
Restore window.scrollTo(0, 0), the history entries, window.fetch, and the DOM in teardown(), and call navGuard.remove().
Step 6. E2E, point the existing block back at the gallery
test/e2e/form-submission-and-race.test.mjs, the block scroll restoration: back-button restores window scroll position at L373-L427. It was deliberately pointed at /docs/routing because of this bug, with an explanatory comment added in #1305. Part of this fix is undoing that.
Exact changes:
Delete the seven-line comment at L378-L384, beginning // This one block runs against /docs rather than /ui and ending // assertion on the page where nothing else is moving the scroll.
Delete the spacer nudge at L387-L395 (the // Make sure the page is tall enough block). It exists only because a docs page might be short. /ui/button measures 2902px, so it can never fire, and leaving it in implies the page might need it.
L409-L410, change the sidebar locator from page.locator('.docs-sidebar a:has-text("Components")').first() to page.locator('a[href="/ui/card"]').first(). Keep the in-page .evaluate((el) => el.click()), and keep the comment above it explaining why, since Playwright's own click would scroll the target into view and move the window first.
L411-L412, change the wait predicate from location.pathname.endsWith('/components') to location.pathname.endsWith('/ui/card').
L416-L417, change the return wait from location.pathname.endsWith('/routing') to location.pathname.endsWith('/ui/button').
L419-L420, replace await page.waitForTimeout(80); with a wait long enough for the page to settle. 80ms is not enough here, and that is the point of the test. Measured on the live site, the growth lands at +65ms and the revalidation's swap at +294ms. Use await page.waitForTimeout(1200); and replace the comment // Give the cached-restore path a frame to run. with one saying the page must be allowed to finish growing and revalidating, because a restore that is correct at 80ms and wrong at 1200ms is exactly the bug.
Keep the < 20 tolerance at L423-L424 and the beforeScroll >= 700 precondition at L401-L402 unchanged.
Verify locally against localhost:5001 (cd website && npm run dev), since the measurements above were taken against production and the local dev build must be confirmed to reproduce the same growth.
Step 7. Bun parity
Not applicable, and here is the argument rather than silence.packages/core/src/router-client.js is browser-only client code. It is reached through index-browser.js, never runs on the server, and the change touches only document.documentElement.style, window event listeners, setTimeout, and requestAnimationFrame. None of those exist in either server runtime, and no runtime-sensitive surface is touched (no serializer, no listener or request path, no SSR or action or CSRF dispatch, no streams, no node:crypto, no TS stripper, no auth or session or cors).
The gate agrees. .claude/hooks/require-bun-parity-with-runtime-src.sh matches staged paths against serialize|/json\.js|file-storage|listener|ts-strip|action|render-server|/ssr\.js|conditional-get|websocket|node-version|csrf|/auth\.js|/session\.js|/cors\.js|crypto|compression|body-limit|/dev\.js|stream. The path packages/core/src/router-client.js matches none of them, so the hook does not fire and WEBJS_BUN_VERIFIED=1 is not needed. Add nothing under test/bun/**.
Step 8. Counterfactual
Commit the fix first, then revert through git rather than a sed toggle.
Revert only step 2, the releaseAnchor = suppressScrollAnchoring(); line and the .then(() => afterTwoFrames(releaseAnchor)) continuation in the popstate cache-hit branch. Leave steps 1 and 3 in place, so the revert isolates the call site rather than deleting the helper.
Assertion B in packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js goes red, reporting a scrollY of roughly 1563 against an expected 800. Assertions A, C, and D go red too. Restore the revert and confirm green.
Step 9. Validation before opening the PR
npm test from the repo root, for the node suite including step 4.
The e2e block from step 6, with the website dev server up: cd website && npm run dev, then WEBJS_E2E=1 node --test test/e2e/form-submission-and-race.test.mjs.
iOS back-swipe sanity check.dogfood: mobile navbar flickers on forward nav (backdrop-blur sticky header) #610 and dogfood: fix iOS sticky-header flicker in the docs app (mobile header) #647 (sticky-header flicker) and dogfood: iOS back-swipe gesture flashes a blank page (client router) #641 (back-swipe flashing blank) are all client-router navigation plus scroll interactions on iOS WebKit, which is touchy on exactly this surface. Verify on a real device that an interactive back-swipe on a /ui/<name> page restores the correct offset and does not flicker. The specific risk to watch for is the touchmove release: a back-swipe gesture may fire touchmove on window and close the restore window before the growth lands, which would leave the swipe path unfixed while the button path is fixed. If that happens, do NOT drop the touchmove release. Report the observation on the PR and let the maintainer choose, because narrowing the release conditions trades a real user-takeover protection for this case.
Tests
Every layer that applies, by path.
Unit, applies.packages/core/test/routing/router-client.test.js, a new test after the existing #601 restore test at L1998. Covers the window opening on the restore, closing after the revalidation, and closing on disableClientRouter(). Necessary but not sufficient.
Browser, applies, and carries the headline. New file packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js, with a fixture that grows after the swap, driving a real history.back(). Four assertions, listed in step 5. Runs across Chromium, Firefox, and WebKit, all of which reproduce the bug.
Browser, existing, must stay green.packages/core/test/routing/browser/nav-scroll-instant.test.js. It guards the behavior: 'instant' that this change must not disturb.
E2E, applies.test/e2e/form-submission-and-race.test.mjs, the block at L373-L427, repointed from /docs/routing to /ui/button with its explanatory comment deleted. Exact edits in step 6.
Bun parity, does NOT apply. Full argument in step 7. Browser-only client code, no runtime-sensitive surface touched, and the parity hook's path regex does not match router-client.js.
Smoke (test/examples/*/smoke/*), does NOT apply. The smoke suites boot an app and assert served output. This defect is a post-hydration scroll behaviour in the browser, invisible to a served-HTML assertion.
Counterfactual. Step 8 names the exact revert and the exact assertion that reds.
Docs
Two surfaces change. Both satisfy .claude/hooks/require-docs-with-src.sh, so WEBJS_NO_DOC_GATE=1 is NOT needed and must not be used.
.agents/skills/webjs/references/client-router-and-streaming.md, the snapshot-cache paragraph at L57, which today ends and scroll is restored on Back/Forward. Add a sentence stating that the router suppresses the browser's scroll anchoring for the duration of a Back/Forward restore, because the saved offset was recorded at the page's settled height and would otherwise be double counted as the restored page grows. Note that an app setting its own overflow-anchor on <html> sees it briefly overridden and then put back.
website/app/docs/client-router/page.ts, the snapshot-restore paragraph at L224 and the scroll paragraph at L225. Add the same sentence in the docs-site voice, next to the existing behavior: 'instant' explanation, since the two facts belong together and a reader debugging a restore will look there.
No other surface changes.
Root AGENTS.md, client-navigation section. Its sentence scroll is restored on back/forward stays true and the file is deliberately lean. No change.
Scaffold templates. There is nothing to sync, verified.packages/cli/templates/.agents/ contains only rules/workflow.md. There is no packages/cli/templates/.agents/skills/webjs/references/ directory. packages/cli/lib/create.js:666 states the skill lives ONCE, canonically, at the repo-root .agents/skills/webjs/, so editing surface 1 above covers the scaffold. Do not go looking for a second copy.
README. Not a headline capability. No change.
Acceptance criteria
On https://webjs.dev/ui/button, scrolling to Sizes, navigating to /ui/card, and pressing Back returns to Sizes
The measured sweep restores exactly at every offset. 400 to 400, 800 to 800, 1200 to 1200, replacing the 734 / 1563 / 1963 in the Problem table
The restore is still instant with no visible slide under html { scroll-behavior: smooth }, and packages/core/test/routing/browser/nav-scroll-instant.test.js stays green
A reader who scrolls during the restore window is not yanked, and the first wheel, touchmove, keydown, or pointerdown closes the window
The window closes on the specific revalidation's settle plus two frames, never on a height observer, so a <webjs-suspense> boundary streaming in after the swap is not fought on any navigation
document.documentElement carries no leftover overflow-anchor after any navigation, after the ceiling fires, or after disableClientRouter()
An author's own inline overflow-anchor on <html> is put back rather than blanked
packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js reproduces the failure with a fixture that grows after the swap, and passes on Chromium, Firefox, and WebKit
Reverting only the step 2 call site reds that test's scrollY assertion at roughly 1563 against an expected 800
The e2e block in test/e2e/form-submission-and-race.test.mjs runs against /ui/button and /ui/card, its #1305 explanatory comment is gone, and it waits long enough for the page to settle
An iOS back-swipe on a /ui/<name> page restores the correct offset without flicker, and the touchmove interaction is reported on the PR
Both doc surfaces in Docs are updated in the same PR
webjs check clean, and webjs doctor clean for website and examples/blog
Out of scope
Do not widen into any of the following.
The cache-miss popstate branch and the forward-navigation scroll paths. Both scroll to offset 0, where anchoring has nothing above the viewport to compensate. The hash-anchor branch targets an element rather than replaying a recorded number, so anchoring there is correct. Reasoning in Design under Scope.
Why the restored /ui/<name> markup lays out 763px short before its components upgrade. That is normal upgrade-time growth and is the condition this fix is designed to survive, not a defect to chase. Do not try to make the snapshot restore at its final height.
A public opt-out, a new event, or any config key. The fix adds no API surface. Do not add webjs.scrollAnchor or a webjs:scroll-restored event.
Changing what snapshotCurrent() records. It is correct, confirmed by measurement. Do not add a recorded document height, and do not switch to Turbo's per-scroll-event sampling.
A recovery path that guesses a scroll position. The router degrades to a full page load rather than guessing, and this change must not introduce an exception.
Filing follow-up issues. Anything this turns up goes on the PR for the maintainer to decide. Fold a small tweak in a file the PR already touches into the PR itself.
Problem
Pressing Back lands the reader roughly 763px too far down the page. They were reading one section, they come back, and they are one or two sections below where they left.
Reproduce on the live site (or locally at
localhost:5001):Expected: back on Sizes. Actual: on Props, about two sections lower.
Two conditions matter. It only shows past ~300px of scroll (near the top the restore is correct), and it needs a page whose content settles taller AFTER the swap.
/ui/<name>gallery pages do./docs/*pages do not, so testing there shows nothing wrong.The router is not restoring the wrong number. It restores the right number too early. Exactly one
window.scrollTohappens during the popstate, and it carries the correct stored value:2902 - 2139 = 763, exactly the observed offset.Sweeping the starting scroll on
/ui/button(re-measured 2026-08-06 against the live site, headless Chromium, viewport 1280x900):The two large cases land on exactly the full height delta. The small ones sit above or partly above the growth region. The offset tracks the growth rather than scaling with the starting position, which is the signature of scroll anchoring rather than a bad stored value.
The measured timeline (this corrects the previous diagnosis)
Per-frame sampling with
window.scrollToandwindow.fetchboth instrumented, timestamps relative to the first sample afterhistory.back():Two claims in the earlier write-up were wrong and are corrected here.
scrollYsits at 800 for roughly 27ms. That window is what makes a fix possible at all.So the corrected sequence is:
applySwap()swaps in the cached HTML, which lays out at 2139pxscrollTo({top: 800})fires immediately and lands correctlyoverflow-anchor: auto, the UA default) holds the visual position by adding that growth toscrollY, carrying 800 to 1563The stored value was never in doubt.
snapshotCurrent()records the scroll offset against the page at its SETTLED height. The restore replays that post-growth number onto a pre-growth document, and anchoring then counts the growth a second time.Verified NOT to be the alternative explanations. The stored value is correct (logged as 800). The final document height equals the height at record time (2902 both). Forcing
scrollTo(0, 800)after the restore puts Sizes back at the top, so page layout is stable and reproducible.Not gallery-specific. Any page whose restored content settles taller after the swap hits this. Custom elements that take their final size on upgrade, images with no intrinsic dimensions, late web fonts. The gallery makes it reproducible because its component previews are exactly that shape.
Line anchors below are against HEAD
ddfc5547and were each re-verified.Design / approach
Settled decision: suppress scroll anchoring across the restore window. Do not re-assert the scroll afterwards.
The window opens when the popstate cache-hit branch writes the restored scroll, and closes on the first of three things. The revalidation for THAT restore settling plus two animation frames, a user input that means the reader has taken over, or a hard ceiling so a hung revalidation can never leave anchoring off.
Why suppression is right
Scroll anchoring exists to hold a reader's visual position when content grows above them. That is correct for a reader sitting on a live page and wrong for exactly this window, because the number being restored ALREADY accounts for the growth. Suppressing anchoring stops the double count at its source. Re-asserting the scroll patches the symptom after the fact.
Three properties follow, and each of them is why the alternative loses.
<webjs-suspense>(feat: <webjs-suspense> streaming SSR boundary + per-component streaming (follow-up to #469) #471, and progressive soft-nav streaming feat: progressive soft-nav streaming in the client router (follow-up to #469) #473).Verified, not assumed
Two probes were run before this was written down.
Probe 1, synthetic. A 4000px page, scrolled to 800, then 763px of content inserted above the viewport, run in all three Playwright engines:
All three engines implement scroll anchoring and all three honour
overflow-anchor: noneon the root scroller. There is no engine to carve out and no engine-specific code path needed.Probe 2, the real fix on the live site. An init script suppressed anchoring on the restore's
instantscroll write and released it after 1200ms or on user input, run against the unmodified production site at https://webjs.dev.Exact restoration at every offset, including through the revalidation's later shrink-and-regrow cycle.
Alternatives rejected
Re-assert the scroll after the revalidation settles. Rejected on the measurement. The position is already wrong at +65ms and the revalidation does not settle until roughly +300ms, so the reader would watch the wrong position for a quarter second and then be jumped. Worse, a single re-assert is not even a fix, because anchoring keeps acting afterwards. The measured revalidation swap at +294ms moves
scrollYfrom 1563 to 1239 and then back, so a re-assert landing between those two events would itself be undone. Making it correct would mean re-asserting on every height change, which is precisely the thing that cannot be distinguished from a<webjs-suspense>boundary streaming in. It also requires a correctness-critical user-scroll cancellation, where a bug yanks the viewport out from under someone.Defer the restore until a settle signal. This is what the one framework that gets this right actually does. Remix v3 (
remix/packages/ui/src/runtime/navigation.ts:81-94) hands the traverse case to the Navigation API'sintercept()and passes noscrolloption, so the UA defaultafter-transitionholds its own scroll restoration untilawait frame.reload()resolves. WebJs cannot copy it. The router is on the popstate path, not the Navigation API, and it already took control withhistory.scrollRestoration = 'manual'(packages/core/src/router-client.js:343), so there is no UA restoration left to defer. Deferring the router's own write instead would paint the destination at the outgoing page's scroll offset for two frames, which is the flash that Turbo, Next, and Astro all avoid by scrolling synchronously right after the swap.Record the snapshot's document height and re-scroll once the live document reaches it. Rejected because a revalidated page legitimately differs in height from its snapshot, which is the entire reason for revalidating. The condition would either never fire or fire by coincidence. It also violates the router's degradation philosophy, which is to fall back rather than guess.
Prior art, and why none of it already solved this
None of the four frameworks read handles late layout growth on a restore. Every one of them scrolls exactly once, synchronously after the swap, and lets the position drift if content grows.
packages/core/src/router-client.js:1235and:1248).turbo/src/core/drive/visit.js:333-343is the only scroll-restoration site in the drive layer, and its sole call site isvisit.js:402-407, immediately afterrenderPageresolves. A one-shotthis.scrolledlatch (visit.js:52, set at:341) means a second render never re-scrolls.grep -rni "overflow.anchor"over the whole Turbo repo returns nothing, and there is no user-scroll detection anywhere in the drive layer. Turbo has this bug.{x, y}intosessionStorage(next.js/packages/next/src/shared/lib/router/router.ts:948-966) and applies it once in the render commit (next.js/packages/next/src/client/index.tsx:758), with no deferral and no second pass. The App Router never assignshistory.scrollRestoration, leaving itautoand handing the whole job to the browser (next.js/packages/next/src/client/components/app-router.tsx:379-398, plusrestore-reducer.ts:52-53explicitly declining to force a scroll). Neither usesoverflow-anchor.<script>during HTML parsing (remix-v2/packages/remix-react/scroll-restoration.tsx:59-71), which is deliberately EARLIER than settle, plus a react-router layout effect gated on the navigation reachingidle. That waits for data to settle, never for layout.scrollToimmediately after a synchronous full-document swap (astro/packages/astro/src/transitions/router.ts:213-214). No re-measure, no re-assert, nooverflow-anchor.The repo sets
overflow-anchornowhere.grep -rn "overflow-anchor\|overflowAnchor" packages/ website/ examples/returns nothing, so the behaviour in play is the UA defaultautoand there is no existing app CSS for the fix to collide with.Scope: which navigation paths change
Only the popstate cache-hit branch. The reasoning for each other path, so nobody widens this for symmetry:
router-client.js:1389) scrolls totop: 0. At offset 0 there is no content above the viewport, so anchoring has nothing to compensate and cannot move the position. No change.router-client.js:2470, and the no-target hash branch at:2465) scrolls totop: 0. Same argument. No change.router-client.js:2464,t.scrollIntoView()) does land at a non-zero offset, but the target is an ELEMENT, not a replayed number. Anchoring holding the visual position of content around that element is the correct behaviour there. No change.The defect is specific to replaying a recorded post-growth offset onto a pre-growth document, and that happens in exactly one branch.
Implementation plan
All line anchors are against HEAD
ddfc5547.Step 1. Add the suppression helper to
packages/core/src/router-client.jsPlace it immediately after the
prevScrollRestorationdeclaration, which ends at L302, and beforeexport function enableClientRouter()at L305. That block is the existing house pattern for saving and restoring a browser scroll setting, so the new one belongs beside it.Current, L292-L302:
Insert after it:
Use
setProperty/getPropertyValue/removePropertyrather than thestyle.overflowAnchorcamelCase accessor. The camelCase form is not reliably modelled by linkedom, which backs the node test harness, and the property form is what the unit test in step 4 reads.Step 2. Open the window at the restore site
packages/core/src/router-client.js, the popstate cache-hit branch insideperformNavigation, currently L1367-L1381.Current:
After:
behavior: 'instant'is unchanged and must stay. It is load-bearing from #601, where an app-levelhtml { scroll-behavior: smooth }otherwise animates the restore, andpackages/core/test/routing/browser/nav-scroll-instant.test.jsguards it.Do not touch the cache-miss
window.scrollToat L1389, and do not touch eitherwindow.scrollToinfetchAndApplyat L2465 and L2470. See the scope reasoning in Design.Step 3. Close any open window when the router is disabled
packages/core/src/router-client.js,disableClientRouter(), currently L356-L375. The router must leave no residue on<html>.Current, L369-L374:
After:
Step 4. Node unit test
packages/core/test/routing/router-client.test.js. Add directly after the existingpopstate cache restore scrolls instantly, not animated (#601)test, which begins at L1998 and follows the same_snapshotCache.setplusglobalThis.locationstub plus_onPopState({})shape already used at L1982, L2029, L2717, L3129, and L3185.Assert three things:
_onPopState({}),document.documentElement.style.getPropertyValue('overflow-anchor')is'none'.''.disableClientRouter()while the window is open clears it.If linkedom does not model
overflow-anchorthroughsetProperty, do not weaken the assertion to a spy on the style object. Assert instead that the restore path called the suppression, by exportingsuppressScrollAnchoringthrough the test-seam export block at L4660-L4713 as_suppressScrollAnchoringand asserting the release contract directly, and let the browser test in step 5 carry the DOM-level proof. Check which one linkedom supports before writing the test rather than guessing.This is a unit test and it is necessary but NOT sufficient (AGENTS.md Code workflow item 1). The headline behaviour is a browser assertion.
Step 5. Browser test, the headline
New file:
packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js, next tonav-scroll-instant.test.js. Follow the house style inpackages/core/test/routing/browser/fetch-revalidates.test.jsfor thesuite()form, theimport { assert } from '../../../../../test/browser-assert.js'path, and the mandatoryinstallNavGuard()from../../../../../test/browser-nav-guard.js.The fixture MUST grow after the swap. A static fixture cannot reproduce this at all, because the growth is the whole mechanism.
Shape:
class GrowBlock extends HTMLElement { connectedCallback() { this.style.display = 'block'; this.style.height = '763px'; } }, registered once per file with a unique tag. As raw parsed markup it is 0px tall, and it takes its 763px on upgrade. That models the real cause exactly.<grow-block-…></grow-block-…><div style="height:3000px"></div>so the grower sits entirely above the viewport once scrolled._snapshotCache(exported as_snapshotCacheat L4685) with{ html, scrollX: 0, scrollY: 800 }wherehtmlis the same markup, so the restore lands a 0px-tall grower that then upgrades.location, which is impossible in a browser. Push two same-origin entries withhistory.pushState(null, '', location.pathname + '?wj=a')and then?wj=b, seed the cache under the?wj=akey, set_setCurrentPageUrl(exported at L4748) to the?wj=bURL, stubwindow.fetchto return the same HTML, then callhistory.back()and await the popstate.Assertions:
document.documentElement.style.getPropertyValue('overflow-anchor')is'none'.window.scrollYis still the restored 800 within a small tolerance, not 1563.''.wheelevent onwindowduring an open window closes it immediately, and the property is back to''.All three engines in the web-test-runner matrix (Chromium, Firefox, WebKit) implement scroll anchoring and honour
overflow-anchor: none, verified by the probe in Design. Do NOT add an engine skip.Restore
window.scrollTo(0, 0), the history entries,window.fetch, and the DOM inteardown(), and callnavGuard.remove().Step 6. E2E, point the existing block back at the gallery
test/e2e/form-submission-and-race.test.mjs, the blockscroll restoration: back-button restores window scroll positionat L373-L427. It was deliberately pointed at/docs/routingbecause of this bug, with an explanatory comment added in #1305. Part of this fix is undoing that.Exact changes:
// This one block runs against /docs rather than /uiand ending// assertion on the page where nothing else is moving the scroll.await page.goto(\${BASE}/docs/routing`);toawait page.goto(`${BASE}/ui/button`);`// Make sure the page is tall enoughblock). It exists only because a docs page might be short./ui/buttonmeasures 2902px, so it can never fire, and leaving it in implies the page might need it.page.locator('.docs-sidebar a:has-text("Components")').first()topage.locator('a[href="/ui/card"]').first(). Keep the in-page.evaluate((el) => el.click()), and keep the comment above it explaining why, since Playwright's own click would scroll the target into view and move the window first.location.pathname.endsWith('/components')tolocation.pathname.endsWith('/ui/card').location.pathname.endsWith('/routing')tolocation.pathname.endsWith('/ui/button').await page.waitForTimeout(80);with a wait long enough for the page to settle. 80ms is not enough here, and that is the point of the test. Measured on the live site, the growth lands at +65ms and the revalidation's swap at +294ms. Useawait page.waitForTimeout(1200);and replace the comment// Give the cached-restore path a frame to run.with one saying the page must be allowed to finish growing and revalidating, because a restore that is correct at 80ms and wrong at 1200ms is exactly the bug.Keep the
< 20tolerance at L423-L424 and thebeforeScroll >= 700precondition at L401-L402 unchanged.Verify locally against
localhost:5001(cd website && npm run dev), since the measurements above were taken against production and the local dev build must be confirmed to reproduce the same growth.Step 7. Bun parity
Not applicable, and here is the argument rather than silence.
packages/core/src/router-client.jsis browser-only client code. It is reached throughindex-browser.js, never runs on the server, and the change touches onlydocument.documentElement.style,windowevent listeners,setTimeout, andrequestAnimationFrame. None of those exist in either server runtime, and no runtime-sensitive surface is touched (no serializer, no listener or request path, no SSR or action or CSRF dispatch, no streams, nonode:crypto, no TS stripper, no auth or session or cors).The gate agrees.
.claude/hooks/require-bun-parity-with-runtime-src.shmatches staged paths againstserialize|/json\.js|file-storage|listener|ts-strip|action|render-server|/ssr\.js|conditional-get|websocket|node-version|csrf|/auth\.js|/session\.js|/cors\.js|crypto|compression|body-limit|/dev\.js|stream. The pathpackages/core/src/router-client.jsmatches none of them, so the hook does not fire andWEBJS_BUN_VERIFIED=1is not needed. Add nothing undertest/bun/**.Step 8. Counterfactual
Commit the fix first, then revert through git rather than a sed toggle.
Revert only step 2, the
releaseAnchor = suppressScrollAnchoring();line and the.then(() => afterTwoFrames(releaseAnchor))continuation in the popstate cache-hit branch. Leave steps 1 and 3 in place, so the revert isolates the call site rather than deleting the helper.Assertion B in
packages/core/test/routing/browser/nav-scroll-anchor-restore.test.jsgoes red, reporting ascrollYof roughly 1563 against an expected 800. Assertions A, C, and D go red too. Restore the revert and confirm green.Step 9. Validation before opening the PR
npm testfrom the repo root, for the node suite including step 4.npm run test:browser, for step 5 across Chromium, Firefox, and WebKit. Confirmnav-scroll-instant.test.jsstays green, since it guards thebehavior: 'instant'that dogfood: nav scroll restoration animates underscroll-behavior: smooth#601 established.cd website && npm run dev, thenWEBJS_E2E=1 node --test test/e2e/form-submission-and-race.test.mjs.webjs check, pluswebjs doctorforwebsiteandexamples/blog, since the requiredconventionsCI job runs it over both and fails on whatever theirwebjs.doctor.gatemarkserror(feat: let doctor gate CI without making every warning fatal #1257)./ui/<name>page restores the correct offset and does not flicker. The specific risk to watch for is thetouchmoverelease: a back-swipe gesture may firetouchmoveonwindowand close the restore window before the growth lands, which would leave the swipe path unfixed while the button path is fixed. If that happens, do NOT drop thetouchmoverelease. Report the observation on the PR and let the maintainer choose, because narrowing the release conditions trades a real user-takeover protection for this case.Tests
Every layer that applies, by path.
Unit, applies.
packages/core/test/routing/router-client.test.js, a new test after the existing #601 restore test at L1998. Covers the window opening on the restore, closing after the revalidation, and closing ondisableClientRouter(). Necessary but not sufficient.Browser, applies, and carries the headline. New file
packages/core/test/routing/browser/nav-scroll-anchor-restore.test.js, with a fixture that grows after the swap, driving a realhistory.back(). Four assertions, listed in step 5. Runs across Chromium, Firefox, and WebKit, all of which reproduce the bug.Browser, existing, must stay green.
packages/core/test/routing/browser/nav-scroll-instant.test.js. It guards thebehavior: 'instant'that this change must not disturb.E2E, applies.
test/e2e/form-submission-and-race.test.mjs, the block at L373-L427, repointed from/docs/routingto/ui/buttonwith its explanatory comment deleted. Exact edits in step 6.Bun parity, does NOT apply. Full argument in step 7. Browser-only client code, no runtime-sensitive surface touched, and the parity hook's path regex does not match
router-client.js.Smoke (
test/examples/*/smoke/*), does NOT apply. The smoke suites boot an app and assert served output. This defect is a post-hydration scroll behaviour in the browser, invisible to a served-HTML assertion.Counterfactual. Step 8 names the exact revert and the exact assertion that reds.
Docs
Two surfaces change. Both satisfy
.claude/hooks/require-docs-with-src.sh, soWEBJS_NO_DOC_GATE=1is NOT needed and must not be used..agents/skills/webjs/references/client-router-and-streaming.md, the snapshot-cache paragraph at L57, which today endsand scroll is restored on Back/Forward. Add a sentence stating that the router suppresses the browser's scroll anchoring for the duration of a Back/Forward restore, because the saved offset was recorded at the page's settled height and would otherwise be double counted as the restored page grows. Note that an app setting its ownoverflow-anchoron<html>sees it briefly overridden and then put back.website/app/docs/client-router/page.ts, the snapshot-restore paragraph at L224 and the scroll paragraph at L225. Add the same sentence in the docs-site voice, next to the existingbehavior: 'instant'explanation, since the two facts belong together and a reader debugging a restore will look there.No other surface changes.
AGENTS.md, client-navigation section. Its sentencescroll is restored on back/forwardstays true and the file is deliberately lean. No change.packages/cli/templates/.agents/contains onlyrules/workflow.md. There is nopackages/cli/templates/.agents/skills/webjs/references/directory.packages/cli/lib/create.js:666states the skill lives ONCE, canonically, at the repo-root.agents/skills/webjs/, so editing surface 1 above covers the scaffold. Do not go looking for a second copy.Acceptance criteria
/ui/card, and pressing Back returns to Sizeshtml { scroll-behavior: smooth }, andpackages/core/test/routing/browser/nav-scroll-instant.test.jsstays greenwheel,touchmove,keydown, orpointerdowncloses the window<webjs-suspense>boundary streaming in after the swap is not fought on any navigationdocument.documentElementcarries no leftoveroverflow-anchorafter any navigation, after the ceiling fires, or afterdisableClientRouter()overflow-anchoron<html>is put back rather than blankedpackages/core/test/routing/browser/nav-scroll-anchor-restore.test.jsreproduces the failure with a fixture that grows after the swap, and passes on Chromium, Firefox, and WebKitscrollYassertion at roughly 1563 against an expected 800test/e2e/form-submission-and-race.test.mjsruns against/ui/buttonand/ui/card, its#1305explanatory comment is gone, and it waits long enough for the page to settle/ui/<name>page restores the correct offset without flicker, and thetouchmoveinteraction is reported on the PRwebjs checkclean, andwebjs doctorclean forwebsiteandexamples/blogOut of scope
Do not widen into any of the following.
/ui/<name>markup lays out 763px short before its components upgrade. That is normal upgrade-time growth and is the condition this fix is designed to survive, not a defect to chase. Do not try to make the snapshot restore at its final height.webjs.scrollAnchoror awebjs:scroll-restoredevent.snapshotCurrent()records. It is correct, confirmed by measurement. Do not add a recorded document height, and do not switch to Turbo's per-scroll-event sampling.behavior: 'instant'. It is load-bearing from dogfood: nav scroll restoration animates underscroll-behavior: smooth#601.