Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -136,7 +136,7 @@ A page (or layout) does not write raw `<head>` 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 `<webjs-frame>` 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 `<audio>`, 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 `<webjs-frame>` 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 `<audio>`, 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 `<script>` inside it is not re-emitted and does not re-run (#1252); a permanent element arriving for the first time, or one with no `id`, is ordinary new content and runs its scripts.

The opt-in is **per page**, so it is a page-scoped meta: put it on a page's metadata to animate that page, or on the root layout to animate the whole app. Navigating to a page that does NOT declare it turns transitions back off, because the soft-nav head merge reconciles page-scoped `<meta>` tags (a stale one the previous page declared is removed, not left to leak, #1046). View transitions **compose with Suspense streaming**: a streamed boundary (a `loading.{js,ts}` skeleton or a `<webjs-suspense>` region) navigated to under an active transition still resolves its content progressively, because the streamed resolve waits for the transition's DOM swap to commit before it applies (#1048).

Expand Down
2 changes: 1 addition & 1 deletion packages/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ the same output in all three.
| `webjs-suspense.js` | The `<webjs-suspense>` component-level streaming boundary element (#471). SSR (`render-server.js`) does the work: `injectDSD`'s `processSuspenseElements` pre-pass reads `.fallback` (carried as `data-webjs-fallback` by `renderTemplate`, since a TemplateResult is not serializer-safe) and, in a streaming context, flushes the fallback as `<webjs-suspense id="sN">` while pushing the children to `ctx.pending` for out-of-order streaming (concurrent across boundaries via `Promise.all`); without a streaming context the children render inline (blocking). This client element is layout-neutral (`display:contents`) and the registration home for the soft-nav apply; first-load streaming needs no client runtime (the inline swap script `replaceWith`s the boundary element with the resolved children, which then upgrade). Every swap path (the inline script, the boot `__webjsResolve`, and the soft-nav `applyStreamedResolve`) removes the transient wrapper, so a boundary settles to the same DOM however the page was reached. SSR-inert (defined client-side only) |
| `context.js` | Context Protocol: `createContext`, `ContextProvider`, `ContextConsumer`, `ContextRequestEvent` |
| `task.js` | `Task` / `TaskStatus` controller for async data in components |
| `router-client.js` | Turbo Drive–style client router; entry: `enableClientRouter` / `navigate`. Also exports `loadFrame(frameEl, url)` (#253), the reusable frame self-load `webjs-frame.js` calls: it fetches `url` as a frame nav (the `x-webjs-frame` header) and applies the matched subtree through the SAME `fetchAndApply` frame-swap path a click uses (no history push / snapshot / optimistic skeleton, since it swaps one region). Post-swap activation of a boundary range goes through `activateSwappedRange` (#1102), the ONE place both tiers (`replaceBoundaryRange`, `swapMarkerRange`) reactivate scripts and upgrade custom elements. Two things it owns and a new call site must keep: it SNAPSHOTS the range before iterating, because `reactivateScripts` replaces a top-level script and a detached node cuts a live `nextSibling` walk (every later node in the range is then silently skipped); and `reactivateScripts` handles container-IS-a-script itself, since `querySelectorAll` never matches the node it is called on. A top-level script therefore re-executes on every swap of its range, INCLUDING one the keyed differ reused by `id`, matching what a descendant script in a reused container has always done. `data-webjs-permanent` is NOT an exemption and must not be made one: its regraft has a both-exist guard, so on the swap that first mounts a route there is no live node to preserve and exempting the inert parsed copy would leave a script that runs on a cold load and never on a soft nav, which is #1102 itself |
| `router-client.js` | Turbo Drive–style client router; entry: `enableClientRouter` / `navigate`. Also exports `loadFrame(frameEl, url)` (#253), the reusable frame self-load `webjs-frame.js` calls: it fetches `url` as a frame nav (the `x-webjs-frame` header) and applies the matched subtree through the SAME `fetchAndApply` frame-swap path a click uses (no history push / snapshot / optimistic skeleton, since it swaps one region). Post-swap activation of a boundary range goes through `activateSwappedRange` (#1102), the ONE place both tiers (`replaceBoundaryRange`, `swapMarkerRange`) reactivate scripts and upgrade custom elements. Two things it owns and a new call site must keep: it SNAPSHOTS the range before iterating, because `reactivateScripts` replaces a top-level script and a detached node cuts a live `nextSibling` walk (every later node in the range is then silently skipped); and `reactivateScripts` handles container-IS-a-script itself, since `querySelectorAll` never matches the node it is called on. A top-level script therefore re-executes on every swap of its range, INCLUDING one the keyed differ reused by `id`, matching what a descendant script in a reused container has always done. `data-webjs-permanent` splits into two cases and they must NOT be unified (#1252). The marked element IS a script: NEVER exempt, whether the walk reaches it as the container or as a descendant of one (the regraft selector has no tag filter, so a marked script IS preserved by identity and does land in the WeakSet, which is why the exemption is STRICT containment and never reflexive). The regraft also has a both-exist guard, so on the swap that first mounts a route there is no live node to preserve and exempting the inert parsed copy would leave a script that runs on a cold load and never on a soft nav, which is #1102 itself. Script INSIDE a preserved marked element: exempt, because the attribute is subtree-scoped (`diffElementInPlace` already returns early rather than recursing into one) and re-emitting an init script against an instance the author kept alive is a double-initialization. The filter keys on the `regraftedPermanents` WeakSet, which the two regrafts populate on every successful path, so it means ACTUALLY preserved by identity rather than merely carrying the attribute; an attribute-only filter would leave a first-mount permanent element's scripts never running at all |
| `webjs-frame.js` | The `<webjs-frame id>` custom element (a swap anchor; the router does the swap). Adds the `src` + `loading` self-load (#253): an eager (`connectedCallback`) or lazy (viewport, via `lazy-loader.js`'s `observeViewportOnce`) self-fetch through `router-client.js`'s `loadFrame`, with a per-element loaded-URL guard so eager connect / the lazy observer / a `src` mutation never double-fetch. SSR-inert (defined client-side only) |
| `webjs-stream.js` | The `<webjs-stream action target>` surgical-update element + `renderStream(payload)` (#248). The element self-applies its action on connect via native DOM (append / prepend / before / after / replace / update / remove against a `target` id or `targets` selector), cloning its single `<template>`, then removes itself. `renderStream(html)` parses a server payload and inserts the elements (they self-apply), so a live channel (`connectWS` / `broadcast`) reuses the SAME applier the HTTP path uses. `router-client.js` side-effect-imports this for app-wide registration and applies a content-negotiated `text/vnd.webjs-stream.html` form response through `renderStream` (sending the stream MIME in `Accept` only on a write, so a JS-off form degrades to a normal render). SSR-inert (defined client-side only) |
| `registry.js` | Custom-element bookkeeping (`register`, `lookup`, `allTags`, `tagOf`, `isLazy`, `primeModuleUrl`) |
Expand Down
82 changes: 69 additions & 13 deletions packages/core/src/router-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -2664,6 +2664,24 @@ function runWithTransition(thunk, afterFinished) {
return Promise.resolve();
}

/**
* Live nodes a regraft actually moved into the incoming tree, so they
* survived the swap BY IDENTITY. Membership is strictly narrower than
* "carries `data-webjs-permanent`": the regrafts have a both-exist guard, so
* a permanent element arriving for the first time is a freshly imported node
* that was never preserved and is not in here. `reactivateScripts` reads this
* to decide whether a script inside a permanent element is a script the
* author kept alive (skip it) or one that has never run (run it).
*
* Weak and keyed by node identity, so a destroyed node drops out on its own
* and a later element reusing the same `#id` is a different object that
* correctly re-runs. Never cleared per navigation: a node preserved across
* several navigations must keep its exemption on every one of them.
*
* @type {WeakSet<Element>}
*/
const regraftedPermanents = new WeakSet();

/**
* Persist `data-webjs-permanent` elements across a swap by NODE IDENTITY.
*
Expand Down Expand Up @@ -2717,6 +2735,7 @@ function regraftPermanentElements(currentRoot, incomingRoot) {
// incoming placeholder. The swap then adopts the live node.
if (placeholder === live) continue;
parent.replaceChild(live, placeholder);
regraftedPermanents.add(live);
}
}

Expand Down Expand Up @@ -2759,12 +2778,16 @@ function regraftPermanentInSlice(liveSlice, incomingSlice) {
const parent = placeholder.parentNode;
if (parent) {
parent.replaceChild(live, placeholder);
regraftedPermanents.add(live);
} else {
// Placeholder is a top-level slice member with no parent (detached):
// replace it in the incomingSlice array so the reconciler inserts the
// live node in that position.
const idx = incomingSlice.indexOf(placeholder);
if (idx !== -1) incomingSlice[idx] = live;
if (idx !== -1) {
incomingSlice[idx] = live;
regraftedPermanents.add(live);
}
}
}
}
Expand Down Expand Up @@ -4401,18 +4424,31 @@ async function streamBoundariesProgressively(reader, dec, initialBuf, isCurrent)
* Replacing the container DETACHES it, which is why both callers snapshot the
* range before iterating rather than walking live `nextSibling` links.
*
* `data-webjs-permanent` is deliberately NOT an exemption here, and must not be
* added as one. It reads like the natural opt-out, but it cannot work: its
* regraft has a both-exist guard (`regraftPermanentInSlice`), so on the swap
* that first mounts a route there is no live node to preserve, the inert parsed
* copy is what lands, and exempting it would leave a script that runs on a cold
* load and never on a soft navigation. That is precisely the #1102 failure,
* reintroduced under the banner of fixing it. The attribute preserves node
* identity for STATEFUL elements (a playing `<audio>`, a third-party widget);
* a script's only state is that it ran, and re-running is the contract for
* everything in a swapped range. A descendant permanent script has always been
* re-emitted by the walk below, so exempting the container would also have made
* the answer depend on nesting depth.
* `data-webjs-permanent` splits into two cases here, and the split is the whole
* rule (#1252):
*
* - The marked element IS a script: NEVER exempt, however the walk reaches
* it. This holds whether it arrives as the `container` or as a descendant
* of one, because the regraft selector has no tag filter and will happily
* preserve a `<script id data-webjs-permanent>` by identity. The exemption
* below is therefore STRICT containment, never reflexive. This must not be
* changed. The regrafts have a both-exist guard, so on the swap that first
* mounts a route there is no live node to preserve, the inert parsed copy
* is what lands, and exempting it would leave a script that runs on a cold
* load and never on a soft navigation. That is precisely the #1102 failure,
* reintroduced under the banner of fixing it. A script's only state is that
* it ran, and re-running is the contract for everything in a swapped range.
* - A script INSIDE a marked element that was ACTUALLY preserved: exempt.
* The attribute means the subtree survives as the same live node, which is
* what `diffElementInPlace` already implements by returning early rather
* than recursing into it. Re-emitting an init script against a widget
* instance the author deliberately kept alive is a double-initialization,
* not a refresh.
*
* The filter keys on `regraftedPermanents` (actual preservation by identity),
* never on the attribute alone. A permanent element arriving for the FIRST time
* was never preserved, so its scripts have never run and must run now; an
* attribute-only filter would leave them never running on any path.
*
* @param {Element} container
* @returns {Element} `container`, or its replacement when it was a script that
Expand All @@ -4430,7 +4466,27 @@ function reactivateScripts(container) {
container.replaceWith(fresh);
return fresh;
}
// Roots whose subtrees survived this swap by identity. Collected from the
// container DOWNWARD so the exemption is bounded to the swapped range by
// construction; `closest()` from a script upward could escape into an outer
// ancestor that was never part of this swap.
/** @type {Element[]} */
const preserved = [];
if (regraftedPermanents.has(container)) preserved.push(container);
for (const el of container.querySelectorAll('[data-webjs-permanent]')) {
if (regraftedPermanents.has(el)) preserved.push(el);
}

for (const old of container.querySelectorAll('script')) {
// STRICT containment: a preserved root exempts its DESCENDANTS, never
// itself. The regrafts select `[data-webjs-permanent][id]` with no tag
// filter, so a `<script id data-webjs-permanent>` present on both sides is
// regrafted like any other element and lands in the WeakSet. Skipping it
// here would exempt the marked script itself whenever the walk reaches it
// as a descendant (the full-body path), while the container branch above
// still re-emits it, so one script would get opposite answers depending on
// which entry point reached it. `contains()` is reflexive, hence `p !== old`.
if (preserved.length && preserved.some((p) => p !== old && p.contains(old))) continue;
old.replaceWith(cloneScriptWithCorrectNonce(/** @type {HTMLScriptElement} */ (old)));
}
return container;
Expand Down
Loading
Loading