@webjsdev/core 0.7.40 → 0.7.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,7 +18,7 @@ import { scanSeeds } from './action-seed-client.js';
18
18
  // reused hydrated light-DOM component across a soft nav (#908).
19
19
  import {
20
20
  SLOT_STATE, LIGHT_SLOT_ATTR, PROJECTION_ATTR, PROJECTION_ACTUAL,
21
- setSlotContent,
21
+ projectAuthored, keyOfName, isAuthoredContentSlot,
22
22
  } from './slot.js';
23
23
 
24
24
  /** The content type a content-negotiated stream-action response carries (#248). */
@@ -2605,6 +2605,23 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
2605
2605
  // carriers, so the inert payload never lands in the live document.
2606
2606
  try { scanSeeds(doc); } catch { /* seeding is best-effort */ }
2607
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
+
2608
2625
  // Any clean swap (no importmap mismatch, including cache restores
2609
2626
  // and frame swaps where we don't even run the mismatch check) is a
2610
2627
  // signal that the user successfully navigated, so clear the reload
@@ -2806,6 +2823,15 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
2806
2823
  // REPLACE anchors at a parent already compared equal; the other tiers
2807
2824
  // require no change at all), and the fresh deeper keys arrive via the
2808
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);
2809
2835
  blurOutgoingFocus();
2810
2836
  }, () => upgradeCustomElementsInRange(live));
2811
2837
  forwardSuspenseResolvers(doc.body);
@@ -3078,6 +3104,14 @@ function diffElementInPlace(dst, src) {
3078
3104
  for (const attr of src.attributes) {
3079
3105
  srcAttrs.add(attr.name);
3080
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;
3081
3115
  if (dst.getAttribute(attr.name) !== attr.value) {
3082
3116
  dst.setAttribute(attr.name, attr.value);
3083
3117
  }
@@ -3133,7 +3167,12 @@ function diffElementInPlace(dst, src) {
3133
3167
  * @returns {boolean}
3134
3168
  */
3135
3169
  function isHydratedComponent(el) {
3136
- return /** @type {any} */ (el)[Symbol.for('webjs.instance')] != null;
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;
3137
3176
  }
3138
3177
 
3139
3178
  /**
@@ -3167,7 +3206,31 @@ function ownActualLightSlots(host) {
3167
3206
  for (const slot of host.querySelectorAll(sel)) {
3168
3207
  const s = /** @type {HTMLSlotElement} */ (slot);
3169
3208
  if (!isOwnLightSlot(s, host)) continue;
3170
- const name = s.getAttribute('name') || null;
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'));
3171
3234
  if (!byName.has(name)) byName.set(name, s);
3172
3235
  }
3173
3236
  return byName;
@@ -3193,19 +3256,104 @@ function ownActualLightSlots(host) {
3193
3256
  * on the page-authored slot children, exactly as #908 shipped.
3194
3257
  * - actual->fallback (content REMOVED) and fallback->actual (content ADDED):
3195
3258
  * a slot's fallback is RENDER-OWNED (the compiled fallback template held by
3196
- * the slot-part), so these are NOT a raw reconcile. Drive them through the
3197
- * slot runtime's own primitives (`applyFallback` / `applyActualAssignment`)
3198
- * on the ONE resolved own slot, which restore or swap the render-owned
3199
- * fallback without reconciling any lit-html part (#912). This is applied
3200
- * surgically to the target slot, NOT via a whole-host application
3201
- * pass: a whole-host pass selects EVERY `data-webjs-light` slot in the
3202
- * subtree (including a nested child component's), so running it here would
3203
- * let this component's assignment reach into a nested child's same-named
3204
- * 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.
3205
3266
  *
3206
3267
  * @param {Element} dst Live hydrated component host.
3207
3268
  * @param {Element} src Incoming SSR copy of the same component.
3208
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
+
3209
3357
  function reprojectSlottedContent(dst, src) {
3210
3358
  // Only a light-DOM component that tracks slot assignments has placed
3211
3359
  // page-authored content to update. No slot state (no <slot>, or a shadow-DOM
@@ -3218,7 +3366,7 @@ function reprojectSlottedContent(dst, src) {
3218
3366
  if (liveSlots.size === 0 && incSlots.size === 0) return;
3219
3367
 
3220
3368
  // #1015: slotted children are VALUES pushed through the ONE public seam,
3221
- // setSlotContent (no cross-module state surgery; the slot runtime owns the
3369
+ // projectAuthored (no cross-module state surgery; the slot runtime owns the
3222
3370
  // record, fires slotchange on a genuine set change, and re-applies). The
3223
3371
  // union walk covers boundary transitions (a name present on only one side).
3224
3372
  const names = new Set([...liveSlots.keys(), ...incSlots.keys()]);
@@ -3233,13 +3381,13 @@ function reprojectSlottedContent(dst, src) {
3233
3381
  // set-equality check makes slotchange fire exactly on an
3234
3382
  // add/remove/replace and stay silent on a pure text edit (#912).
3235
3383
  reconcileChildren(liveSlot, incSlot);
3236
- setSlotContent(dst, name, [...liveSlot.childNodes]);
3384
+ projectAuthored(dst, name, [...liveSlot.childNodes]);
3237
3385
  } else if (incSlot) {
3238
3386
  // fallback->actual: incoming ADDED content. Import and push.
3239
- setSlotContent(dst, name, [...incSlot.childNodes].map((n) => document.importNode(n, true)));
3387
+ projectAuthored(dst, name, [...incSlot.childNodes].map((n) => document.importNode(n, true)));
3240
3388
  } else {
3241
3389
  // actual->fallback: incoming DROPPED the content. Reset to fallback.
3242
- setSlotContent(dst, name, null);
3390
+ projectAuthored(dst, name, null);
3243
3391
  }
3244
3392
  }
3245
3393
  }