diff --git a/.agents/skills/webjs/references/client-router-and-streaming.md b/.agents/skills/webjs/references/client-router-and-streaming.md
index eb64338f2..87c9dda66 100644
--- a/.agents/skills/webjs/references/client-router-and-streaming.md
+++ b/.agents/skills/webjs/references/client-router-and-streaming.md
@@ -76,7 +76,7 @@ document.addEventListener('webjs:navigation-fallback', (e) => {
**Form state.** A form submitting through the router gets `aria-busy="true"` for the in-flight duration, plus bubbling `webjs:submit-start` and `webjs:submit-end` (detail `{ form, url, ok }`) events. Style `form[aria-busy="true"]` in pure CSS or listen for the events.
-**Inline scripts in a swapped range re-execute, so write them to be re-runnable (#1102).** A script the swap brings in runs again on every navigation that swaps its range, whether it sits inside the swapped content or is a top-level node of the range itself (a layout emitting its enhancement script as a sibling of `${children}`). A script parsed out of the response carries the HTML spec's already-started flag and is inert, so the router replaces it with a fresh clone, and the clone is what runs; the clone carries the page-load CSP nonce rather than the one the response was rendered with. Giving the script an `id` does NOT make it run once: the keyed differ reuses the live element and the router still re-emits it. So a script that installs a listener or a `MutationObserver` must be idempotent or guard on a flag it sets the first time. The alternative default, running once and then never again, is the failure this replaced (a progressive-enhancement highlighter that stopped working after the first soft nav). When work genuinely must happen once, put it in the ROOT layout, whose markup is never swapped. `data-webjs-permanent` is NOT an escape hatch here: it preserves node identity for stateful elements, its regraft only fires when the node exists on both sides, and a script marked with it is re-emitted like any other.
+**Inline scripts in a swapped range re-execute, so write them to be re-runnable (#1102).** A script the swap brings in runs again on every navigation that swaps its range, whether it sits inside the swapped content or is a top-level node of the range itself (a layout emitting its enhancement script as a sibling of `${children}`). A script parsed out of the response carries the HTML spec's already-started flag and is inert, so the router replaces it with a fresh clone, and the clone is what runs; the clone carries the page-load CSP nonce rather than the one the response was rendered with. Giving the script an `id` does NOT make it run once: the keyed differ reuses the live element and the router still re-emits it. So a script that installs a listener or a `MutationObserver` must be idempotent or guard on a flag it sets the first time. The alternative default, running once and then never again, is the failure this replaced (a progressive-enhancement highlighter that stopped working after the first soft nav). When work genuinely must happen once, put it in the ROOT layout, whose markup is never swapped. `data-webjs-permanent` splits into two cases (#1252). A script that IS the marked element is re-emitted like any other, so the attribute is not an escape hatch for a script itself: its regraft only fires when the node exists on both sides, so exempting it would leave a script that runs on a cold load and never on a soft nav. A script INSIDE a marked element the swap actually preserved is left alone, because the attribute is SUBTREE-scoped and that node survived by identity. The exemption is conditional on real preservation, so a permanent element arriving for the first time, or one with no `id` (which can never be regrafted), still runs its scripts.
## Link Prefetch
@@ -136,7 +136,7 @@ A page (or layout) does not write raw `
` markup, so emit that meta through
export const metadata = { other: { 'view-transition': 'same-origin' } };
```
-The accepted value is `same-origin`. When enabled it wraps every swap path (the two-tier boundary swap, the `` swap, and the background-revalidation full-body path). When `startViewTransition` is unavailable the swap runs synchronously with no flash and no throw. To persist a live element (a playing ``, an open menu) across a swap by node identity, mark it `data-webjs-permanent` and give it an `id`.
+The accepted value is `same-origin`. When enabled it wraps every swap path (the two-tier boundary swap, the `` swap, and the background-revalidation full-body path). When `startViewTransition` is unavailable the swap runs synchronously with no flash and no throw. To persist a live element (a playing ``, an open menu) across a swap by node identity, mark it `data-webjs-permanent` and give it an `id`. The attribute is SUBTREE-scoped, so once the element has actually been preserved, a `' +
+ '' +
+ '';
+
+ test('morph tier: a preserved permanent element does not re-run its inner script (#1252)', async () => {
+ // The headline claim, and it is only provable here: the unit layer runs
+ // under linkedom, which never executes a script, so it can only observe
+ // node replacement. `data-webjs-permanent` means the subtree survives as
+ // the same live node, so re-running an init script against the instance
+ // the author kept alive is a double-initialization.
+ //
+ // Equal route-keys morph `/` in place, and the regraft inside
+ // `reconcileChildren` is what preserves `#wj1102-box` by identity.
+ mount(
+ '' + permBoxMarkup + '',
+ () => body('' + permBoxMarkup + ''),
+ );
+ try {
+ const boxBefore = document.getElementById('wj1102-box');
+
+ await navigate(location.origin + '/?perm=morph');
+ for (let i = 0; i < 20 && !window.__wj1102.length; i++) await tick();
+
+ assert.equal(document.getElementById('wj1102-box'), boxBefore,
+ 'precondition: the permanent element survived the swap as the same node');
+ assert.deepEqual(window.__wj1102, ['sib'],
+ `only the ordinary sibling re-ran; got ${JSON.stringify(window.__wj1102)}`);
+ } finally { unmount(); }
+ });
+
+ test('replace tier: a preserved permanent element does not re-run its inner script (#1252)', async () => {
+ // The other regraft branch. A CHANGED inner route-key remounts at the
+ // parent `/` range, where the permanent element is a TOP-LEVEL member of
+ // the imported slice, so its incoming copy is parentless and the regraft
+ // writes into the slice array rather than calling `replaceChild`. That
+ // branch is easy to miss, and missing it leaves exactly this shape
+ // unprotected.
+ const withInner = (innerKey) =>
+ '' +
+ permBoxMarkup +
+ `` +
+ 'between
' +
+ '' +
+ '';
+
+ mount(withInner('/docs/a'), () => body(withInner('/docs/b')));
+ try {
+ const boxBefore = document.getElementById('wj1102-box');
+
+ await navigate(location.origin + '/docs/b');
+ for (let i = 0; i < 20 && !window.__wj1102.length; i++) await tick();
+
+ assert.equal(document.getElementById('wj1102-box'), boxBefore,
+ 'precondition: the permanent element survived the swap as the same node');
+ assert.deepEqual(window.__wj1102, ['sib'],
+ `only the ordinary sibling re-ran; got ${JSON.stringify(window.__wj1102)}`);
+ } finally { unmount(); }
+ });
+
+ test('a permanent element arriving for the FIRST time runs its inner script (#1252)', async () => {
+ // The both-exist guard means "permanent" does not imply "was preserved".
+ // Nothing here can be regrafted (the live range has no `#wj1102-box`), so
+ // the container is a freshly imported node whose script has never run. An
+ // exemption keyed on the attribute rather than on actual preservation
+ // would leave it never running on any path.
+ mount(
+ 'plain
',
+ () => body('' + permBoxMarkup + ''),
+ );
+ try {
+ await navigate(location.origin + '/?perm=first');
+ for (let i = 0; i < 20 && window.__wj1102.length < 2; i++) await tick();
+
+ assert.deepEqual(window.__wj1102, ['inner', 'sib'],
+ `a first-mount permanent element still runs its script; got ${JSON.stringify(window.__wj1102)}`);
+ } finally { unmount(); }
+ });
});
diff --git a/packages/core/test/routing/router-client.test.js b/packages/core/test/routing/router-client.test.js
index 584a19412..d76da9669 100644
--- a/packages/core/test/routing/router-client.test.js
+++ b/packages/core/test/routing/router-client.test.js
@@ -35,7 +35,7 @@ let _collect, _plan, _keyOf, _diffEl, _reconcile,
_eligibleAnchorHref, _prefetchSuppressed, _prefetchMode, _prefetchHasHoverPointer, _prefetch, _prefetchTake, _prefetchAnchor,
_buildHaveHeader,
_prefetchSaysSaveData, _prefetchPeek, _prefetchInflightSize, _resetPrefetch,
- _viewTransitionsEnabled, _runWithTransition, _regraftPermanentElements,
+ _viewTransitionsEnabled, _runWithTransition, _regraftPermanentElements, _regraftPermanentInSlice,
_applyStreamedResolve,
enableClientRouter, disableClientRouter, revalidate,
WebComponent, html;
@@ -119,6 +119,7 @@ before(async () => {
_viewTransitionsEnabled,
_runWithTransition,
_regraftPermanentElements,
+ _regraftPermanentInSlice,
_applyStreamedResolve,
navigate,
revalidate,
@@ -1129,16 +1130,118 @@ test('reactivateScripts: data-webjs-permanent does NOT exempt a script (#1102)',
assert.equal(document.getElementById('perm').getAttribute('nonce'), 'page-nonce');
});
-test('reactivateScripts: a permanent DESCENDANT script is re-emitted too (#1102)', () => {
- // The consistency the exemption would have broken: nesting depth must not
- // change whether a script re-runs.
+test('reactivateScripts: a REGRAFTED permanent container keeps its descendant scripts (#1252)', () => {
+ // The settled rule: `data-webjs-permanent` is SUBTREE-scoped, so a script
+ // inside an element the swap preserved by identity is not re-emitted. The
+ // author kept that widget alive on purpose; re-running its init script
+ // against the live instance is a double-initialization, not a refresh.
+ //
+ // Driving a real regraft is load-bearing: the exemption keys on the node
+ // having ACTUALLY been preserved, never on the attribute, so merely setting
+ // the attribute must not be enough to reach this state.
+ document.head.innerHTML = ' ';
+ const live = bodyFrom('
');
+ const incoming = bodyFrom('
');
+ const liveNode = live.querySelector('#w');
+ const innerBefore = liveNode.querySelector('#pd');
+
+ _regraftPermanentElements(live, incoming);
+ assert.equal(incoming.querySelector('#w'), liveNode, 'precondition: the live node was regrafted');
+
+ _reactivateScripts(incoming);
+
+ assert.strictEqual(incoming.querySelector('#pd'), innerBefore,
+ 'the preserved subtree keeps the same script node, so it never re-runs');
+});
+
+test('reactivateScripts: a permanent container that was NOT regrafted re-emits its scripts (#1252)', () => {
+ // The both-exist guard means "permanent" does not imply "was preserved". A
+ // permanent element arriving for the first time is a freshly imported node
+ // whose scripts have never run, so an attribute-only filter would leave them
+ // never running on any path. This is the counterfactual against that filter.
document.head.innerHTML = ' ';
document.body.innerHTML =
- '
';
+ '';
const before = document.getElementById('pd');
- _reactivateScripts(document.getElementById('w'));
+
+ _reactivateScripts(document.getElementById('fresh'));
+
assert.notStrictEqual(document.getElementById('pd'), before,
- 'the descendant path re-emits it, exactly as the container path now does');
+ 'nothing was preserved here, so the script is re-emitted and runs on arrival');
+ assert.equal(document.getElementById('pd').getAttribute('nonce'), 'page-nonce');
+});
+
+test('reactivateScripts: an id-less permanent element gets no exemption (#1252)', () => {
+ // The regrafts select `[data-webjs-permanent][id]`, so an element with no
+ // `id` can never be preserved. Its scripts must therefore keep re-running,
+ // matching the documented `id` requirement.
+ document.head.innerHTML = ' ';
+ const live = bodyFrom('
');
+ const incoming = bodyFrom('
');
+ const innerBefore = incoming.querySelector('#nid');
+
+ _regraftPermanentElements(live, incoming);
+ _reactivateScripts(incoming);
+
+ assert.notStrictEqual(incoming.querySelector('#nid'), innerBefore,
+ 'no id means no regraft, so no exemption');
+});
+
+test('reactivateScripts: the slice regraft protects a detached top-level permanent (#1252)', () => {
+ // `regraftPermanentInSlice` has two success branches, and the detached one
+ // (a permanent node that is a direct child of the swapped range, so its
+ // placeholder has no parent) writes into the slice array instead of calling
+ // `replaceChild`. Missing that branch leaves exactly this shape unprotected.
+ document.head.innerHTML = ' ';
+ const live = bodyFrom('
');
+ const incomingHost = bodyFrom('
');
+ const liveNode = live.querySelector('#w');
+ const innerBefore = liveNode.querySelector('#sd');
+ // Detach the incoming member so it is a parentless top-level slice entry.
+ const placeholder = incomingHost.querySelector('#w');
+ placeholder.remove();
+ const incomingSlice = [placeholder];
+
+ _regraftPermanentInSlice([liveNode], incomingSlice);
+ assert.equal(incomingSlice[0], liveNode, 'precondition: the slice entry became the live node');
+
+ // The reconciler inserts the slice entries; reactivation then runs over the
+ // container they landed in.
+ const host = bodyFrom('');
+ host.append(incomingSlice[0]);
+ _reactivateScripts(host);
+
+ assert.strictEqual(host.querySelector('#sd'), innerBefore,
+ 'the detached-branch regraft marked the node, so its script is exempt too');
+});
+
+test('reactivateScripts: a REGRAFTED permanent SCRIPT is still re-emitted (#1252 / #1102)', () => {
+ // The two cases must not be unified, and this is the seam where unifying them
+ // hides. The regraft selector is `[data-webjs-permanent][id]` with NO tag
+ // filter, so a marked script present on both sides is preserved by identity
+ // and lands in the WeakSet exactly like a marked div. If the exemption were
+ // reflexive (`p === old`), the descendant walk would then skip it, while the
+ // container-is-a-script branch above still re-emits it: one script, opposite
+ // answers depending on which entry point reached it, and #1102's
+ // stops-working-after-the-first-soft-nav failure back for that shape.
+ document.head.innerHTML = ' ';
+ const live = bodyFrom('
');
+ const incoming = bodyFrom('
');
+ const liveScript = live.querySelector('#ps');
+
+ _regraftPermanentElements(live, incoming);
+ assert.equal(incoming.querySelector('#ps'), liveScript,
+ 'precondition: a marked SCRIPT is regrafted like any other marked element');
+
+ // Reached as a DESCENDANT, which is the full-body path (`reactivateScripts`
+ // is called on `document.body`, not on the script).
+ _reactivateScripts(incoming);
+
+ assert.notStrictEqual(incoming.querySelector('#ps'), liveScript,
+ 'the marked script itself is never exempt, however the walk reaches it');
+ assert.equal(incoming.querySelector('#ps').getAttribute('nonce'), 'page-nonce');
});
test('reactivateScripts: a detached script inserts nothing (#1102)', () => {
diff --git a/website/app/docs/client-router/page.ts b/website/app/docs/client-router/page.ts
index 09ba4ece3..37dabc858 100644
--- a/website/app/docs/client-router/page.ts
+++ b/website/app/docs/client-router/page.ts
@@ -18,10 +18,10 @@ export default function ClientRouter() {
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 two-tier swap 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: Document.parseHTMLUnsafe strips every comment in some browser versions, so the router probes it once and parses with DOMParser instead when it is lossy.
The diff inside the swap region is keyed by data-key or id. Matched elements are reused with in-place attribute updates. Live attributes (value, checked, selected, indeterminate, disabled, open, popover) are never overwritten, so user input and disclosure state survive the swap.
The <head> is add-only merged (preserves runtime-injected styles like Tailwind's), <script> tags re-execute, custom elements upgrade, URL updates via pushState.
- Every <script> 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 \${children}). 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.
+ Every <script> 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 \${children}). 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 data-webjs-permanent, covered below.
A webjs:navigate event fires on document with the final URL.
- Write swapped scripts to be re-runnable. A script inside a swapped range runs again on every navigation that swaps that range, and giving it an id does not change that. The keyed differ reuses the live element, and the router still re-emits it. So a script that installs a listener or a MutationObserver should either be idempotent or guard on a flag it sets the first time. The alternative, running once and then never again, is the worse default: it is exactly what a progressive-enhancement script (a syntax highlighter, a chart initializer) must not do after a soft nav. Put anything that genuinely must run once in the root layout, whose markup is never swapped. data-webjs-permanent does not help here: it preserves node identity for stateful elements like a playing <audio>, and a script carrying it is re-emitted like any other.
+ Write swapped scripts to be re-runnable. A script inside a swapped range runs again on every navigation that swaps that range, and giving it an id does not change that. The keyed differ reuses the live element, and the router still re-emits it. So a script that installs a listener or a MutationObserver should either be idempotent or guard on a flag it sets the first time. The alternative, running once and then never again, is the worse default: it is exactly what a progressive-enhancement script (a syntax highlighter, a chart initializer) must not do after a soft nav. Put anything that genuinely must run once in the root layout, whose markup is never swapped. data-webjs-permanent splits into two cases here. A script that IS the marked element is re-emitted like any other, so the attribute is not an escape hatch for a script itself. A script INSIDE a marked element that the swap actually preserved is left alone, because the attribute means that whole subtree survives as the same live node.
Wire-byte optimization : the router sends an X-Webjs-Have request header listing segment:route-key entries for the boundaries it already has (the key lets the server re-render a dynamic layout the client holds for different params instead of skipping it). The server walks the target page's layout chain innermost-to-outermost, short-circuits at the first match, and returns only the divergent fragment wrapped in that layout's boundary pair. Outer layouts are never re-serialized for same-shell navigations, and a reduced response is served private so no shared cache can store it and serve it to a full-page navigation. It also carries Vary: X-Webjs-Have for caches that honour that header, but the guarantee does not depend on it: Cloudflare honours only Accept-Encoding. On a page that opted into caching via metadata.cacheControl, the fragment still carries an ETag, so the router's revalidating fetches stay cheap; on a default no-store page there is nothing to validate either way.
Progressive streaming on navigation
@@ -214,6 +214,9 @@ connectWS('/posts/' + id + '/feed', { onMessage: (m) => renderStream(m) });
An id present in the current but ABSENT from the incoming doc is NOT force-persisted (it is being removed; the swap removes it as usual).
Only a CURRENT node actually carrying data-webjs-permanent is moved (an incoming #id that resolves to a non-permanent current element is left untouched).
The node is placed exactly where the incoming document puts it, so it never escapes a frame / region boundary.
+ The attribute is SUBTREE-scoped, so a <script> inside a preserved element is NOT re-emitted and does not run again. That is the point: re-running a widget's init script against the live instance you asked the router to keep is a double-initialization, not a refresh.
+ The script exemption applies only once the element has ACTUALLY been preserved. The first time a permanent element arrives there is nothing to preserve (the both-exist rule above), so it is ordinary new content and its scripts run like any other. An element with no id can never be preserved, so it never gets the exemption either.
+ A script that IS the marked element is always re-emitted. The attribute preserves accumulated JS state, and a script's only state is that it ran; exempting it would leave a script that runs on a cold load and never on a soft navigation.
Progressive enhancement: with JS off, data-webjs-permanent is an inert attribute and the navigation is a normal full-page load.