@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/core",
3
- "version": "0.7.59",
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",
@@ -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
- const mo = new MutationObserver(() => scan());
83
- mo.observe(document.body, { childList: true, subtree: true });
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
- const have = refresh ? '' : buildHaveHeader();
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] same-URL in-place refresh
45
- * mode (#1398). `'shell'` takes the full-body tier directly, because the
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).