@webjsdev/core 0.7.50 → 0.7.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/webjs-core-browser.js +5 -5
  2. package/dist/webjs-core-browser.js.map +3 -3
  3. package/dist/webjs-core-testing.js +20 -20
  4. package/dist/webjs-core-testing.js.map +3 -3
  5. package/dist/webjs-core.js +7 -7
  6. package/dist/webjs-core.js.map +3 -3
  7. package/index-browser.js +1 -1
  8. package/index.d.ts +1 -1
  9. package/index.js +1 -1
  10. package/package.json +1 -1
  11. package/src/component/lifecycle.js +1481 -0
  12. package/src/component/reactive.js +171 -0
  13. package/src/component/server-element.js +248 -0
  14. package/src/component.js +2 -1896
  15. package/src/render-client/parts.js +2007 -0
  16. package/src/render-client/reconciler.js +388 -0
  17. package/src/render-client/template-compiler.js +525 -0
  18. package/src/render-client.js +1 -2938
  19. package/src/render-server/attrs.js +160 -0
  20. package/src/render-server/dsd.js +445 -0
  21. package/src/render-server/html-scan.js +402 -0
  22. package/src/render-server/slots.js +213 -0
  23. package/src/render-server/stream.js +163 -0
  24. package/src/render-server/template-renderer.js +923 -0
  25. package/src/render-server/text.js +163 -0
  26. package/src/render-server.js +6 -2405
  27. package/src/router-client/anchors.js +44 -0
  28. package/src/router-client/boundaries.js +221 -0
  29. package/src/router-client/constants.js +187 -0
  30. package/src/router-client/diagnostics.js +377 -0
  31. package/src/router-client/dom-differ.js +709 -0
  32. package/src/router-client/dom-parse.js +150 -0
  33. package/src/router-client/events.js +139 -0
  34. package/src/router-client/fetch-apply.js +387 -0
  35. package/src/router-client/form-encoder.js +135 -0
  36. package/src/router-client/frames.js +329 -0
  37. package/src/router-client/head-merge.js +344 -0
  38. package/src/router-client/nav-error.js +105 -0
  39. package/src/router-client/navigator.js +781 -0
  40. package/src/router-client/prefetch.js +824 -0
  41. package/src/router-client/scroll.js +259 -0
  42. package/src/router-client/snapshot-cache.js +97 -0
  43. package/src/router-client/state.js +92 -0
  44. package/src/router-client/stream.js +206 -0
  45. package/src/router-client/swap.js +451 -0
  46. package/src/router-client/upgrade.js +261 -0
  47. package/src/router-client/view-transition.js +223 -0
  48. package/src/router-client.d.ts +10 -1
  49. package/src/router-client.js +75 -5345
  50. package/src/slot/assignment.js +304 -0
  51. package/src/slot/interception.js +558 -0
  52. package/src/slot/polyfills.js +365 -0
  53. package/src/slot/project.js +432 -0
  54. package/src/slot/sensors.js +303 -0
  55. package/src/slot/state.js +245 -0
  56. package/src/slot/symbols.js +85 -0
  57. package/src/slot.js +7 -2199
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Client router: upgrade.
3
+ *
4
+ * Moved verbatim out of the pre-split `router-client.js`; that barrel holds
5
+ * the router's full contract and the public entry points.
6
+ *
7
+ * @module
8
+ */
9
+ import { buildHaveHeader, collectBoundaries, planBoundarySwap } from './boundaries.js';
10
+ import { FALLBACK_MARKER_KEY, FRAME_TOP, LIVE_ATTRS } from './constants.js';
11
+ import { isPreBootNavigation } from './diagnostics.js';
12
+ import { applyOptimisticLoading, blurOutgoingFocus, diffElementInPlace, keyOf, reconcileChildren, restoreOptimistic } from './dom-differ.js';
13
+ import { parseHTML, resetParseProbe } from './dom-parse.js';
14
+ import { onPopState, onSubmit } from './events.js';
15
+ import { buildSubmitFormData, encodeSubmitBody, getSubmitAction, getSubmitEnctype, getSubmitMethod } from './form-encoder.js';
16
+ import { activeFrameId, clearFormBusy, clearFrameBusy, markFormBusy, markFrameBusy, resolveTargetFrameId } from './frames.js';
17
+ import { addNewHeadElements, cloneScriptWithCorrectNonce, mergeHead } from './head-merge.js';
18
+
19
+ import { eligibleAnchorHref, prefetch, prefetchAnchor, prefetchCache, prefetchHasHoverPointer, prefetchMode, prefetchSaysSaveData, prefetchSuppressed, prefetchTake } from './prefetch.js';
20
+ import { snapshotCache } from './snapshot-cache.js';
21
+ import { applyStreamedResolve, readStreamedShell, streamBoundariesProgressively, takeResolveUnit } from './stream.js';
22
+ import { applySwap } from './swap.js';
23
+ import { regraftPermanentElements, regraftPermanentInSlice, regraftedPermanents, runWithTransition, viewTransitionsEnabled } from './view-transition.js';
24
+
25
+ /**
26
+ * Global MutationObserver that upgrades any custom element inserted into
27
+ * the document. Safety net: if our diff / replaceChildren / View
28
+ * Transitions ever leave an un-upgraded element behind, this catches it.
29
+ */
30
+ let upgradeObserver = null;
31
+
32
+ export function ensureUpgradeObserver() {
33
+ if (upgradeObserver || typeof MutationObserver === 'undefined' || typeof customElements === 'undefined') return;
34
+ upgradeObserver = new MutationObserver((mutations) => {
35
+ for (const m of mutations) {
36
+ for (const node of m.addedNodes) {
37
+ if (node.nodeType !== 1) continue;
38
+ const el = /** @type {Element} */ (node);
39
+ if (el.tagName?.includes('-')) customElements.upgrade(el);
40
+ for (const child of el.querySelectorAll('*')) {
41
+ if (child.tagName?.includes('-')) customElements.upgrade(child);
42
+ }
43
+ }
44
+ }
45
+ });
46
+ upgradeObserver.observe(document.body, { childList: true, subtree: true });
47
+ }
48
+
49
+ /**
50
+ * Re-upgrade custom elements between a marker pair after a transitioned
51
+ * swap settles. The View Transitions API snapshots and replaces DOM, so
52
+ * elements can need a re-upgrade once the animation finishes.
53
+ *
54
+ * @param {{ start: Comment, end: Comment | null } | undefined} range
55
+ */
56
+ export function upgradeCustomElementsInRange(range) {
57
+ if (!range || !range.start) return;
58
+ for (let n = range.start.nextSibling; n && n !== range.end; n = n.nextSibling) {
59
+ if (n.nodeType === 1) upgradeCustomElements(/** @type {Element} */ (n));
60
+ }
61
+ }
62
+
63
+ /** @param {Element} container */
64
+ export function upgradeCustomElements(container) {
65
+ if (typeof customElements === 'undefined') return;
66
+ upgradeTree(container);
67
+ }
68
+
69
+ /** @param {Element | DocumentFragment} root */
70
+ export function upgradeTree(root) {
71
+ const els = root instanceof Element
72
+ ? [root, ...root.querySelectorAll('*')]
73
+ : [...root.querySelectorAll('*')];
74
+ for (const el of els) {
75
+ if (el.tagName && el.tagName.includes('-')) {
76
+ customElements.upgrade(el);
77
+ if (el.shadowRoot) upgradeTree(el.shadowRoot);
78
+ }
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Re-execute every `<script>` in `container`, INCLUDING `container` itself when
84
+ * it is one (#1102). A script parsed by `DOMParser` carries the spec's
85
+ * "already started" flag, so the node grafted into the live document is inert
86
+ * and only a fresh clone runs. `querySelectorAll` never matches the element it
87
+ * is called on, so a container-is-a-script was silently skipped: the two swap
88
+ * tiers hand this function each TOP-LEVEL node of the swapped range in turn, so
89
+ * a script emitted as a sibling of the content (a layout's progressive-
90
+ * enhancement script, the shape that surfaced this) never ran after a soft nav.
91
+ *
92
+ * Replacing the container DETACHES it, which is why both callers snapshot the
93
+ * range before iterating rather than walking live `nextSibling` links.
94
+ *
95
+ * `data-webjs-permanent` splits into two cases here, and the split is the whole
96
+ * rule (#1252):
97
+ *
98
+ * - The marked element IS a script: NEVER exempt, however the walk reaches
99
+ * it. This holds whether it arrives as the `container` or as a descendant
100
+ * of one, because the regraft selector has no tag filter and will happily
101
+ * preserve a `<script id data-webjs-permanent>` by identity. The exemption
102
+ * below is therefore STRICT containment, never reflexive. This must not be
103
+ * changed. The regrafts have a both-exist guard, so on the swap that first
104
+ * mounts a route there is no live node to preserve, the inert parsed copy
105
+ * is what lands, and exempting it would leave a script that runs on a cold
106
+ * load and never on a soft navigation. That is precisely the #1102 failure,
107
+ * reintroduced under the banner of fixing it. A script's only state is that
108
+ * it ran, and re-running is the contract for everything in a swapped range.
109
+ * - A script INSIDE a marked element that was ACTUALLY preserved: exempt.
110
+ * The attribute means the subtree survives as the same live node, which is
111
+ * what `diffElementInPlace` already implements by returning early rather
112
+ * than recursing into it. Re-emitting an init script against a widget
113
+ * instance the author deliberately kept alive is a double-initialization,
114
+ * not a refresh.
115
+ *
116
+ * The filter keys on `regraftedPermanents` (actual preservation by identity),
117
+ * never on the attribute alone. A permanent element arriving for the FIRST time
118
+ * was never preserved, so its scripts have never run and must run now; an
119
+ * attribute-only filter would leave them never running on any path.
120
+ *
121
+ * @param {Element} container
122
+ * @returns {Element} `container`, or its replacement when it was a script that
123
+ * was re-emitted. The replacement sits wherever `container` was; when
124
+ * `container` was already detached, `replaceWith` is a spec no-op and the
125
+ * returned clone is detached too, so callers must not assume it is connected.
126
+ */
127
+ export function reactivateScripts(container) {
128
+ if (container.tagName === 'SCRIPT') {
129
+ const fresh = cloneScriptWithCorrectNonce(/** @type {HTMLScriptElement} */ (container));
130
+ // A no-op when the node has no parent. That happens when an EARLIER
131
+ // script's reactivation ran code that removed this node from the range
132
+ // (reactivation executes synchronously), so a stale snapshot entry cannot
133
+ // resurrect itself into the document.
134
+ container.replaceWith(fresh);
135
+ return fresh;
136
+ }
137
+ // Roots whose subtrees survived this swap by identity. Collected from the
138
+ // container DOWNWARD so the exemption is bounded to the swapped range by
139
+ // construction; `closest()` from a script upward could escape into an outer
140
+ // ancestor that was never part of this swap.
141
+ /** @type {Element[]} */
142
+ const preserved = [];
143
+ if (regraftedPermanents.has(container)) preserved.push(container);
144
+ for (const el of container.querySelectorAll('[data-webjs-permanent]')) {
145
+ if (regraftedPermanents.has(el)) preserved.push(el);
146
+ }
147
+
148
+ for (const old of container.querySelectorAll('script')) {
149
+ // STRICT containment: a preserved root exempts its DESCENDANTS, never
150
+ // itself. The regrafts select `[data-webjs-permanent][id]` with no tag
151
+ // filter, so a `<script id data-webjs-permanent>` present on both sides is
152
+ // regrafted like any other element and lands in the WeakSet. Skipping it
153
+ // here would exempt the marked script itself whenever the walk reaches it
154
+ // as a descendant (the full-body path), while the container branch above
155
+ // still re-emits it, so one script would get opposite answers depending on
156
+ // which entry point reached it. `contains()` is reflexive, hence `p !== old`.
157
+ if (preserved.length && preserved.some((p) => p !== old && p.contains(old))) continue;
158
+ old.replaceWith(cloneScriptWithCorrectNonce(/** @type {HTMLScriptElement} */ (old)));
159
+ }
160
+ return container;
161
+ }
162
+
163
+ /**
164
+ * Reactivate scripts + upgrade custom elements across a just-swapped boundary
165
+ * range. The range is SNAPSHOT first: `reactivateScripts` replaces a top-level
166
+ * script node, which detaches it and cuts a live `nextSibling` walk, silently
167
+ * skipping every node after it (#1102).
168
+ *
169
+ * The snapshot is taken AFTER the tier has finished writing the range (the
170
+ * replace tier's insert loop, the morph tier's `reconcileSiblings`), so every
171
+ * entry is attached when it is recorded. It can still go stale DURING the walk,
172
+ * because reactivating a script executes it synchronously and that code may
173
+ * mutate the range. Two consequences, both deliberate. A node an earlier script
174
+ * REMOVED is skipped, since `replaceWith` on a parentless node is a spec no-op.
175
+ * A node an earlier script INSERTED is not visited, unlike the live walk this
176
+ * replaced. That costs nothing reachable: such a node is connected by
177
+ * definition, so the browser upgrades it on insertion, and `ensureUpgradeObserver`
178
+ * catches it on the microtask regardless. Do NOT "restore" the live walk to
179
+ * recover it. A correct live walk has to advance off the REPLACEMENT (advancing
180
+ * off the detached original is bug #1102 itself), and in that form a script
181
+ * appending a sibling script loops forever, each clone appending the next; it
182
+ * would also re-run a script that already executed when its own creator
183
+ * inserted it. The snapshot is additionally safer under a MOVE, since a live
184
+ * walk would follow a node out of the range and start re-executing unrelated
185
+ * scripts in the rest of the body.
186
+ *
187
+ * @param {{ start: Comment, end: Comment }} range
188
+ */
189
+ export function activateSwappedRange(range) {
190
+ /** @type {Element[]} */
191
+ const swapped = [];
192
+ for (let n = range.start.nextSibling; n && n !== range.end; n = n.nextSibling) {
193
+ if (n.nodeType === 1) swapped.push(/** @type {Element} */ (n));
194
+ }
195
+ for (const el of swapped) {
196
+ const live = reactivateScripts(el);
197
+ // Nothing to upgrade in a detached tree. `customElements.upgrade` off
198
+ // document runs the CONSTRUCTOR (not `connectedCallback`, which waits for
199
+ // insertion), so this skips constructing elements for a tree that was just
200
+ // removed and will never be seen.
201
+ if (live.isConnected !== false) upgradeCustomElements(live);
202
+ }
203
+ }
204
+
205
+ /* ====================================================================
206
+ * Internal exports for unit testing
207
+ * ==================================================================== */
208
+
209
+ export {
210
+ addNewHeadElements as _addNewHeadElements,
211
+ mergeHead as _mergeHead,
212
+ reactivateScripts as _reactivateScripts,
213
+ isPreBootNavigation as _isPreBootNavigation,
214
+ FALLBACK_MARKER_KEY as _FALLBACK_MARKER_KEY,
215
+ activateSwappedRange as _activateSwappedRange,
216
+ activeFrameId as _activeFrameId,
217
+ resolveTargetFrameId as _resolveTargetFrameId,
218
+ FRAME_TOP as _FRAME_TOP,
219
+ markFrameBusy as _markFrameBusy,
220
+ clearFrameBusy as _clearFrameBusy,
221
+ markFormBusy as _markFormBusy,
222
+ clearFormBusy as _clearFormBusy,
223
+ collectBoundaries as _collectBoundaries,
224
+ planBoundarySwap as _planBoundarySwap,
225
+ parseHTML as _parseHTML,
226
+ resetParseProbe as _resetParseProbe,
227
+ keyOf as _keyOf,
228
+ diffElementInPlace as _diffElementInPlace,
229
+ reconcileChildren as _reconcileChildren,
230
+ onPopState as _onPopState,
231
+ applySwap as _applySwap,
232
+ buildHaveHeader as _buildHaveHeader,
233
+ snapshotCache as _snapshotCache,
234
+ prefetchCache as _prefetchCache,
235
+ LIVE_ATTRS as _LIVE_ATTRS,
236
+ blurOutgoingFocus as _blurOutgoingFocus,
237
+ onSubmit as _onSubmit,
238
+ getSubmitMethod as _getSubmitMethod,
239
+ getSubmitAction as _getSubmitAction,
240
+ buildSubmitFormData as _buildSubmitFormData,
241
+ getSubmitEnctype as _getSubmitEnctype,
242
+ encodeSubmitBody as _encodeSubmitBody,
243
+ restoreOptimistic as _restoreOptimistic,
244
+ eligibleAnchorHref as _eligibleAnchorHref,
245
+ viewTransitionsEnabled as _viewTransitionsEnabled,
246
+ runWithTransition as _runWithTransition,
247
+ regraftPermanentElements as _regraftPermanentElements,
248
+ regraftPermanentInSlice as _regraftPermanentInSlice,
249
+ prefetchSuppressed as _prefetchSuppressed,
250
+ prefetchMode as _prefetchMode,
251
+ prefetchHasHoverPointer as _prefetchHasHoverPointer,
252
+ prefetch as _prefetch,
253
+ prefetchTake as _prefetchTake,
254
+ prefetchAnchor as _prefetchAnchor,
255
+ applyOptimisticLoading as _applyOptimisticLoading,
256
+ prefetchSaysSaveData as _prefetchSaysSaveData,
257
+ readStreamedShell as _readStreamedShell,
258
+ takeResolveUnit as _takeResolveUnit,
259
+ applyStreamedResolve as _applyStreamedResolve,
260
+ streamBoundariesProgressively as _streamBoundariesProgressively,
261
+ };
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Client router: view-transition.
3
+ *
4
+ * Moved verbatim out of the pre-split `router-client.js`; that barrel holds
5
+ * the router's full contract and the public entry points.
6
+ *
7
+ * @module
8
+ */
9
+
10
+ /**
11
+ * Whether the current page opts into the native View Transitions API for
12
+ * client-router swaps. OFF by default (no animation surprise, no
13
+ * regression for browsers without the API): a transition is purely
14
+ * opt-in via a `<meta name="view-transition" content="same-origin">` in
15
+ * the document head, mirroring Turbo's `<meta name="view-transition">`
16
+ * convention. The accepted opt-in value is `same-origin` (every
17
+ * client-router swap is same-origin by construction, so it reads as "yes,
18
+ * animate these in-app navigations"). Any other value, or the meta being
19
+ * absent, keeps transitions off.
20
+ *
21
+ * Re-read per navigation rather than cached: the meta can be added or
22
+ * removed by a swap (the head merge brings in the new page's head), so a
23
+ * page can turn transitions on or off as the user navigates.
24
+ *
25
+ * @returns {boolean}
26
+ */
27
+ export function viewTransitionsEnabled() {
28
+ if (typeof document === 'undefined') return false;
29
+ const meta = document.querySelector('meta[name="view-transition"]');
30
+ if (!meta) return false;
31
+ const content = (meta.getAttribute('content') || '').trim().toLowerCase();
32
+ return content === 'same-origin';
33
+ }
34
+
35
+ /**
36
+ * Run a synchronous DOM-mutation thunk, wrapping it in
37
+ * `document.startViewTransition()` when the page has opted in AND the
38
+ * browser supports the API. Otherwise the thunk runs synchronously,
39
+ * byte-identical to the pre-View-Transitions behaviour (no flash, no
40
+ * regression). The thunk is the SAME swap code in both branches; the
41
+ * transition only captures the before/after around the mutation (the
42
+ * fetch already happened, so it is never inside the callback).
43
+ *
44
+ * @param {() => void} thunk The synchronous DOM swap to perform.
45
+ * @param {() => void} [afterFinished] Optional post-transition work
46
+ * (e.g. re-upgrade custom elements) run when the transition settles; for
47
+ * the synchronous fallback it runs immediately after the thunk.
48
+ */
49
+ export function runWithTransition(thunk, afterFinished) {
50
+ const start = typeof document !== 'undefined'
51
+ ? /** @type any */ (document).startViewTransition
52
+ : undefined;
53
+ if (viewTransitionsEnabled() && typeof start === 'function') {
54
+ const t = start.call(document, thunk);
55
+ if (t && t.finished && typeof t.finished.then === 'function') {
56
+ t.finished.then(() => { if (afterFinished) afterFinished(); }).catch(() => {});
57
+ } else if (afterFinished) {
58
+ afterFinished();
59
+ }
60
+ // Resolve when the DOM MUTATION (the thunk) has actually committed, NOT when
61
+ // the animation finishes. Under `startViewTransition` the thunk is deferred a
62
+ // frame, so anything that reads the swapped-in DOM (a progressively-streamed
63
+ // Suspense resolve, #1048) must await this, or it runs against the pre-swap
64
+ // DOM and drops. `updateCallbackDone` is that signal; fall back to a resolved
65
+ // promise if the browser does not expose it.
66
+ return (t && t.updateCallbackDone && typeof t.updateCallbackDone.then === 'function')
67
+ ? t.updateCallbackDone.catch(() => {})
68
+ : Promise.resolve();
69
+ }
70
+ thunk();
71
+ if (afterFinished) afterFinished();
72
+ return Promise.resolve();
73
+ }
74
+
75
+ /**
76
+ * Live nodes a regraft actually moved into the incoming tree, so they
77
+ * survived the swap BY IDENTITY. Membership is strictly narrower than
78
+ * "carries `data-webjs-permanent`": the regrafts have a both-exist guard, so
79
+ * a permanent element arriving for the first time is a freshly imported node
80
+ * that was never preserved and is not in here. `reactivateScripts` reads this
81
+ * to decide whether a script inside a permanent element is a script the
82
+ * author kept alive (skip it) or one that has never run (run it).
83
+ *
84
+ * Weak and keyed by node identity, so a destroyed node drops out on its own
85
+ * and a later element reusing the same `#id` is a different object that
86
+ * correctly re-runs. Never cleared per navigation: a node preserved across
87
+ * several navigations must keep its exemption on every one of them.
88
+ *
89
+ * @type {WeakSet<Element>}
90
+ */
91
+ export const regraftedPermanents = new WeakSet();
92
+
93
+ /**
94
+ * Persist `data-webjs-permanent` elements across a swap by NODE IDENTITY.
95
+ *
96
+ * Mirrors Turbo's permanent-element behaviour: an element the author
97
+ * marks `data-webjs-permanent` (and which carries an `id`) survives a
98
+ * destructive swap as the SAME live DOM node, so a playing
99
+ * `<audio>` / `<video>`, a live widget, an open menu, or any element with
100
+ * accumulated JS state keeps running across the navigation instead of
101
+ * being destroyed and re-created from the incoming HTML.
102
+ *
103
+ * The mechanism runs BEFORE the destructive `replaceChildren` / range
104
+ * delete: for each `[data-webjs-permanent][id]` in the CURRENT subtree, if
105
+ * the INCOMING tree has a matching `#id`, the live current node is MOVED
106
+ * into the incoming tree's position (replacing the incoming placeholder).
107
+ * The subsequent swap then ADOPTS the live node (it is already part of the
108
+ * incoming tree) rather than destroying the current one. The keyed
109
+ * reconciler matches it by id afterwards and leaves it in place.
110
+ *
111
+ * Guards (correctness):
112
+ * - both-exist: only regraft an id present in BOTH the current and
113
+ * incoming subtree. An id in the current but NOT the incoming is being
114
+ * removed; leave it (do not force it to persist).
115
+ * - current-is-permanent: only move when the CURRENT node actually
116
+ * carries `data-webjs-permanent` (an incoming `#id` that resolves to a
117
+ * non-permanent current element is left untouched).
118
+ * - boundary-respecting: the live node is placed exactly where the
119
+ * incoming document puts it, so it never escapes a frame/region.
120
+ *
121
+ * @param {ParentNode} currentRoot The live subtree being swapped out.
122
+ * @param {ParentNode} incomingRoot The incoming subtree being swapped in.
123
+ */
124
+ export function regraftPermanentElements(currentRoot, incomingRoot) {
125
+ if (!currentRoot || !incomingRoot) return;
126
+ if (typeof currentRoot.querySelectorAll !== 'function') return;
127
+ const permanents = currentRoot.querySelectorAll('[data-webjs-permanent][id]');
128
+ for (const live of permanents) {
129
+ const id = live.id;
130
+ if (!id) continue;
131
+ // both-exist guard: the incoming subtree must carry a matching #id.
132
+ let placeholder = null;
133
+ try {
134
+ placeholder = incomingRoot.querySelector(`#${CSS.escape(id)}`);
135
+ } catch { placeholder = null; }
136
+ if (!placeholder) continue;
137
+ // current-is-permanent guard is implicit in the selector above, but
138
+ // re-assert defensively (the live node is the one we move).
139
+ if (!live.hasAttribute || !live.hasAttribute('data-webjs-permanent')) continue;
140
+ const parent = placeholder.parentNode;
141
+ if (!parent) continue;
142
+ // Move the LIVE node into the incoming tree's position, replacing the
143
+ // incoming placeholder. The swap then adopts the live node.
144
+ if (placeholder === live) continue;
145
+ parent.replaceChild(live, placeholder);
146
+ regraftedPermanents.add(live);
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Permanent-element regraft for the marker-range path, where the two
152
+ * sides are ARRAYS of sibling nodes (the live slice between markers, and
153
+ * the imported-but-detached incoming slice) rather than single roots.
154
+ *
155
+ * For each `[data-webjs-permanent][id]` reachable from the LIVE slice, if
156
+ * a matching `#id` exists anywhere in the INCOMING slice, replace the
157
+ * incoming (freshly-imported) copy with the LIVE node so the reconciler
158
+ * adopts the live node by identity. Searches both top-level slice members
159
+ * and their descendants. The same both-exist + current-is-permanent
160
+ * guards as `regraftPermanentElements` apply.
161
+ *
162
+ * @param {Node[]} liveSlice
163
+ * @param {Node[]} incomingSlice
164
+ */
165
+ export function regraftPermanentInSlice(liveSlice, incomingSlice) {
166
+ /** @type {Element[]} */
167
+ const livePermanents = [];
168
+ for (const n of liveSlice) {
169
+ if (n.nodeType !== 1) continue;
170
+ const el = /** @type {Element} */ (n);
171
+ if (el.hasAttribute && el.hasAttribute('data-webjs-permanent') && el.id) {
172
+ livePermanents.push(el);
173
+ }
174
+ if (typeof el.querySelectorAll === 'function') {
175
+ for (const d of el.querySelectorAll('[data-webjs-permanent][id]')) livePermanents.push(d);
176
+ }
177
+ }
178
+ if (!livePermanents.length) return;
179
+
180
+ for (const live of livePermanents) {
181
+ const id = live.id;
182
+ if (!id) continue;
183
+ const placeholder = findInSlice(incomingSlice, id);
184
+ if (!placeholder) continue; // both-exist guard
185
+ if (placeholder === live) continue;
186
+ const parent = placeholder.parentNode;
187
+ if (parent) {
188
+ parent.replaceChild(live, placeholder);
189
+ regraftedPermanents.add(live);
190
+ } else {
191
+ // Placeholder is a top-level slice member with no parent (detached):
192
+ // replace it in the incomingSlice array so the reconciler inserts the
193
+ // live node in that position.
194
+ const idx = incomingSlice.indexOf(placeholder);
195
+ if (idx !== -1) {
196
+ incomingSlice[idx] = live;
197
+ regraftedPermanents.add(live);
198
+ }
199
+ }
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Find an element with `#id` within an array of (possibly detached)
205
+ * sibling nodes, searching each member and its descendants.
206
+ *
207
+ * @param {Node[]} slice
208
+ * @param {string} id
209
+ * @returns {Element | null}
210
+ */
211
+ function findInSlice(slice, id) {
212
+ for (const n of slice) {
213
+ if (n.nodeType !== 1) continue;
214
+ const el = /** @type {Element} */ (n);
215
+ if (el.id === id) return el;
216
+ if (typeof el.querySelector === 'function') {
217
+ let match = null;
218
+ try { match = el.querySelector(`#${CSS.escape(id)}`); } catch { match = null; }
219
+ if (match) return match;
220
+ }
221
+ }
222
+ return null;
223
+ }
@@ -6,5 +6,14 @@ export function navigate(url: Route, opts?: { replace?: boolean }): Promise<void
6
6
  export function loadFrame(
7
7
  frameEl: Element,
8
8
  url: string,
9
- ): Promise<{ ok: boolean; status: number | null; aborted: boolean }>;
9
+ ): Promise<{ ok: boolean; status: number | null; aborted: boolean; applied: boolean }>;
10
10
  export function revalidate(url?: string): void;
11
+ /**
12
+ * Re-render the current url on the server and apply it in place, with no page
13
+ * reload. Records no history entry and never scrolls. `'page'` (the default)
14
+ * morphs the deepest shared boundary, preserving hydrated component state
15
+ * outside it; `'shell'` replaces the whole body, which a layout change needs.
16
+ * Resolves `false` when the refresh did not apply, so the caller can fall back
17
+ * to a full load.
18
+ */
19
+ export function refreshPage(mode?: 'page' | 'shell'): Promise<boolean>;