@webjsdev/core 0.7.39 → 0.7.40

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.
@@ -18,7 +18,7 @@ import { scanSeeds } from './action-seed-client.js';
18
18
  // reused hydrated light-DOM component across a soft nav (#908).
19
19
  import {
20
20
  SLOT_STATE, LIGHT_SLOT_ATTR, PROJECTION_ATTR, PROJECTION_ACTUAL,
21
- applyActualAssignment, applyFallback, fireSlotChange,
21
+ setSlotContent,
22
22
  } from './slot.js';
23
23
 
24
24
  /** The content type a content-negotiated stream-action response carries (#248). */
@@ -39,26 +39,32 @@ const STREAM_MIME = 'text/vnd.webjs-stream.html';
39
39
  * `disableClientRouter()` to opt out, or `enableClientRouter()` for
40
40
  * programmatic control.
41
41
  *
42
- * Mechanism: auto-derived from folder structure:
43
- * 1. SSR injects `<!--wj:children:<segment-path>-->...<!--/wj:children-->`
44
- * comment markers around each layout's `${children}` interpolation
45
- * (one pair per layout in the chain).
46
- * 2. On link click, walk both the live DOM and the incoming HTML for
47
- * these markers and build path → range maps.
48
- * 3. Find the longest shared marker path. That's the deepest layout
49
- * both pages have in common.
50
- * 4. Replace nodes between that marker pair in the live DOM with the
51
- * equivalent range from the incoming HTML, using a keyed reconciler
52
- * that preserves input values, scroll, popover state, and the
53
- * identity of any matched DOM nodes.
42
+ * Mechanism: auto-derived from folder structure (#1015):
43
+ * 1. SSR injects KEYED boundary comment pairs around each layout's
44
+ * `${children}` interpolation and around the page itself:
45
+ * `<!--wj:children:<segment>:<route-key>-->` ... `<!--/wj:children:<segment>-->`.
46
+ * The close carries the segment (deterministic id-matched pairing, no
47
+ * LIFO), the open carries the resolved route-key (param values
48
+ * percent-encoded).
49
+ * 2. On link click, STRICTLY scan both the live DOM and the incoming HTML
50
+ * into segment → {routeKey, range} maps. Any pairing violation poisons
51
+ * the scan.
52
+ * 3. Two-tier decision (Next.js remount parity): a CHANGED route-key
53
+ * REPLACES (fresh remount) at the PARENT of the shallowest changed
54
+ * boundary (whose range contains the changed layout's own markup),
55
+ * else MORPH (keyed reconcile preserving input values, scroll, popover
56
+ * state, and node identity) at the deepest shared boundary.
57
+ * 4. A poisoned scan or no shared boundary degrades to a FULL PAGE LOAD:
58
+ * bounded and correct, never a guessed recovery.
54
59
  * 5. Merge head, re-run scripts, upgrade custom elements, pushState.
55
60
  *
56
61
  * Optimizations bundled into the same response cycle:
57
- * - `X-Webjs-Have` request header lists the marker paths the client
58
- * already has. Server walks the target's layout chain, skips
59
- * layouts at-or-above the deepest match, returns only the
60
- * divergent fragment (wrapped in the deepest shared marker). Real
61
- * wire-byte savings: the layout chain is never re-serialized for
62
+ * - `X-Webjs-Have` request header lists `segment:route-key` entries for
63
+ * the boundaries the client already has. The server walks the target's
64
+ * layout chain and short-circuits at the deepest FULL match (segment AND
65
+ * key, so a dynamic layout held for other params is re-rendered), then
66
+ * returns only the divergent fragment (wrapped in the matched boundary).
67
+ * Real wire-byte savings: the layout chain is never re-serialized for
62
68
  * same-shell navigations.
63
69
  * - URL-keyed snapshot cache (Turbo SnapshotCache pattern). Back/
64
70
  * forward via popstate restores from cache instantly, then
@@ -79,18 +85,26 @@ const STREAM_MIME = 'text/vnd.webjs-stream.html';
79
85
  */
80
86
 
81
87
  /**
82
- * Parse HTML into a Document. Prefers Document.parseHTMLUnsafe (processes
83
- * Declarative Shadow DOM) over DOMParser (does NOT process DSD).
88
+ * Parse a navigation response into a Document, PRESERVING COMMENTS.
89
+ *
90
+ * Comments are load-bearing here, not incidental: the partial swap pairs on
91
+ * `<!--wj:children:<path>-->` markers and hydration keys off `<!--webjs-hydrate-->`.
92
+ * `Document.parseHTMLUnsafe` would be the natural choice (it is the only
93
+ * single-pass API that also processes Declarative Shadow DOM) but it STRIPS
94
+ * EVERY COMMENT in Chromium 150 (#1007), which deletes both. So it is used only
95
+ * when a one-time probe proves it lossless on this engine, and otherwise we parse
96
+ * with DOMParser (comments preserved; DSD is left unprocessed, see
97
+ * `parseDocumentPreservingComments` for why that gap beats both ways of closing it).
84
98
  *
85
99
  * A partial-nav response (#936) is an INNER fragment that BEGINS with the
86
- * `<!--wj:children:<path>-->` layout marker and carries no `<!doctype>`/`<html>`.
87
- * Parsing such a fragment as a DOCUMENT hoists that leading comment OUT of
88
- * `<body>` (the HTML parser's "before html" insertion mode makes a leading
89
- * comment a child of the document, before `<html>`), so
90
- * `collectChildrenSlots(doc.body)` never sees the opening marker, finds no
91
- * shared slot, and `applySwap` falls to the destructive full-body swap that
92
- * strips the head stylesheets and the outer layout. So a fragment is parsed in
93
- * BODY (fragment) context instead, keeping the marker with its content.
100
+ * `<!--wj:children:<segment>:<route-key>-->` boundary open and carries no
101
+ * `<!doctype>`/`<html>`. Parsing such a fragment as a DOCUMENT hoists that
102
+ * leading comment OUT of `<body>` (the HTML parser's "before html" insertion
103
+ * mode makes a leading comment a child of the document, before `<html>`), so
104
+ * `collectBoundaries(doc.body)` never sees the opening boundary, finds no
105
+ * shared segment, and `applySwap` degrades to a full load. So a fragment is
106
+ * parsed in BODY (fragment) context instead, keeping the boundary with its
107
+ * content.
94
108
  * `body.setHTMLUnsafe` also processes Declarative Shadow DOM, so a shadow
95
109
  * component inside the swapped content still re-attaches its root; the
96
110
  * `<template>` path is the fallback for browsers without it (markers preserved,
@@ -116,13 +130,99 @@ function parseHTML(html) {
116
130
  // Fall through to a document parse (still functional, just the #936 path).
117
131
  }
118
132
  }
119
- if (typeof Document !== 'undefined' && typeof Document.parseHTMLUnsafe === 'function') {
133
+ if (
134
+ typeof Document !== 'undefined' &&
135
+ typeof Document.parseHTMLUnsafe === 'function' &&
136
+ parseHTMLUnsafePreservesComments()
137
+ ) {
120
138
  return Document.parseHTMLUnsafe(html);
121
139
  }
122
- if (typeof DOMParser !== 'undefined') {
123
- return new DOMParser().parseFromString(html, 'text/html');
140
+ return parseDocumentPreservingComments(html);
141
+ }
142
+
143
+ /**
144
+ * Is `Document.parseHTMLUnsafe` lossless for comments?
145
+ *
146
+ * Chromium 150 strips EVERY comment from `Document.parseHTMLUnsafe` output
147
+ * (#1007). No other parse API does: `DOMParser`, `setHTMLUnsafe`,
148
+ * `template.innerHTML`, and plain `innerHTML` all preserve them, and the
149
+ * document's own navigation parser preserves them (which is why a hard refresh
150
+ * always looked fine and only soft nav broke). MDN documents parseHTMLUnsafe as
151
+ * the parse-WITHOUT-sanitization entry point, so this reads as a browser defect
152
+ * rather than intent, but the whole router rides on comments (`wj:children`
153
+ * layout markers) and so does hydration (`webjs-hydrate`), so we cannot take
154
+ * the risk either way.
155
+ *
156
+ * Probed once, lazily, rather than version-sniffed: when the browser is fixed
157
+ * we silently return to the fast single-pass native path, and a future browser
158
+ * that regresses the same way is caught with no code change.
159
+ *
160
+ * @returns {boolean}
161
+ */
162
+ let _parseUnsafeLossless = null;
163
+ function parseHTMLUnsafePreservesComments() {
164
+ if (_parseUnsafeLossless !== null) return _parseUnsafeLossless;
165
+ try {
166
+ const probe = Document.parseHTMLUnsafe('<!doctype html><body><!--c--><i></i>');
167
+ _parseUnsafeLossless = probe?.body?.firstChild?.nodeType === 8;
168
+ } catch {
169
+ _parseUnsafeLossless = false;
124
170
  }
125
- return null;
171
+ return _parseUnsafeLossless;
172
+ }
173
+
174
+ /**
175
+ * Clear the memoized losslessness probe. Test-only: a browser cannot change
176
+ * mid-session, so nothing in the runtime needs this. Tests SIMULATE a stripping
177
+ * parser (rather than depending on the runner's browser actually being an
178
+ * affected version, which the Chromium web-test-runner currently resolves is not) and reset the
179
+ * memo around that stub.
180
+ */
181
+ function resetParseProbe() {
182
+ _parseUnsafeLossless = null;
183
+ }
184
+
185
+ /**
186
+ * Parse a FULL document while preserving comments.
187
+ *
188
+ * `DOMParser` keeps comments but does NOT process Declarative Shadow DOM, so a
189
+ * `<template shadowrootmode>` stays an inert template here. That is a DELIBERATE
190
+ * limitation on this path, and both obvious ways to "fix" it are worse than the
191
+ * gap (measured on Chromium 150, not reasoned about):
192
+ *
193
+ * - `body.setHTMLUnsafe(body.innerHTML)` re-serializes, and that round-trip is
194
+ * not idempotent: Chromium omits the spec's LF-compensation rule (append
195
+ * U+000A when a `pre` / `textarea` / `listing` element's first Text child
196
+ * starts with one), so `<textarea>\n\nfoo</textarea>` parses to `"\nfoo"`
197
+ * natively but `"foo"` after a round-trip. In a `<textarea>` that is silent
198
+ * form-data corruption: a soft nav would submit different bytes than a hard
199
+ * refresh.
200
+ * - Attaching each root by hand (`host.attachShadow()` + move the template's
201
+ * nodes) is USELESS on the common path and HARMFUL on the other. Useless
202
+ * because the marker swap imports with `document.importNode(n, true)`, which
203
+ * drops a shadow root unless it is `clonable`, and SSR emits a bare
204
+ * `<template shadowrootmode="open">`, so the root never survives the import
205
+ * and `component.js` re-attaches from scratch exactly as it always did.
206
+ * Harmful because the full-body-swap path ADOPTS instead, so a script-created
207
+ * root does survive, and a script-created root is not `declarative`: the spec
208
+ * only permits a second `attachShadow()` over an existing root when that root
209
+ * is declarative, so any element whose constructor unconditionally calls
210
+ * `attachShadow()` then throws `NotSupportedError` on upgrade, where the
211
+ * native parse it replaced worked fine.
212
+ *
213
+ * The gap this leaves is narrow and strictly better than the bug it replaces: on
214
+ * a comment-stripping browser only, an element that depends on DSD content and
215
+ * ships NO JavaScript loses that content on a full-body-swap navigation. Every
216
+ * WebJs `static shadow = true` component is unaffected, because it attaches and
217
+ * renders its own root on upgrade (`component.js`, guarded by `if
218
+ * (!this.shadowRoot)`), and a soft nav runs JS by definition. Tracked separately.
219
+ *
220
+ * @param {string} html
221
+ * @returns {Document | null}
222
+ */
223
+ function parseDocumentPreservingComments(html) {
224
+ if (typeof DOMParser === 'undefined') return null;
225
+ return new DOMParser().parseFromString(html, 'text/html');
126
226
  }
127
227
 
128
228
  let enabled = false;
@@ -642,6 +742,44 @@ function warnOnce(key, message) {
642
742
  if (typeof console !== 'undefined' && console.warn) console.warn(message);
643
743
  }
644
744
 
745
+ /**
746
+ * True when a nav must degrade to a full page load because the document is
747
+ * still parsing. A forward, main-document nav fired at `readyState: 'loading'`
748
+ * races the DOM: the leaving page's closing layout markers may not be attached
749
+ * yet, so a soft swap would snapshot an incomplete tree and corrupt the DOM
750
+ * (#1008 / #936). Scoped to frameless forward navs (popstate is browser-driven,
751
+ * a frame nav carries its own boundary element).
752
+ *
753
+ * @param {boolean} isPopState
754
+ * @param {string | null | undefined} frameId
755
+ * @returns {boolean}
756
+ */
757
+ function shouldFullLoadDuringParse(isPopState, frameId) {
758
+ return (
759
+ !isPopState &&
760
+ !frameId &&
761
+ typeof document !== 'undefined' &&
762
+ document.readyState === 'loading'
763
+ );
764
+ }
765
+
766
+ /**
767
+ * Dev-only diagnostic: the client router degraded a soft navigation to a full
768
+ * page load. Records WHY (the `cause`) so the "why did my SPA nav do a full
769
+ * reload?" question is answerable from the console instead of guessed at. Silent
770
+ * in production and deduped per cause so a repeated trigger does not spam.
771
+ *
772
+ * @param {string} cause a short stable slug for the degradation reason
773
+ * @param {string} href the destination the router fell back to loading
774
+ */
775
+ function devWarnFallback(cause, href) {
776
+ if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production') return;
777
+ warnOnce(
778
+ `fallback:${cause}`,
779
+ `[webjs] client router fell back to a full page load (${cause}) navigating to ${href}. This is correct (no DOM corruption), just not a soft nav.`
780
+ );
781
+ }
782
+
645
783
  /**
646
784
  * Dev-only, fire-once hint: the router forces an INSTANT scroll-to-top on a
647
785
  * forward navigation (matching a native page load), so an app-level
@@ -701,112 +839,192 @@ function setNavigating(on) {
701
839
  }
702
840
 
703
841
  /* ====================================================================
704
- * Marker discovery (the heart of the partial-swap mechanism)
842
+ * Boundary discovery (the heart of the partial-swap mechanism, #1015)
705
843
  * ==================================================================== */
706
844
 
707
845
  /**
708
- * Walk a node tree collecting wj:children marker pairs into a Map
846
+ * Walk a node tree collecting KEYED children-boundary pairs into a Map
709
847
  * keyed by segment path.
710
848
  *
711
- * Markers are HTML comments emitted by SSR around each layout's
712
- * children interpolation:
713
- * <!--wj:children:/docs-->
849
+ * Boundaries are HTML comments emitted by SSR around each layout's
850
+ * children interpolation AND around the page itself:
851
+ * <!--wj:children:/docs:/docs--> (open: segment + route-key)
714
852
  * <page content>
715
- * <!--/wj:children-->
716
- *
717
- * The walk uses a stack to track nested marker pairs: a path can
718
- * appear multiple times in a document only if a layout transitively
719
- * includes itself (pathological; we take the outermost).
720
- *
721
- * `recoverOrphans` (#994): register an OPEN marker whose closing
722
- * `<!--/wj:children-->` comment is missing, using `end: null` (meaning "the
723
- * children run to the end of the containing element"). Some browsers
724
- * intermittently DROP the trailing close comment while parsing a soft-nav
725
- * response, a parse/timing race that surfaced first on Android Chrome (a soft
726
- * nav to `/blog` randomly dropped the navbar; #939/#940 confirmed the OPEN
727
- * marker survived, `markers:1`, yet the router still fell to the destructive
728
- * full-body swap) and was later reproduced on DESKTOP Chromium too, so this is
729
- * NOT browser-specific, only rarer on faster hardware. Without both comments
730
- * this walk registers no slot, so `longestSharedPath` finds nothing and
731
- * `applySwap` wipes the outer layout (navbar). Recovering the orphaned open lets
732
- * the correct scoped swap run and keeps the navbar (it sits BEFORE the open
733
- * marker, so it is never in the swap range). The recovery keys off the SYMPTOM
734
- * (a missing close), never any user-agent check, so it fixes every engine
735
- * uniformly. Off by default so the exported helper's strict pairing is
736
- * unchanged; the swap-decision and `X-Webjs-Have` call sites opt in.
853
+ * <!--/wj:children:/docs--> (close: segment)
854
+ *
855
+ * The close carries the SEGMENT, so pairing is deterministic id-matching:
856
+ * a close must match the segment of the INNERMOST open boundary (proper
857
+ * nesting), never a positional LIFO guess. The open additionally carries
858
+ * the resolved ROUTE-KEY (param values percent-encoded at emit), which
859
+ * `planBoundarySwap` compares between the live and incoming DOM to pick
860
+ * the swap tier.
861
+ *
862
+ * STRICT INTEGRITY, by design (#1015): this scanner never guesses. ANY
863
+ * violation poisons the whole scan and returns null:
864
+ * - an open with no route-key (the legacy anonymous format, or truncation)
865
+ * - a close whose segment does not match the innermost open boundary
866
+ * - a close with no open boundary at all
867
+ * - a duplicate segment (two boundaries claiming one id)
868
+ * - an open boundary never closed (truncated response)
869
+ * The caller degrades a poisoned side to a FULL PAGE LOAD: bounded, correct,
870
+ * and honest, where the deleted heuristic recovery (#994's orphan recovery +
871
+ * trailing-count bounding) could guess wrong and corrupt silently. The main
872
+ * PRODUCER of mispairing (our own comment-stripping parse, #1007, and
873
+ * mid-parse soft navs, #1008) is already fixed upstream, so poisoning is a
874
+ * rare backstop, not a common path.
737
875
  *
738
876
  * @param {ParentNode} root
739
- * @param {{ recoverOrphans?: boolean }} [options]
740
- * @returns {Map<string, { start: Comment, end: Comment | null }>}
741
- */
742
- export function collectChildrenSlots(root, options) {
743
- const recoverOrphans = !!(options && options.recoverOrphans);
744
- /** @type {Map<string, { start: Comment, end: Comment | null }>} */
745
- const slots = new Map();
746
- /** @type {{ path: string, start: Comment }[]} */
877
+ * @returns {Map<string, { routeKey: string, start: Comment, end: Comment }> | null}
878
+ * The boundary map, or null when the tree's boundaries are malformed.
879
+ */
880
+ export function collectBoundaries(root) {
881
+ /** @type {Map<string, { routeKey: string, start: Comment, end: Comment }>} */
882
+ const out = new Map();
883
+ /** @type {{ segment: string, routeKey: string, start: Comment }[]} */
747
884
  const stack = [];
885
+ let poisoned = false;
748
886
 
749
887
  // Plain recursive comment walk: TreeWalker/NodeFilter aren't available
750
888
  // in every DOM polyfill (notably linkedom in tests). Iterative depth-
751
889
  // first traversal keeps us portable across linkedom + native + jsdom.
752
890
  /** @param {Node} node */
753
891
  function visit(node) {
892
+ if (poisoned) return;
754
893
  if (node.nodeType === 8 /* COMMENT_NODE */) {
755
- const c = /** @type {Comment} */ (node);
756
- const data = c.data;
757
- const open = /^wj:children:(.+)$/.exec(data);
758
- if (open) {
759
- stack.push({ path: open[1], start: c });
894
+ const data = /** @type {Comment} */ (node).data.trim();
895
+ if (data.startsWith('wj:children:')) {
896
+ const rest = data.slice('wj:children:'.length);
897
+ // The route-key is everything after the LAST ':'. Substituted param
898
+ // values are percent-encoded at emit and static pieces have their
899
+ // delimiter characters encoded too, so the last colon is unambiguous
900
+ // for framework-emitted boundaries. A hand-authored folder name that
901
+ // still smuggles a delimiter through can only MIS-SPLIT here, which
902
+ // mismatches the close and poisons the scan: degrade-only, never a
903
+ // wrong pairing.
904
+ const cut = rest.lastIndexOf(':');
905
+ if (cut <= 0 || cut === rest.length - 1) { poisoned = true; return; }
906
+ const segment = rest.slice(0, cut);
907
+ const routeKey = rest.slice(cut + 1);
908
+ if (out.has(segment) || stack.some((f) => f.segment === segment)) {
909
+ poisoned = true;
910
+ return;
911
+ }
912
+ stack.push({ segment, routeKey, start: /** @type {Comment} */ (node) });
760
913
  return;
761
914
  }
762
- if (data.trim() === '/wj:children') {
915
+ if (data.startsWith('/wj:children')) {
916
+ const seg = data.slice('/wj:children'.length).replace(/^:/, '');
763
917
  const frame = stack.pop();
764
- if (frame && !slots.has(frame.path)) {
765
- slots.set(frame.path, { start: frame.start, end: c });
918
+ if (!frame || frame.segment !== seg) { poisoned = true; return; }
919
+ // Same-parent integrity: HTML parser reparenting (a <p> auto-closed
920
+ // by block content) can split a pair across parents. The range
921
+ // operations walk nextSibling from start and insert before end, so a
922
+ // cross-parent pair would empty the region and then throw mid-swap.
923
+ // Poison instead: degrade up front.
924
+ if (frame.start.parentNode !== /** @type {Comment} */ (node).parentNode) {
925
+ poisoned = true;
926
+ return;
927
+ }
928
+ // Table-context integrity: foster-parenting moves CONTENT out of the
929
+ // table while comment tokens stay put, so a boundary emitted in table
930
+ // context shares a parent (passing the check above) while its actual
931
+ // children were fostered OUTSIDE the range. Swapping that empty range
932
+ // would silently leave stale visible content. A `${children}` slot
933
+ // directly in table context cannot work as a swap boundary at all,
934
+ // so poison it.
935
+ {
936
+ const pt = /** @type {Element} */ (frame.start.parentNode).tagName;
937
+ if (pt === 'TABLE' || pt === 'TBODY' || pt === 'THEAD' || pt === 'TFOOT' || pt === 'TR') {
938
+ poisoned = true;
939
+ return;
940
+ }
766
941
  }
942
+ out.set(frame.segment, {
943
+ routeKey: frame.routeKey,
944
+ start: frame.start,
945
+ end: /** @type {Comment} */ (node),
946
+ });
767
947
  return;
768
948
  }
769
949
  return;
770
950
  }
771
951
  if (node.hasChildNodes && node.hasChildNodes()) {
772
- for (let child = node.firstChild; child; child = child.nextSibling) {
952
+ for (let child = node.firstChild; child && !poisoned; child = child.nextSibling) {
773
953
  visit(child);
774
954
  }
775
955
  }
776
956
  }
777
957
  visit(/** @type {Node} */ (root));
778
958
 
779
- // #994: any open marker still on the stack was never closed (its
780
- // `<!--/wj:children-->` was dropped by the browser's parser, any engine).
781
- // Register it with a null end so the shared-path match succeeds and the scoped
782
- // swap preserves the outer layout, instead of the destructive full-body
783
- // fallback. Outermost first (stack order), never overwriting a slot a proper
784
- // close already paired.
785
- if (recoverOrphans) {
786
- for (const frame of stack) {
787
- if (!slots.has(frame.path)) slots.set(frame.path, { start: frame.start, end: null });
959
+ if (poisoned || stack.length > 0) return null;
960
+ return out;
961
+ }
962
+
963
+ /**
964
+ * Plan the two-tier boundary swap from the live + incoming boundary maps
965
+ * (#1015). Boundary segments are nested path prefixes, so the shared segments
966
+ * form a chain from `/` down to the deepest shared boundary D.
967
+ *
968
+ * Rules (Next.js remount-vs-preserve parity):
969
+ * - A changed route-key REPLACES at the PARENT of the shallowest changed
970
+ * boundary. The parent, not the changed boundary itself: a LAYOUT's
971
+ * boundary wraps its CHILDREN slot, so the layout's OWN markup (an
972
+ * `[org]`-name header it renders around `${children}`) lives inside the
973
+ * PARENT's range. Anchoring at the parent remounts the changed layout's
974
+ * chrome AND its subtree, exactly like Next re-rendering the layout with
975
+ * new params. The page boundary composes the same way: its parent is the
976
+ * nearest layout's children slot, so `/blog/a` -> `/blog/b` under a
977
+ * `/blog` layout remounts just the page while the `/blog` layout chrome
978
+ * is preserved; with no intermediate layout the anchor is `/` and the
979
+ * root layout's chrome (outside its own children boundary) is still
980
+ * preserved. A changed boundary with NO shared parent degrades (null).
981
+ * - No route-key changed but the subtree below D diverges (D is a LAYOUT,
982
+ * not the deepest boundary, on either side, e.g. `/about` -> `/contact`
983
+ * under a shared static root layout): REPLACE D's contents wholesale.
984
+ * - No route-key changed and D is the deepest boundary on BOTH sides: MORPH
985
+ * D. The searchParams-only / refresh / revalidate nav, which must preserve
986
+ * hydrated component state while updating searchParam-driven DOM.
987
+ * - No shared segment at all: null (the caller degrades to a full load). In
988
+ * practice the page boundary exists on both sides of any same-app nav, so
989
+ * this is reached only for a divergent or malformed shell.
990
+ *
991
+ * @param {Map<string, { routeKey: string, start: Comment, end: Comment }>} here
992
+ * @param {Map<string, { routeKey: string, start: Comment, end: Comment }>} there
993
+ * @returns {{ mode: 'replace' | 'morph', segment: string,
994
+ * live: { routeKey: string, start: Comment, end: Comment },
995
+ * incoming: { routeKey: string, start: Comment, end: Comment } } | null}
996
+ */
997
+ export function planBoundarySwap(here, there) {
998
+ // Shared segments, shallowest first (a nested path prefix is shorter).
999
+ const shared = [...here.keys()].filter((s) => there.has(s)).sort((a, b) => a.length - b.length);
1000
+ if (shared.length === 0) return null;
1001
+ // A changed route-key remounts at the PARENT of the shallowest change.
1002
+ for (const seg of shared) {
1003
+ if (here.get(seg).routeKey !== there.get(seg).routeKey) {
1004
+ let parent = null;
1005
+ for (const p of shared) {
1006
+ if (p === seg) break;
1007
+ if (seg.startsWith(p === '/' ? p : p + '/')) parent = p;
1008
+ }
1009
+ if (!parent) return null; // no anchored parent: degrade
1010
+ return { mode: 'replace', segment: parent, live: here.get(parent), incoming: there.get(parent) };
788
1011
  }
789
1012
  }
790
- return slots;
791
- }
792
-
793
- /**
794
- * Pick the longest path that exists in both maps.
795
- *
796
- * Path comparison is plain string equality (matches Next.js's
797
- * `matchSegment`). Longest wins so the swap is as scoped as possible.
798
- *
799
- * @param {Map<string, unknown>} here
800
- * @param {Map<string, unknown>} there
801
- * @returns {string | null}
802
- */
803
- export function longestSharedPath(here, there) {
804
- let best = null;
805
- for (const p of here.keys()) {
806
- if (!there.has(p)) continue;
807
- if (best === null || p.length > best.length) best = p;
808
- }
809
- return best;
1013
+ // No route-key changed. D = deepest shared boundary.
1014
+ const D = shared[shared.length - 1];
1015
+ /** @param {Map<string, unknown>} m */
1016
+ const deepestOf = (m) => {
1017
+ let best = null;
1018
+ for (const s of m.keys()) if (best === null || s.length > best.length) best = s;
1019
+ return best;
1020
+ };
1021
+ const leafOnBoth = deepestOf(here) === D && deepestOf(there) === D;
1022
+ return {
1023
+ mode: leafOnBoth ? 'morph' : 'replace',
1024
+ segment: D,
1025
+ live: here.get(D),
1026
+ incoming: there.get(D),
1027
+ };
810
1028
  }
811
1029
 
812
1030
  /* ====================================================================
@@ -890,6 +1108,22 @@ function cacheKey(url) {
890
1108
  * @param {string | null} frameId Active <webjs-frame> id, or null.
891
1109
  */
892
1110
  async function performNavigation(href, isPopState, frameId) {
1111
+ // #1008 / #936: a forward, main-document nav fired while the document is
1112
+ // still parsing (`readyState === 'loading'`) races the DOM. The leaving
1113
+ // page's closing layout markers at the bottom of the body may not exist yet,
1114
+ // so `snapshotCurrent` plus region discovery would capture an incomplete tree
1115
+ // and drive a corrupt or over-wide swap (the suspected root cause of the
1116
+ // dropped-marker reports). The PREFETCH path already skips this window (see
1117
+ // the `buildHaveHeader` call site); the click / `navigate()` path did not.
1118
+ // Degrade to a correct full-page load, which is what an MPA would do anyway.
1119
+ // Scoped to frameless forward navs: popstate is browser-driven, and a frame
1120
+ // nav carries its own boundary element.
1121
+ if (shouldFullLoadDuringParse(isPopState, frameId) && typeof location !== 'undefined') {
1122
+ devWarnFallback('readyState-loading', href);
1123
+ location.href = href;
1124
+ return;
1125
+ }
1126
+
893
1127
  // Cancel any in-flight fetch: Turbo Drive's navigator.stop().
894
1128
  if (activeAbortController) activeAbortController.abort();
895
1129
  activeAbortController = new AbortController();
@@ -937,7 +1171,7 @@ async function performNavigation(href, isPopState, frameId) {
937
1171
  // Fire-and-forget revalidation. Uses a fresh AbortController
938
1172
  // since this background fetch is allowed to overlap with the
939
1173
  // next foreground nav (it'll get aborted if a new nav lands).
940
- fetchAndApply(href, frameId, /* recordHistory */ false, optimisticState, 'GET', null, signal, myToken)
1174
+ fetchAndApply(href, frameId, /* recordHistory */ false, optimisticState, 'GET', null, signal, myToken, /* revalidating */ true)
941
1175
  .catch(() => {});
942
1176
  return;
943
1177
  }
@@ -1071,27 +1305,27 @@ async function performSubmission(href, method, body, frameId, form) {
1071
1305
  }
1072
1306
 
1073
1307
  /**
1074
- * Build the X-Webjs-Have header value from the live DOM's marker paths.
1075
- * Comma-separated, in document order (no canonicalization needed; the
1076
- * server intersects with the target's layout chain).
1308
+ * Build the X-Webjs-Have header value from the live DOM's boundaries.
1309
+ * Comma-separated `<segment>:<route-key>` entries in document order. The
1310
+ * ROUTE-KEY rides along so the server's short-circuit can distinguish "the
1311
+ * client has this layout" from "the client has this layout rendered for
1312
+ * DIFFERENT params": a dynamic `[org]` layout the client holds for org-a
1313
+ * must be re-rendered (and re-shipped) on an org-b navigation, or the
1314
+ * parent-anchored REPLACE would have no fresh layout markup to swap in.
1315
+ * The server ignores the page-boundary entry (never a layout segment).
1316
+ *
1317
+ * A poisoned live DOM (malformed boundaries) reports an EMPTY have, so the
1318
+ * server returns the full page. The subsequent applySwap re-scans, sees the
1319
+ * same poisoned live tree, and degrades to a full load: consistent with the
1320
+ * integrity-gate model, never a reduced fragment spliced against a tree we
1321
+ * cannot trust (#1015).
1077
1322
  *
1078
1323
  * @returns {string}
1079
1324
  */
1080
1325
  function buildHaveHeader() {
1081
- // Strict pairing here, deliberately (#994): if the SOLE or OUTERMOST close
1082
- // marker was dropped, its orphaned open is NOT reported, so `have` omits that
1083
- // layout and the server returns the FULL page (with the outer layout's trailing
1084
- // chrome) rather than a reduced marker-pair-only fragment. `applySwap` then
1085
- // recovers the orphan and bounds the swap against that full page's
1086
- // trailing-sibling count, preserving the navbar and the footer. Recovering here
1087
- // would send `have=/`, get back a reduced fragment with no trailing chrome
1088
- // (`tail === 0`), and sweep an unwrapped layout's footer. The extra bytes of a
1089
- // full page on the rare orphaned nav are the price of that correctness. A
1090
- // dropped INNER close in a nested layout is a separate, unfixed mispairing
1091
- // limitation (the surviving outer close pairs with the inner open); the wrapped
1092
- // `${children}` idiom keeps it harmless.
1093
- const slots = collectChildrenSlots(document.body);
1094
- return [...slots.keys()].join(',');
1326
+ const boundaries = collectBoundaries(document.body);
1327
+ if (!boundaries) return '';
1328
+ return [...boundaries.entries()].map(([seg, b]) => `${seg}:${b.routeKey}`).join(',');
1095
1329
  }
1096
1330
 
1097
1331
  /* ====================================================================
@@ -1623,8 +1857,9 @@ function refreshPrefetchObservers() {
1623
1857
  */
1624
1858
  function renderInPlaceNavError(status) {
1625
1859
  if (typeof document === 'undefined' || !document.body) return false;
1626
- const here = collectChildrenSlots(document.body);
1627
- // The deepest slot is the same swap target a normal partial swap writes
1860
+ const here = collectBoundaries(document.body);
1861
+ if (!here) return false; // poisoned live tree: let the caller hard-load
1862
+ // The deepest boundary is the same swap target a normal partial swap writes
1628
1863
  // to (longest path wins), so the outer chrome / nav are preserved.
1629
1864
  /** @type {{ start: Comment, end: Comment } | undefined} */
1630
1865
  let deepest;
@@ -1705,6 +1940,9 @@ function handleNavigationError(href, status, error) {
1705
1940
  * @param {BodyInit | null} [body] Request body for non-GET methods.
1706
1941
  * @param {AbortSignal | null} [signal] Abort signal. A newer nav cancels this fetch.
1707
1942
  * @param {number} [token] Nav-token captured at the caller's entry; stale → skip apply.
1943
+ * @param {boolean} [revalidating] True for the BACKGROUND refresh after a
1944
+ * snapshot restore: the user is already viewing a page, so a boundary
1945
+ * mismatch must degrade in place (never a jarring `location.href` load).
1708
1946
  * @returns {Promise<{ ok: boolean, status: number | null, aborted: boolean }>}
1709
1947
  * The fetch outcome, so a caller (the form-submission busy/event lifecycle)
1710
1948
  * can report whether the submission settled as a success, an error, or an
@@ -1713,7 +1951,7 @@ function handleNavigationError(href, status, error) {
1713
1951
  * for an abort (which also sets `aborted:true`). `status` is the HTTP status
1714
1952
  * or `null` when the request never produced one.
1715
1953
  */
1716
- async function fetchAndApply(href, frameId, recordHistory, optimisticState, method, body, signal, token) {
1954
+ async function fetchAndApply(href, frameId, recordHistory, optimisticState, method, body, signal, token, revalidating) {
1717
1955
  method = method || 'GET';
1718
1956
  const myToken = typeof token === 'number' ? token : currentNavigationToken;
1719
1957
  let html;
@@ -1880,7 +2118,15 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
1880
2118
  // recover in place rather than a destructive full reload.
1881
2119
  if (!doc) { restoreOptimistic(optimisticState); handleNavigationError(href, null, new Error('navigation response did not parse as HTML')); return { ok: false, status: respStatus, aborted: false }; }
1882
2120
 
1883
- applySwap(doc, frameId, false, finalUrl, incomingBuild, incomingSrc);
2121
+ const disposition = applySwap(doc, frameId, !!revalidating, finalUrl, incomingBuild, incomingSrc);
2122
+ // A discarded revalidation must be discarded OUTRIGHT: a streamed response's
2123
+ // boundary templates must not splice into the restored snapshot afterward
2124
+ // (boundary ids are per-render sequential, so a reduced render's numbering
2125
+ // need not line up with the snapshot's). Cancel the reader and stop here.
2126
+ if (disposition === 'discard') {
2127
+ if (streamCtx && streamCtx.reader) { try { streamCtx.reader.cancel(); } catch { /* ignore */ } }
2128
+ return { ok: respOk, status: respStatus, aborted: false };
2129
+ }
1884
2130
 
1885
2131
  if (recordHistory) history.pushState(null, '', finalUrl);
1886
2132
 
@@ -2536,45 +2782,68 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
2536
2782
  return;
2537
2783
  }
2538
2784
 
2539
- // 2. Auto-derived layout-marker swap. Recover an orphaned open marker on
2540
- // either side (#994): a dropped close comment must not force the destructive
2541
- // path-3 fallback that wipes the outer layout (navbar).
2542
- const here = collectChildrenSlots(document.body, { recoverOrphans: true });
2543
- const there = collectChildrenSlots(doc.body, { recoverOrphans: true });
2544
- const sharedPath = longestSharedPath(here, there);
2545
-
2546
- if (sharedPath) {
2547
- const target = here.get(sharedPath);
2548
- const source = there.get(sharedPath);
2549
- // #994: a recovered orphan carries `end: null` (children run to the marker's
2550
- // PARENT end). That is exactly right when the children fill their parent (the
2551
- // shipped idiom wraps `${children}` in a `<main>`, so the close marker is the
2552
- // parent's last child and trailing chrome like `<footer>` is a sibling
2553
- // OUTSIDE it). But a layout that puts trailing content in the marker's OWN
2554
- // parent would have that content swept (a live orphan) or duplicated (an
2555
- // incoming orphan). When exactly one side is orphaned, bound it against the
2556
- // well-formed side's trailing-sibling count so the trailing chrome is
2557
- // preserved either way.
2558
- boundRecoveredEnds(target, source);
2559
- // ADD-ONLY head merge for the same reason: outer layout stays
2560
- // mounted, its head-bound runtime state must not be invalidated.
2785
+ // 2. Two-tier keyed boundary swap (#1015). Scan both trees STRICTLY: a
2786
+ // poisoned side (malformed, truncated, or mispaired boundaries) yields null
2787
+ // and falls through to the integrity degradation below. A valid pair of
2788
+ // scans picks the swap tier by route-key comparison: a CHANGED key REPLACES
2789
+ // (remount, Next param-change parity) at the PARENT of the shallowest
2790
+ // change, else MORPH (state-preserving keyed reconcile) at the deepest
2791
+ // shared boundary.
2792
+ const here = collectBoundaries(document.body);
2793
+ const there = collectBoundaries(doc.body);
2794
+ const plan = here && there ? planBoundarySwap(here, there) : null;
2795
+
2796
+ if (plan) {
2797
+ const { mode, live, incoming } = plan;
2798
+ // ADD-ONLY head merge: the outer layout stays mounted, so its head-bound
2799
+ // runtime state (Tailwind injection, etc.) must not be invalidated.
2561
2800
  addNewHeadElements(doc.head);
2562
2801
  runWithTransition(() => {
2563
- swapMarkerRange(target, source, doc);
2802
+ if (mode === 'replace') replaceBoundaryRange(live, incoming);
2803
+ else swapMarkerRange(live, incoming, doc);
2804
+ // No key sync is needed on the anchor's own comments: the plan's anchor
2805
+ // carries EQUAL live/incoming route-keys in every tier (a changed-key
2806
+ // REPLACE anchors at a parent already compared equal; the other tiers
2807
+ // require no change at all), and the fresh deeper keys arrive via the
2808
+ // physically replaced boundary comments inside the range.
2564
2809
  blurOutgoingFocus();
2565
- }, () => upgradeCustomElementsInRange(target));
2810
+ }, () => upgradeCustomElementsInRange(live));
2566
2811
  forwardSuspenseResolvers(doc.body);
2567
2812
  return;
2568
2813
  }
2569
2814
 
2570
- // 3. Full body swap fallback: no shared layout marker (a genuine root-layout
2571
- // change, or a same-layout nav whose markers could not be paired). Full head
2572
- // merge, so a real root-layout change removes stale head elements. `mergeHead`
2573
- // now PRESERVES stylesheets and `<style>` unconditionally (#936): even if the
2574
- // client reaches this path with an incoming head that lacks the app's
2575
- // `<link rel=stylesheet>` (a partial or mangled response, e.g. from a
2576
- // mid-parse empty-`have` prefetch), the live CSS is never stripped, so the
2577
- // swap can no longer leave the page unstyled.
2815
+ // 3. Integrity degradation (#1015). No trustworthy shared boundary exists:
2816
+ // one side is poisoned, or the trees share no segment (a divergent shell).
2817
+ // For a FOREGROUND nav, degrade to a FULL PAGE LOAD: bounded, correct, and
2818
+ // exactly what an MPA would do, where the deleted heuristic recovery could
2819
+ // guess wrong and corrupt silently. Dev logs the cause so a systematic
2820
+ // producer of malformed boundaries is visible immediately. A REVALIDATION
2821
+ // (the background refresh after a snapshot restore) is excluded: the user
2822
+ // is already viewing a page, so a background op must never yank them
2823
+ // through a hard load; it takes the in-place path below.
2824
+ if (href && !revalidating && typeof location !== 'undefined') {
2825
+ devWarnFallback(!here ? 'live-boundaries-malformed'
2826
+ : !there ? 'incoming-boundaries-malformed'
2827
+ : 'no-shared-boundary', href);
2828
+ location.href = href;
2829
+ return;
2830
+ }
2831
+
2832
+ // A BACKGROUND revalidation (revalidating + href) with no trustworthy plan
2833
+ // DISCARDS the response outright: the user is viewing a valid restored
2834
+ // snapshot, and the response may be a reduced (chrome-less) X-Webjs-Have
2835
+ // fragment, so the full-body swap below would wipe the shell from a
2836
+ // background op. Doing nothing is the only safe degradation here.
2837
+ if (href && revalidating) {
2838
+ devWarnFallback('revalidation-discarded', href);
2839
+ return 'discard';
2840
+ }
2841
+
2842
+ // 4. In-place full-body swap: the background paths only (a snapshot
2843
+ // restore or its revalidation, where a full load is not an option
2844
+ // because the user is already viewing the page). Full head merge;
2845
+ // `mergeHead` PRESERVES stylesheets and `<style>` unconditionally
2846
+ // (#936) so the swap can never leave the page unstyled.
2578
2847
  mergeHead(doc.head);
2579
2848
  // Persist permanent elements by node identity across the full-body
2580
2849
  // swap: move each live [data-webjs-permanent][id] node into the matching
@@ -2620,91 +2889,61 @@ function blurOutgoingFocus() {
2620
2889
  }
2621
2890
 
2622
2891
  /**
2623
- * Count the siblings after `marker` up to the end of its parent. Used to
2624
- * measure how much trailing outer-layout chrome sits after a close marker
2625
- * WITHIN the marker's own parent (#994).
2892
+ * Wholesale-REPLACE the contents of a boundary range (#1015): remove every
2893
+ * live node between `target.start` and `target.end` (exclusive) and insert a
2894
+ * fresh import of the incoming range. This is the REMOUNT tier: the boundary's
2895
+ * route-key changed (a param change, or a different page under a shared static
2896
+ * layout), so Next.js parity demands fresh component instances, not a keyed
2897
+ * reuse of old-page DOM. The only nodes that survive by identity are
2898
+ * `data-webjs-permanent` elements, regrafted into the imported slice before
2899
+ * insertion so a playing `<audio>`/widget keeps running.
2626
2900
  *
2627
- * @param {Comment} marker
2628
- * @returns {number}
2901
+ * @param {{ start: Comment, end: Comment }} target The live boundary.
2902
+ * @param {{ start: Comment, end: Comment }} source The incoming boundary.
2629
2903
  */
2630
- function trailingSiblingCount(marker) {
2631
- let n = 0;
2632
- for (let s = marker.nextSibling; s; s = s.nextSibling) n++;
2633
- return n;
2904
+ function replaceBoundaryRange(target, source) {
2905
+ const liveParent = target.start.parentNode;
2906
+ if (!liveParent) return;
2907
+ /** @type {Node[]} */
2908
+ const liveSlice = [];
2909
+ for (let n = target.start.nextSibling; n && n !== target.end; n = n.nextSibling) {
2910
+ liveSlice.push(n);
2911
+ }
2912
+ /** @type {Node[]} */
2913
+ const incomingSlice = [];
2914
+ for (let n = source.start.nextSibling; n && n !== source.end; n = n.nextSibling) {
2915
+ incomingSlice.push(document.importNode(n, true));
2916
+ }
2917
+ regraftPermanentInSlice(liveSlice, incomingSlice);
2918
+ for (const n of liveSlice) {
2919
+ if (n.parentNode === liveParent) liveParent.removeChild(n);
2920
+ }
2921
+ for (const n of incomingSlice) {
2922
+ liveParent.insertBefore(n, target.end);
2923
+ }
2924
+ for (let n = target.start.nextSibling; n && n !== target.end; n = n.nextSibling) {
2925
+ if (n.nodeType === 1) {
2926
+ reactivateScripts(/** @type {Element} */ (n));
2927
+ upgradeCustomElements(/** @type {Element} */ (n));
2928
+ }
2929
+ }
2634
2930
  }
2635
2931
 
2636
2932
  /**
2637
- * Give a recovered orphan (`end: null`, #994) a concrete bounded end so trailing
2638
- * outer-layout chrome in the marker's own parent is preserved. Runs only when
2639
- * EXACTLY one side is orphaned: the well-formed side's close marker reveals how
2640
- * many trailing siblings sit after the children within the parent (`tail`), and
2641
- * a same-layout nav shares that outer-layout structure, so the orphan's children
2642
- * end `tail` nodes before its parent's end. A `tail` of zero (the shipped
2643
- * wrap-`${children}`-in-`<main>` idiom, where the close is the parent's last
2644
- * child) leaves `end: null` and the sweep-to-parent-end stays correct. Both-side
2645
- * orphans keep `null` on both (symmetric sweep, nothing to align against).
2933
+ * MORPH the contents of a boundary range (#1015): reconcile the nodes between
2934
+ * `target.start` and `target.end` (exclusive) in the live document against the
2935
+ * nodes between `source.start` and `source.end` in the parsed Document, using
2936
+ * a keyed reconciler that preserves DOM identity for matched elements + their
2937
+ * live attributes (scroll, value, etc.). This is the state-preserving tier for
2938
+ * a searchParams-only / refresh nav, where the route-key is unchanged and
2939
+ * hydrated component state must survive.
2646
2940
  *
2647
- * This works for a SOLE or OUTERMOST dropped close because `buildHaveHeader`
2648
- * reports STRICTLY paired markers, so an orphaned live page omits the layout from
2649
- * `have` and the server returns the FULL page (trailing chrome included,
2650
- * `tail > 0`) rather than a reduced marker-pair-only fragment. Two cases it does
2651
- * NOT cover: an incoming side whose close was lost during parse (rare: the
2652
- * response is parsed from a complete string, not streamed), and a dropped INNER
2653
- * close in a nested layout (the surviving outer close mispairs with the inner
2654
- * open, so both slot ends are non-null and this bounding is skipped). The wrapped
2655
- * `${children}` idiom keeps both harmless (the marker's parent holds only the
2656
- * children, so any sweep stays inside it).
2941
+ * Boundaries are strictly paired by the scanner (#1015), so `end` is always a
2942
+ * real close comment here (the null-orphan tolerance of the deleted #994
2943
+ * recovery is gone with it).
2657
2944
  *
2658
- * @param {{ start: Comment, end: Comment | null } | undefined} target
2659
- * @param {{ start: Comment, end: Comment | null } | undefined} source
2660
- */
2661
- function boundRecoveredEnds(target, source) {
2662
- if (!target || !source) return;
2663
- if (target.end === null && source.end !== null) target.end = boundOrphanEnd(target.start, source.end);
2664
- else if (source.end === null && target.end !== null) source.end = boundOrphanEnd(source.start, target.end);
2665
- }
2666
-
2667
- /**
2668
- * Compute the bounded end sentinel (exclusive) for an orphaned open marker,
2669
- * preserving `tail` trailing siblings at the end of the marker's parent, where
2670
- * `tail` is the well-formed counterpart's trailing-sibling count (#994).
2671
- *
2672
- * @param {Comment} orphanStart The orphan's open marker.
2673
- * @param {Comment} wellFormedEnd The counterpart's real close marker.
2674
- * @returns {Comment | Node | null} A node to stop before, or null (sweep to end).
2675
- */
2676
- function boundOrphanEnd(orphanStart, wellFormedEnd) {
2677
- const tail = trailingSiblingCount(wellFormedEnd);
2678
- if (tail === 0) return null;
2679
- // Collect the orphan's children region (start.nextSibling to parent end).
2680
- /** @type {Node[]} */
2681
- const nodes = [];
2682
- for (let n = orphanStart.nextSibling; n; n = n.nextSibling) nodes.push(n);
2683
- // Preserve the last `tail` nodes by stopping before index `cut`. A `cut` at or
2684
- // below zero is a degenerate mismatch (the well-formed side claims more
2685
- // trailing siblings than the orphan has nodes at all, which a same-layout nav
2686
- // should never produce): fall back to `null` (sweep to the parent end, a normal
2687
- // full-region swap) rather than an empty range, which would duplicate or blank
2688
- // the children.
2689
- const cut = nodes.length - tail;
2690
- if (cut <= 0) return null;
2691
- return nodes[cut];
2692
- }
2693
-
2694
- /**
2695
- * Replace nodes between `target.start` and `target.end` (exclusive) in the
2696
- * live document with the nodes between `source.start` and `source.end` in
2697
- * the parsed Document. Uses a keyed reconciler that preserves DOM
2698
- * identity for matched elements + their live attributes (scroll, value,
2699
- * etc.).
2700
- *
2701
- * A null `end` (an orphaned open marker recovered per #994) means "run to the
2702
- * end of the containing element": the slice-collection loops stop at the natural
2703
- * sibling end and `reconcileSiblings` appends before `null` (end of parent), so
2704
- * a dropped close comment swaps the whole children region without a close marker.
2705
- *
2706
- * @param {{ start: Comment, end: Comment | null } | undefined} target
2707
- * @param {{ start: Comment, end: Comment | null } | undefined} source
2945
+ * @param {{ start: Comment, end: Comment } | undefined} target
2946
+ * @param {{ start: Comment, end: Comment } | undefined} source
2708
2947
  * @param {Document} _doc
2709
2948
  */
2710
2949
  function swapMarkerRange(target, source, _doc) {
@@ -2934,39 +3173,7 @@ function ownActualLightSlots(host) {
2934
3173
  return byName;
2935
3174
  }
2936
3175
 
2937
- /**
2938
- * The component's own light slot for `name`, whatever its projection state
2939
- * (first-wins by document order). Used to resolve the slot to fill on a
2940
- * fallback->actual transition, where the target slot is currently `fallback`
2941
- * so it is absent from `ownActualLightSlots`. Nested-child slots are excluded.
2942
- *
2943
- * @param {Element} host
2944
- * @param {string|null} name
2945
- * @returns {HTMLSlotElement | null}
2946
- */
2947
- function ownLightSlotForName(host, name) {
2948
- for (const slot of host.querySelectorAll(`slot[${LIGHT_SLOT_ATTR}]`)) {
2949
- const s = /** @type {HTMLSlotElement} */ (slot);
2950
- if (!isOwnLightSlot(s, host)) continue;
2951
- if ((s.getAttribute('name') || null) === name) return s;
2952
- }
2953
- return null;
2954
- }
2955
3176
 
2956
- /**
2957
- * Per-index node-identity equality of two node lists. Used to decide whether a
2958
- * slot's assigned-node SET actually changed (so `slotchange` should fire); an
2959
- * in-place text edit that reuses the same node is NOT a set change.
2960
- *
2961
- * @param {ArrayLike<Node>} a
2962
- * @param {ArrayLike<Node>} b
2963
- * @returns {boolean}
2964
- */
2965
- function sameNodeList(a, b) {
2966
- if (a.length !== b.length) return false;
2967
- for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
2968
- return true;
2969
- }
2970
3177
 
2971
3178
  /**
2972
3179
  * Re-project the page-authored slotted content of a REUSED hydrated light-DOM
@@ -2990,8 +3197,8 @@ function sameNodeList(a, b) {
2990
3197
  * slot runtime's own primitives (`applyFallback` / `applyActualAssignment`)
2991
3198
  * on the ONE resolved own slot, which restore or swap the render-owned
2992
3199
  * fallback without reconciling any lit-html part (#912). This is applied
2993
- * surgically to the target slot, NOT via a whole-host `projectChildren`
2994
- * pass: `projectChildren` selects EVERY `data-webjs-light` slot in the
3200
+ * surgically to the target slot, NOT via a whole-host application
3201
+ * pass: a whole-host pass selects EVERY `data-webjs-light` slot in the
2995
3202
  * subtree (including a nested child component's), so running it here would
2996
3203
  * let this component's assignment reach into a nested child's same-named
2997
3204
  * slot and clobber its render-owned nodes (the #906 hazard, one level down).
@@ -3000,54 +3207,39 @@ function sameNodeList(a, b) {
3000
3207
  * @param {Element} src Incoming SSR copy of the same component.
3001
3208
  */
3002
3209
  function reprojectSlottedContent(dst, src) {
3003
- // Only a light-DOM component that tracks slot assignments has projected
3210
+ // Only a light-DOM component that tracks slot assignments has placed
3004
3211
  // page-authored content to update. No slot state (no <slot>, or a shadow-DOM
3005
3212
  // component whose slotted nodes are ordinary light children) means nothing
3006
- // to re-project here.
3007
- const state = /** @type {any} */ (dst)[SLOT_STATE];
3008
- if (!state) return;
3213
+ // to update here.
3214
+ if (!(/** @type {any} */ (dst)[SLOT_STATE])) return;
3009
3215
 
3010
3216
  const liveSlots = ownActualLightSlots(dst);
3011
3217
  const incSlots = ownActualLightSlots(src);
3012
3218
  if (liveSlots.size === 0 && incSlots.size === 0) return;
3013
3219
 
3014
- // A slot name is `actual` on the live side, the incoming side, or both. Walk
3015
- // the union so a boundary transition (present on only one side) is handled.
3220
+ // #1015: slotted children are VALUES pushed through the ONE public seam,
3221
+ // setSlotContent (no cross-module state surgery; the slot runtime owns the
3222
+ // record, fires slotchange on a genuine set change, and re-applies). The
3223
+ // union walk covers boundary transitions (a name present on only one side).
3016
3224
  const names = new Set([...liveSlots.keys(), ...incSlots.keys()]);
3017
3225
  for (const name of names) {
3018
3226
  const liveSlot = liveSlots.get(name);
3019
3227
  const incSlot = incSlots.get(name);
3020
3228
  if (liveSlot && incSlot) {
3021
- // actual->actual: the slot's children are page-authored, so
3022
- // reconcileChildren is safe: it preserves node identity where it can and
3023
- // never touches lit-html parts. Keep the slot runtime's assignment
3024
- // bookkeeping in sync so a later re-render materialises THESE nodes.
3025
- const prevAssigned = state.lastSnapshot.get(liveSlot) || [];
3229
+ // actual->actual: reconcile IN PLACE first so page-authored slotted
3230
+ // nodes keep DOM identity where they match (#908: a nested live
3231
+ // component survives; an in-place text edit reuses the same node),
3232
+ // then push the resulting set through the public API. The runtime's
3233
+ // set-equality check makes slotchange fire exactly on an
3234
+ // add/remove/replace and stay silent on a pure text edit (#912).
3026
3235
  reconcileChildren(liveSlot, incSlot);
3027
- const children = [...liveSlot.childNodes];
3028
- state.assignedByName.set(name, children);
3029
- state.lastSnapshot.set(liveSlot, children.slice());
3030
- // Fire slotchange on an assigned-node-SET change (add / remove / reorder),
3031
- // not an in-place text edit that reuses the same node: that matches the
3032
- // spec and keeps this branch uniform with the boundary branches below.
3033
- if (!sameNodeList(prevAssigned, children)) fireSlotChange(liveSlot);
3034
- } else if (liveSlot && !incSlot) {
3035
- // actual->fallback: incoming REMOVED this slot's content. Clear the
3036
- // assignment and flip THIS slot to fallback; applyFallback restores the
3037
- // render-owned fallback from the slot-part holding fragment.
3038
- state.assignedByName.delete(name);
3039
- if (applyFallback(state, liveSlot)) fireSlotChange(liveSlot);
3236
+ setSlotContent(dst, name, [...liveSlot.childNodes]);
3237
+ } else if (incSlot) {
3238
+ // fallback->actual: incoming ADDED content. Import and push.
3239
+ setSlotContent(dst, name, [...incSlot.childNodes].map((n) => document.importNode(n, true)));
3040
3240
  } else {
3041
- // fallback->actual: incoming ADDED content where the live slot shows
3042
- // fallback. The target slot is currently fallback, so resolve it directly
3043
- // (it is absent from the actual-only map) and fill it with the imported
3044
- // page-authored nodes; applyActualAssignment tucks the render-owned
3045
- // fallback into the holding fragment first.
3046
- const targetSlot = ownLightSlotForName(dst, name);
3047
- if (!targetSlot) continue;
3048
- const nodes = [...incSlot.childNodes].map((n) => document.importNode(n, true));
3049
- state.assignedByName.set(name, nodes);
3050
- if (applyActualAssignment(state, targetSlot, nodes)) fireSlotChange(targetSlot);
3241
+ // actual->fallback: incoming DROPPED the content. Reset to fallback.
3242
+ setSlotContent(dst, name, null);
3051
3243
  }
3052
3244
  }
3053
3245
  }
@@ -3181,16 +3373,21 @@ const LIVE_ATTRS = new Set([
3181
3373
  * @returns {{ slot: { start: Comment, end: Comment }, oldChildren: Node[], token: number } | null}
3182
3374
  */
3183
3375
  function applyOptimisticLoading() {
3184
- const slots = collectChildrenSlots(document.body);
3185
- if (slots.size === 0) return null;
3186
- // Pick the deepest current slot (longest path).
3376
+ const slots = collectBoundaries(document.body);
3377
+ if (!slots || slots.size === 0) return null;
3378
+ // Walk boundaries deepest-first and use the first whose segment has a
3379
+ // loading template. Loading templates are keyed by LAYOUT segment
3380
+ // (loading.ts files live next to layouts), while the deepest boundary is
3381
+ // usually the PAGE's own (#1015), which has no template: skipping over it
3382
+ // finds the innermost layout skeleton, matching the pre-#1015 behaviour.
3383
+ const bySegment = [...slots.keys()].sort((a, b) => b.length - a.length);
3187
3384
  let deepest = null;
3188
- for (const p of slots.keys()) {
3189
- if (deepest === null || p.length > deepest.length) deepest = p;
3385
+ let tpl = null;
3386
+ for (const p of bySegment) {
3387
+ const t = document.getElementById(`wj-loading:${p}`);
3388
+ if (t instanceof HTMLTemplateElement) { deepest = p; tpl = t; break; }
3190
3389
  }
3191
- if (deepest === null) return null;
3192
- const tpl = document.getElementById(`wj-loading:${deepest}`);
3193
- if (!(tpl instanceof HTMLTemplateElement)) return null;
3390
+ if (deepest === null || tpl === null) return null;
3194
3391
 
3195
3392
  const slot = slots.get(deepest);
3196
3393
  /** @type {Node[]} */
@@ -3684,9 +3881,10 @@ export {
3684
3881
  clearFrameBusy as _clearFrameBusy,
3685
3882
  markFormBusy as _markFormBusy,
3686
3883
  clearFormBusy as _clearFormBusy,
3687
- collectChildrenSlots as _collectChildrenSlots,
3688
- longestSharedPath as _longestSharedPath,
3884
+ collectBoundaries as _collectBoundaries,
3885
+ planBoundarySwap as _planBoundarySwap,
3689
3886
  parseHTML as _parseHTML,
3887
+ resetParseProbe as _resetParseProbe,
3690
3888
  keyOf as _keyOf,
3691
3889
  diffElementInPlace as _diffElementInPlace,
3692
3890
  reconcileChildren as _reconcileChildren,
@@ -3744,6 +3942,11 @@ export function _setCurrentPageUrl(u) { currentPageUrl = u; }
3744
3942
  /** Test-only: clear the fire-once warning guards so a case can be re-exercised. */
3745
3943
  export function _resetWarnOnce() { warnedKeys.clear(); smoothScrollChecked = false; }
3746
3944
 
3945
+ /** Test-only: the readyState-loading full-load degradation predicate (#1008). */
3946
+ export function _shouldFullLoadDuringParse(isPopState, frameId) {
3947
+ return shouldFullLoadDuringParse(isPopState, frameId);
3948
+ }
3949
+
3747
3950
  /**
3748
3951
  * Predicate used by the onClick handler to decide whether a same-origin
3749
3952
  * href should bypass the router. Exposed for unit testing.