@webjsdev/core 0.7.34 → 0.7.36

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.34",
3
+ "version": "0.7.36",
4
4
  "type": "module",
5
5
  "description": "webjs core runtime - html/css tags, WebComponent base, isomorphic renderers",
6
6
  "types": "./index.d.ts",
package/src/component.js CHANGED
@@ -779,6 +779,16 @@ class WebComponentBase extends Base {
779
779
  // hydrated element (whose attribute already arrived from SSR) is unchanged.
780
780
  this._reflectDeclaredAttributes();
781
781
  const Ctor = /** @type any */ (this.constructor);
782
+ // Mark LIGHT-DOM component hosts so the framework default host rule
783
+ // (`@layer webjs-host { :where([data-wj-host]) { display: block } }`)
784
+ // applies. SSR already stamps this on server-rendered light hosts
785
+ // (idempotent here); this also covers a client-only light component (never
786
+ // SSR'd) so it does not collapse. Shadow hosts are NOT marked: a document
787
+ // rule would override the shadow author's own `:host { display: … }`, so
788
+ // shadow components control their host display via `:host` in `static styles`.
789
+ if (Ctor.shadow !== true && !this.hasAttribute('data-wj-host')) {
790
+ this.setAttribute('data-wj-host', '');
791
+ }
782
792
  if (Ctor.shadow === true) {
783
793
  const hadSSRShadow = !!this.shadowRoot;
784
794
  if (!this.shadowRoot) {
@@ -502,6 +502,24 @@ async function injectDSD(html, ctx, ancestors = [], dev) {
502
502
  // this.hidden on the host). Reading after render() captures all three.
503
503
  // Appending keeps the original tag byte-identical when nothing changed.
504
504
  opening = appendReflectedAttrs(opening, instance, presentAttrNames);
505
+ // Mark LIGHT-DOM component hosts so the framework default
506
+ // `@layer webjs-host { :where([data-wj-host]) { display: block } }`
507
+ // (injected once in the document head) applies at first paint. A custom
508
+ // element is `display:inline` by default, which collapses a component used
509
+ // as a block container (a board / card) until an author style intervenes.
510
+ // The low-priority `@layer` keeps it overridable by any author style,
511
+ // INCLUDING Tailwind's layered utilities (`class="flex"` wins). Emitted
512
+ // uniformly regardless of elision, so the elision on-vs-off differential is
513
+ // preserved.
514
+ //
515
+ // Shadow hosts are NOT marked: a document-level rule targeting the host
516
+ // beats the shadow tree's own `:host { display: … }` (the encapsulation-
517
+ // context criterion outranks both layer and specificity for normal
518
+ // declarations), so marking them would silently override the shadow
519
+ // author's `:host` display. Shadow components set their own host display
520
+ // via `:host` in `static styles` (the idiomatic mechanism), which the
521
+ // framework must not clobber.
522
+ if (!isShadow) opening = withHostMarker(opening);
505
523
  // Render the template to HTML. injectDSD recurses on the result so
506
524
  // nested custom elements (e.g. <theme-toggle> inside <blog-shell>)
507
525
  // get their own DSD pass.
@@ -635,6 +653,14 @@ async function injectDSD(html, ctx, ancestors = [], dev) {
635
653
  // path), not land in light DOM. Otherwise the client renders the error
636
654
  // into the shadow root while the light error box lingers underneath.
637
655
  const isShadowErr = /** @type any */ (Cls).shadow === true;
656
+ // Mark the LIGHT host here too, so a component whose SSR render() throws
657
+ // paints its error state as display:block (not the inline default),
658
+ // matching the success path. When an `async render()` rejects, it throws
659
+ // before the success-path withHostMarker (above) ran, so `opening` is still
660
+ // unmarked; when a later template render throws, the success marker already
661
+ // ran and this call is a no-op (withHostMarker is idempotent). Shadow hosts
662
+ // stay unmarked (their :host must win).
663
+ if (!isShadowErr) opening = withHostMarker(opening);
638
664
  let text;
639
665
  if (isShadowErr) {
640
666
  const rawStyles = /** @type any */ (Cls).styles;
@@ -1032,6 +1058,18 @@ function seedServerAttrs(instance, attrs) {
1032
1058
  * @param {Set<string>} presentAttrNames lowercased names already in the source tag
1033
1059
  * @returns {string}
1034
1060
  */
1061
+ /**
1062
+ * Add the component host marker (`data-wj-host`) to an opening tag, unless it
1063
+ * is already present. Insert before the closing `>` the same way
1064
+ * `appendReflectedAttrs` does. Idempotent so a re-processed tag is unchanged.
1065
+ * @param {string} opening the element's opening tag, ending in `>`
1066
+ * @returns {string}
1067
+ */
1068
+ function withHostMarker(opening) {
1069
+ if (/\sdata-wj-host(?=[\s>=])/i.test(opening)) return opening;
1070
+ return `${opening.slice(0, -1)} data-wj-host>`;
1071
+ }
1072
+
1035
1073
  function appendReflectedAttrs(opening, instance, presentAttrNames) {
1036
1074
  if (!instance || typeof instance.getAttributeNames !== 'function') return opening;
1037
1075
  let extra = '';
@@ -14,6 +14,12 @@ import './webjs-suspense.js';
14
14
  // Ingest SSR action seeds (#472) from an incoming soft-nav document before its
15
15
  // components hydrate, so a navigated async component resolves from the seed.
16
16
  import { scanSeeds } from './action-seed-client.js';
17
+ // Slot-runtime constants for re-projecting page-authored slotted content of a
18
+ // reused hydrated light-DOM component across a soft nav (#908).
19
+ import {
20
+ SLOT_STATE, LIGHT_SLOT_ATTR, PROJECTION_ATTR, PROJECTION_ACTUAL,
21
+ applyActualAssignment, applyFallback, fireSlotChange,
22
+ } from './slot.js';
17
23
 
18
24
  /** The content type a content-negotiated stream-action response carries (#248). */
19
25
  const STREAM_MIME = 'text/vnd.webjs-stream.html';
@@ -76,10 +82,40 @@ const STREAM_MIME = 'text/vnd.webjs-stream.html';
76
82
  * Parse HTML into a Document. Prefers Document.parseHTMLUnsafe (processes
77
83
  * Declarative Shadow DOM) over DOMParser (does NOT process DSD).
78
84
  *
85
+ * A partial-nav response (#936) is an INNER fragment that BEGINS with the
86
+ * `<!--wj:children:<path>-->` layout marker and carries no `<!doctype>`/`<html>`.
87
+ * Parsing such a fragment as a DOCUMENT hoists that leading comment OUT of
88
+ * `<body>` (the HTML parser's "before html" insertion mode makes a leading
89
+ * comment a child of the document, before `<html>`), so
90
+ * `collectChildrenSlots(doc.body)` never sees the opening marker, finds no
91
+ * shared slot, and `applySwap` falls to the destructive full-body swap that
92
+ * strips the head stylesheets and the outer layout. So a fragment is parsed in
93
+ * BODY (fragment) context instead, keeping the marker with its content.
94
+ * `body.setHTMLUnsafe` also processes Declarative Shadow DOM, so a shadow
95
+ * component inside the swapped content still re-attaches its root; the
96
+ * `<template>` path is the fallback for browsers without it (markers preserved,
97
+ * DSD not, which matches the pre-`setHTMLUnsafe` baseline).
98
+ *
79
99
  * @param {string} html
80
100
  * @returns {Document | null}
81
101
  */
82
102
  function parseHTML(html) {
103
+ const isFragment = !/^\s*(?:<!doctype|<html)/i.test(html);
104
+ if (isFragment && typeof document !== 'undefined' && document.implementation) {
105
+ try {
106
+ const doc = document.implementation.createHTMLDocument();
107
+ if (typeof doc.body.setHTMLUnsafe === 'function') {
108
+ doc.body.setHTMLUnsafe(html);
109
+ } else {
110
+ const t = doc.createElement('template');
111
+ t.innerHTML = html;
112
+ doc.body.appendChild(t.content);
113
+ }
114
+ return doc;
115
+ } catch {
116
+ // Fall through to a document parse (still functional, just the #936 path).
117
+ }
118
+ }
83
119
  if (typeof Document !== 'undefined' && typeof Document.parseHTMLUnsafe === 'function') {
84
120
  return Document.parseHTMLUnsafe(html);
85
121
  }
@@ -1273,9 +1309,19 @@ function prefetch(href) {
1273
1309
  return;
1274
1310
  }
1275
1311
 
1312
+ const have = buildHaveHeader();
1313
+ // #936: while the document is still parsing, the closing `<!--/wj:children-->`
1314
+ // marker at the bottom of the body may not exist yet, so `buildHaveHeader()`
1315
+ // returns '' meaning "markers not parsed yet", NOT "this page has no layout".
1316
+ // A touch-device viewport prefetch fires early enough (mid-parse) to hit this
1317
+ // window on real Android Chrome. Caching that empty-`have` response (a full
1318
+ // page) would later drive the destructive full-body swap fallback. Skip the
1319
+ // speculative fetch; the click path re-fetches with a correct `have` once the
1320
+ // document has parsed (and applySwap now falls back to a full load anyway).
1321
+ if (!have && typeof document !== 'undefined' && document.readyState === 'loading') return;
1322
+
1276
1323
  prefetchInflight.add(key);
1277
1324
  const headers = { 'x-webjs-router': '1', 'x-webjs-prefetch': '1' };
1278
- const have = buildHaveHeader();
1279
1325
  if (have) headers['x-webjs-have'] = have;
1280
1326
 
1281
1327
  fetch(href, { method: 'GET', headers, credentials: 'same-origin' })
@@ -2464,8 +2510,14 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
2464
2510
  return;
2465
2511
  }
2466
2512
 
2467
- // 3. Full body swap fallback. Use full head merge: different root
2468
- // layout, so stale head elements should be removed.
2513
+ // 3. Full body swap fallback: no shared layout marker (a genuine root-layout
2514
+ // change, or a same-layout nav whose markers could not be paired). Full head
2515
+ // merge, so a real root-layout change removes stale head elements. `mergeHead`
2516
+ // now PRESERVES stylesheets and `<style>` unconditionally (#936): even if the
2517
+ // client reaches this path with an incoming head that lacks the app's
2518
+ // `<link rel=stylesheet>` (a partial or mangled response, e.g. from a
2519
+ // mid-parse empty-`have` prefetch), the live CSS is never stripped, so the
2520
+ // swap can no longer leave the page unstyled.
2469
2521
  mergeHead(doc.head);
2470
2522
  // Persist permanent elements by node identity across the full-body
2471
2523
  // swap: move each live [data-webjs-permanent][id] node into the matching
@@ -2677,7 +2729,16 @@ function diffElementInPlace(dst, src) {
2677
2729
  // property change through `attributeChangedCallback`, so the component
2678
2730
  // re-renders ITSELF; the router must not touch its internals. This mirrors
2679
2731
  // Turbo/morphdom, which leave custom elements alone by default.
2680
- if (isHydratedComponent(dst)) return;
2732
+ //
2733
+ // One carve-out (#908): a light-DOM component's projected <slot> content is
2734
+ // page-authored (moved into the slot by the slot runtime), NOT render-owned,
2735
+ // so a reused component would otherwise keep showing STALE slotted content
2736
+ // when the nav supplies different content. Re-project ONLY those slot
2737
+ // children; the render-owned nodes stay untouched, so #906 does not regress.
2738
+ if (isHydratedComponent(dst)) {
2739
+ reprojectSlottedContent(dst, src);
2740
+ return;
2741
+ }
2681
2742
 
2682
2743
  // Recurse into children: collect both sides, run reconcileSiblings on
2683
2744
  // them with synthetic boundary markers. Cheap implementation: use
@@ -2702,6 +2763,161 @@ function isHydratedComponent(el) {
2702
2763
  return /** @type {any} */ (el)[Symbol.for('webjs.instance')] != null;
2703
2764
  }
2704
2765
 
2766
+ /**
2767
+ * True when `slot` belongs directly to `host`, i.e. no OTHER custom element
2768
+ * sits between them. A slot nested inside a child custom element belongs to
2769
+ * THAT component (its own slot state owns it), so the host must not touch it.
2770
+ *
2771
+ * @param {Element} slot
2772
+ * @param {Element} host
2773
+ * @returns {boolean}
2774
+ */
2775
+ function isOwnLightSlot(slot, host) {
2776
+ for (let p = slot.parentElement; p && p !== host; p = p.parentElement) {
2777
+ if (p.tagName.includes('-')) return false;
2778
+ }
2779
+ return true;
2780
+ }
2781
+
2782
+ /**
2783
+ * Group a component's own `data-projection="actual"` light slots by name,
2784
+ * first-wins (mirroring the slot runtime + SSR first-wins rule). Slots nested
2785
+ * inside a child custom element are excluded (they belong to that child).
2786
+ *
2787
+ * @param {Element} host
2788
+ * @returns {Map<string|null, HTMLSlotElement>}
2789
+ */
2790
+ function ownActualLightSlots(host) {
2791
+ /** @type {Map<string|null, HTMLSlotElement>} */
2792
+ const byName = new Map();
2793
+ const sel = `slot[${LIGHT_SLOT_ATTR}][${PROJECTION_ATTR}="${PROJECTION_ACTUAL}"]`;
2794
+ for (const slot of host.querySelectorAll(sel)) {
2795
+ const s = /** @type {HTMLSlotElement} */ (slot);
2796
+ if (!isOwnLightSlot(s, host)) continue;
2797
+ const name = s.getAttribute('name') || null;
2798
+ if (!byName.has(name)) byName.set(name, s);
2799
+ }
2800
+ return byName;
2801
+ }
2802
+
2803
+ /**
2804
+ * The component's own light slot for `name`, whatever its projection state
2805
+ * (first-wins by document order). Used to resolve the slot to fill on a
2806
+ * fallback->actual transition, where the target slot is currently `fallback`
2807
+ * so it is absent from `ownActualLightSlots`. Nested-child slots are excluded.
2808
+ *
2809
+ * @param {Element} host
2810
+ * @param {string|null} name
2811
+ * @returns {HTMLSlotElement | null}
2812
+ */
2813
+ function ownLightSlotForName(host, name) {
2814
+ for (const slot of host.querySelectorAll(`slot[${LIGHT_SLOT_ATTR}]`)) {
2815
+ const s = /** @type {HTMLSlotElement} */ (slot);
2816
+ if (!isOwnLightSlot(s, host)) continue;
2817
+ if ((s.getAttribute('name') || null) === name) return s;
2818
+ }
2819
+ return null;
2820
+ }
2821
+
2822
+ /**
2823
+ * Per-index node-identity equality of two node lists. Used to decide whether a
2824
+ * slot's assigned-node SET actually changed (so `slotchange` should fire); an
2825
+ * in-place text edit that reuses the same node is NOT a set change.
2826
+ *
2827
+ * @param {ArrayLike<Node>} a
2828
+ * @param {ArrayLike<Node>} b
2829
+ * @returns {boolean}
2830
+ */
2831
+ function sameNodeList(a, b) {
2832
+ if (a.length !== b.length) return false;
2833
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
2834
+ return true;
2835
+ }
2836
+
2837
+ /**
2838
+ * Re-project the page-authored slotted content of a REUSED hydrated light-DOM
2839
+ * component across a soft nav (#908), without touching its render-owned
2840
+ * subtree.
2841
+ *
2842
+ * The #906 guard treats a hydrated component as opaque so the router never
2843
+ * corrupts its lit-html-owned nodes. But the projected children inside a
2844
+ * light-DOM `<slot data-webjs-light data-projection="actual">` are
2845
+ * page-authored (moved there by the slot runtime), NOT held by lit-html parts,
2846
+ * so reconciling ONLY those children is safe and cannot reintroduce #906. Both
2847
+ * the live DOM and the incoming SSR HTML carry the same slot markers
2848
+ * (render-server emits them), so slots pair up by name + document order.
2849
+ *
2850
+ * Three cases, by how a slot's projection state changes across the nav:
2851
+ * - actual->actual (content changed): identity-preserving `reconcileChildren`
2852
+ * on the page-authored slot children, exactly as #908 shipped.
2853
+ * - actual->fallback (content REMOVED) and fallback->actual (content ADDED):
2854
+ * a slot's fallback is RENDER-OWNED (the compiled fallback template held by
2855
+ * the slot-part), so these are NOT a raw reconcile. Drive them through the
2856
+ * slot runtime's own primitives (`applyFallback` / `applyActualAssignment`)
2857
+ * on the ONE resolved own slot, which restore or swap the render-owned
2858
+ * fallback without reconciling any lit-html part (#912). This is applied
2859
+ * surgically to the target slot, NOT via a whole-host `projectChildren`
2860
+ * pass: `projectChildren` selects EVERY `data-webjs-light` slot in the
2861
+ * subtree (including a nested child component's), so running it here would
2862
+ * let this component's assignment reach into a nested child's same-named
2863
+ * slot and clobber its render-owned nodes (the #906 hazard, one level down).
2864
+ *
2865
+ * @param {Element} dst Live hydrated component host.
2866
+ * @param {Element} src Incoming SSR copy of the same component.
2867
+ */
2868
+ function reprojectSlottedContent(dst, src) {
2869
+ // Only a light-DOM component that tracks slot assignments has projected
2870
+ // page-authored content to update. No slot state (no <slot>, or a shadow-DOM
2871
+ // component whose slotted nodes are ordinary light children) means nothing
2872
+ // to re-project here.
2873
+ const state = /** @type {any} */ (dst)[SLOT_STATE];
2874
+ if (!state) return;
2875
+
2876
+ const liveSlots = ownActualLightSlots(dst);
2877
+ const incSlots = ownActualLightSlots(src);
2878
+ if (liveSlots.size === 0 && incSlots.size === 0) return;
2879
+
2880
+ // A slot name is `actual` on the live side, the incoming side, or both. Walk
2881
+ // the union so a boundary transition (present on only one side) is handled.
2882
+ const names = new Set([...liveSlots.keys(), ...incSlots.keys()]);
2883
+ for (const name of names) {
2884
+ const liveSlot = liveSlots.get(name);
2885
+ const incSlot = incSlots.get(name);
2886
+ if (liveSlot && incSlot) {
2887
+ // actual->actual: the slot's children are page-authored, so
2888
+ // reconcileChildren is safe: it preserves node identity where it can and
2889
+ // never touches lit-html parts. Keep the slot runtime's assignment
2890
+ // bookkeeping in sync so a later re-render materialises THESE nodes.
2891
+ const prevAssigned = state.lastSnapshot.get(liveSlot) || [];
2892
+ reconcileChildren(liveSlot, incSlot);
2893
+ const children = [...liveSlot.childNodes];
2894
+ state.assignedByName.set(name, children);
2895
+ state.lastSnapshot.set(liveSlot, children.slice());
2896
+ // Fire slotchange on an assigned-node-SET change (add / remove / reorder),
2897
+ // not an in-place text edit that reuses the same node: that matches the
2898
+ // spec and keeps this branch uniform with the boundary branches below.
2899
+ if (!sameNodeList(prevAssigned, children)) fireSlotChange(liveSlot);
2900
+ } else if (liveSlot && !incSlot) {
2901
+ // actual->fallback: incoming REMOVED this slot's content. Clear the
2902
+ // assignment and flip THIS slot to fallback; applyFallback restores the
2903
+ // render-owned fallback from the slot-part holding fragment.
2904
+ state.assignedByName.delete(name);
2905
+ if (applyFallback(state, liveSlot)) fireSlotChange(liveSlot);
2906
+ } else {
2907
+ // fallback->actual: incoming ADDED content where the live slot shows
2908
+ // fallback. The target slot is currently fallback, so resolve it directly
2909
+ // (it is absent from the actual-only map) and fill it with the imported
2910
+ // page-authored nodes; applyActualAssignment tucks the render-owned
2911
+ // fallback into the holding fragment first.
2912
+ const targetSlot = ownLightSlotForName(dst, name);
2913
+ if (!targetSlot) continue;
2914
+ const nodes = [...incSlot.childNodes].map((n) => document.importNode(n, true));
2915
+ state.assignedByName.set(name, nodes);
2916
+ if (applyActualAssignment(state, targetSlot, nodes)) fireSlotChange(targetSlot);
2917
+ }
2918
+ }
2919
+ }
2920
+
2705
2921
  /**
2706
2922
  * Reconcile dst's children to match src's children, in-place.
2707
2923
  *
@@ -3044,6 +3260,31 @@ function addNewHeadElements(newHead) {
3044
3260
  }
3045
3261
  }
3046
3262
 
3263
+ /**
3264
+ * Is `el` a stylesheet the head merge must never remove: a `<style>` or a
3265
+ * `<link rel~="stylesheet">`. WebJs ALWAYS keeps these on a soft nav, with no
3266
+ * opt-out, and that is a deliberate divergence from Turbo. Turbo removes a
3267
+ * stylesheet absent from the new head when it is tagged
3268
+ * `data-turbo-track="dynamic"`, which is sound in Turbo because a Turbo visit
3269
+ * always compares a COMPLETE old head to a COMPLETE new head, so "absent" means
3270
+ * "this page removed it". WebJs's `X-Webjs-Have` optimization returns a REDUCED
3271
+ * head (the shared app stylesheet is omitted because the client already has it),
3272
+ * so "absent from the incoming head" means "optimized away", NOT "removed". A
3273
+ * dynamic-removal opt-out would therefore re-introduce #936 (it would strip a
3274
+ * still-needed sheet on any partial response), and WebJs is Tailwind-first (one
3275
+ * global sheet, no page-specific sheets to drop), so the knob would be unsafe
3276
+ * and unused. Keeping every stylesheet is correct here; a genuinely changed one
3277
+ * is dropped by the deploy-level hard reload (build-id mismatch), not a soft swap.
3278
+ *
3279
+ * @param {Element} el
3280
+ * @returns {boolean}
3281
+ */
3282
+ function isPersistentHeadStyle(el) {
3283
+ if (el.tagName === 'STYLE') return true;
3284
+ return el.tagName === 'LINK' &&
3285
+ (el.getAttribute('rel') || '').toLowerCase().split(/\s+/).includes('stylesheet');
3286
+ }
3287
+
3047
3288
  /** @param {HTMLHeadElement} newHead */
3048
3289
  function mergeHead(newHead) {
3049
3290
  const currentHead = document.head;
@@ -3069,6 +3310,14 @@ function mergeHead(newHead) {
3069
3310
  if (el.tagName === 'SCRIPT' && el.getAttribute('type') === 'importmap') continue;
3070
3311
  if (el.tagName === 'BASE') continue;
3071
3312
  if (el.tagName === 'TITLE') continue;
3313
+ // #936: NEVER remove a stylesheet or a `<style>` on a soft nav (Turbo's
3314
+ // persistent-CSS model). The incoming head of a full-body-swap fallback can
3315
+ // legitimately lack the app's `<link rel=stylesheet>` (a partial or mangled
3316
+ // response, e.g. from a mid-parse empty-`have` prefetch): removing the live
3317
+ // one there leaves the whole page unstyled until a manual refresh, the
3318
+ // headline #936 symptom. Keeping it is safe: a genuinely stale sheet is
3319
+ // dropped by the deploy-level hard reload (build-id mismatch), not here.
3320
+ if (isPersistentHeadStyle(el)) continue;
3072
3321
  if (!newSet.has(outerHTMLForDiff(el))) el.remove();
3073
3322
  }
3074
3323
 
@@ -3303,11 +3552,13 @@ export {
3303
3552
  clearFormBusy as _clearFormBusy,
3304
3553
  collectChildrenSlots as _collectChildrenSlots,
3305
3554
  longestSharedPath as _longestSharedPath,
3555
+ parseHTML as _parseHTML,
3306
3556
  keyOf as _keyOf,
3307
3557
  diffElementInPlace as _diffElementInPlace,
3308
3558
  reconcileChildren as _reconcileChildren,
3309
3559
  onPopState as _onPopState,
3310
3560
  applySwap as _applySwap,
3561
+ buildHaveHeader as _buildHaveHeader,
3311
3562
  snapshotCache as _snapshotCache,
3312
3563
  prefetchCache as _prefetchCache,
3313
3564
  LIVE_ATTRS as _LIVE_ATTRS,
package/src/slot.js CHANGED
@@ -709,7 +709,7 @@ function handleSlotRemoved(state, slot) {
709
709
  * @returns {boolean} True if the slot's assignment changed compared to
710
710
  * its last snapshot (so slotchange should fire).
711
711
  */
712
- function applyActualAssignment(state, slot, assigned) {
712
+ export function applyActualAssignment(state, slot, assigned) {
713
713
  const wasFallback = slot.getAttribute(PROJECTION_ATTR) !== PROJECTION_ACTUAL;
714
714
  const prev = state.lastSnapshot.get(slot) || [];
715
715
  const equal = !wasFallback && arraysEqual(prev, assigned);
@@ -747,7 +747,7 @@ function applyActualAssignment(state, slot, assigned) {
747
747
  * @returns {boolean} True if the slot transitioned from actual to
748
748
  * fallback this pass.
749
749
  */
750
- function applyFallback(state, slot) {
750
+ export function applyFallback(state, slot) {
751
751
  const wasActual = slot.getAttribute(PROJECTION_ATTR) === PROJECTION_ACTUAL;
752
752
  slot.setAttribute(PROJECTION_ATTR, PROJECTION_FALLBACK);
753
753
  if (!wasActual) {
@@ -841,7 +841,7 @@ export function moveSlotChildrenToPending(host, slot) {
841
841
  // ---------------------------------------------------------------------------
842
842
 
843
843
  /** Fire a `slotchange` event on the slot (bubbles, not composed; per spec). */
844
- function fireSlotChange(slot) {
844
+ export function fireSlotChange(slot) {
845
845
  slot.dispatchEvent(new Event('slotchange', { bubbles: true, composed: false }));
846
846
  }
847
847