Skip to content

dogfood: record history before the swap so iOS back-swipe is not blank #1406

Description

@vivek7405

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:

  1. applySwap(doc, ...) (fetch-apply.js:261): the outgoing page's DOM is replaced by the incoming one.
  2. history.pushState(null, '', finalUrl) (fetch-apply.js:284): the outgoing URL's entry is finalized.
  3. 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.

  1. There is a THIRD hardNavigate(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.
  2. 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.
  3. 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:

  renderPage(snapshot, isPreview = false, willRender = true, visit) {
    const shouldMorphPage = ...
    const rendererClass = shouldMorphPage ? MorphingPageRenderer : PageRenderer
    const renderer = new rendererClass(this.snapshot, snapshot, isPreview, willRender)

    if (!renderer.shouldRender) {
      this.forceReloaded = true
    } else {
      visit?.changeHistory()
    }

    return this.render(renderer)
  }

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


Implementation plan

All in packages/core/src/. Plain .js with JSDoc, no .ts (AGENTS.md, "Working in the WebJs framework repo itself").

Step 1: give applySwap a commit-time history callback

File: packages/core/src/router-client/swap.js

1a. Signature. Today, swap.js:50:

export function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc, refresh) {

After:

export function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc, refresh, recordHistoryNow) {

Add the JSDoc param alongside the existing @param block that ends at swap.js:49:

 * @param {(() => void) | null} [recordHistoryNow]  Record this navigation's
 *   history entry (#1406). Called at each COMMIT point, immediately BEFORE the
 *   DOM mutation, for the same reason `ingestSeeds` is: this function can still
 *   decide to throw the response away after parsing it, and a `pushState`
 *   issued for a swap that never happened is worse than a late one. It must run
 *   while the OUTGOING page is still in the DOM and still holds its scroll
 *   offset, because WebKit binds a same-document entry's back-forward gesture
 *   snapshot to the page state at the moment the entry is recorded, and a
 *   snapshot taken against the incoming (often shorter) document previews
 *   blank. The caller's thunk is one-shot, so it is safe to call here AND on
 *   the caller's fall-through. Omitted or null on every path that records no
 *   history (a revalidation, a refresh, the popstate restore).

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

Refresh shell, swap.js:286-290. Today:

  if (refresh === 'shell') {
    ingestSeeds();
    swapFullBody(doc);
    return;
  }

After:

  if (refresh === 'shell') {
    ingestSeeds();
    if (recordHistoryNow) recordHistoryNow();
    swapFullBody(doc);
    return;
  }

(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 above
  swapFullBody(doc);
}

After:

  ingestSeeds();   // committed: past both discard branches above
  if (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

File: packages/core/src/router-client/fetch-apply.js

Today, fetch-apply.js:255-284:

  const doc = 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, new Error('navigation response did not parse as HTML')); return { ok: false, status: respStatus, aborted: false, applied: false }; }

  const disposition = applySwap(doc, frameId, !!revalidating, finalUrl, incomingBuild, incomingSrc, refresh);

...

  if (recordHistory) history.pushState(null, '', finalUrl);

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:

  const doc = 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, 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
  // 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.
  let historyRecorded = false;
  const recordHistoryNow = recordHistory
    ? () => {
      if (historyRecorded) return;
      historyRecorded = true;
      history.pushState(null, '', finalUrl);
    }
    : null;

  const disposition = 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:

  1. document.body.textContent still contains the OUTGOING page's sentinel text and does NOT contain the incoming one.
  2. 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.
  • Scroll the window to a fixed offset (800 is the value dogfood: back-button scroll restores ~763px too low on pages that grow after swap #1310 used and is comfortably inside a 3000px page). Do NOT stub window.scrollTo in this file: unlike nav-scroll-instant.test.js, the point here is the real clamped offset, so the scroll has to actually happen.
  • 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:

  1. 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.
  2. 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.
  3. 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/*
  • The back button, forward navigation, and back/forward scroll restoration are unchanged, including the dogfood: back-button scroll restores ~763px too low on pages that grow after swap #1310 grow-after-swap case (test/e2e/form-submission-and-race.test.mjs line 429 still green)
  • Android and desktop behaviour unchanged
  • 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

Out of scope

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions