Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2208d18
chore: add guarded on-device levers to A/B the iOS back-swipe blank
vivek7405 Aug 18, 2026
0868ed7
fix: guard the deferred back-swipe scroll on the navigation token
vivek7405 Aug 18, 2026
f24864e
test: abort the superseding navigation instead of leaving it in flight
vivek7405 Aug 18, 2026
1613eb1
chore: add a scrollRestoration lever for the back-swipe A/B
vivek7405 Aug 20, 2026
af024f5
docs: correct the phantom-entry claim in the raf lever guard
vivek7405 Aug 20, 2026
b3f2c13
fix: stop taking manual scroll restoration, so iOS previews the back-…
vivek7405 Aug 20, 2026
d35b015
docs: correct the scroll-restoration contract and the #1406 mechanism
vivek7405 Aug 20, 2026
441931d
chore: remove the back-swipe diagnostic levers
vivek7405 Aug 20, 2026
1ccb644
test: pin that a frame nav cannot scroll a restore in progress
vivek7405 Aug 20, 2026
c604588
refactor: reserve the restored height instead of chasing a clamped of…
vivek7405 Aug 20, 2026
d3f33a6
test: pin the restore against a browser-recorded offset
vivek7405 Aug 20, 2026
776a0ba
docs: describe the height reservation, and drop the chase it replaced
vivek7405 Aug 20, 2026
edf86d7
Merge remote-tracking branch 'origin/main' into fix/ios-back-swipe-sn…
vivek7405 Aug 20, 2026
38961e1
test: retarget the frame guard's rationale at #1429
vivek7405 Aug 20, 2026
389cec0
refactor: let the browser own the Back/Forward scroll restore
vivek7405 Aug 20, 2026
8f7be06
docs: the browser owns the Back/Forward restore
vivek7405 Aug 20, 2026
3abaece
Merge remote-tracking branch 'origin/main' into fix/ios-back-swipe-sn…
vivek7405 Aug 20, 2026
c901be4
test: wait for the restore to land before the reader interrupts it
vivek7405 Aug 20, 2026
4d9d960
test: scope the cyclic-array case to engines with a working join guard
vivek7405 Aug 20, 2026
3782e2f
fix: apply the review findings, and bound the restore write-back
vivek7405 Aug 20, 2026
f75984b
test: scope the other two cyclic-array cases for Bun 1.4.0
vivek7405 Aug 20, 2026
f3239b7
fix: put the app's scrollRestoration back, and make its guard discrim…
vivek7405 Aug 20, 2026
2bcc3e4
fix: defer the cache-miss re-assert past the UA's replay
vivek7405 Aug 20, 2026
61133fc
Merge remote-tracking branch 'origin/main' into fix/ios-back-swipe-sn…
vivek7405 Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,11 @@ It does NOT reload changed component modules and cannot: `customElements.define`

**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.
- **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 restore, which is the BROWSER's (see the next bullet) and which the router protects with a suppression window while the page settles. If Back lands in the wrong place, that is a framework bug to report, not something to patch in app code.
- **The BROWSER restores Back/Forward scroll, not the router, and an app must not set `history.scrollRestoration = 'manual'`** (#1428). The router FORCES `history.scrollRestoration` to `auto` on start (and puts the app's own value back on `disableClientRouter()`), so setting `'manual'` yourself does not take effect while the router runs. Under `auto` the browser records a scroll position per history entry, replays it on a traverse, and composes the iOS edge back-swipe GESTURE PREVIEW from that same recorded state. The router writes no scroll on a restore at all: it reserves the recorded height (below) so the browser's replay lands on a document that can hold the offset, and that is the whole mechanism. One writer, the same model Next and Remix 3 use. Taking `manual` suppresses the recording, so every scrolled page previews BLANK for the whole gesture. That is what the router itself used to do, inherited from Turbo Drive's `assumeControlOfScrollRestoration`, and it is why Turbo still previews blank the same way: Turbo is single-writer too, but the writer is the APP. An app that sets `manual` re-breaks the preview app-wide.
- **An app that sets `overflow-anchor` on `<html>` 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="<id>"` 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 on the offset being reachable, and follows the chase onto it.** 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, and more tightly than the window a landed restore gets: a few hundred milliseconds from the RESTORE, not the 2s ceiling, and the suppression it installs on landing shares that same deadline rather than starting a fresh one. 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. Anchoring is left on only WHILE the offset is out of reach, which is the part that heals the clamp. The moment the chase lands on the offset it suppresses anchoring too, because the growth that made the offset reachable is rarely all of it and every later stage would otherwise be added on top of what was just written. Both halves end together on the bound. After it, the router writes no more scroll and anchoring is back on, so a component that reaches its final height later than the bound (a chart, an embed measured from its content) has its growth added and the reader drifts BELOW the offset, the same way they would without this fix at all, rather than sitting at the clamp.
- **The recorded HEIGHT is reserved across the restore, so the offset is always reachable.** A snapshot records the page's settled `scrollHeight` alongside the offset, and the restore holds that height on the root element until the page has filled in. Without it the swapped-in markup is briefly shorter than the page it came from, the browser clamps the restore to whatever the short document allowed, and the reader lands short. The reservation removes that window rather than correcting for it afterwards, which is what retired the older catch-up that used to chase the offset as the page grew. It is released on the same settle that closes the anchoring window, on the same ceiling, and when another navigation supersedes the restore, but never on user input: releasing the height under a reader mid-scroll is the one harm an early release could do. An app's own inline `min-height` on the root is saved and put back, the same contract the anchoring window keeps.
- **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.
Expand Down
4 changes: 3 additions & 1 deletion .agents/skills/webjs/references/muscle-memory-gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,9 @@ Navigation is automatic. The client router auto-enables when `@webjsdev/core` lo

### No `<ScrollRestoration>`, and no scroll restore of your own

Remix ships a `<ScrollRestoration />` 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.
Remix ships a `<ScrollRestoration />` 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, and the BROWSER is what does it: the router reserves the page's recorded height across the swap so the browser's own per-entry replay lands correctly, and writes no scroll itself. 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.

**Do not set `history.scrollRestoration = 'manual'` either**, which is the one line most of those recipes start with. The browser only records a per-entry scroll offset under the default `auto`, and WebKit composes the iOS edge back-swipe gesture preview from that recording, so `manual` makes every scrolled page preview BLANK for the whole gesture (#1428). The router itself used to set it, inherited from Turbo Drive, and had the same bug. It no longer does.

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.

Expand Down
4 changes: 2 additions & 2 deletions blog/client-router-turbo-drive-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ The client router turns itself on as soon as `@webjsdev/core` loads, which any p
1. SSR injects `<!--wj:children:<segment-path>:<route-key>-->...<!--/wj:children:<segment-path>-->` comment markers around each layout's `${children}` interpolation, one pair per layout in the chain, plus one around the page itself (skipped when the page's segment would collide with the innermost layout's). The route key is the resolved path with param values filled in.
2. On link click, walk both the live DOM and the incoming HTML for these markers and build a path-to-range map.
3. Compare the two maps. A shared boundary whose route key CHANGED wins first, and the swap is anchored at the parent of the shallowest such change, which remounts that layout the way Next does. Only when no key changed does the deepest shared boundary become the target.
4. Record the history entry with `history.pushState`, then merge head tags, then apply the swap. The push leads on purpose: WebKit binds a same-document history entry's back-forward gesture snapshot to the page state at the moment the entry is recorded, so recording it after the content swap makes an iOS back-swipe preview the destination instead of the page being returned to. A replace then tears the live range out and inserts the incoming nodes, which is a real remount, and only elements marked `data-webjs-permanent` are carried across. A morph instead reconciles the two ranges with a keyed reconciler that preserves DOM identity, input values, scroll, and popover state; it is the more expensive path and it exists precisely to keep that state. Morphing is chosen only when the target boundary is the leaf on both sides and no route key changed.
4. Record the history entry with `history.pushState`, then merge head tags, then apply the swap. The push leads on purpose: the entry for the page being left is finalized while that page is still the live document, rather than against the content that replaced it. This is Turbo Drive's ordering too (`PageView.renderPage` calls `visit.changeHistory()` ahead of the render). A replace then tears the live range out and inserts the incoming nodes, which is a real remount, and only elements marked `data-webjs-permanent` are carried across. A morph instead reconciles the two ranges with a keyed reconciler that preserves DOM identity, input values, scroll, and popover state; it is the more expensive path and it exists precisely to keep that state. Morphing is chosen only when the target boundary is the leaf on both sides and no route key changed.
5. Re-run the scripts the swap brought in and upgrade custom elements.

The whole loop runs in a microtask. The body never repaints between pages.
Expand Down Expand Up @@ -97,7 +97,7 @@ lit ships no built-in router. You bring your own (vaadin-router, lit-router, etc

Stencil ships a router closer in spirit to WebJs's, but it does not have the layouts-stay-mounted optimization. Every navigation re-mounts the full component tree.

Hotwire's Turbo Drive is the closest precedent. Same DOM-swap philosophy, same scroll-restoration logic, similar form integration. WebJs's version is written from scratch in plain JavaScript with JSDoc types, like the rest of the framework packages, and it is web-component aware (it walks `composedPath()` for shadow-DOM-piercing link detection), but the design borrows heavily.
Hotwire's Turbo Drive is the closest precedent. Same DOM-swap philosophy, closely related scroll-restoration logic, similar form integration. One deliberate divergence: Turbo takes `history.scrollRestoration = 'manual'` to stop the browser's own restore racing its snapshot restore, and WebJs did too until that turned out to suppress the per-entry scroll recording WebKit composes its back-swipe gesture preview from. WebJs now leaves the mode alone and absorbs the browser's late restore inside its own restore window instead. WebJs's version is written from scratch in plain JavaScript with JSDoc types, like the rest of the framework packages, and it is web-component aware (it walks `composedPath()` for shadow-DOM-piercing link detection), but the design borrows heavily.


# Why I shipped this in core
Expand Down
31 changes: 25 additions & 6 deletions packages/core/src/router-client/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,19 @@ export const STREAM_MIME = 'text/vnd.webjs-stream.html';
* 5. Commit, in this order: `history.pushState`, then merge head, then apply
* the replace/morph from step 3, then re-run scripts and upgrade custom
* elements. Only the push moved (#1406); the rest of the sequence is
* unchanged. The push leads because
* WebKit binds a same-document entry's
* back-forward gesture snapshot to the page state when the entry is
* recorded, so recording it after the content swap makes an iOS
* back-swipe preview the destination instead of the page being returned
* to (#1406).
* unchanged. The push leads so the entry for the page being left is
* finalized while that page is still the live document, rather than
* against the content that replaced it. This is Turbo Drive's ordering
* too.
*
* #1406 justified the move as the fix for the blank iOS back-swipe
* preview, on the theory that WebKit binds the gesture snapshot to page
* state at the moment the entry is recorded. That theory is FALSE, shown
* on-device in #1428: Turbo Drive has this exact ordering and previews
* blank the same way, and the real cause was
* `history.scrollRestoration = 'manual'` suppressing the browser's
* per-entry scroll recording. The ordering stands on its own merits;
* it just never fixed that bug.
*
* Optimizations bundled into the same response cycle:
* - `X-Webjs-Have` request header lists `segment:route-key` entries for
Expand Down Expand Up @@ -185,3 +192,15 @@ export const META_KEY_CSP_NONCE = 'name=csp-nonce';
export function _isNonHtmlPath(pathname) {
return NON_HTML_EXTENSIONS.test(pathname);
}

/**
* How long the restore window's write-back stays armed (#1428).
*
* It exists to reconcile ONE event, the browser's replay of its own recorded
* scroll offset, which lands about a frame after the popstate handler. This is
* generous for that (roughly fifteen frames at 60Hz) and far short of the
* window's own life, so a legitimate programmatic scroll arriving later, a
* component's `scrollIntoView()` during upgrade or a find-in-page jump, is left
* alone rather than reverted.
*/
export const WRITE_BACK_ARMED_MS = 250;
32 changes: 20 additions & 12 deletions packages/core/src/router-client/fetch-apply.js
Original file line number Diff line number Diff line change
Expand Up @@ -272,13 +272,18 @@ export async function fetchAndApply(href, frameId, recordHistory, optimisticStat
// recover in place rather than a destructive full reload.
if (!doc) { restoreOptimistic(optimisticState); handleNavigationError(href, null, new Error('navigation response did not parse as HTML')); return { ok: false, status: respStatus, aborted: false, applied: false }; }

// #1406: the history entry this navigation records is what the iOS back-swipe
// gesture later previews, and WebKit binds a same-document entry's snapshot to
// the page state at the moment the entry is recorded. Recording it after the
// swap finalizes it against the DESTINATION document at a scroll offset the
// browser has already clamped to that document's height (measured on the
// gallery: 1600 to 252), so the gesture previews a page that never existed and
// renders blank. So the push rides into `applySwap` as a COMMIT-time callback
// #1406: recording the push after the swap finalizes the outgoing page's
// entry against the DESTINATION document, at a scroll offset the browser has
// already clamped to that document's height (measured on the gallery: 1600 to
// 252). Ordering it ahead of the mutation keeps the entry tied to the page it
// belongs to.
//
// #1406 also claimed this fixed the blank iOS back-swipe preview, via WebKit
// binding the gesture snapshot at the moment the entry is recorded. That is
// FALSE (#1428, on-device): Turbo Drive uses this same ordering and previews
// blank too, and the cause was `history.scrollRestoration = 'manual'`
// suppressing per-entry scroll recording. The ordering is kept on its own
// merits. So the push rides into `applySwap` as a COMMIT-time callback
// and fires ahead of the mutation, which is Turbo Drive's ordering
// (`PageView.renderPage` calls `visit.changeHistory()` ahead of
// `this.render(renderer)`).
Expand Down Expand Up @@ -335,11 +340,14 @@ export async function fetchAndApply(href, frameId, recordHistory, optimisticStat
// Scroll only for foreground (history-recording) navigations. When
// `recordHistory` is false we're either:
// (a) the background revalidation after a cached popstate restore
// - performNavigation already set scroll from the cached
// position; we must NOT clobber it here.
// (b) a cache-miss popstate: modern browsers fire scroll-
// restoration themselves before dispatching popstate, so
// leaving scroll alone preserves the browser-native UX.
// - the restore is already settled (the browser replayed the
// offset and the restore window is guarding it); a write here
// would land on top of it.
// (b) a cache-miss popstate: the browser performs its own restore
// for the entry, so leaving scroll alone preserves the
// browser-native UX. Note the UA's replay lands a frame AFTER
// the popstate handler, not before it (#1428, measured), which
// is the ordering the restore window is built around.
//
// And never for a FRAME-scoped response (#1427). `recordHistory` means "a
// foreground navigation the reader initiated", which a frame click is (it
Expand Down
Loading
Loading