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
Line anchors re-derived at HEAD 105372de. Every anchor in the previous
version of this body pointed into the pre-split monolith packages/core/src/router-client.js. #1365 (landed in 105372de) BARRELLED
that file: it is now 130 lines of re-exports and every line cited before lives
in a different file under packages/core/src/router-client/. The table in
"Where the code actually is" maps each old anchor to its real file and its
real current line. Re-verify before editing if main has moved.
Problem
On a real iPhone, the interactive edge back-swipe gesture renders a blank page for the whole duration of the animation when navigating back to a page the reader had scrolled. The back BUTTON is fine, and so is WebJs's own scroll restoration. It is iOS-only (WebKit), and it is what a reader sees on gallery.webjs.dev every time they scroll the index, open a demo, and swipe back.
Reproduced live on gallery.webjs.dev:
Scroll the gallery index down, tap a feature card, swipe back: the preview is blank for the whole gesture.
Open /features/client-router (short page, reader at the top), tap "Go to page two", swipe back: the preview shows the destination correctly.
The variable is the scroll offset at navigation time, not the route shape. Two on-device A/B runs confirmed it: tapping a first-row card from an UNSCROLLED index gives a correct preview, and scrolling a long demo page to the bottom before tapping a link to a shorter page reproduces the blank preview inside /features/*, where the swap is a morph rather than a replace.
This supersedes the guess in #641 (closed, no fix commit, same symptom). That issue attributed the blank to popstate paint timing on the RESTORE side and proposed a requestAnimationFrame lever that was written and reverted. The measurements below put the cause on the FORWARD navigation instead: the history entry the gesture later previews is recorded after the page it belongs to is already gone.
The mechanism
The gesture preview is WebKit's back-forward snapshot for the entry being returned to. For a same-document (pushState) entry, that snapshot is bound to the page state at the time the entry is recorded. WebJs records it too late.
fetchAndApply in packages/core/src/router-client/fetch-apply.js runs, in this order:
applySwap(doc, ...) (fetch-apply.js:261): the outgoing page's DOM is replaced by the incoming one.
history.pushState(null, '', finalUrl) (fetch-apply.js:284): the outgoing URL's entry is finalized.
window.scrollTo({ left: 0, top: 0, behavior: 'instant' }) (fetch-apply.js:304 / fetch-apply.js:309): scroll-to-top for the forward nav.
Between 1 and 2 the document is the DESTINATION while the scroll offset is still the SOURCE's, so the browser clamps that offset against the shorter incoming document before the entry is ever recorded. Instrumented on the live gallery by wrapping history.pushState and reading layout at the call:
navigation
tier
at pushState
/features/client-router to /second
morph (nodes preserved)
scrollY 0, nothing observably changed
/ at scrollY 1600 to /features/websockets
replace (main's children all new)
scrollY already clamped 1600 to 252, DOM already the short destination
The second row is the defect: the entry for / is finalized against a document that is not /, at an offset that means nothing in /'s layout. Case one hides it because there is nothing to get wrong (same nodes, offset 0, near-identical shells), which is exactly why the bug reads as route-specific and is not.
WebJs's own back/forward cache is unaffected and stays correct, which is why the back BUTTON works: snapshotCurrent(currentPageUrl) runs early in performNavigation (packages/core/src/router-client/navigator.js:399), before the fetch and before the swap, so it records / at 1600 properly.
Where the code actually is (old anchor to new anchor)
Previous body cited
Real file at 105372de
Real line
What it is
router-client.js:3167
packages/core/src/router-client/fetch-apply.js
261
the applySwap(...) call
router-client.js:3190
packages/core/src/router-client/fetch-apply.js
284
if (recordHistory) history.pushState(null, '', finalUrl);
router-client.js:3200-3216
packages/core/src/router-client/fetch-apply.js
294-311
the post-swap scroll block
router-client.js:3165
packages/core/src/router-client/fetch-apply.js
255-259
const doc = parseHTML(html); plus its null guard
router-client.js:3082
packages/core/src/router-client/fetch-apply.js
176
if (resp.redirected && resp.url) finalUrl = resp.url;
router-client.js:3089-3093
packages/core/src/router-client/fetch-apply.js
183-188
the 204 / 205 branch (pushes with no swap)
router-client.js:3185
packages/core/src/router-client/fetch-apply.js
279-282
if (disposition === 'discard')
router-client.js:3229
packages/core/src/router-client/swap.js
249, 311, 405
the three _swapCommit = runWithTransition(...) sites
router-client.js:2026
packages/core/src/router-client/navigator.js
423-505
the popstate cached-restore branch
router-client.js:1803
packages/core/src/router-client/snapshot-cache.js
27
snapshotCurrent
router-client.js:1936
packages/core/src/router-client/navigator.js
399
its call site in performNavigation
router-client.js:3858 / :3865
packages/core/src/router-client/swap.js
185, 192
the two deploy-mismatch hardNavigate(href) calls
Three corrections to the previous body, all found by re-deriving the anchors.
There is a THIRDhardNavigate(href) that returns 'none', at swap.js:350, in the integrity-degradation branch (a poisoned or disjoint boundary scan). The previous body counted only the two deploy-mismatch ones. It matters because it is on the same footing as the other two for the 'none' question below.
The previous body assumed the push happens only where a swap committed, and worried a reorder would newly record an entry on the hard-navigation paths. It already does. The fall-through is deliberate and documented in place at fetch-apply.js:267-273 ("These paths used to fall through to the history push, the scroll block, and the streaming tail, and an early return would silently change all three"). So 'none' already records an entry AND then hard-navigates, today, on all three sites. The design below preserves that byte for byte, which dissolves the open question rather than trading it off.
The view-transition path was flagged as needing a check. It does not need a fix. See "What the reorder does under a view transition".
Everything else in the previous body verified as still accurate. snapshotCurrent and its call site are correct and must not move. The 204 / 205 branch at fetch-apply.js:183-188 pushes with no swap at all and needs no change. disposition === 'discard' is reachable only from the revalidation path, which passes recordHistory: false (navigator.js:520), so it is unaffected.
Design / approach
Settled decision: record the history entry at the SWAP COMMIT, immediately before the DOM mutation, by passing the push into applySwap as a commit-time callback. The callback is idempotent and fetchAndApply still calls it on the fall-through, so every non-committing path behaves exactly as it does today.
Concretely, fetchAndApply builds a one-shot thunk and hands it to applySwap. applySwap invokes it at each of its four commit points, ahead of addNewHeadElements and ahead of runWithTransition. fetchAndApply then calls the same thunk on the way out, where it is a no-op if the swap already fired it and the today-behaviour if it did not.
Why this shape and not the simpler line move
Moving the single history.pushState line from fetch-apply.js:284 up to just before fetch-apply.js:261 would fix the measured defect, and it was the previous body's proposal. It is rejected because it silently changes three paths that are documented as deliberately unchanged. On a deploy mismatch, an integrity degradation, or a frame-missing response, applySwap returns 'none' after doing work (hardNavigate at swap.js:185, swap.js:192, swap.js:350, and a webjs:frame-missing dispatch at swap.js:266-275). A blanket push ahead of the call records the entry BEFORE those decisions instead of after them, which is a different history state on a path nobody measured. The commit-callback keeps the ordering change scoped to exactly the case the bug is about.
It also costs nothing in complexity, because the file already has this exact shape for the same exact reason. applySwap's ingestSeeds (swap.js:67-72) is a lazily-invoked thunk called at each commit point, with the comment "Called at each COMMIT point rather than once up front, because this function can still decide to throw the response away after parsing it". The history push has the identical constraint. Adding a second thunk beside the first is DRY on knowledge, not a new mechanism.
Prior art
Turbo Drive is the precedent, and it is exact.~/Documents/Projects/frameworks/turbo/src/core/drive/page_view.js:18-30:
Two things to read off it. Turbo calls changeHistory() BEFORE this.render(renderer), which is the ordering this issue is about. And Turbo gates it on renderer.shouldRender, skipping the history update entirely when the renderer declines and the visit degrades to a full page load, which is the same question 'none' raises here. Visit.changeHistory() itself (turbo/src/core/drive/visit.js:155-162) is one-shot, guarded by this.historyChanged, which is where the idempotent thunk comes from.
Next.js is directionally similar and not transferable. It records the entry from a useInsertionEffect inside HistoryUpdater (~/Documents/Projects/frameworks/next.js/packages/next/src/client/components/app-router.tsx:63-98), so the push happens as part of the React commit rather than after the swapped DOM has settled. The instinct matches, but the ordering is bound to React's commit phases, which WebJs has no analogue for, so it yields no rule to copy. Turbo is the precedent this follows.
What the reorder does under a view transition
Nothing. The view-transition path is already correct, and the implementer should not go hunting there.
runWithTransition (packages/core/src/router-client/view-transition.js:49-73) hands the swap thunk to document.startViewTransition, which defers the DOM mutation by a frame and returns immediately. So today, on the view-transition path, applySwap returns while the outgoing DOM is still live, and fetch-apply.js:284 pushes BEFORE the mutation. That is already the ordering this issue asks for. Moving the push to the commit point moves it slightly earlier on that path (from just after runWithTransition returned to just before it is called) and changes no observable ordering relative to the DOM mutation.
This is also corroborating evidence for the diagnosis rather than an inconvenience: the one path that already records history ahead of the mutation is the one path this class of defect should not be reachable on.
The popstate restore under a view transition (navigator.js:470-492) is a separate mechanism and is untouched. It records no history at all (the browser already did), so it never receives a thunk.
Rejected alternatives
Move the pushState line ahead of applySwap. Rejected above: it changes the 'none' paths that fetch-apply.js:267-273 documents as deliberately unchanged.
Return early on 'none' and skip the push (full Turbo shouldRender parity). Rejected. It is a defensible design and Turbo's, but it is a SECOND behaviour change riding a one-line ordering fix, on paths (deploy mismatch, poisoned boundary, missing frame) that no measurement in this issue covers. WebJs has no back-compat burden, so this can be revisited on its own evidence later. It is not this issue's scope.
Add the JSDoc param alongside the existing @param block that ends at swap.js:49:
* @param{(()=>void)|null}[recordHistoryNow]Recordthisnavigation's*historyentry(#1406).CalledateachCOMMITpoint,immediatelyBEFOREthe*DOMmutation,forthesamereason`ingestSeeds`is: thisfunctioncanstill*decidetothrowtheresponseawayafterparsingit,anda`pushState`*issuedforaswapthatneverhappenedisworsethanalateone.Itmustrun*whiletheOUTGOINGpageisstillintheDOMandstillholdsitsscroll*offset,becauseWebKitbindsasame-documententry's back-forward gesture
*snapshottothepagestateatthemomenttheentryisrecorded,anda*snapshottakenagainsttheincoming(oftenshorter)documentpreviews*blank.Thecaller's thunk is one-shot, so it is safe to call here AND on
*thecaller's fall-through. Omitted or null on every path that records no
*history(arevalidation,arefresh,thepopstaterestore).
1b. Four call sites, one per commit point. Each is a single new line. There is no other change to this file.
Frame swap, swap.js:237. Today:
if(target&&source){// ADD-ONLY head merge: preserve runtime-generated head content
After (the push goes inside the target && source block, so the frame-MISSING outcome keeps falling through to the caller's tail exactly as today):
if(target&&source){// #1406: record at the commit, before any mutation, including the head// merge below (a stylesheet it adds can change layout height).if(recordHistoryNow)recordHistoryNow();// ADD-ONLY head merge: preserve runtime-generated head content
(A refresh always passes recordHistory: false, so this is a no-op in practice. It is written anyway so the invariant "the push happens at the commit" holds at every commit point without a caller having to reason about reachability.)
Boundary plan, the main path, swap.js:303-310. Today:
if(plan){// Committed: this response is being applied, so its seeds are the ones the// user is about to look at.ingestSeeds();const{ mode, live, incoming }=plan;// ADD-ONLY head merge: the outer layout stays mounted, so its head-bound// runtime state (Tailwind injection, etc.) must not be invalidated.addNewHeadElements(doc.head);
After:
if(plan){// Committed: this response is being applied, so its seeds are the ones the// user is about to look at.ingestSeeds();// #1406: and the history entry is recorded here, while the outgoing page is// still in the DOM at its own scroll offset. This is the path the iOS// back-swipe defect was measured on.if(recordHistoryNow)recordHistoryNow();const{ mode, live, incoming }=plan;// ADD-ONLY head merge: the outer layout stays mounted, so its head-bound// runtime state (Tailwind injection, etc.) must not be invalidated.addNewHeadElements(doc.head);
Full-body tail, swap.js:364-368. Today:
ingestSeeds();// committed: past both discard branches aboveswapFullBody(doc);}
After:
ingestSeeds();// committed: past both discard branches aboveif(recordHistoryNow)recordHistoryNow();swapFullBody(doc);}
(Unreachable from fetchAndApply, which always passes a non-null href, so a foreground nav that gets this far has already taken the hardNavigate at swap.js:350 or the 'discard' at swap.js:361. It is reached only by the popstate restore at navigator.js:428, which passes no thunk. Written for the same uniformity reason as the shell case.)
Step 2: build the one-shot thunk in fetchAndApply and pass it
constdoc=parseHTML(html);// The body claimed text/html but didn't parse into a document (a// malformed/empty HTML body). Surface a navigation-error so the app can// recover in place rather than a destructive full reload.if(!doc){restoreOptimistic(optimisticState);handleNavigationError(href,null,newError('navigation response did not parse as HTML'));return{ok: false,status: respStatus,aborted: false,applied: false};}constdisposition=applySwap(doc,frameId,!!revalidating,finalUrl,incomingBuild,incomingSrc,refresh);
After. Insert the thunk between the parseHTML null guard and the applySwap call, pass it as the eighth argument, and replace the standalone push at fetch-apply.js:284 with a call to the same thunk:
constdoc=parseHTML(html);// The body claimed text/html but didn't parse into a document (a// malformed/empty HTML body). Surface a navigation-error so the app can// recover in place rather than a destructive full reload.if(!doc){restoreOptimistic(optimisticState);handleNavigationError(href,null,newError('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// and fires while the outgoing page is still live, which is Turbo Drive's// ordering (`PageView.renderPage` calls `visit.changeHistory()` ahead of// `this.render(renderer)`).//// One-shot, exactly like Turbo's `historyChanged` guard, so calling it here// AND on the fall-through below is safe. The fall-through is required, not// belt-and-braces: `applySwap` can return `'none'` after hard-navigating or// after a `webjs:frame-missing` dispatch, and those paths record history today// (see the `applied` comment below). Keeping the tail call preserves them byte// for byte, so this commit changes the ORDER on committing swaps and nothing// else.lethistoryRecorded=false;constrecordHistoryNow=recordHistory
? ()=>{if(historyRecorded)return;historyRecorded=true;history.pushState(null,'',finalUrl);}
: null;constdisposition=applySwap(doc,frameId,!!revalidating,finalUrl,incomingBuild,incomingSrc,refresh,recordHistoryNow);
and, at what is currently fetch-apply.js:284:
if(recordHistoryNow)recordHistoryNow();
Do not touch anything else in this function. In particular:
The scroll block (fetch-apply.js:294-311) STAYS where it is, after the swap. It must measure the incoming page, and the hash-anchor branch's document.getElementById(url.hash.slice(1)) cannot resolve until the swap has run.
The 204 / 205 branch (fetch-apply.js:183-188) keeps its own inline history.pushState. It never reaches applySwap and swaps nothing, so it has no commit point to hang a callback on.
The 'discard' early return (fetch-apply.js:279-282) is unchanged. It returns before the tail call, as today, and its path always has recordHistory: false anyway.
finalUrl is fully resolved before the thunk is built: the resp.redirected reassignment is at fetch-apply.js:176, and the prefetch fast path sets it at fetch-apply.js:107.
Step 3: nothing to change in navigator.js or snapshot-cache.js
snapshotCurrent (snapshot-cache.js:27) and its two call sites (navigator.js:399, navigator.js:623) are already correct and MUST NOT move: they run before the fetch and before the swap, which is why the back BUTTON already works. The popstate cached-restore branch (navigator.js:423-505) calls applySwap with four arguments and therefore passes undefined for the new parameter, which is exactly right (the browser already moved history). The #1310 machinery (suppressScrollAnchoring, catchUpToRestoredScroll, the restoreGeneration counter in packages/core/src/router-client/scroll.js) is untouched.
Step 4: rebuild the core bundle before the e2e and Bun runs
packages/core/dist is built, not committed, and e2e resolves the BUILT bundle, so a src-only edit is invisible to it and a counterfactual passes vacuously:
npm run build:dist --workspace=@webjsdev/core
Tests
Every path below is verified to exist at 105372de.
Browser (headline, mandatory)
New file:packages/core/test/routing/browser/nav-history-before-swap.test.js
Named to sit beside its two siblings nav-scroll-instant.test.js and nav-scroll-anchor-restore.test.js, which already cover the neighbouring scroll behaviour on the same surface. Follow nav-scroll-instant.test.js for the file shape: it imports enableClientRouter, navigate from ../../../src/router-client.js, assert from ../../../../../test/browser-assert.js, and drives a nav with a stubbed window.fetch.
Install the shared nav guard from test/browser-nav-guard.js (installNavGuard()), per packages/core/AGENTS.md. This test does not click an anchor, but applySwap can still degrade to a hard navigation, and an escaped location.href assignment aborts the entire web-test-runner session rather than failing one test.
The assertion. Wrap history.pushState, and at the moment it fires for a forward navigation, record BOTH:
document.body.textContent still contains the OUTGOING page's sentinel text and does NOT contain the incoming one.
window.scrollY still holds the outgoing offset (at least the value it was scrolled to, within a small tolerance).
Fixture shape that makes both halves real:
Outgoing body: a keyed boundary pair (<!--wj:children:/:/--> ... <!--/wj:children:/-->) wrapping a sentinel string plus a block with an explicit tall height (3000px or more), so the document genuinely scrolls.
Stubbed fetch returns a SHORT incoming document with the same boundary segment, a different sentinel, and no tall block, plus content-type: text/html and x-webjs-build: '' (matching the sibling file's stub, so the importmap guard sees an unknown build id and does not hard-reload).
Restore history.pushState, window.fetch, the body, and the scroll in a finally, exactly as the sibling files do.
The counterfactual. Both halves fail on the current ordering, and the implementer must SEE them fail before landing the fix. The procedure: stash the two source edits (git stash push packages/core/src/router-client/swap.js packages/core/src/router-client/fetch-apply.js), run npm run test:browser, confirm the new test reports the incoming sentinel present and scrollY clamped near the short document's maximum rather than near 800, then git stash pop. Note both observed numbers in the PR body. If either half passes with the fix reverted, the fixture is not exercising the replace tier and must be made taller or more divergent before the test is trusted.
Also add, in the same file, a second test that the push happens EXACTLY ONCE per navigation (count the wrapped calls). This is the guard on the idempotent thunk, and it is the failure mode a future edit to either call site would introduce.
Unit
Existing file to extend:packages/core/test/routing/router-client.test.js (linkedom, _applySwap already imported and exercised at router-client.test.js:1326 and :4252).
Three assertions, calling _applySwap directly with the new eighth argument:
Committing swap fires the callback before the mutation. Build a live body and an incoming doc that share a boundary segment, pass a thunk that records document.body.textContent at call time, and assert the recorded text is the OUTGOING content. This is the ordering invariant in a form that does not need a real browser.
A non-committing swap does not fire it. Two cases, both currently returning 'none'. Frame-missing (swap.js:275): pass frameId for a frame that is in neither tree, assert the thunk was never called and the return is 'none'. Integrity degradation (swap.js:350): pass an incoming doc whose boundaries share no segment with the live body, with _setHardNavigate installed as a recorder (the seam at packages/core/src/router-client/state.js:51), assert the thunk was never called, the return is 'none', and the hard navigation was recorded.
The caller's fall-through still records on those paths. Drive navigate() against a stubbed fetch that produces a disjoint document, with _setHardNavigate recording, and assert history.pushState was still called once. This is the assertion that proves the deliberate fall-through at fetch-apply.js:267-273 was preserved and that the 'none' decision in "Design / approach" actually holds in code.
e2e
Existing file to extend:test/e2e/form-submission-and-race.test.mjs, which already holds test('scroll restoration: back-button restores window scroll position', ...) at line 429. That test is the #1310 guard (it scrolls /ui/button to 800, navigates to /ui/card with an in-page click, goes Back, waits 1200ms for the restored page to finish growing and revalidating, and asserts the restored offset is within 20px).
Do not rewrite it. Run it and confirm it still passes, and add ONE sibling test in the same file: a scrolled-source forward navigation that asserts, via a page-side history.pushState wrapper installed before the click, that at the push the document still held the outgoing route's marker. Same fixture and same in-page click technique as the existing test (Playwright's own click scrolls the target into view first, which would move the window before the router recorded its position and invalidate the measurement).
Run with WEBJS_E2E=1, after Step 4's dist rebuild.
Smoke
Does NOT apply. test/examples/*/smoke/* boots an app and asserts on served bytes. This change alters no SSR output and no served source, only the order of two client-side operations inside one navigation, which a smoke test cannot observe.
Bun
No new test/bun/** file, and none is warranted. Stated explicitly so the implementer does not invent one.
The changed files are packages/core/src/router-client/swap.js and packages/core/src/router-client/fetch-apply.js. That module tree is browser-only: it touches document, window, history, and location, and it never executes under Bun or under Node as a server. There is no cross-runtime behaviour to assert, and a test/bun script that booted a server and asserted on SSR bytes would be asserting something this change cannot affect.
The .claude/hooks/require-bun-parity-with-runtime-src.sh gate does not fire on these paths (verified: its second filter matches serialize|/json\.js|file-storage|listener|ts-strip|action|render-server|/ssr[./]|conditional-get|websocket|node-version|csrf|/auth\.js|/session\.js|/cors\.js|crypto|compression|body-limit|/dev[./]|stream, and neither router-client/swap.js nor router-client/fetch-apply.js contains any of those substrings). If it fires anyway on a later main, commit with WEBJS_BUN_VERIFIED=1 and put the reason above in the commit body.
Still run the matrix and report it green, because the dist rebuild in Step 4 is a real input to it:
node scripts/run-bun-tests.js
The nearest existing cross-runtime coverage of the surface this reorder depends on is test/bun/keyed-boundaries.mjs (and its Node wrapper test/bun/keyed-boundaries.test.mjs), which proves the keyed children-boundary SSR emission (<!--wj:children:<segment>:<route-key>-->) is byte-identical on both runtimes. That emission is what collectBoundaries and planBoundarySwap pair on to reach the commit point this change hooks. It needs no edit, and it must stay green.
Full command list
npm test# node unit, incl. router-client.test.js
npm run test:browser # the headline assertion
npm run build:dist --workspace=@webjsdev/core # BEFORE e2e and bun
WEBJS_E2E=1 node --test test/e2e/form-submission-and-race.test.mjs
node scripts/run-bun-tests.js
( cd gallery && npx webjs check ) # the app the defect was measured on
Docs
No doc surface changes, and this is a deliberate finding rather than a skipped step.
The observable behaviour is identical on every platform a doc could describe. The URL advances at the same point in the navigation from the app's perspective, the same single entry is recorded, webjs:navigate fires at the same place (fetch-apply.js:331), the scroll behaviour is untouched, and no export, option, config key, or event changes. The only difference is the microsecond-scale ordering of history.pushState against the DOM mutation, and the user-visible consequence is a WebKit gesture preview that stops rendering blank. Nothing in website/app/docs/client-router/page.ts, .agents/skills/webjs/references/client-router-and-streaming.md, or the scaffold copy at packages/cli/templates/.agents/skills/webjs/references/ makes a claim this contradicts or leaves out.
.claude/hooks/require-docs-with-src.sh BLOCKS a commit staging packages/*/src source with no doc surface alongside it, so commit with:
WEBJS_NO_DOC_GATE=1 git commit ...
and put this justification in the commit body: an ordering fix inside the client router with an unchanged public surface and unchanged observable behaviour, whose only effect is on a WebKit back-forward gesture snapshot.
The one thing that DOES belong in writing is the ordering constraint itself, and it belongs in the code, not the docs: the JSDoc block in Step 1a and the comment block in Step 2 are the record, placed where the next person to touch either call site will read them.
Acceptance criteria
On a real iOS device, swiping back to a page that was scrolled shows that page in the gesture preview, not a blank one
Reproduces as fixed on gallery.webjs.dev: scroll the index, open a demo, swipe back
Both on-device A/B cases from the Problem section re-checked: the scrolled index card, and the long-demo-to-short-page nav inside /features/*
Exactly one history entry per navigation, on the committing paths AND on the three 'none' paths (deploy mismatch swap.js:185 / swap.js:192, integrity degradation swap.js:350, frame-missing swap.js:275)
packages/core/test/routing/browser/nav-history-before-swap.test.js asserts the outgoing DOM and outgoing scrollY are both still live at the pushState call
That test is verified to FAIL with the source change reverted, and both observed values (incoming sentinel present, clamped scrollY) are recorded in the PR body
packages/core/test/routing/router-client.test.js asserts the callback fires before the mutation on a committing swap, does not fire on 'none', and that the caller's fall-through still records history there
packages/core/dist rebuilt before the e2e and Bun runs
npm test, npm run test:browser, the e2e file, and node scripts/run-bun-tests.js all reported green in the PR body
( cd gallery && npx webjs check ) clean
The commit body carries the WEBJS_NO_DOC_GATE=1 justification
Full Turbo shouldRender parity (returning early on 'none' and skipping the push). Discussed and rejected under "Rejected alternatives". A separate behaviour change on paths this issue has no measurement for.
Renaming or further splitting anything under packages/core/src/router-client/. The barrel keeps the original path on purpose (package.jsonexports maps ./client-router at ./src/router-client.js, and 32 test files import it relatively). This PR adds one parameter and five lines.
Problem
On a real iPhone, the interactive edge back-swipe gesture renders a blank page for the whole duration of the animation when navigating back to a page the reader had scrolled. The back BUTTON is fine, and so is WebJs's own scroll restoration. It is iOS-only (WebKit), and it is what a reader sees on
gallery.webjs.devevery time they scroll the index, open a demo, and swipe back.Reproduced live on
gallery.webjs.dev:/features/client-router(short page, reader at the top), tap "Go to page two", swipe back: the preview shows the destination correctly.The variable is the scroll offset at navigation time, not the route shape. Two on-device A/B runs confirmed it: tapping a first-row card from an UNSCROLLED index gives a correct preview, and scrolling a long demo page to the bottom before tapping a link to a shorter page reproduces the blank preview inside
/features/*, where the swap is a morph rather than a replace.This supersedes the guess in #641 (closed, no fix commit, same symptom). That issue attributed the blank to popstate paint timing on the RESTORE side and proposed a
requestAnimationFramelever that was written and reverted. The measurements below put the cause on the FORWARD navigation instead: the history entry the gesture later previews is recorded after the page it belongs to is already gone.The mechanism
The gesture preview is WebKit's back-forward snapshot for the entry being returned to. For a same-document (
pushState) entry, that snapshot is bound to the page state at the time the entry is recorded. WebJs records it too late.fetchAndApplyinpackages/core/src/router-client/fetch-apply.jsruns, in this order:applySwap(doc, ...)(fetch-apply.js:261): the outgoing page's DOM is replaced by the incoming one.history.pushState(null, '', finalUrl)(fetch-apply.js:284): the outgoing URL's entry is finalized.window.scrollTo({ left: 0, top: 0, behavior: 'instant' })(fetch-apply.js:304/fetch-apply.js:309): scroll-to-top for the forward nav.Between 1 and 2 the document is the DESTINATION while the scroll offset is still the SOURCE's, so the browser clamps that offset against the shorter incoming document before the entry is ever recorded. Instrumented on the live gallery by wrapping
history.pushStateand reading layout at the call:pushState/features/client-routerto/secondscrollY0, nothing observably changed/atscrollY1600 to/features/websocketsmain's children all new)scrollYalready clamped 1600 to 252, DOM already the short destinationThe second row is the defect: the entry for
/is finalized against a document that is not/, at an offset that means nothing in/'s layout. Case one hides it because there is nothing to get wrong (same nodes, offset 0, near-identical shells), which is exactly why the bug reads as route-specific and is not.WebJs's own back/forward cache is unaffected and stays correct, which is why the back BUTTON works:
snapshotCurrent(currentPageUrl)runs early inperformNavigation(packages/core/src/router-client/navigator.js:399), before the fetch and before the swap, so it records/at 1600 properly.Where the code actually is (old anchor to new anchor)
105372derouter-client.js:3167packages/core/src/router-client/fetch-apply.jsapplySwap(...)callrouter-client.js:3190packages/core/src/router-client/fetch-apply.jsif (recordHistory) history.pushState(null, '', finalUrl);router-client.js:3200-3216packages/core/src/router-client/fetch-apply.jsrouter-client.js:3165packages/core/src/router-client/fetch-apply.jsconst doc = parseHTML(html);plus its null guardrouter-client.js:3082packages/core/src/router-client/fetch-apply.jsif (resp.redirected && resp.url) finalUrl = resp.url;router-client.js:3089-3093packages/core/src/router-client/fetch-apply.jsrouter-client.js:3185packages/core/src/router-client/fetch-apply.jsif (disposition === 'discard')router-client.js:3229packages/core/src/router-client/swap.js_swapCommit = runWithTransition(...)sitesrouter-client.js:2026packages/core/src/router-client/navigator.jsrouter-client.js:1803packages/core/src/router-client/snapshot-cache.jssnapshotCurrentrouter-client.js:1936packages/core/src/router-client/navigator.jsperformNavigationrouter-client.js:3858/:3865packages/core/src/router-client/swap.jshardNavigate(href)callsThree corrections to the previous body, all found by re-deriving the anchors.
hardNavigate(href)that returns'none', atswap.js:350, in the integrity-degradation branch (a poisoned or disjoint boundary scan). The previous body counted only the two deploy-mismatch ones. It matters because it is on the same footing as the other two for the'none'question below.fetch-apply.js:267-273("These paths used to fall through to the history push, the scroll block, and the streaming tail, and an early return would silently change all three"). So'none'already records an entry AND then hard-navigates, today, on all three sites. The design below preserves that byte for byte, which dissolves the open question rather than trading it off.Everything else in the previous body verified as still accurate.
snapshotCurrentand its call site are correct and must not move. The 204 / 205 branch atfetch-apply.js:183-188pushes with no swap at all and needs no change.disposition === 'discard'is reachable only from the revalidation path, which passesrecordHistory: false(navigator.js:520), so it is unaffected.Design / approach
Settled decision: record the history entry at the SWAP COMMIT, immediately before the DOM mutation, by passing the push into
applySwapas a commit-time callback. The callback is idempotent andfetchAndApplystill calls it on the fall-through, so every non-committing path behaves exactly as it does today.Concretely,
fetchAndApplybuilds a one-shot thunk and hands it toapplySwap.applySwapinvokes it at each of its four commit points, ahead ofaddNewHeadElementsand ahead ofrunWithTransition.fetchAndApplythen calls the same thunk on the way out, where it is a no-op if the swap already fired it and the today-behaviour if it did not.Why this shape and not the simpler line move
Moving the single
history.pushStateline fromfetch-apply.js:284up to just beforefetch-apply.js:261would fix the measured defect, and it was the previous body's proposal. It is rejected because it silently changes three paths that are documented as deliberately unchanged. On a deploy mismatch, an integrity degradation, or a frame-missing response,applySwapreturns'none'after doing work (hardNavigateatswap.js:185,swap.js:192,swap.js:350, and awebjs:frame-missingdispatch atswap.js:266-275). A blanket push ahead of the call records the entry BEFORE those decisions instead of after them, which is a different history state on a path nobody measured. The commit-callback keeps the ordering change scoped to exactly the case the bug is about.It also costs nothing in complexity, because the file already has this exact shape for the same exact reason.
applySwap'singestSeeds(swap.js:67-72) is a lazily-invoked thunk called at each commit point, with the comment "Called at each COMMIT point rather than once up front, because this function can still decide to throw the response away after parsing it". The history push has the identical constraint. Adding a second thunk beside the first is DRY on knowledge, not a new mechanism.Prior art
Turbo Drive is the precedent, and it is exact.
~/Documents/Projects/frameworks/turbo/src/core/drive/page_view.js:18-30:Two things to read off it. Turbo calls
changeHistory()BEFOREthis.render(renderer), which is the ordering this issue is about. And Turbo gates it onrenderer.shouldRender, skipping the history update entirely when the renderer declines and the visit degrades to a full page load, which is the same question'none'raises here.Visit.changeHistory()itself (turbo/src/core/drive/visit.js:155-162) is one-shot, guarded bythis.historyChanged, which is where the idempotent thunk comes from.Next.js is directionally similar and not transferable. It records the entry from a
useInsertionEffectinsideHistoryUpdater(~/Documents/Projects/frameworks/next.js/packages/next/src/client/components/app-router.tsx:63-98), so the push happens as part of the React commit rather than after the swapped DOM has settled. The instinct matches, but the ordering is bound to React's commit phases, which WebJs has no analogue for, so it yields no rule to copy. Turbo is the precedent this follows.What the reorder does under a view transition
Nothing. The view-transition path is already correct, and the implementer should not go hunting there.
runWithTransition(packages/core/src/router-client/view-transition.js:49-73) hands the swap thunk todocument.startViewTransition, which defers the DOM mutation by a frame and returns immediately. So today, on the view-transition path,applySwapreturns while the outgoing DOM is still live, andfetch-apply.js:284pushes BEFORE the mutation. That is already the ordering this issue asks for. Moving the push to the commit point moves it slightly earlier on that path (from just afterrunWithTransitionreturned to just before it is called) and changes no observable ordering relative to the DOM mutation.This is also corroborating evidence for the diagnosis rather than an inconvenience: the one path that already records history ahead of the mutation is the one path this class of defect should not be reachable on.
The popstate restore under a view transition (
navigator.js:470-492) is a separate mechanism and is untouched. It records no history at all (the browser already did), so it never receives a thunk.Rejected alternatives
pushStateline ahead ofapplySwap. Rejected above: it changes the'none'paths thatfetch-apply.js:267-273documents as deliberately unchanged.'none'and skip the push (full TurboshouldRenderparity). Rejected. It is a defensible design and Turbo's, but it is a SECOND behaviour change riding a one-line ordering fix, on paths (deploy mismatch, poisoned boundary, missing frame) that no measurement in this issue covers. WebJs has no back-compat burden, so this can be revisited on its own evidence later. It is not this issue's scope.applySwapto arequestAnimationFrameafter the push (the dogfood: iOS back-swipe gesture flashes a blank page (client router) #641 lever re-aimed at the forward nav). Rejected for this PR. See "Out of scope" for the exact condition that reopens it.scroll-behavior: smooth#601 with it) and dogfood: back-button scroll restores ~763px too low on pages that grow after swap #1310 established that re-asserting a scroll after the fact fights the browser rather than the bug.Implementation plan
All in
packages/core/src/. Plain.jswith JSDoc, no.ts(AGENTS.md, "Working in the WebJs framework repo itself").Step 1: give
applySwapa commit-time history callbackFile:
packages/core/src/router-client/swap.js1a. Signature. Today,
swap.js:50:After:
Add the JSDoc param alongside the existing
@paramblock that ends atswap.js:49:1b. Four call sites, one per commit point. Each is a single new line. There is no other change to this file.
Frame swap,
swap.js:237. Today:After (the push goes inside the
target && sourceblock, so the frame-MISSING outcome keeps falling through to the caller's tail exactly as today):Refresh shell,
swap.js:286-290. Today:After:
(A refresh always passes
recordHistory: false, so this is a no-op in practice. It is written anyway so the invariant "the push happens at the commit" holds at every commit point without a caller having to reason about reachability.)Boundary plan, the main path,
swap.js:303-310. Today:After:
Full-body tail,
swap.js:364-368. Today:After:
(Unreachable from
fetchAndApply, which always passes a non-nullhref, so a foreground nav that gets this far has already taken thehardNavigateatswap.js:350or the'discard'atswap.js:361. It is reached only by the popstate restore atnavigator.js:428, which passes no thunk. Written for the same uniformity reason as the shell case.)Step 2: build the one-shot thunk in
fetchAndApplyand pass itFile:
packages/core/src/router-client/fetch-apply.jsToday,
fetch-apply.js:255-284:...
After. Insert the thunk between the
parseHTMLnull guard and theapplySwapcall, pass it as the eighth argument, and replace the standalone push atfetch-apply.js:284with a call to the same thunk:and, at what is currently
fetch-apply.js:284:Do not touch anything else in this function. In particular:
fetch-apply.js:294-311) STAYS where it is, after the swap. It must measure the incoming page, and the hash-anchor branch'sdocument.getElementById(url.hash.slice(1))cannot resolve until the swap has run.fetch-apply.js:183-188) keeps its own inlinehistory.pushState. It never reachesapplySwapand swaps nothing, so it has no commit point to hang a callback on.'discard'early return (fetch-apply.js:279-282) is unchanged. It returns before the tail call, as today, and its path always hasrecordHistory: falseanyway.finalUrlis fully resolved before the thunk is built: theresp.redirectedreassignment is atfetch-apply.js:176, and the prefetch fast path sets it atfetch-apply.js:107.Step 3: nothing to change in
navigator.jsorsnapshot-cache.jssnapshotCurrent(snapshot-cache.js:27) and its two call sites (navigator.js:399,navigator.js:623) are already correct and MUST NOT move: they run before the fetch and before the swap, which is why the back BUTTON already works. The popstate cached-restore branch (navigator.js:423-505) callsapplySwapwith four arguments and therefore passesundefinedfor the new parameter, which is exactly right (the browser already moved history). The #1310 machinery (suppressScrollAnchoring,catchUpToRestoredScroll, therestoreGenerationcounter inpackages/core/src/router-client/scroll.js) is untouched.Step 4: rebuild the core bundle before the e2e and Bun runs
packages/core/distis built, not committed, and e2e resolves the BUILT bundle, so asrc-only edit is invisible to it and a counterfactual passes vacuously:Tests
Every path below is verified to exist at
105372de.Browser (headline, mandatory)
New file:
packages/core/test/routing/browser/nav-history-before-swap.test.jsNamed to sit beside its two siblings
nav-scroll-instant.test.jsandnav-scroll-anchor-restore.test.js, which already cover the neighbouring scroll behaviour on the same surface. Follownav-scroll-instant.test.jsfor the file shape: it importsenableClientRouter, navigatefrom../../../src/router-client.js,assertfrom../../../../../test/browser-assert.js, and drives a nav with a stubbedwindow.fetch.Install the shared nav guard from
test/browser-nav-guard.js(installNavGuard()), perpackages/core/AGENTS.md. This test does not click an anchor, butapplySwapcan still degrade to a hard navigation, and an escapedlocation.hrefassignment aborts the entire web-test-runner session rather than failing one test.The assertion. Wrap
history.pushState, and at the moment it fires for a forward navigation, record BOTH:document.body.textContentstill contains the OUTGOING page's sentinel text and does NOT contain the incoming one.window.scrollYstill holds the outgoing offset (at least the value it was scrolled to, within a small tolerance).Fixture shape that makes both halves real:
<!--wj:children:/:/-->...<!--/wj:children:/-->) wrapping a sentinel string plus a block with an explicit tall height (3000px or more), so the document genuinely scrolls.window.scrollToin this file: unlikenav-scroll-instant.test.js, the point here is the real clamped offset, so the scroll has to actually happen.fetchreturns a SHORT incoming document with the same boundary segment, a different sentinel, and no tall block, pluscontent-type: text/htmlandx-webjs-build: ''(matching the sibling file's stub, so the importmap guard sees an unknown build id and does not hard-reload).history.pushState,window.fetch, the body, and the scroll in afinally, exactly as the sibling files do.The counterfactual. Both halves fail on the current ordering, and the implementer must SEE them fail before landing the fix. The procedure: stash the two source edits (
git stash push packages/core/src/router-client/swap.js packages/core/src/router-client/fetch-apply.js), runnpm run test:browser, confirm the new test reports the incoming sentinel present andscrollYclamped near the short document's maximum rather than near 800, thengit stash pop. Note both observed numbers in the PR body. If either half passes with the fix reverted, the fixture is not exercising the replace tier and must be made taller or more divergent before the test is trusted.Also add, in the same file, a second test that the push happens EXACTLY ONCE per navigation (count the wrapped calls). This is the guard on the idempotent thunk, and it is the failure mode a future edit to either call site would introduce.
Unit
Existing file to extend:
packages/core/test/routing/router-client.test.js(linkedom,_applySwapalready imported and exercised atrouter-client.test.js:1326and:4252).Three assertions, calling
_applySwapdirectly with the new eighth argument:document.body.textContentat call time, and assert the recorded text is the OUTGOING content. This is the ordering invariant in a form that does not need a real browser.'none'. Frame-missing (swap.js:275): passframeIdfor a frame that is in neither tree, assert the thunk was never called and the return is'none'. Integrity degradation (swap.js:350): pass an incoming doc whose boundaries share no segment with the live body, with_setHardNavigateinstalled as a recorder (the seam atpackages/core/src/router-client/state.js:51), assert the thunk was never called, the return is'none', and the hard navigation was recorded.navigate()against a stubbed fetch that produces a disjoint document, with_setHardNavigaterecording, and asserthistory.pushStatewas still called once. This is the assertion that proves the deliberate fall-through atfetch-apply.js:267-273was preserved and that the'none'decision in "Design / approach" actually holds in code.e2e
Existing file to extend:
test/e2e/form-submission-and-race.test.mjs, which already holdstest('scroll restoration: back-button restores window scroll position', ...)at line 429. That test is the #1310 guard (it scrolls/ui/buttonto 800, navigates to/ui/cardwith an in-page click, goes Back, waits 1200ms for the restored page to finish growing and revalidating, and asserts the restored offset is within 20px).Do not rewrite it. Run it and confirm it still passes, and add ONE sibling test in the same file: a scrolled-source forward navigation that asserts, via a page-side
history.pushStatewrapper installed before the click, that at the push the document still held the outgoing route's marker. Same fixture and same in-page click technique as the existing test (Playwright's own click scrolls the target into view first, which would move the window before the router recorded its position and invalidate the measurement).Run with
WEBJS_E2E=1, after Step 4'sdistrebuild.Smoke
Does NOT apply.
test/examples/*/smoke/*boots an app and asserts on served bytes. This change alters no SSR output and no served source, only the order of two client-side operations inside one navigation, which a smoke test cannot observe.Bun
No new
test/bun/**file, and none is warranted. Stated explicitly so the implementer does not invent one.The changed files are
packages/core/src/router-client/swap.jsandpackages/core/src/router-client/fetch-apply.js. That module tree is browser-only: it touchesdocument,window,history, andlocation, and it never executes under Bun or under Node as a server. There is no cross-runtime behaviour to assert, and atest/bunscript that booted a server and asserted on SSR bytes would be asserting something this change cannot affect.The
.claude/hooks/require-bun-parity-with-runtime-src.shgate does not fire on these paths (verified: its second filter matchesserialize|/json\.js|file-storage|listener|ts-strip|action|render-server|/ssr[./]|conditional-get|websocket|node-version|csrf|/auth\.js|/session\.js|/cors\.js|crypto|compression|body-limit|/dev[./]|stream, and neitherrouter-client/swap.jsnorrouter-client/fetch-apply.jscontains any of those substrings). If it fires anyway on a later main, commit withWEBJS_BUN_VERIFIED=1and put the reason above in the commit body.Still run the matrix and report it green, because the
distrebuild in Step 4 is a real input to it:The nearest existing cross-runtime coverage of the surface this reorder depends on is
test/bun/keyed-boundaries.mjs(and its Node wrappertest/bun/keyed-boundaries.test.mjs), which proves the keyed children-boundary SSR emission (<!--wj:children:<segment>:<route-key>-->) is byte-identical on both runtimes. That emission is whatcollectBoundariesandplanBoundarySwappair on to reach the commit point this change hooks. It needs no edit, and it must stay green.Full command list
Docs
No doc surface changes, and this is a deliberate finding rather than a skipped step.
The observable behaviour is identical on every platform a doc could describe. The URL advances at the same point in the navigation from the app's perspective, the same single entry is recorded,
webjs:navigatefires at the same place (fetch-apply.js:331), the scroll behaviour is untouched, and no export, option, config key, or event changes. The only difference is the microsecond-scale ordering ofhistory.pushStateagainst the DOM mutation, and the user-visible consequence is a WebKit gesture preview that stops rendering blank. Nothing inwebsite/app/docs/client-router/page.ts,.agents/skills/webjs/references/client-router-and-streaming.md, or the scaffold copy atpackages/cli/templates/.agents/skills/webjs/references/makes a claim this contradicts or leaves out..claude/hooks/require-docs-with-src.shBLOCKS a commit stagingpackages/*/srcsource with no doc surface alongside it, so commit with:and put this justification in the commit body: an ordering fix inside the client router with an unchanged public surface and unchanged observable behaviour, whose only effect is on a WebKit back-forward gesture snapshot.
The one thing that DOES belong in writing is the ordering constraint itself, and it belongs in the code, not the docs: the JSDoc block in Step 1a and the comment block in Step 2 are the record, placed where the next person to touch either call site will read them.
Acceptance criteria
gallery.webjs.dev: scroll the index, open a demo, swipe back/features/*test/e2e/form-submission-and-race.test.mjsline 429 still green)'none'paths (deploy mismatchswap.js:185/swap.js:192, integrity degradationswap.js:350, frame-missingswap.js:275)packages/core/test/routing/browser/nav-history-before-swap.test.jsasserts the outgoing DOM and outgoingscrollYare both still live at thepushStatecallscrollY) are recorded in the PR bodypackages/core/test/routing/router-client.test.jsasserts the callback fires before the mutation on a committing swap, does not fire on'none', and that the caller's fall-through still records history therepackages/core/distrebuilt before the e2e and Bun runsnpm test,npm run test:browser, the e2e file, andnode scripts/run-bun-tests.jsall reported green in the PR body( cd gallery && npx webjs check )cleanWEBJS_NO_DOC_GATE=1justificationOut of scope
requestAnimationFramebetween the push andapplySwap). Do NOT ship it in this PR. It is the dogfood: iOS back-swipe gesture flashes a blank page (client router) #641 lever re-aimed at the forward navigation, it costs a frame on EVERY navigation, and dogfood: iOS back-swipe gesture flashes a blank page (client router) #641 is the record of what happens when a lever lands here on a guess. It reopens only on one condition: the reorder ships, is confirmed on a real iOS device, and the blank preview PERSISTS. That would mean WebKit captures at the next paint rather than synchronously at thepushStatecall, in which case no reordering within one task can help. Confirm on device with the query-param A/B method from research: iOS sticky-header flicker on client-router nav, fix options + DX (#610) #642 / dogfood: mobile navbar flickers on forward nav (backdrop-blur sticky header) #610 before writing a line of it, and file it against a fresh measurement rather than folding it in here.shouldRenderparity (returning early on'none'and skipping the push). Discussed and rejected under "Rejected alternatives". A separate behaviour change on paths this issue has no measurement for.snapshot-cache.js, or the dogfood: back-button scroll restores ~763px too low on pages that grow after swap #1310 machinery inscroll.js(suppressScrollAnchoring,catchUpToRestoredScroll,restoreGeneration). Correct as-is. The e2e test attest/e2e/form-submission-and-race.test.mjs:429is the guard that they stayed correct.navigator.js:423-505), including its view-transition branch. It records no history and receives no callback.fetch-apply.js:294-311). Correct (fix: force instant scroll on navigation so smooth CSS does not animate it #603, which fixed dogfood: nav scroll restoration animates underscroll-behavior: smooth#601), stays after the swap, and is not the bug.position: fixedheader rule. Different iOS defect, already fixed, unrelated surface.packages/core/src/router-client/. The barrel keeps the original path on purpose (package.jsonexportsmaps./client-routerat./src/router-client.js, and 32 test files import it relatively). This PR adds one parameter and five lines.