Skip to content

Commit dd3909d

Browse files
committed
docs: correct the swap step order and bound the ordering claim
Two claims were stated more strongly than the code supports. The JSDoc and the call-site comment said the callback runs while the outgoing page is still in the DOM. On a route whose deepest live boundary is covered by a loading template, applyOptimisticLoading has already replaced that range with the skeleton and let the engine clamp the offset before the fetch is issued, so the entry is recorded against the shell plus a skeleton. Still better than the destination document, but not the page the reader left, and recording ahead of the optimistic swap is a separate change on a path nothing here measured. The docs site and the router's own module header both listed pushState last in the swap sequence, after the head merge, script re-execution, and custom-element upgrade. It now runs first, so both said the opposite of what the code does.
1 parent 3291bdf commit dd3909d

4 files changed

Lines changed: 31 additions & 11 deletions

File tree

packages/core/src/router-client/constants.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ export const STREAM_MIME = 'text/vnd.webjs-stream.html';
4242
* state, and node identity) at the deepest shared boundary.
4343
* 4. A poisoned scan or no shared boundary degrades to a FULL PAGE LOAD:
4444
* bounded and correct, never a guessed recovery.
45-
* 5. Merge head, re-run scripts, upgrade custom elements, pushState.
45+
* 5. pushState FIRST (before any mutation, so the back-forward gesture
46+
* snapshot is of the page being left), then merge head, re-run scripts,
47+
* upgrade custom elements.
4648
*
4749
* Optimizations bundled into the same response cycle:
4850
* - `X-Webjs-Have` request header lists `segment:route-key` entries for

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,10 +265,17 @@ export async function fetchAndApply(href, frameId, recordHistory, optimisticStat
265265
// browser has already clamped to that document's height (measured on the
266266
// gallery: 1600 to 252), so the gesture previews a page that never existed and
267267
// renders blank. So the push rides into `applySwap` as a COMMIT-time callback
268-
// and fires while the outgoing page is still live, which is Turbo Drive's
269-
// ordering (`PageView.renderPage` calls `visit.changeHistory()` ahead of
268+
// and fires ahead of the mutation, which is Turbo Drive's ordering
269+
// (`PageView.renderPage` calls `visit.changeHistory()` ahead of
270270
// `this.render(renderer)`).
271271
//
272+
// One route class gets less than the full benefit: where a `loading.{js,ts}`
273+
// covers the deepest live boundary, `applyOptimisticLoading` replaced that
274+
// range with the skeleton before this function ever ran, so the entry is
275+
// recorded against the shell plus a skeleton rather than the outgoing page.
276+
// Better than the destination document, still not the page the reader left.
277+
// See the `recordHistoryNow` JSDoc in `swap.js`.
278+
//
272279
// One-shot, exactly like Turbo's `historyChanged` guard, so calling it here
273280
// AND on the fall-through below is safe. The fall-through is required, not
274281
// belt-and-braces: `applySwap` can return `'none'` after hard-navigating or

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

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,24 @@ export let _swapCommit = Promise.resolve();
5050
* history entry (#1406). Called at each COMMIT point, immediately BEFORE the
5151
* DOM mutation, for the same reason `ingestSeeds` is: this function can still
5252
* decide to throw the response away after parsing it, and a `pushState`
53-
* issued for a swap that never happened is worse than a late one. It must run
54-
* while the OUTGOING page is still in the DOM and still holds its scroll
55-
* offset, because WebKit binds a same-document entry's back-forward gesture
56-
* snapshot to the page state at the moment the entry is recorded, and a
57-
* snapshot taken against the incoming (often shorter) document previews
58-
* blank. The caller's thunk is one-shot, so it is safe to call here AND on
59-
* the caller's fall-through. Omitted or null on every path that records no
53+
* issued for a swap that never happened is worse than a late one. It runs
54+
* BEFORE the mutation, because WebKit binds a same-document entry's
55+
* back-forward gesture snapshot to the page state at the moment the entry is
56+
* recorded, and a snapshot taken against the incoming (often shorter)
57+
* document previews blank.
58+
*
59+
* What that buys is bounded by what is on screen when it fires, and on one
60+
* route class that is NOT the outgoing page: where a `loading.{js,ts}`
61+
* covers the deepest live boundary, `applyOptimisticLoading` has already
62+
* replaced that range with the skeleton and let the engine clamp the offset
63+
* before the fetch was issued. There the snapshot is of this route's own
64+
* shell plus a skeleton, which still beats the destination document, but it
65+
* is not the page the reader left. Fixing that means recording the entry
66+
* ahead of the optimistic swap too, which is a separate change on a path
67+
* nothing here measured.
68+
*
69+
* The caller's thunk is one-shot, so it is safe to call here AND on the
70+
* caller's fall-through. Omitted or null on every path that records no
6071
* history (a revalidation, a refresh, the popstate restore).
6172
*/
6273
export function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc, refresh, recordHistoryNow) {

website/app/docs/client-router/page.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export default function ClientRouter() {
1717
<li>SSR emits KEYED boundary comment pairs around each layout's <code>\${children}</code> interpolation and around the page itself: open <code>&lt;!--wj:children:&lt;segment&gt;:&lt;route-key&gt;--&gt;</code>, close <code>&lt;!--/wj:children:&lt;segment&gt;--&gt;</code>. The segment is the folder-derived pattern; the route-key is the resolved path for this request (dynamic params substituted, values percent-encoded). Derived from folder structure, with authors writing nothing.</li>
1818
<li>On a click or form submit, the router STRICTLY scans both the live DOM and the incoming HTML into segment maps (a close must match its innermost open; any truncated, mispaired, or duplicated boundary poisons the scan) and picks a <strong>two-tier swap</strong> with Next.js parity: a boundary whose route-key CHANGED is wholesale REPLACED at the PARENT of the shallowest change (a layout's boundary wraps only its children, so anchoring at the parent remounts the changed layout's own markup too, exactly like Next re-rendering a layout with new params), while an all-keys-equal nav (a searchParams-only change) MORPHS the deepest shared boundary in place so hydrated component state survives. A poisoned scan or no shared boundary degrades to a normal full page load, never a guessed swap, so a malformed response cannot corrupt the live DOM. Because the boundaries are comments, the parse that turns a response into a document has to preserve them: <code>Document.parseHTMLUnsafe</code> strips every comment in some browser versions, so the router probes it once and parses with <code>DOMParser</code> instead when it is lossy.</li>
1919
<li>The diff inside the swap region is keyed by <code>data-key</code> or <code>id</code>. Matched elements are reused with in-place attribute updates. <strong>Live attributes</strong> (<code>value</code>, <code>checked</code>, <code>selected</code>, <code>indeterminate</code>, <code>disabled</code>, <code>open</code>, <code>popover</code>) are never overwritten, so user input and disclosure state survive the swap.</li>
20-
<li>The <code>&lt;head&gt;</code> is add-only merged (preserves runtime-injected styles like Tailwind's), <code>&lt;script&gt;</code> tags re-execute, custom elements upgrade, URL updates via <code>pushState</code>.</li>
20+
<li>The URL updates via <code>pushState</code> <strong>before</strong> anything is mutated, then the <code>&lt;head&gt;</code> is add-only merged (preserves runtime-injected styles like Tailwind's), <code>&lt;script&gt;</code> tags re-execute, and custom elements upgrade. The history entry comes first on purpose: WebKit binds a same-document entry's back-forward gesture snapshot to the page state at the moment the entry is recorded, so recording it after the swap makes iOS preview the destination page during a back-swipe instead of the page being returned to.</li>
2121
<li>Every <code>&lt;script&gt;</code> the swap brings in re-executes, whether it sits inside the swapped content or is a top-level node of the swapped range itself (a layout emitting its enhancement script as a sibling of <code>\${children}</code>). A parsed script node carries the HTML spec's "already started" flag, so the router replaces it with a fresh clone; that is what makes it run. The clone carries the page-load CSP nonce, not the nonce the response was rendered with. The one exception is a script INSIDE an element the swap preserved by identity through <code>data-webjs-permanent</code>, covered below.</li>
2222
<li>A <code>webjs:navigate</code> event fires on <code>document</code> with the final URL.</li>
2323
</ol>

0 commit comments

Comments
 (0)