@webjsdev/core 0.7.59 → 0.7.61
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 +5 -5
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-lazy-loader.js +1 -1
- package/dist/webjs-core-lazy-loader.js.map +2 -2
- package/dist/webjs-core.js +6 -6
- package/dist/webjs-core.js.map +3 -3
- package/package.json +1 -1
- package/src/lazy-loader.js +25 -3
- package/src/router-client/dom-differ.js +1 -1
- package/src/router-client/fetch-apply.js +12 -2
- package/src/router-client/layout-chrome.js +271 -0
- package/src/router-client/swap.js +27 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/core",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.61",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The runtime for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Ships the html and css template tags, the WebComponent base class, signals, directives, and the isomorphic renderers.",
|
|
6
6
|
"types": "./index.d.ts",
|
package/src/lazy-loader.js
CHANGED
|
@@ -22,6 +22,17 @@
|
|
|
22
22
|
* // ...
|
|
23
23
|
* }
|
|
24
24
|
* MyWidget.register('my-lazy-widget');
|
|
25
|
+
*
|
|
26
|
+
* A component that another shipping module imports for registration
|
|
27
|
+
* (`import './my-lazy-widget.ts'`) stays lazy too (#1524): the server keeps
|
|
28
|
+
* that import for SSR, and the browser copy of the importer is served with
|
|
29
|
+
* the line rewritten to an `observeLazy({...})` call, so the module loads
|
|
30
|
+
* when an element with the tag is first visible: a hidden tab panel when it
|
|
31
|
+
* shows, a dialog's content when it opens, an element added later by a
|
|
32
|
+
* client render (the MutationObserver below catches it).
|
|
33
|
+
*
|
|
34
|
+
* The scan reads the light DOM (`document.querySelectorAll`), so a lazy tag
|
|
35
|
+
* rendered inside another component's shadow root is not found.
|
|
25
36
|
*/
|
|
26
37
|
|
|
27
38
|
/** @type {Map<string, string>} tag → module URL */
|
|
@@ -30,6 +41,14 @@ const pending = new Map();
|
|
|
30
41
|
/** @type {IntersectionObserver | null} */
|
|
31
42
|
let observer = null;
|
|
32
43
|
|
|
44
|
+
/**
|
|
45
|
+
* ONE MutationObserver for the whole page, however often `observeLazy` runs
|
|
46
|
+
* (#1524): the boot script calls it, and so does every served module whose
|
|
47
|
+
* side-effect import of a `static lazy` component was deferred to it.
|
|
48
|
+
* @type {MutationObserver | null}
|
|
49
|
+
*/
|
|
50
|
+
let mutations = null;
|
|
51
|
+
|
|
33
52
|
/** @type {Map<string, Promise<unknown>>} in-flight module loads, keyed by URL */
|
|
34
53
|
const inflight = new Map();
|
|
35
54
|
|
|
@@ -78,15 +97,18 @@ export function observeLazy(entries) {
|
|
|
78
97
|
scan();
|
|
79
98
|
|
|
80
99
|
// Also watch for dynamically added elements via MutationObserver.
|
|
81
|
-
if (typeof MutationObserver !== 'undefined') {
|
|
82
|
-
|
|
83
|
-
|
|
100
|
+
if (!mutations && typeof MutationObserver !== 'undefined') {
|
|
101
|
+
mutations = new MutationObserver(() => scan());
|
|
102
|
+
mutations.observe(document.body, { childList: true, subtree: true });
|
|
84
103
|
}
|
|
85
104
|
}
|
|
86
105
|
|
|
87
106
|
function scan() {
|
|
88
107
|
if (!observer || !pending.size) return;
|
|
89
108
|
for (const tag of pending.keys()) {
|
|
109
|
+
// Already defined some other way (an eager import elsewhere): nothing
|
|
110
|
+
// left to load for this tag.
|
|
111
|
+
if (customElements.get(tag)) { pending.delete(tag); continue; }
|
|
90
112
|
for (const el of document.querySelectorAll(tag)) {
|
|
91
113
|
observer.observe(el);
|
|
92
114
|
}
|
|
@@ -289,7 +289,7 @@ export function diffElementInPlace(dst, src) {
|
|
|
289
289
|
* @param {Element} el
|
|
290
290
|
* @returns {boolean}
|
|
291
291
|
*/
|
|
292
|
-
function isHydratedComponent(el) {
|
|
292
|
+
export function isHydratedComponent(el) {
|
|
293
293
|
// Opaque to the router when it has rendered (INSTANCE) OR merely has slot
|
|
294
294
|
// state installed but has not yet run its deferred first render (SLOT_STATE):
|
|
295
295
|
// in that window a same-task morph would otherwise reconcile INTO the host
|
|
@@ -75,6 +75,10 @@ import { _swapCommit, applySwap } from './swap.js';
|
|
|
75
75
|
export async function fetchAndApply(href, frameId, recordHistory, optimisticState, method, body, signal, token, revalidating, opts) {
|
|
76
76
|
method = method || 'GET';
|
|
77
77
|
const refresh = (opts && opts.refresh) || undefined;
|
|
78
|
+
// A write submitted to the PAGE (#1557): its response re-renders the layouts
|
|
79
|
+
// and the swap refreshes their chrome too. A frame-targeted submission swaps
|
|
80
|
+
// one region by contract and keeps its own path.
|
|
81
|
+
const mutation = body != null && method !== 'GET' && method !== 'HEAD' && !frameId;
|
|
78
82
|
const noPrefetch = !!(opts && opts.noPrefetch);
|
|
79
83
|
const preserveScroll = !!(opts && opts.preserveScroll);
|
|
80
84
|
const myToken = typeof token === 'number' ? token : currentNavigationToken;
|
|
@@ -143,7 +147,13 @@ export async function fetchAndApply(href, frameId, recordHistory, optimisticStat
|
|
|
143
147
|
// matches every one of them, so the response would omit the layouts and a
|
|
144
148
|
// layout edit would be invisible. Sending nothing forces the full chain to
|
|
145
149
|
// render.
|
|
146
|
-
|
|
150
|
+
//
|
|
151
|
+
// A mutating submission sends none either (#1557), for the same reason
|
|
152
|
+
// reached differently: the action may have changed what a layout renders
|
|
153
|
+
// (a sign-in sets the cookie the header reads), and `fetch` follows the
|
|
154
|
+
// redirect carrying this header, so the server would short-circuit the
|
|
155
|
+
// very layouts that need to re-run.
|
|
156
|
+
const have = refresh || mutation ? '' : buildHaveHeader();
|
|
147
157
|
if (have) headers['x-webjs-have'] = have;
|
|
148
158
|
if (frameId) headers['x-webjs-frame'] = frameId;
|
|
149
159
|
// Content-negotiate a stream-action response on a write submission (a
|
|
@@ -325,7 +335,7 @@ export async function fetchAndApply(href, frameId, recordHistory, optimisticStat
|
|
|
325
335
|
}
|
|
326
336
|
: null;
|
|
327
337
|
|
|
328
|
-
const disposition = applySwap(doc, frameId, !!revalidating, finalUrl, incomingBuild, incomingSrc, refresh, recordHistoryNow);
|
|
338
|
+
const disposition = applySwap(doc, frameId, !!revalidating, finalUrl, incomingBuild, incomingSrc, refresh || (mutation ? 'layouts' : undefined), recordHistoryNow);
|
|
329
339
|
// `'none'` means applySwap returned WITHOUT committing anything: the frame the
|
|
330
340
|
// response was for is missing, or it degraded to a hard navigation (an
|
|
331
341
|
// importmap/build mismatch, a poisoned boundary scan). The page is not left
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client router: refreshing the layout chrome after a mutation (#1557).
|
|
3
|
+
*
|
|
4
|
+
* A boundary swap rewrites what sits INSIDE one `<!--wj:children:...-->` range.
|
|
5
|
+
* A layout's own markup (the header, nav, footer it renders around
|
|
6
|
+
* `${children}`) sits OUTSIDE every range it owns, so no boundary tier can
|
|
7
|
+
* reach it. That is right for a link click, where the layout is identical by
|
|
8
|
+
* construction, and wrong after a form submission: the action may have set or
|
|
9
|
+
* cleared a cookie the layout reads (a sign-in that should flip "Sign in" to
|
|
10
|
+
* "Sign out"), and the layout markup on screen predates it.
|
|
11
|
+
*
|
|
12
|
+
* So a mutating submission is applied in two steps. The boundary plan runs
|
|
13
|
+
* exactly as it does for any navigation (its remount-vs-morph rules are
|
|
14
|
+
* unchanged), and then this module MORPHS the chrome: every node outside the
|
|
15
|
+
* plan's anchor range, walked level by level from `<body>` down to the range.
|
|
16
|
+
* The morph is the same keyed + positional reconcile the boundary tier uses,
|
|
17
|
+
* so a hydrated component in the layout keeps its instance unless its own
|
|
18
|
+
* markup changed, and a form control keeps what the reader typed.
|
|
19
|
+
*
|
|
20
|
+
* It is split into a PLAN and an APPLY so the caller can decide before
|
|
21
|
+
* touching the DOM. The plan refuses (returns null) whenever the walk cannot be
|
|
22
|
+
* proven safe, and the caller then takes the full-body tier instead, which is
|
|
23
|
+
* always correct and only loses component state:
|
|
24
|
+
* - the live and incoming ancestor chains of the range differ in depth or in
|
|
25
|
+
* tag at any level, so there is no level-by-level pairing to trust;
|
|
26
|
+
* - a hydrated component sits on the chain (a layout whose `${children}`
|
|
27
|
+
* renders inside a slotted shell component). Its subtree is render-owned,
|
|
28
|
+
* and reconciling into it would corrupt its template parts (#906).
|
|
29
|
+
*
|
|
30
|
+
* Nodes appended to `<body>` at runtime (a dev overlay, a dialog portal, a
|
|
31
|
+
* toast region) have no server-rendered counterpart. They always sit AFTER the
|
|
32
|
+
* server-rendered content, so at the body level a live node with no incoming
|
|
33
|
+
* peer is kept when it trails the last matched node, and removed only when it
|
|
34
|
+
* sits among the server-rendered nodes. Below `<body>` every node is layout
|
|
35
|
+
* output, so an unmatched live node there is removed like in any morph.
|
|
36
|
+
*
|
|
37
|
+
* @module
|
|
38
|
+
*/
|
|
39
|
+
import { LIVE_ATTRS } from './constants.js';
|
|
40
|
+
import { diffElementInPlace, isHydratedComponent, keyOf } from './dom-differ.js';
|
|
41
|
+
import { reactivateScripts, upgradeCustomElements } from './upgrade.js';
|
|
42
|
+
import { regraftPermanentInSlice } from './view-transition.js';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @typedef {{ live: Element[], incoming: Element[],
|
|
46
|
+
* liveRange: { start: Comment, end: Comment },
|
|
47
|
+
* incomingRange: { start: Comment, end: Comment } }} ChromePlan
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The ancestor chain of `node` from `root` down to its parent, `root` first.
|
|
52
|
+
* Null when `node` is not inside `root`.
|
|
53
|
+
*
|
|
54
|
+
* @param {Node} node
|
|
55
|
+
* @param {Element} root
|
|
56
|
+
* @returns {Element[] | null}
|
|
57
|
+
*/
|
|
58
|
+
function chainTo(node, root) {
|
|
59
|
+
/** @type {Element[]} */
|
|
60
|
+
const chain = [];
|
|
61
|
+
for (let el = node.parentNode; el; el = el.parentNode) {
|
|
62
|
+
chain.unshift(/** @type {Element} */ (el));
|
|
63
|
+
if (el === root) return chain;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Decide whether the chrome around a boundary range can be morphed level by
|
|
70
|
+
* level, before anything is written.
|
|
71
|
+
*
|
|
72
|
+
* @param {{ start: Comment, end: Comment }} liveRange The plan's live anchor.
|
|
73
|
+
* @param {{ start: Comment, end: Comment }} incomingRange Its incoming peer.
|
|
74
|
+
* @param {Element} liveBody
|
|
75
|
+
* @param {Element} incomingBody
|
|
76
|
+
* @returns {ChromePlan | null} Null when the walk is not provably safe.
|
|
77
|
+
*/
|
|
78
|
+
export function planLayoutChrome(liveRange, incomingRange, liveBody, incomingBody) {
|
|
79
|
+
const live = chainTo(liveRange.start, liveBody);
|
|
80
|
+
const incoming = chainTo(incomingRange.start, incomingBody);
|
|
81
|
+
if (!live || !incoming || live.length !== incoming.length) return null;
|
|
82
|
+
for (let i = 1; i < live.length; i++) {
|
|
83
|
+
if (live[i].tagName !== incoming[i].tagName) return null;
|
|
84
|
+
if (isHydratedComponent(live[i])) return null;
|
|
85
|
+
}
|
|
86
|
+
return { live, incoming, liveRange, incomingRange };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The child nodes of `parent` strictly before `from` and strictly after `to`.
|
|
91
|
+
*
|
|
92
|
+
* @param {Element} parent
|
|
93
|
+
* @param {Node} from
|
|
94
|
+
* @param {Node} to
|
|
95
|
+
* @returns {{ before: Node[], after: Node[] }}
|
|
96
|
+
*/
|
|
97
|
+
function sidesOf(parent, from, to) {
|
|
98
|
+
/** @type {Node[]} */
|
|
99
|
+
const before = [];
|
|
100
|
+
/** @type {Node[]} */
|
|
101
|
+
const after = [];
|
|
102
|
+
let side = before;
|
|
103
|
+
for (let n = parent.firstChild; n; n = n.nextSibling) {
|
|
104
|
+
if (n === from) { side = null; }
|
|
105
|
+
if (side) side.push(n);
|
|
106
|
+
if (n === to) side = after;
|
|
107
|
+
}
|
|
108
|
+
return { before, after };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The attribute half of `diffElementInPlace`, for an element ON the chain: its
|
|
113
|
+
* children are split around the chain and morphed run by run, so the recursion
|
|
114
|
+
* that function would do is exactly what must not happen here.
|
|
115
|
+
*
|
|
116
|
+
* @param {Element} dst
|
|
117
|
+
* @param {Element} src
|
|
118
|
+
*/
|
|
119
|
+
function syncAttributes(dst, src) {
|
|
120
|
+
for (const attr of src.attributes) {
|
|
121
|
+
if (LIVE_ATTRS.has(attr.name) || attr.name === 'data-wj-serialized') continue;
|
|
122
|
+
if (dst.getAttribute(attr.name) !== attr.value) dst.setAttribute(attr.name, attr.value);
|
|
123
|
+
}
|
|
124
|
+
for (const attr of [...dst.attributes]) {
|
|
125
|
+
if (LIVE_ATTRS.has(attr.name)) continue;
|
|
126
|
+
if (!src.hasAttribute(attr.name)) dst.removeAttribute(attr.name);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Pair a run of live siblings with its incoming run, in order. An element
|
|
132
|
+
* matches by `keyOf` first, then the NEXT unkeyed live element of the same tag
|
|
133
|
+
* at or after the last match; a text or comment node matches the next live
|
|
134
|
+
* node of its type the same way. Each match is diffed in place.
|
|
135
|
+
*
|
|
136
|
+
* A forward scan rather than the same-index pairing `reconcileChildren` uses,
|
|
137
|
+
* because chrome changes by INSERTING or REMOVING a sibling (a banner shown
|
|
138
|
+
* only when signed in), and same-index pairing shifts every node after the
|
|
139
|
+
* change onto the wrong peer: the header would no longer match the header, and
|
|
140
|
+
* the body-level rule below would then keep the old one beside the new one.
|
|
141
|
+
*
|
|
142
|
+
* @param {Node[]} live
|
|
143
|
+
* @param {Node[]} incoming Already imported into the live document.
|
|
144
|
+
* @returns {Node[]} The final ordered list: reused live nodes and fresh ones.
|
|
145
|
+
*/
|
|
146
|
+
function matchRun(live, incoming) {
|
|
147
|
+
/** @type {Map<string, Element>} */
|
|
148
|
+
const keyed = new Map();
|
|
149
|
+
for (const n of live) {
|
|
150
|
+
if (n.nodeType !== 1) continue;
|
|
151
|
+
const k = keyOf(/** @type {Element} */ (n));
|
|
152
|
+
if (k) keyed.set(k, /** @type {Element} */ (n));
|
|
153
|
+
}
|
|
154
|
+
const used = new Set();
|
|
155
|
+
let cursor = 0;
|
|
156
|
+
/** @type {Node[]} */
|
|
157
|
+
const out = [];
|
|
158
|
+
for (const inc of incoming) {
|
|
159
|
+
if (inc.nodeType === 1) {
|
|
160
|
+
const k = keyOf(/** @type {Element} */ (inc));
|
|
161
|
+
const hit = k ? keyed.get(k) : null;
|
|
162
|
+
if (hit && !used.has(hit)) {
|
|
163
|
+
used.add(hit);
|
|
164
|
+
diffElementInPlace(hit, /** @type {Element} */ (inc));
|
|
165
|
+
out.push(hit);
|
|
166
|
+
cursor = Math.max(cursor, live.indexOf(hit) + 1);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
let j = cursor;
|
|
171
|
+
for (; j < live.length; j++) {
|
|
172
|
+
const n = live[j];
|
|
173
|
+
if (used.has(n) || n.nodeType !== inc.nodeType) continue;
|
|
174
|
+
if (n.nodeType !== 1) break;
|
|
175
|
+
const el = /** @type {Element} */ (n);
|
|
176
|
+
if (!keyOf(el) && el.tagName === /** @type {Element} */ (inc).tagName) break;
|
|
177
|
+
}
|
|
178
|
+
if (j < live.length && (inc.nodeType === 1 || inc.nodeType === 3 || inc.nodeType === 8)) {
|
|
179
|
+
const n = live[j];
|
|
180
|
+
used.add(n);
|
|
181
|
+
if (n.nodeType === 1) diffElementInPlace(/** @type {Element} */ (n), /** @type {Element} */ (inc));
|
|
182
|
+
else if (n.nodeValue !== inc.nodeValue) n.nodeValue = inc.nodeValue;
|
|
183
|
+
out.push(n);
|
|
184
|
+
cursor = j + 1;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
out.push(inc);
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Morph one run of siblings (the nodes on one side of the chain at one level)
|
|
194
|
+
* into place before `ref`, and return the nodes it inserted fresh.
|
|
195
|
+
*
|
|
196
|
+
* Placement walks back to front and skips a node already sitting before its
|
|
197
|
+
* successor, so a reused node is never moved. Moving a connected custom
|
|
198
|
+
* element fires `disconnectedCallback` and `connectedCallback`, which a
|
|
199
|
+
* component can reasonably treat as an unmount.
|
|
200
|
+
*
|
|
201
|
+
* @param {Element} parent
|
|
202
|
+
* @param {Node[]} live
|
|
203
|
+
* @param {Node[]} incomingRaw Incoming nodes, still in the parsed document.
|
|
204
|
+
* @param {Node | null} ref Insert before this node (null appends). Never a
|
|
205
|
+
* node of `live`, which this run may remove.
|
|
206
|
+
* @param {boolean} keepTrailing Keep unmatched live nodes after the last
|
|
207
|
+
* matched one (the body level, where they are runtime additions).
|
|
208
|
+
* @returns {Node[]}
|
|
209
|
+
*/
|
|
210
|
+
function morphRun(parent, live, incomingRaw, ref, keepTrailing) {
|
|
211
|
+
const incoming = incomingRaw.map((n) => document.importNode(n, true));
|
|
212
|
+
regraftPermanentInSlice(live, incoming);
|
|
213
|
+
const finalNodes = matchRun(live, incoming);
|
|
214
|
+
const finalSet = new Set(finalNodes);
|
|
215
|
+
let anchor = ref;
|
|
216
|
+
if (keepTrailing) {
|
|
217
|
+
let last = -1;
|
|
218
|
+
for (let i = 0; i < live.length; i++) if (finalSet.has(live[i])) last = i;
|
|
219
|
+
const trailing = live.slice(last + 1);
|
|
220
|
+
if (trailing.length) {
|
|
221
|
+
for (const n of trailing) finalSet.add(n);
|
|
222
|
+
anchor = trailing[0];
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
for (const n of live) {
|
|
226
|
+
if (!finalSet.has(n) && n.parentNode === parent) parent.removeChild(n);
|
|
227
|
+
}
|
|
228
|
+
for (let i = finalNodes.length - 1; i >= 0; i--) {
|
|
229
|
+
const n = finalNodes[i];
|
|
230
|
+
if (n.parentNode !== parent || n.nextSibling !== anchor) parent.insertBefore(n, anchor);
|
|
231
|
+
anchor = n;
|
|
232
|
+
}
|
|
233
|
+
const reused = new Set(live);
|
|
234
|
+
return finalNodes.filter((n) => !reused.has(n));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Morph the chrome a `planLayoutChrome` plan describes. Runs AFTER the
|
|
239
|
+
* boundary plan has written the range, and never touches the range itself.
|
|
240
|
+
*
|
|
241
|
+
* @param {ChromePlan} plan
|
|
242
|
+
*/
|
|
243
|
+
export function morphLayoutChrome(plan) {
|
|
244
|
+
const { live, incoming, liveRange, incomingRange } = plan;
|
|
245
|
+
/** @type {Node[]} */
|
|
246
|
+
const touched = [];
|
|
247
|
+
for (let i = 0; i < live.length; i++) {
|
|
248
|
+
const L = live[i];
|
|
249
|
+
const I = incoming[i];
|
|
250
|
+
// `<body>` attributes are left alone: a dialog or a scroll lock writes
|
|
251
|
+
// them at runtime, and no layout renders the body tag itself.
|
|
252
|
+
if (i > 0) syncAttributes(L, I);
|
|
253
|
+
const last = i === live.length - 1;
|
|
254
|
+
const liveFrom = last ? liveRange.start : live[i + 1];
|
|
255
|
+
const liveTo = last ? liveRange.end : live[i + 1];
|
|
256
|
+
const incFrom = last ? incomingRange.start : incoming[i + 1];
|
|
257
|
+
const incTo = last ? incomingRange.end : incoming[i + 1];
|
|
258
|
+
const l = sidesOf(L, liveFrom, liveTo);
|
|
259
|
+
const n = sidesOf(I, incFrom, incTo);
|
|
260
|
+
const fresh = [
|
|
261
|
+
...morphRun(L, l.before, n.before, liveFrom, false),
|
|
262
|
+
// The after-run owns every node to the end of the parent, so it appends.
|
|
263
|
+
...morphRun(L, l.after, n.after, null, i === 0),
|
|
264
|
+
];
|
|
265
|
+
for (const node of fresh) if (node.nodeType === 1) touched.push(reactivateScripts(/** @type {Element} */ (node)));
|
|
266
|
+
for (const side of [l.before, l.after]) {
|
|
267
|
+
for (const node of side) if (node.nodeType === 1 && node.isConnected) touched.push(/** @type {Element} */ (node));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
for (const el of touched) if (el.isConnected) upgradeCustomElements(/** @type {Element} */ (el));
|
|
271
|
+
}
|
|
@@ -12,6 +12,7 @@ import { reportFallback } from './diagnostics.js';
|
|
|
12
12
|
import { blurOutgoingFocus, diffChildren, replaceBoundaryRange, resyncEnclosingHostSlots, swapMarkerRange } from './dom-differ.js';
|
|
13
13
|
import { trackedReloadSignature } from './frames.js';
|
|
14
14
|
import { addNewHeadElements, mergeHead } from './head-merge.js';
|
|
15
|
+
import { morphLayoutChrome, planLayoutChrome } from './layout-chrome.js';
|
|
15
16
|
import { clearPrefetchRefused, prefetchClearAll } from './prefetch.js';
|
|
16
17
|
import { snapshotCache } from './snapshot-cache.js';
|
|
17
18
|
import { hardNavigate } from './state.js';
|
|
@@ -41,11 +42,16 @@ export let _swapCommit = Promise.resolve();
|
|
|
41
42
|
* anything (a missing frame, or a degradation to a hard navigation), and
|
|
42
43
|
* `undefined` when a swap committed. `fetchAndApply` maps the first two to
|
|
43
44
|
* `applied: false`.
|
|
44
|
-
* @param {'page' | 'shell' | undefined} [refresh]
|
|
45
|
-
*
|
|
45
|
+
* @param {'page' | 'shell' | 'layouts' | undefined} [refresh] The swap tier
|
|
46
|
+
* override. `'shell'` and `'page'` are the same-URL in-place refresh modes
|
|
47
|
+
* (#1398). `'shell'` takes the full-body tier directly, because the
|
|
46
48
|
* layout's OWN markup changed and that lives outside every children range.
|
|
47
49
|
* `'page'` falls through to the normal two-tier logic, which morphs the
|
|
48
50
|
* deepest shared boundary and so preserves outer-layout component state.
|
|
51
|
+
* `'layouts'` is the response to a mutating form submission (#1557): the
|
|
52
|
+
* normal two-tier logic, followed by a morph of the layout chrome outside
|
|
53
|
+
* the plan's range (`morphLayoutChrome`), or the full-body tier when that
|
|
54
|
+
* chrome cannot be walked safely.
|
|
49
55
|
* @param {(() => void) | null} [recordHistoryNow] Record this navigation's
|
|
50
56
|
* history entry (#1406). Called at each COMMIT point, immediately BEFORE the
|
|
51
57
|
* DOM mutation, for the same reason `ingestSeeds` is: this function can still
|
|
@@ -343,6 +349,22 @@ export function applySwap(doc, frameId, revalidating, href, incomingBuild, incom
|
|
|
343
349
|
const there = collectBoundaries(doc.body);
|
|
344
350
|
const plan = here && there ? planBoundarySwap(here, there) : null;
|
|
345
351
|
|
|
352
|
+
// After a mutating submission (#1557) the layouts were re-rendered with
|
|
353
|
+
// whatever the action changed (a cookie a header reads), and their own markup
|
|
354
|
+
// sits outside the plan's range. Decide BEFORE writing anything whether that
|
|
355
|
+
// chrome can be morphed level by level; when it cannot, the full-body tier is
|
|
356
|
+
// the correct answer, since applying the range alone would leave the stale
|
|
357
|
+
// header on screen, which is the bug.
|
|
358
|
+
const chrome = plan && refresh === 'layouts'
|
|
359
|
+
? planLayoutChrome(plan.live, plan.incoming, document.body, doc.body)
|
|
360
|
+
: null;
|
|
361
|
+
if (plan && refresh === 'layouts' && !chrome) {
|
|
362
|
+
ingestSeeds();
|
|
363
|
+
if (recordHistoryNow) recordHistoryNow();
|
|
364
|
+
swapFullBody(doc);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
346
368
|
if (plan) {
|
|
347
369
|
// Committed: this response is being applied, so its seeds are the ones the
|
|
348
370
|
// user is about to look at.
|
|
@@ -372,6 +394,9 @@ export function applySwap(doc, frameId, revalidating, href, incomingBuild, incom
|
|
|
372
394
|
// applySlotAssignments would wipe the freshly swapped content and
|
|
373
395
|
// restore the stale list.
|
|
374
396
|
resyncEnclosingHostSlots(live.start, incoming.start);
|
|
397
|
+
// The range is final, so the chrome around it can be morphed now
|
|
398
|
+
// without ever touching it.
|
|
399
|
+
if (chrome) morphLayoutChrome(chrome);
|
|
375
400
|
// Resolve buffered Suspense boundaries INSIDE the swap so the placeholder
|
|
376
401
|
// exists first. Doing this after `runWithTransition` returned raced the
|
|
377
402
|
// async view-transition swap and stuck the skeleton (#1048).
|