From 3e279c458fa784b50b970884b868d998998d79cf Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 5 Aug 2026 15:03:36 +0530 Subject: [PATCH] fix: a preserved permanent element keeps its descendant scripts `data-webjs-permanent` preserves an element across a swap as the same live DOM node, and the reconciler already treats that as a SUBTREE guarantee (`diffElementInPlace` returns early rather than recursing into one). The reactivation pass disagreed: it re-emitted every script inside the preserved subtree, and re-emitting a script is what makes it execute. So a widget whose init script sits inside the permanent element got that script re-run on every soft navigation, against an instance the author explicitly asked the router to keep alive. The exemption keys on whether the node was ACTUALLY preserved by identity, not on the attribute. The regrafts have a both-exist guard, so a permanent element arriving for the first time is a freshly imported node whose scripts have never run; an attribute-only filter would leave those never running on any path. Both regrafts now add the live node to a module-scope WeakSet as they move it, and `reactivateScripts` skips a script under any preserved root. The container-is-a-script case stays unexempt, deliberately. That one has no live node to preserve on the swap that first mounts a route, so exempting it is #1102 exactly, and a test pins the asymmetry. --- .../references/client-router-and-streaming.md | 4 +- packages/core/AGENTS.md | 2 +- packages/core/src/router-client.js | 82 ++++++++++-- .../toplevel-script-reactivate.test.js | 85 +++++++++++++ .../core/test/routing/router-client.test.js | 117 ++++++++++++++++-- website/app/docs/client-router/page.ts | 7 +- 6 files changed, 272 insertions(+), 25 deletions(-) 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 `