@webjsdev/core 0.7.39 → 0.7.41
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.
- package/dist/webjs-core-browser.js +3 -3
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +19 -19
- package/dist/webjs-core-testing.js.map +2 -2
- package/dist/webjs-core.js +5 -5
- package/dist/webjs-core.js.map +3 -3
- package/package.json +1 -1
- package/src/component.js +62 -28
- package/src/render-client.js +208 -53
- package/src/render-server.js +55 -35
- package/src/router-client.js +675 -324
- package/src/slot.js +1811 -389
- package/src/webjs-frame.js +1 -1
package/src/router-client.js
CHANGED
|
@@ -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
|
-
|
|
21
|
+
projectAuthored, keyOfName, isAuthoredContentSlot,
|
|
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
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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
|
|
58
|
-
* already has.
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
|
83
|
-
*
|
|
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:<
|
|
87
|
-
* Parsing such a fragment as a DOCUMENT hoists that
|
|
88
|
-
* `<body>` (the HTML parser's "before html" insertion
|
|
89
|
-
* comment a child of the document, before `<html>`), so
|
|
90
|
-
* `
|
|
91
|
-
* shared
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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 (
|
|
133
|
+
if (
|
|
134
|
+
typeof Document !== 'undefined' &&
|
|
135
|
+
typeof Document.parseHTMLUnsafe === 'function' &&
|
|
136
|
+
parseHTMLUnsafePreservesComments()
|
|
137
|
+
) {
|
|
120
138
|
return Document.parseHTMLUnsafe(html);
|
|
121
139
|
}
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
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
|
-
*
|
|
842
|
+
* Boundary discovery (the heart of the partial-swap mechanism, #1015)
|
|
705
843
|
* ==================================================================== */
|
|
706
844
|
|
|
707
845
|
/**
|
|
708
|
-
* Walk a node tree collecting
|
|
846
|
+
* Walk a node tree collecting KEYED children-boundary pairs into a Map
|
|
709
847
|
* keyed by segment path.
|
|
710
848
|
*
|
|
711
|
-
*
|
|
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
|
|
718
|
-
*
|
|
719
|
-
*
|
|
720
|
-
*
|
|
721
|
-
* `
|
|
722
|
-
*
|
|
723
|
-
*
|
|
724
|
-
*
|
|
725
|
-
*
|
|
726
|
-
*
|
|
727
|
-
*
|
|
728
|
-
*
|
|
729
|
-
*
|
|
730
|
-
*
|
|
731
|
-
*
|
|
732
|
-
*
|
|
733
|
-
*
|
|
734
|
-
*
|
|
735
|
-
*
|
|
736
|
-
*
|
|
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
|
-
* @
|
|
740
|
-
*
|
|
741
|
-
*/
|
|
742
|
-
export function
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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.
|
|
915
|
+
if (data.startsWith('/wj:children')) {
|
|
916
|
+
const seg = data.slice('/wj:children'.length).replace(/^:/, '');
|
|
763
917
|
const frame = stack.pop();
|
|
764
|
-
if (frame
|
|
765
|
-
|
|
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
|
-
|
|
780
|
-
|
|
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 });
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
return slots;
|
|
959
|
+
if (poisoned || stack.length > 0) return null;
|
|
960
|
+
return out;
|
|
791
961
|
}
|
|
792
962
|
|
|
793
963
|
/**
|
|
794
|
-
*
|
|
795
|
-
*
|
|
796
|
-
*
|
|
797
|
-
*
|
|
798
|
-
*
|
|
799
|
-
*
|
|
800
|
-
*
|
|
801
|
-
*
|
|
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}
|
|
802
996
|
*/
|
|
803
|
-
export function
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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) };
|
|
1011
|
+
}
|
|
808
1012
|
}
|
|
809
|
-
|
|
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
|
|
1075
|
-
* Comma-separated
|
|
1076
|
-
*
|
|
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
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
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 =
|
|
1627
|
-
|
|
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,
|
|
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
|
|
|
@@ -2359,6 +2605,23 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
|
|
|
2359
2605
|
// carriers, so the inert payload never lands in the live document.
|
|
2360
2606
|
try { scanSeeds(doc); } catch { /* seeding is best-effort */ }
|
|
2361
2607
|
|
|
2608
|
+
// Every host in this parsed doc is FRAMEWORK-SERIALIZED markup (an SSR
|
|
2609
|
+
// fragment or a back/forward snapshot of post-hydration HTML), never
|
|
2610
|
+
// author-written children. Stamp them so connectedCallback's slot chooser
|
|
2611
|
+
// ADOPTS instead of capture-hoovering the rendered tree, which matters for
|
|
2612
|
+
// a restored host whose serialized shape carries no projected slot (a
|
|
2613
|
+
// conditionally closed slot at snapshot time) where the structural
|
|
2614
|
+
// slot-marker detector has nothing to see. The chooser consumes and removes
|
|
2615
|
+
// the attribute on upgrade.
|
|
2616
|
+
// (An ELIDED display-only host never upgrades, so its stamp is retained as
|
|
2617
|
+
// an inert attribute; diffElementInPlace never copies it onto a live host,
|
|
2618
|
+
// and the upgrade path consumes it, so it cannot mis-route anything.)
|
|
2619
|
+
try {
|
|
2620
|
+
for (const el of doc.querySelectorAll('[data-wj-host]')) {
|
|
2621
|
+
el.setAttribute('data-wj-serialized', '');
|
|
2622
|
+
}
|
|
2623
|
+
} catch { /* stamping is best-effort */ }
|
|
2624
|
+
|
|
2362
2625
|
// Any clean swap (no importmap mismatch, including cache restores
|
|
2363
2626
|
// and frame swaps where we don't even run the mismatch check) is a
|
|
2364
2627
|
// signal that the user successfully navigated, so clear the reload
|
|
@@ -2536,45 +2799,77 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
|
|
|
2536
2799
|
return;
|
|
2537
2800
|
}
|
|
2538
2801
|
|
|
2539
|
-
// 2.
|
|
2540
|
-
//
|
|
2541
|
-
//
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
//
|
|
2553
|
-
//
|
|
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.
|
|
2802
|
+
// 2. Two-tier keyed boundary swap (#1015). Scan both trees STRICTLY: a
|
|
2803
|
+
// poisoned side (malformed, truncated, or mispaired boundaries) yields null
|
|
2804
|
+
// and falls through to the integrity degradation below. A valid pair of
|
|
2805
|
+
// scans picks the swap tier by route-key comparison: a CHANGED key REPLACES
|
|
2806
|
+
// (remount, Next param-change parity) at the PARENT of the shallowest
|
|
2807
|
+
// change, else MORPH (state-preserving keyed reconcile) at the deepest
|
|
2808
|
+
// shared boundary.
|
|
2809
|
+
const here = collectBoundaries(document.body);
|
|
2810
|
+
const there = collectBoundaries(doc.body);
|
|
2811
|
+
const plan = here && there ? planBoundarySwap(here, there) : null;
|
|
2812
|
+
|
|
2813
|
+
if (plan) {
|
|
2814
|
+
const { mode, live, incoming } = plan;
|
|
2815
|
+
// ADD-ONLY head merge: the outer layout stays mounted, so its head-bound
|
|
2816
|
+
// runtime state (Tailwind injection, etc.) must not be invalidated.
|
|
2561
2817
|
addNewHeadElements(doc.head);
|
|
2562
2818
|
runWithTransition(() => {
|
|
2563
|
-
|
|
2819
|
+
if (mode === 'replace') replaceBoundaryRange(live, incoming);
|
|
2820
|
+
else swapMarkerRange(live, incoming, doc);
|
|
2821
|
+
// No key sync is needed on the anchor's own comments: the plan's anchor
|
|
2822
|
+
// carries EQUAL live/incoming route-keys in every tier (a changed-key
|
|
2823
|
+
// REPLACE anchors at a parent already compared equal; the other tiers
|
|
2824
|
+
// require no change at all), and the fresh deeper keys arrive via the
|
|
2825
|
+
// physically replaced boundary comments inside the range.
|
|
2826
|
+
//
|
|
2827
|
+
// When the swapped range lives INSIDE a light-DOM slot (a layout whose
|
|
2828
|
+
// ${children} render inside a slotted shell component), the raw swap
|
|
2829
|
+
// just rewrote nodes the slot runtime believes it owns, so its record is
|
|
2830
|
+
// now stale. Resync the owning host's record from the slot's real
|
|
2831
|
+
// children through the one public seam, or the host's next
|
|
2832
|
+
// applySlotAssignments would wipe the freshly swapped content and
|
|
2833
|
+
// restore the stale list.
|
|
2834
|
+
resyncEnclosingHostSlots(live.start, incoming.start);
|
|
2564
2835
|
blurOutgoingFocus();
|
|
2565
|
-
}, () => upgradeCustomElementsInRange(
|
|
2836
|
+
}, () => upgradeCustomElementsInRange(live));
|
|
2566
2837
|
forwardSuspenseResolvers(doc.body);
|
|
2567
2838
|
return;
|
|
2568
2839
|
}
|
|
2569
2840
|
|
|
2570
|
-
// 3.
|
|
2571
|
-
//
|
|
2572
|
-
//
|
|
2573
|
-
//
|
|
2574
|
-
//
|
|
2575
|
-
//
|
|
2576
|
-
//
|
|
2577
|
-
//
|
|
2841
|
+
// 3. Integrity degradation (#1015). No trustworthy shared boundary exists:
|
|
2842
|
+
// one side is poisoned, or the trees share no segment (a divergent shell).
|
|
2843
|
+
// For a FOREGROUND nav, degrade to a FULL PAGE LOAD: bounded, correct, and
|
|
2844
|
+
// exactly what an MPA would do, where the deleted heuristic recovery could
|
|
2845
|
+
// guess wrong and corrupt silently. Dev logs the cause so a systematic
|
|
2846
|
+
// producer of malformed boundaries is visible immediately. A REVALIDATION
|
|
2847
|
+
// (the background refresh after a snapshot restore) is excluded: the user
|
|
2848
|
+
// is already viewing a page, so a background op must never yank them
|
|
2849
|
+
// through a hard load; it takes the in-place path below.
|
|
2850
|
+
if (href && !revalidating && typeof location !== 'undefined') {
|
|
2851
|
+
devWarnFallback(!here ? 'live-boundaries-malformed'
|
|
2852
|
+
: !there ? 'incoming-boundaries-malformed'
|
|
2853
|
+
: 'no-shared-boundary', href);
|
|
2854
|
+
location.href = href;
|
|
2855
|
+
return;
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
// A BACKGROUND revalidation (revalidating + href) with no trustworthy plan
|
|
2859
|
+
// DISCARDS the response outright: the user is viewing a valid restored
|
|
2860
|
+
// snapshot, and the response may be a reduced (chrome-less) X-Webjs-Have
|
|
2861
|
+
// fragment, so the full-body swap below would wipe the shell from a
|
|
2862
|
+
// background op. Doing nothing is the only safe degradation here.
|
|
2863
|
+
if (href && revalidating) {
|
|
2864
|
+
devWarnFallback('revalidation-discarded', href);
|
|
2865
|
+
return 'discard';
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
// 4. In-place full-body swap: the background paths only (a snapshot
|
|
2869
|
+
// restore or its revalidation, where a full load is not an option
|
|
2870
|
+
// because the user is already viewing the page). Full head merge;
|
|
2871
|
+
// `mergeHead` PRESERVES stylesheets and `<style>` unconditionally
|
|
2872
|
+
// (#936) so the swap can never leave the page unstyled.
|
|
2578
2873
|
mergeHead(doc.head);
|
|
2579
2874
|
// Persist permanent elements by node identity across the full-body
|
|
2580
2875
|
// swap: move each live [data-webjs-permanent][id] node into the matching
|
|
@@ -2620,91 +2915,61 @@ function blurOutgoingFocus() {
|
|
|
2620
2915
|
}
|
|
2621
2916
|
|
|
2622
2917
|
/**
|
|
2623
|
-
*
|
|
2624
|
-
*
|
|
2625
|
-
*
|
|
2626
|
-
*
|
|
2627
|
-
*
|
|
2628
|
-
*
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
}
|
|
2635
|
-
|
|
2636
|
-
/**
|
|
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).
|
|
2646
|
-
*
|
|
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).
|
|
2657
|
-
*
|
|
2658
|
-
* @param {{ start: Comment, end: Comment | null } | undefined} target
|
|
2659
|
-
* @param {{ start: Comment, end: Comment | null } | undefined} source
|
|
2918
|
+
* Wholesale-REPLACE the contents of a boundary range (#1015): remove every
|
|
2919
|
+
* live node between `target.start` and `target.end` (exclusive) and insert a
|
|
2920
|
+
* fresh import of the incoming range. This is the REMOUNT tier: the boundary's
|
|
2921
|
+
* route-key changed (a param change, or a different page under a shared static
|
|
2922
|
+
* layout), so Next.js parity demands fresh component instances, not a keyed
|
|
2923
|
+
* reuse of old-page DOM. The only nodes that survive by identity are
|
|
2924
|
+
* `data-webjs-permanent` elements, regrafted into the imported slice before
|
|
2925
|
+
* insertion so a playing `<audio>`/widget keeps running.
|
|
2926
|
+
*
|
|
2927
|
+
* @param {{ start: Comment, end: Comment }} target The live boundary.
|
|
2928
|
+
* @param {{ start: Comment, end: Comment }} source The incoming boundary.
|
|
2660
2929
|
*/
|
|
2661
|
-
function
|
|
2662
|
-
|
|
2663
|
-
if (
|
|
2664
|
-
|
|
2930
|
+
function replaceBoundaryRange(target, source) {
|
|
2931
|
+
const liveParent = target.start.parentNode;
|
|
2932
|
+
if (!liveParent) return;
|
|
2933
|
+
/** @type {Node[]} */
|
|
2934
|
+
const liveSlice = [];
|
|
2935
|
+
for (let n = target.start.nextSibling; n && n !== target.end; n = n.nextSibling) {
|
|
2936
|
+
liveSlice.push(n);
|
|
2937
|
+
}
|
|
2938
|
+
/** @type {Node[]} */
|
|
2939
|
+
const incomingSlice = [];
|
|
2940
|
+
for (let n = source.start.nextSibling; n && n !== source.end; n = n.nextSibling) {
|
|
2941
|
+
incomingSlice.push(document.importNode(n, true));
|
|
2942
|
+
}
|
|
2943
|
+
regraftPermanentInSlice(liveSlice, incomingSlice);
|
|
2944
|
+
for (const n of liveSlice) {
|
|
2945
|
+
if (n.parentNode === liveParent) liveParent.removeChild(n);
|
|
2946
|
+
}
|
|
2947
|
+
for (const n of incomingSlice) {
|
|
2948
|
+
liveParent.insertBefore(n, target.end);
|
|
2949
|
+
}
|
|
2950
|
+
for (let n = target.start.nextSibling; n && n !== target.end; n = n.nextSibling) {
|
|
2951
|
+
if (n.nodeType === 1) {
|
|
2952
|
+
reactivateScripts(/** @type {Element} */ (n));
|
|
2953
|
+
upgradeCustomElements(/** @type {Element} */ (n));
|
|
2954
|
+
}
|
|
2955
|
+
}
|
|
2665
2956
|
}
|
|
2666
2957
|
|
|
2667
2958
|
/**
|
|
2668
|
-
*
|
|
2669
|
-
*
|
|
2670
|
-
* `
|
|
2671
|
-
*
|
|
2672
|
-
*
|
|
2673
|
-
*
|
|
2674
|
-
*
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
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
|
|
2959
|
+
* MORPH the contents of a boundary range (#1015): reconcile the nodes between
|
|
2960
|
+
* `target.start` and `target.end` (exclusive) in the live document against the
|
|
2961
|
+
* nodes between `source.start` and `source.end` in the parsed Document, using
|
|
2962
|
+
* a keyed reconciler that preserves DOM identity for matched elements + their
|
|
2963
|
+
* live attributes (scroll, value, etc.). This is the state-preserving tier for
|
|
2964
|
+
* a searchParams-only / refresh nav, where the route-key is unchanged and
|
|
2965
|
+
* hydrated component state must survive.
|
|
2966
|
+
*
|
|
2967
|
+
* Boundaries are strictly paired by the scanner (#1015), so `end` is always a
|
|
2968
|
+
* real close comment here (the null-orphan tolerance of the deleted #994
|
|
2969
|
+
* recovery is gone with it).
|
|
2970
|
+
*
|
|
2971
|
+
* @param {{ start: Comment, end: Comment } | undefined} target
|
|
2972
|
+
* @param {{ start: Comment, end: Comment } | undefined} source
|
|
2708
2973
|
* @param {Document} _doc
|
|
2709
2974
|
*/
|
|
2710
2975
|
function swapMarkerRange(target, source, _doc) {
|
|
@@ -2839,6 +3104,14 @@ function diffElementInPlace(dst, src) {
|
|
|
2839
3104
|
for (const attr of src.attributes) {
|
|
2840
3105
|
srcAttrs.add(attr.name);
|
|
2841
3106
|
if (LIVE_ATTRS.has(attr.name)) continue;
|
|
3107
|
+
// The serialized-restore stamp is a message to a NOT-YET-UPGRADED
|
|
3108
|
+
// element's connectedCallback; copying it onto a live reused host would
|
|
3109
|
+
// leave a consume-once marker lingering in the live DOM forever. Note
|
|
3110
|
+
// the REMOVAL loop below never strips an existing stamp either (the
|
|
3111
|
+
// stamp is in srcAttrs, added before this skip) and that retention is
|
|
3112
|
+
// load-bearing: a not-yet-upgraded `static lazy` host must KEEP its
|
|
3113
|
+
// stamp across an intervening morph so its late upgrade still adopts.
|
|
3114
|
+
if (attr.name === 'data-wj-serialized') continue;
|
|
2842
3115
|
if (dst.getAttribute(attr.name) !== attr.value) {
|
|
2843
3116
|
dst.setAttribute(attr.name, attr.value);
|
|
2844
3117
|
}
|
|
@@ -2894,7 +3167,12 @@ function diffElementInPlace(dst, src) {
|
|
|
2894
3167
|
* @returns {boolean}
|
|
2895
3168
|
*/
|
|
2896
3169
|
function isHydratedComponent(el) {
|
|
2897
|
-
|
|
3170
|
+
// Opaque to the router when it has rendered (INSTANCE) OR merely has slot
|
|
3171
|
+
// state installed but has not yet run its deferred first render (SLOT_STATE):
|
|
3172
|
+
// in that window a same-task morph would otherwise reconcile INTO the host
|
|
3173
|
+
// through the slot interception.
|
|
3174
|
+
const a = /** @type {any} */ (el);
|
|
3175
|
+
return a[Symbol.for('webjs.instance')] != null || a[SLOT_STATE] != null;
|
|
2898
3176
|
}
|
|
2899
3177
|
|
|
2900
3178
|
/**
|
|
@@ -2928,45 +3206,37 @@ function ownActualLightSlots(host) {
|
|
|
2928
3206
|
for (const slot of host.querySelectorAll(sel)) {
|
|
2929
3207
|
const s = /** @type {HTMLSlotElement} */ (slot);
|
|
2930
3208
|
if (!isOwnLightSlot(s, host)) continue;
|
|
2931
|
-
|
|
3209
|
+
// The runtime's invariant applies here too: a slot inside AUTHORED
|
|
3210
|
+
// content (an author-relocated rendered chunk, a spoofed stamp) is
|
|
3211
|
+
// inert content, never a reprojection target; collecting it would
|
|
3212
|
+
// project into (or evict from) a slot the apply refuses to place.
|
|
3213
|
+
if (isAuthoredContentSlot(host, s)) continue;
|
|
3214
|
+
// Src-side (parsed doc) hosts have no record, so the authored test is
|
|
3215
|
+
// inert there; the SERIALIZED shape of content is structural instead: a
|
|
3216
|
+
// slot nested inside an ACTUAL-mode light slot of the same host is
|
|
3217
|
+
// content (an SSR'd forwarded slot rides inside the inner host's own
|
|
3218
|
+
// actual slot), never a reprojection target. A slot inside a
|
|
3219
|
+
// FALLBACK-mode container stays collectable: fallback content is
|
|
3220
|
+
// template markup, and a slot there is legitimate.
|
|
3221
|
+
let nestedInActual = false;
|
|
3222
|
+
for (let a = s.parentElement; a && a !== host; a = a.parentElement) {
|
|
3223
|
+
if (
|
|
3224
|
+
a.tagName === 'SLOT' &&
|
|
3225
|
+
a.hasAttribute(LIGHT_SLOT_ATTR) &&
|
|
3226
|
+
a.getAttribute(PROJECTION_ATTR) === PROJECTION_ACTUAL
|
|
3227
|
+
) {
|
|
3228
|
+
nestedInActual = true;
|
|
3229
|
+
break;
|
|
3230
|
+
}
|
|
3231
|
+
}
|
|
3232
|
+
if (nestedInActual) continue;
|
|
3233
|
+
const name = keyOfName(s.getAttribute('name'));
|
|
2932
3234
|
if (!byName.has(name)) byName.set(name, s);
|
|
2933
3235
|
}
|
|
2934
3236
|
return byName;
|
|
2935
3237
|
}
|
|
2936
3238
|
|
|
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
3239
|
|
|
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
3240
|
|
|
2971
3241
|
/**
|
|
2972
3242
|
* Re-project the page-authored slotted content of a REUSED hydrated light-DOM
|
|
@@ -2986,68 +3256,138 @@ function sameNodeList(a, b) {
|
|
|
2986
3256
|
* on the page-authored slot children, exactly as #908 shipped.
|
|
2987
3257
|
* - actual->fallback (content REMOVED) and fallback->actual (content ADDED):
|
|
2988
3258
|
* a slot's fallback is RENDER-OWNED (the compiled fallback template held by
|
|
2989
|
-
* the slot-part), so these are NOT a raw reconcile.
|
|
2990
|
-
*
|
|
2991
|
-
*
|
|
2992
|
-
*
|
|
2993
|
-
*
|
|
2994
|
-
* pass
|
|
2995
|
-
*
|
|
2996
|
-
* let this component's assignment reach into a nested child's same-named
|
|
2997
|
-
* slot and clobber its render-owned nodes (the #906 hazard, one level down).
|
|
3259
|
+
* the slot-part), so these are NOT a raw reconcile. All three cases route
|
|
3260
|
+
* through `projectAuthored`, the record seam, whose apply pass restores or
|
|
3261
|
+
* swaps the render-owned fallback without reconciling any lit-html part
|
|
3262
|
+
* (#912). The #906 one-level-down hazard (this component's assignment
|
|
3263
|
+
* reaching a nested child's same-named slot) is answered by the apply
|
|
3264
|
+
* pass's own-slot filtering (`isOwnSlot` + the authored-content
|
|
3265
|
+
* exclusion), not by surgical single-slot application.
|
|
2998
3266
|
*
|
|
2999
3267
|
* @param {Element} dst Live hydrated component host.
|
|
3000
3268
|
* @param {Element} src Incoming SSR copy of the same component.
|
|
3001
3269
|
*/
|
|
3270
|
+
/**
|
|
3271
|
+
* After a boundary swap, if the swapped range's parent is a light-DOM slot,
|
|
3272
|
+
* resync the owning host's slot record from the slot's REAL children through
|
|
3273
|
+
* the one public seam (`projectAuthored`). The router's raw range write is the
|
|
3274
|
+
* one sanctioned write into a region the slot runtime also places (a layout's
|
|
3275
|
+
* `${children}` rendered inside a slotted shell puts the `wj:children` markers
|
|
3276
|
+
* INSIDE that shell's slot), so without this sync the record goes stale and
|
|
3277
|
+
* the host's next apply would wipe the swapped-in page content and restore the
|
|
3278
|
+
* pruned old list. Walking up from the slot, the owner is the nearest
|
|
3279
|
+
* `SLOT_STATE` host with no other custom element in between; anything else
|
|
3280
|
+
* (a nested stateless component, a shadow slot) bails.
|
|
3281
|
+
*
|
|
3282
|
+
* @param {Comment} startMarker
|
|
3283
|
+
*/
|
|
3284
|
+
function resyncEnclosingSlotRecord(startMarker) {
|
|
3285
|
+
const p = startMarker.parentNode;
|
|
3286
|
+
if (!p || p.nodeType !== 1) return;
|
|
3287
|
+
const slotEl = /** @type {Element} */ (p);
|
|
3288
|
+
if (slotEl.tagName !== 'SLOT' || !slotEl.hasAttribute(LIGHT_SLOT_ATTR)) return;
|
|
3289
|
+
let host = null;
|
|
3290
|
+
for (let a = slotEl.parentElement; a; a = a.parentElement) {
|
|
3291
|
+
if (/** @type {any} */ (a)[SLOT_STATE]) { host = a; break; }
|
|
3292
|
+
if (a.tagName.includes('-')) return; // belongs to a stateless nested element
|
|
3293
|
+
}
|
|
3294
|
+
if (!host) return;
|
|
3295
|
+
projectAuthored(host, keyOfName(slotEl.getAttribute('name')), [...slotEl.childNodes]);
|
|
3296
|
+
}
|
|
3297
|
+
|
|
3298
|
+
/**
|
|
3299
|
+
* Resync the ENCLOSING HOST's slots after a boundary swap whose markers live
|
|
3300
|
+
* inside a light-DOM slot (a layout whose `${children}` render inside a
|
|
3301
|
+
* slotted shell). The boundary swap only rewrites the DEFAULT slice (the
|
|
3302
|
+
* `wj:children` markers always partition to the default slot), so a page that
|
|
3303
|
+
* emits top-level `slot=`-attributed children left the shell's NAMED slots
|
|
3304
|
+
* showing the previous page's content (#1024). This resyncs the enclosing
|
|
3305
|
+
* (default) slot from its just-swapped LIVE children, then reprojects the
|
|
3306
|
+
* sibling NAMED slots from the INCOMING parsed host.
|
|
3307
|
+
*
|
|
3308
|
+
* @param {Comment} liveStart
|
|
3309
|
+
* @param {Comment} incStart
|
|
3310
|
+
*/
|
|
3311
|
+
function resyncEnclosingHostSlots(liveStart, incStart) {
|
|
3312
|
+
const lp = liveStart.parentNode;
|
|
3313
|
+
if (!lp || lp.nodeType !== 1) return;
|
|
3314
|
+
const liveSlot = /** @type {Element} */ (lp);
|
|
3315
|
+
if (liveSlot.tagName !== 'SLOT' || !liveSlot.hasAttribute(LIGHT_SLOT_ATTR)) return;
|
|
3316
|
+
let liveHost = null;
|
|
3317
|
+
for (let a = liveSlot.parentElement; a; a = a.parentElement) {
|
|
3318
|
+
if (/** @type {any} */ (a)[SLOT_STATE]) { liveHost = a; break; }
|
|
3319
|
+
if (a.tagName.includes('-')) return; // enclosing slot belongs to a stateless nested element
|
|
3320
|
+
}
|
|
3321
|
+
if (!liveHost) return;
|
|
3322
|
+
const enclosingName = keyOfName(liveSlot.getAttribute('name'));
|
|
3323
|
+
// 1. The enclosing (boundary) slot, from its own just-swapped live children.
|
|
3324
|
+
projectAuthored(liveHost, enclosingName, [...liveSlot.childNodes]);
|
|
3325
|
+
|
|
3326
|
+
// 2. The sibling NAMED slots, from the incoming parsed host. Find the
|
|
3327
|
+
// incoming host structurally (the parsed copy is not upgraded, so no
|
|
3328
|
+
// SLOT_STATE): the nearest custom-element ancestor of the incoming
|
|
3329
|
+
// boundary marker's enclosing slot.
|
|
3330
|
+
const ip = incStart.parentNode;
|
|
3331
|
+
if (!ip || ip.nodeType !== 1) return;
|
|
3332
|
+
const incSlot = /** @type {Element} */ (ip);
|
|
3333
|
+
if (incSlot.tagName !== 'SLOT') return;
|
|
3334
|
+
let incHost = null;
|
|
3335
|
+
for (let a = incSlot.parentElement; a; a = a.parentElement) {
|
|
3336
|
+
if (a.tagName.includes('-')) { incHost = a; break; }
|
|
3337
|
+
}
|
|
3338
|
+
if (!incHost || incHost.tagName !== liveHost.tagName) return;
|
|
3339
|
+
|
|
3340
|
+
const liveSlots = ownActualLightSlots(liveHost);
|
|
3341
|
+
const incSlots = ownActualLightSlots(incHost);
|
|
3342
|
+
for (const name of new Set([...liveSlots.keys(), ...incSlots.keys()])) {
|
|
3343
|
+
if (name === enclosingName) continue; // handled in (1); never re-reconcile the swapped range
|
|
3344
|
+
const inc = incSlots.get(name);
|
|
3345
|
+
if (inc) {
|
|
3346
|
+
projectAuthored(
|
|
3347
|
+
liveHost,
|
|
3348
|
+
name,
|
|
3349
|
+
[...inc.childNodes].map((n) => document.importNode(n, true)),
|
|
3350
|
+
);
|
|
3351
|
+
} else if (liveSlots.get(name)) {
|
|
3352
|
+
projectAuthored(liveHost, name, null); // dropped by the incoming page: revert to fallback
|
|
3353
|
+
}
|
|
3354
|
+
}
|
|
3355
|
+
}
|
|
3356
|
+
|
|
3002
3357
|
function reprojectSlottedContent(dst, src) {
|
|
3003
|
-
// Only a light-DOM component that tracks slot assignments has
|
|
3358
|
+
// Only a light-DOM component that tracks slot assignments has placed
|
|
3004
3359
|
// page-authored content to update. No slot state (no <slot>, or a shadow-DOM
|
|
3005
3360
|
// component whose slotted nodes are ordinary light children) means nothing
|
|
3006
|
-
// to
|
|
3007
|
-
|
|
3008
|
-
if (!state) return;
|
|
3361
|
+
// to update here.
|
|
3362
|
+
if (!(/** @type {any} */ (dst)[SLOT_STATE])) return;
|
|
3009
3363
|
|
|
3010
3364
|
const liveSlots = ownActualLightSlots(dst);
|
|
3011
3365
|
const incSlots = ownActualLightSlots(src);
|
|
3012
3366
|
if (liveSlots.size === 0 && incSlots.size === 0) return;
|
|
3013
3367
|
|
|
3014
|
-
//
|
|
3015
|
-
//
|
|
3368
|
+
// #1015: slotted children are VALUES pushed through the ONE public seam,
|
|
3369
|
+
// projectAuthored (no cross-module state surgery; the slot runtime owns the
|
|
3370
|
+
// record, fires slotchange on a genuine set change, and re-applies). The
|
|
3371
|
+
// union walk covers boundary transitions (a name present on only one side).
|
|
3016
3372
|
const names = new Set([...liveSlots.keys(), ...incSlots.keys()]);
|
|
3017
3373
|
for (const name of names) {
|
|
3018
3374
|
const liveSlot = liveSlots.get(name);
|
|
3019
3375
|
const incSlot = incSlots.get(name);
|
|
3020
3376
|
if (liveSlot && incSlot) {
|
|
3021
|
-
// actual->actual:
|
|
3022
|
-
//
|
|
3023
|
-
//
|
|
3024
|
-
//
|
|
3025
|
-
|
|
3377
|
+
// actual->actual: reconcile IN PLACE first so page-authored slotted
|
|
3378
|
+
// nodes keep DOM identity where they match (#908: a nested live
|
|
3379
|
+
// component survives; an in-place text edit reuses the same node),
|
|
3380
|
+
// then push the resulting set through the public API. The runtime's
|
|
3381
|
+
// set-equality check makes slotchange fire exactly on an
|
|
3382
|
+
// add/remove/replace and stay silent on a pure text edit (#912).
|
|
3026
3383
|
reconcileChildren(liveSlot, incSlot);
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
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);
|
|
3384
|
+
projectAuthored(dst, name, [...liveSlot.childNodes]);
|
|
3385
|
+
} else if (incSlot) {
|
|
3386
|
+
// fallback->actual: incoming ADDED content. Import and push.
|
|
3387
|
+
projectAuthored(dst, name, [...incSlot.childNodes].map((n) => document.importNode(n, true)));
|
|
3040
3388
|
} else {
|
|
3041
|
-
// fallback
|
|
3042
|
-
|
|
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);
|
|
3389
|
+
// actual->fallback: incoming DROPPED the content. Reset to fallback.
|
|
3390
|
+
projectAuthored(dst, name, null);
|
|
3051
3391
|
}
|
|
3052
3392
|
}
|
|
3053
3393
|
}
|
|
@@ -3181,16 +3521,21 @@ const LIVE_ATTRS = new Set([
|
|
|
3181
3521
|
* @returns {{ slot: { start: Comment, end: Comment }, oldChildren: Node[], token: number } | null}
|
|
3182
3522
|
*/
|
|
3183
3523
|
function applyOptimisticLoading() {
|
|
3184
|
-
const slots =
|
|
3185
|
-
if (slots.size === 0) return null;
|
|
3186
|
-
//
|
|
3524
|
+
const slots = collectBoundaries(document.body);
|
|
3525
|
+
if (!slots || slots.size === 0) return null;
|
|
3526
|
+
// Walk boundaries deepest-first and use the first whose segment has a
|
|
3527
|
+
// loading template. Loading templates are keyed by LAYOUT segment
|
|
3528
|
+
// (loading.ts files live next to layouts), while the deepest boundary is
|
|
3529
|
+
// usually the PAGE's own (#1015), which has no template: skipping over it
|
|
3530
|
+
// finds the innermost layout skeleton, matching the pre-#1015 behaviour.
|
|
3531
|
+
const bySegment = [...slots.keys()].sort((a, b) => b.length - a.length);
|
|
3187
3532
|
let deepest = null;
|
|
3188
|
-
|
|
3189
|
-
|
|
3533
|
+
let tpl = null;
|
|
3534
|
+
for (const p of bySegment) {
|
|
3535
|
+
const t = document.getElementById(`wj-loading:${p}`);
|
|
3536
|
+
if (t instanceof HTMLTemplateElement) { deepest = p; tpl = t; break; }
|
|
3190
3537
|
}
|
|
3191
|
-
if (deepest === null) return null;
|
|
3192
|
-
const tpl = document.getElementById(`wj-loading:${deepest}`);
|
|
3193
|
-
if (!(tpl instanceof HTMLTemplateElement)) return null;
|
|
3538
|
+
if (deepest === null || tpl === null) return null;
|
|
3194
3539
|
|
|
3195
3540
|
const slot = slots.get(deepest);
|
|
3196
3541
|
/** @type {Node[]} */
|
|
@@ -3684,9 +4029,10 @@ export {
|
|
|
3684
4029
|
clearFrameBusy as _clearFrameBusy,
|
|
3685
4030
|
markFormBusy as _markFormBusy,
|
|
3686
4031
|
clearFormBusy as _clearFormBusy,
|
|
3687
|
-
|
|
3688
|
-
|
|
4032
|
+
collectBoundaries as _collectBoundaries,
|
|
4033
|
+
planBoundarySwap as _planBoundarySwap,
|
|
3689
4034
|
parseHTML as _parseHTML,
|
|
4035
|
+
resetParseProbe as _resetParseProbe,
|
|
3690
4036
|
keyOf as _keyOf,
|
|
3691
4037
|
diffElementInPlace as _diffElementInPlace,
|
|
3692
4038
|
reconcileChildren as _reconcileChildren,
|
|
@@ -3744,6 +4090,11 @@ export function _setCurrentPageUrl(u) { currentPageUrl = u; }
|
|
|
3744
4090
|
/** Test-only: clear the fire-once warning guards so a case can be re-exercised. */
|
|
3745
4091
|
export function _resetWarnOnce() { warnedKeys.clear(); smoothScrollChecked = false; }
|
|
3746
4092
|
|
|
4093
|
+
/** Test-only: the readyState-loading full-load degradation predicate (#1008). */
|
|
4094
|
+
export function _shouldFullLoadDuringParse(isPopState, frameId) {
|
|
4095
|
+
return shouldFullLoadDuringParse(isPopState, frameId);
|
|
4096
|
+
}
|
|
4097
|
+
|
|
3747
4098
|
/**
|
|
3748
4099
|
* Predicate used by the onClick handler to decide whether a same-origin
|
|
3749
4100
|
* href should bypass the router. Exposed for unit testing.
|