@sigil-dev/runtime 0.9.10 → 0.9.12

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.
Files changed (3) hide show
  1. package/index.test.ts +58 -13
  2. package/index.ts +349 -49
  3. package/package.json +1 -1
package/index.test.ts CHANGED
@@ -1188,24 +1188,23 @@ describe("hydrateKeyedList", () => {
1188
1188
  const pool: ChildNode[] = [open, li1, li2, close];
1189
1189
  const items = [{ id: "a" }, { id: "b" }];
1190
1190
 
1191
+ // compiled row bodies claim their element from the pool
1191
1192
  const result = hydrateKeyedList(
1192
1193
  pool,
1193
1194
  items,
1194
1195
  (item) => item.id,
1195
- (item) => {
1196
- const li = document.createElement("li");
1197
- li.textContent = item.id;
1198
- return li;
1199
- },
1196
+ () => claim(pool, "li"),
1200
1197
  );
1201
1198
 
1202
1199
  expect(result.anchor).toBe(open);
1203
1200
  expect(result.keyMap.size).toBe(2);
1204
1201
  expect(result.keyMap.get("a")).toBe(li1);
1205
1202
  expect(result.keyMap.get("b")).toBe(li2);
1206
- // Pool lost anchor (g) via claimComment.
1207
- // li nodes + /g remain in pool array (walk removes from DOM only)
1208
- expect(pool.length).toBe(3); // li1, li2, /g still in pool array
1203
+ // the list's whole segment is consumed: a row created later can never
1204
+ // claim a node that is already in use
1205
+ expect(pool.length).toBe(0);
1206
+ expect(close.parentNode).toBeNull();
1207
+ expect(Array.from(parent.children)).toEqual([li1, li2]);
1209
1208
  });
1210
1209
 
1211
1210
  test("handles SSR/client order mismatch by reconciling", () => {
@@ -1231,11 +1230,7 @@ describe("hydrateKeyedList", () => {
1231
1230
  pool,
1232
1231
  items,
1233
1232
  (item) => item.id,
1234
- (item) => {
1235
- const li = document.createElement("li");
1236
- li.textContent = item.id;
1237
- return li;
1238
- },
1233
+ () => claim(pool, "li"),
1239
1234
  );
1240
1235
 
1241
1236
  // keyMap should have the claimed nodes (mapped by SSR position)
@@ -1907,3 +1902,53 @@ describe("transition directives (runtime)", () => {
1907
1902
  expect(el.parentNode).toBeNull();
1908
1903
  });
1909
1904
  });
1905
+
1906
+ // An exported `$store` is a signal getter; files that import it read
1907
+ // `store.key` directly because the compiler only rewrites reads in the
1908
+ // declaring file.
1909
+ describe("object signal read from another module", () => {
1910
+ test("property reads and writes reach the reactive object", () => {
1911
+ const store = createSignal({ user: "a", n: 1 }) as unknown as {
1912
+ user: string;
1913
+ n: number;
1914
+ };
1915
+ expect(store.user).toBe("a");
1916
+ store.n = 2;
1917
+ expect((store as unknown as () => { n: number })().n).toBe(2);
1918
+ expect(Object.keys(store)).toEqual(["user", "n"]);
1919
+ expect("user" in store).toBe(true);
1920
+ expect({ ...store }).toEqual({ user: "a", n: 2 });
1921
+ });
1922
+
1923
+ test("reads are tracked inside effects", async () => {
1924
+ const store = createSignal({ n: 1 }) as unknown as { n: number };
1925
+ const seen: number[] = [];
1926
+ createEffect(() => {
1927
+ seen.push(store.n);
1928
+ });
1929
+ store.n = 5;
1930
+ await tick();
1931
+ expect(seen).toEqual([1, 5]);
1932
+ });
1933
+
1934
+ test("arrays work through the getter", () => {
1935
+ const list = createSignal([1, 2, 3]) as unknown as number[];
1936
+ expect(list.length).toBe(3);
1937
+ expect(list.map((x) => x * 2)).toEqual([2, 4, 6]);
1938
+ expect([...list]).toEqual([1, 2, 3]);
1939
+ list.push(4);
1940
+ expect(list.length).toBe(4);
1941
+ });
1942
+
1943
+ test("snapshot unwraps the getter", () => {
1944
+ const store = createSignal({ a: { b: 1 } });
1945
+ expect(snapshot(store as unknown as object)).toEqual({ a: { b: 1 } });
1946
+ });
1947
+
1948
+ test("set and peek keep their signal meaning", () => {
1949
+ const store = createSignal({ a: 1 });
1950
+ store.set({ a: 2 });
1951
+ expect(store().a).toBe(2);
1952
+ expect(store.peek()).toEqual({ a: 2 });
1953
+ });
1954
+ });
package/index.ts CHANGED
@@ -220,6 +220,8 @@ export function tick(): Promise<void> {
220
220
  return new Promise((resolve) => queueMicrotask(resolve));
221
221
  }
222
222
 
223
+ const primitiveSignals = new WeakSet<object>();
224
+
223
225
  function createPrimitiveSignal<T>(initial: T): Signal<T> {
224
226
  const subs = new Set<() => void>();
225
227
  let value = initial;
@@ -240,10 +242,27 @@ function createPrimitiveSignal<T>(initial: T): Signal<T> {
240
242
  };
241
243
 
242
244
  getter.peek = () => value;
245
+ primitiveSignals.add(getter);
243
246
 
244
247
  return getter;
245
248
  }
246
249
 
250
+ /**
251
+ * The value behind a store imported from another module. The compiler wraps
252
+ * bare reads of project imports in this: an exported primitive `$store`
253
+ * arrives as its signal getter and is called (tracked inside effects);
254
+ * anything else, including object stores and plain functions, is returned
255
+ * as is.
256
+ */
257
+ export function readStore<T>(value: T): T {
258
+ return typeof value === "function" && primitiveSignals.has(value)
259
+ ? (value as () => T)()
260
+ : value;
261
+ }
262
+
263
+ // object signal getter → its reactive object
264
+ const storeGetters = new WeakMap<object, object>();
265
+
247
266
  export function createSignal<T>(initial: T): Signal<T> {
248
267
  if (typeof initial === "object" && initial !== null) {
249
268
  const proxy = createReactiveProxy(initial);
@@ -256,7 +275,27 @@ export function createSignal<T>(initial: T): Signal<T> {
256
275
  };
257
276
  getter.set = (next: T) => (proxy as any).set(next);
258
277
  getter.peek = () => initial;
259
- return getter;
278
+ // The compiler turns reads of a signal into calls only in the file that
279
+ // declares it. An exported `$store` reaches other modules as this
280
+ // getter, and they read `store.key` straight off it; forward those
281
+ // reads and writes to the reactive object so they see real values
282
+ // (tracked inside effects, like any proxy read).
283
+ const forwarded = new Proxy(getter, {
284
+ get: (fn, key) =>
285
+ key === "set" || key === "peek"
286
+ ? Reflect.get(fn, key)
287
+ : Reflect.get(proxy, key),
288
+ set: (_fn, key, value) => Reflect.set(proxy, key, value),
289
+ has: (_fn, key) => key in proxy,
290
+ deleteProperty: (_fn, key) => Reflect.deleteProperty(proxy, key),
291
+ ownKeys: () => Reflect.ownKeys(proxy),
292
+ getOwnPropertyDescriptor: (_fn, key) => {
293
+ const d = Reflect.getOwnPropertyDescriptor(proxy, key);
294
+ return d && { ...d, configurable: true };
295
+ },
296
+ });
297
+ storeGetters.set(forwarded, proxy);
298
+ return forwarded as unknown as Signal<T>;
260
299
  }
261
300
 
262
301
  return createPrimitiveSignal(initial);
@@ -269,6 +308,8 @@ export function createRawSignal<T>(initial: T): Signal<T> {
269
308
 
270
309
  // A2: $state.snapshot — deep un-proxy a signal value
271
310
  function deepUnproxy<T>(value: T): T {
311
+ if (typeof value === "function" && storeGetters.has(value))
312
+ return deepUnproxy(storeGetters.get(value) as T);
272
313
  if (typeof value !== "object" || value === null) return value;
273
314
  const raw = proxyToTarget.get(value as object);
274
315
  if (!raw) return value; // not a proxy, return as-is
@@ -594,6 +635,18 @@ export function reconcile<T>(
594
635
  */
595
636
  const SVG_NS = "http://www.w3.org/2000/svg";
596
637
 
638
+ const claimedNodes = new WeakSet<Node>();
639
+
640
+ /**
641
+ * True when `claim()` took this element from server HTML rather than creating
642
+ * it. Hydration skips the element's static attributes then: the server
643
+ * already rendered them, and writing them again would wipe anything a page
644
+ * script changed before hydration ran (an added class, a data-* flag).
645
+ */
646
+ export function isClaimed(el: Node): boolean {
647
+ return claimedNodes.has(el);
648
+ }
649
+
597
650
  export function claim(
598
651
  nodes: ChildNode[],
599
652
  tag: string,
@@ -617,6 +670,7 @@ export function claim(
617
670
  continue;
618
671
  }
619
672
  nodes.splice(i, 1);
673
+ claimedNodes.add(node);
620
674
  return node;
621
675
  }
622
676
  }
@@ -638,11 +692,6 @@ export function claim(
638
692
  * expando the DOM never sees. On SVG-namespace elements everything is
639
693
  * attribute-routed — IDL property assignment throws there too (`svg.width`).
640
694
  */
641
- const SVG_ATTR_FIX: Record<string, string> = {
642
- className: "class",
643
- htmlFor: "for",
644
- };
645
-
646
695
  // Never attribute-routed, even inside SVG.
647
696
  const SVG_PROP_ONLY = new Set(["textContent", "innerHTML", "outerHTML"]);
648
697
 
@@ -669,20 +718,153 @@ function isWritableProp(el: Element, name: string): boolean {
669
718
  return writable || Object.hasOwn(el, name);
670
719
  }
671
720
 
721
+ // JSX prop name → attribute name, where they differ.
722
+ const PROP_ATTR: Record<string, string> = {
723
+ className: "class",
724
+ htmlFor: "for",
725
+ };
726
+
727
+ /**
728
+ * null/undefined remove the attribute. Booleans follow HTML for plain names
729
+ * (true → present, false → absent) but are spelled out for dashed ones,
730
+ * because `aria-hidden="true"` and `data-open="false"` carry their value.
731
+ */
672
732
  function setAttr(el: Element, attr: string, value: unknown): void {
673
- if (value == null || value === false) el.removeAttribute(attr);
674
- else el.setAttribute(attr, value === true ? "" : String(value));
733
+ if (value == null) el.removeAttribute(attr);
734
+ else if (typeof value === "boolean" && !attr.includes("-")) {
735
+ if (value) el.setAttribute(attr, "");
736
+ else el.removeAttribute(attr);
737
+ } else el.setAttribute(attr, String(value));
738
+ }
739
+
740
+ // `el.href = undefined` would store the string "undefined": clear instead.
741
+ function clearProp(el: Element, name: string): void {
742
+ const rec = el as unknown as Record<string, unknown>;
743
+ if (typeof rec[name] === "boolean") rec[name] = false;
744
+ else if (
745
+ name === "value" ||
746
+ name === "innerHTML" ||
747
+ name === "textContent" ||
748
+ name === "innerText"
749
+ )
750
+ rec[name] = "";
751
+ else el.removeAttribute(PROP_ATTR[name] ?? name.toLowerCase());
752
+ }
753
+
754
+ // CSS properties that take plain numbers. Every other number gets "px",
755
+ // the same list React uses. Keep in sync with the SSR copy in the compiler.
756
+ const UNITLESS = new Set([
757
+ "animation-iteration-count",
758
+ "aspect-ratio",
759
+ "border-image-outset",
760
+ "border-image-slice",
761
+ "border-image-width",
762
+ "column-count",
763
+ "columns",
764
+ "fill-opacity",
765
+ "flex",
766
+ "flex-grow",
767
+ "flex-shrink",
768
+ "flood-opacity",
769
+ "font-weight",
770
+ "grid-area",
771
+ "grid-column",
772
+ "grid-column-end",
773
+ "grid-column-start",
774
+ "grid-row",
775
+ "grid-row-end",
776
+ "grid-row-start",
777
+ "line-clamp",
778
+ "line-height",
779
+ "opacity",
780
+ "order",
781
+ "orphans",
782
+ "scale",
783
+ "stop-opacity",
784
+ "stroke-dasharray",
785
+ "stroke-dashoffset",
786
+ "stroke-miterlimit",
787
+ "stroke-opacity",
788
+ "stroke-width",
789
+ "tab-size",
790
+ "widows",
791
+ "z-index",
792
+ "zoom",
793
+ ]);
794
+
795
+ /** fontSize → font-size, WebkitMask → -webkit-mask, --accent stays. */
796
+ export function cssName(key: string): string {
797
+ if (key.startsWith("--")) return key;
798
+ const kebab = key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
799
+ return kebab.startsWith("ms-") ? `-${kebab}` : kebab;
800
+ }
801
+
802
+ /** One style declaration value: numbers get px unless the property is unitless. */
803
+ export function cssValue(name: string, value: unknown): string | null {
804
+ if (value == null || value === false || value === "") return null;
805
+ if (
806
+ typeof value === "number" &&
807
+ value !== 0 &&
808
+ !name.startsWith("--") &&
809
+ !UNITLESS.has(name)
810
+ )
811
+ return `${value}px`;
812
+ return String(value);
813
+ }
814
+
815
+ /** `style={{ marginTop: 4 }}` → one declaration; null/false removes it. */
816
+ export function setStyleProp(el: Element, key: string, value: unknown): void {
817
+ const style = (el as HTMLElement).style;
818
+ const name = cssName(key);
819
+ const v = cssValue(name, value);
820
+ if (v === null) style.removeProperty(name);
821
+ else style.setProperty(name, v);
822
+ }
823
+
824
+ function setStyle(el: Element, value: unknown): void {
825
+ if (value == null || value === false) el.removeAttribute("style");
826
+ else if (typeof value === "object") {
827
+ el.removeAttribute("style");
828
+ for (const [k, v] of Object.entries(value)) setStyleProp(el, k, v);
829
+ } else el.setAttribute("style", String(value));
675
830
  }
676
831
 
677
832
  export function setProp(el: Element, name: string, value: unknown): void {
678
- if (el.namespaceURI === SVG_NS) {
833
+ if (name === "style") setStyle(el, value);
834
+ else if (el.namespaceURI === SVG_NS) {
679
835
  if (SVG_PROP_ONLY.has(name))
680
836
  (el as unknown as Record<string, unknown>)[name] = value;
681
- else setAttr(el, SVG_ATTR_FIX[name] ?? name, value);
682
- } else if (isWritableProp(el, name)) {
683
- (el as unknown as Record<string, unknown>)[name] = value;
837
+ else setAttr(el, PROP_ATTR[name] ?? name, value);
838
+ } else if (!name.includes("-") && isWritableProp(el, name)) {
839
+ if (value == null) clearProp(el, name);
840
+ else (el as unknown as Record<string, unknown>)[name] = value;
684
841
  } else {
685
- setAttr(el, name, value);
842
+ setAttr(el, PROP_ATTR[name] ?? name, value);
843
+ }
844
+ }
845
+
846
+ const SPREAD_PROP: Record<string, string> = {
847
+ class: "className",
848
+ for: "htmlFor",
849
+ };
850
+
851
+ /**
852
+ * `<div {...props}>`: listeners for on* functions, everything else through
853
+ * setProp. `attrs: false` attaches only the listeners (hydrating a claimed
854
+ * element, whose attributes are already in the server HTML).
855
+ */
856
+ export function spread(
857
+ el: Element,
858
+ props: Record<string, unknown> | null | undefined,
859
+ attrs = true,
860
+ ): void {
861
+ if (!props) return;
862
+ for (const [key, value] of Object.entries(props)) {
863
+ if (/^on[A-Z]/.test(key) && typeof value === "function") {
864
+ el.addEventListener(key.slice(2).toLowerCase(), value as EventListener);
865
+ } else if (attrs && key !== "children" && key !== "key") {
866
+ setProp(el, SPREAD_PROP[key] ?? key, value);
867
+ }
686
868
  }
687
869
  }
688
870
 
@@ -727,11 +909,16 @@ export function claimComment(
727
909
  }
728
910
 
729
911
  /**
730
- * Hydrate a keyed list: claim the comment anchor, walk its sibling elements
731
- * to build a keyMap, then reconcile with signal data.
912
+ * Hydrate a keyed list. SSR renders the rows as flat siblings between a
913
+ * `<!--g-->` / `<!--/g-->` pair.
732
914
  *
733
- * SSR renders items as flat siblings. Hydration claims them by position,
734
- * builds the keyMap, removes from DOM, then reconcile re-inserts in order.
915
+ * Each row is hydrated by running its own row body (`createNode`) against
916
+ * that row's server markup, so listeners, effects and bindings inside rows
917
+ * attach to the existing nodes. While the rows hydrate, the pool holds only
918
+ * the list's own segment: a row the server didn't render must create a fresh
919
+ * node, never claim some element that happens to follow the list. Afterwards
920
+ * the segment is gone from the pool for good, so rows created by later
921
+ * updates can't claim a node that is already in use.
735
922
  */
736
923
  export function hydrateKeyedList<T>(
737
924
  nodesVar: ChildNode[],
@@ -740,38 +927,42 @@ export function hydrateKeyedList<T>(
740
927
  createNode: (item: T) => Node,
741
928
  parent?: Element,
742
929
  ): { anchor: Comment; keyMap: Map<unknown, Node> } {
743
- // 1. Claim the <!--g--> opening delimiter as anchor
744
- const anchor = claimComment(nodesVar, "g", parent);
745
-
746
- // 2. Walk siblings after anchor, collect elements up to <!--/g-->, build keyMap
747
930
  const keyMap = new Map<unknown, Node>();
748
- const anchorParent = anchor.parentNode;
749
- if (anchorParent && signalItems.length > 0) {
750
- const claimed: Element[] = [];
751
- let next = anchor.nextSibling;
752
- while (next) {
753
- const current = next;
754
- next = next.nextSibling;
755
- // Stop at closing <!--/g--> delimiter and consume it
756
- if (current instanceof Comment && current.data === "/g") {
757
- current.remove();
758
- break;
759
- }
760
- if (current instanceof Element) {
761
- current.remove();
762
- claimed.push(current);
763
- }
764
- }
765
- // Map claimed elements to keys by index
766
- for (let i = 0; i < claimed.length && i < signalItems.length; i++) {
767
- //@ts-expect-error i is always in bounds
768
- const key = getKey(signalItems[i], i);
769
- //@ts-expect-error i is always in bounds
770
- keyMap.set(key, claimed[i]);
771
- }
931
+ const isMarker = (n: ChildNode, data: string) =>
932
+ n instanceof Comment && n.data === data;
933
+ const start = nodesVar.findIndex((n) => isMarker(n, "g"));
934
+
935
+ if (start === -1) {
936
+ // no server markup (client navigation): build the list fresh
937
+ const anchor = claimComment(nodesVar, "g", parent);
938
+ reconcile(anchor, keyMap, signalItems, getKey, createNode);
939
+ return { anchor, keyMap };
772
940
  }
773
941
 
774
- // 3. Reconcile — keyMap is pre-populated with claimed nodes
942
+ const anchor = nodesVar[start] as Comment;
943
+ let end = nodesVar.findIndex((n, i) => i > start && isMarker(n, "/g"));
944
+ if (end === -1) end = nodesVar.length;
945
+ const all = nodesVar.splice(
946
+ 0,
947
+ nodesVar.length,
948
+ ...nodesVar.slice(start + 1, end),
949
+ );
950
+ for (let i = 0; i < signalItems.length; i++) {
951
+ const item = signalItems[i] as T;
952
+ const node = createNode(item);
953
+ lastItemFor.set(node, item);
954
+ keyMap.set(getKey(item, i), node);
955
+ }
956
+ // rows the server rendered but the client has no item for
957
+ for (const leftover of nodesVar) leftover.remove();
958
+ (all[end] as ChildNode | undefined)?.remove();
959
+ nodesVar.splice(
960
+ 0,
961
+ nodesVar.length,
962
+ ...all.slice(0, start),
963
+ ...all.slice(end + 1),
964
+ );
965
+
775
966
  reconcile(anchor, keyMap, signalItems, getKey, createNode);
776
967
  return { anchor, keyMap };
777
968
  }
@@ -825,11 +1016,14 @@ export function hydrateSlot(
825
1016
  insert(parent, value);
826
1017
  return;
827
1018
  }
1019
+ // Slots nest (a component's {children} can hold a child's own slot), so
1020
+ // match the closing marker by depth rather than taking the first one.
828
1021
  let close = open + 1;
829
- while (close < nodes.length) {
1022
+ for (let depth = 0; close < nodes.length; close++) {
830
1023
  const node = nodes[close];
831
- if (node instanceof Comment && node.data === "/g") break;
832
- close++;
1024
+ if (!(node instanceof Comment)) continue;
1025
+ if (node.data === "g") depth++;
1026
+ else if (node.data === "/g" && depth-- === 0) break;
833
1027
  }
834
1028
  const inner = nodes.slice(open + 1, close);
835
1029
  // Drop the whole run so later siblings can't claim slot content.
@@ -842,6 +1036,112 @@ export function hydrateSlot(
842
1036
  }
843
1037
  }
844
1038
 
1039
+ /**
1040
+ * A slot whose value can change: `{title}` or `{children}` in a component,
1041
+ * where the prop may be a string, nodes, or a thunk, and may be a getter over
1042
+ * state (see the compiler's reactive props).
1043
+ *
1044
+ * The first run is exactly `hydrateSlot` (or a fresh insert when there is no
1045
+ * server markup). After that, whenever `read` sees a new value, the content
1046
+ * between the slot's markers is replaced: a text node is updated in place,
1047
+ * anything else is rebuilt. Only `read` is tracked; building the content is
1048
+ * not, so a child component's own reads never re-run the slot.
1049
+ */
1050
+ export function liveSlot(
1051
+ nodes: ChildNode[],
1052
+ parent: Element | DocumentFragment,
1053
+ read: () => unknown,
1054
+ ): void {
1055
+ // Claim the slot's markers now, not in the effect's first run: effects
1056
+ // can be deferred while a page hydrates, and a sibling claimed after this
1057
+ // call must not find these markers still in the pool.
1058
+ let start: Comment | undefined;
1059
+ let end: Comment | undefined;
1060
+ let inner: ChildNode[] | null = null;
1061
+ const open = nodes.findIndex((n) => n instanceof Comment && n.data === "g");
1062
+ if (open !== -1) {
1063
+ let close = open + 1;
1064
+ for (let depth = 0; close < nodes.length; close++) {
1065
+ const node = nodes[close];
1066
+ if (!(node instanceof Comment)) continue;
1067
+ if (node.data === "g") depth++;
1068
+ else if (node.data === "/g" && depth-- === 0) break;
1069
+ }
1070
+ if (close < nodes.length) {
1071
+ start = nodes[open] as Comment;
1072
+ end = nodes[close] as Comment;
1073
+ inner = nodes.slice(open + 1, close);
1074
+ nodes.splice(open, close - open + 1);
1075
+ }
1076
+ }
1077
+ if (!start || !end) {
1078
+ start = document.createComment("g");
1079
+ end = document.createComment("/g");
1080
+ parent.appendChild(start);
1081
+ parent.appendChild(end);
1082
+ }
1083
+ let first = true;
1084
+ createEffect(() => {
1085
+ const value = read();
1086
+ untrack(() => {
1087
+ if (first) {
1088
+ first = false;
1089
+ if (inner) {
1090
+ pushHydrationNodes(inner);
1091
+ try {
1092
+ attachHydrated(parent, value);
1093
+ } finally {
1094
+ popHydrationNodes();
1095
+ }
1096
+ } else {
1097
+ mountBefore(end!, value);
1098
+ }
1099
+ return;
1100
+ }
1101
+ const between: ChildNode[] = [];
1102
+ for (let n = start!.nextSibling; n && n !== end; n = n.nextSibling) {
1103
+ between.push(n);
1104
+ }
1105
+ const primitive =
1106
+ value === null ||
1107
+ value === undefined ||
1108
+ typeof value === "string" ||
1109
+ typeof value === "number" ||
1110
+ typeof value === "boolean";
1111
+ if (primitive && between.length === 1 && between[0] instanceof Text) {
1112
+ between[0].data =
1113
+ value === null || value === undefined || value === false
1114
+ ? ""
1115
+ : String(value);
1116
+ return;
1117
+ }
1118
+ for (const n of between) n.remove();
1119
+ mountBefore(end!, value);
1120
+ });
1121
+ });
1122
+ }
1123
+
1124
+ function mountBefore(marker: ChildNode, value: unknown): void {
1125
+ if (value === null || value === undefined || value === false) return;
1126
+ if (typeof value === "function") {
1127
+ // a deferred child built now has no server markup to adopt
1128
+ pushHydrationNodes([]);
1129
+ try {
1130
+ mountBefore(marker, (value as () => unknown)());
1131
+ } finally {
1132
+ popHydrationNodes();
1133
+ }
1134
+ return;
1135
+ }
1136
+ if (Array.isArray(value)) {
1137
+ for (const item of value) mountBefore(marker, item);
1138
+ return;
1139
+ }
1140
+ marker.before(
1141
+ value instanceof Node ? value : document.createTextNode(String(value)),
1142
+ );
1143
+ }
1144
+
845
1145
  /**
846
1146
  * Realize a slot value that already has SSR markup in the DOM: run thunks,
847
1147
  * skip primitives (their text is already rendered), and append only nodes
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@sigil-dev/runtime",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.9.10",
5
+ "version": "0.9.12",
6
6
  "description": "Runtime for the Sigil framework",
7
7
  "private": false,
8
8
  "devDependencies": {