@sigil-dev/runtime 0.9.7 → 0.9.11

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 +80 -13
  2. package/index.ts +383 -50
  3. package/package.json +1 -1
package/index.test.ts CHANGED
@@ -805,6 +805,28 @@ describe("setProp", () => {
805
805
  setProp(input, "value", "hello");
806
806
  expect(input.value).toBe("hello");
807
807
  });
808
+
809
+ test("routes getter-only HTML properties through setAttribute", () => {
810
+ const input = claim([], "input") as HTMLInputElement;
811
+ expect(() => setProp(input, "list", "people")).not.toThrow();
812
+ expect(input.getAttribute("list")).toBe("people");
813
+ setProp(input, "list", null);
814
+ expect(input.hasAttribute("list")).toBe(false);
815
+ });
816
+
817
+ test("routes unknown HTML props through setAttribute", () => {
818
+ const div = claim([], "div");
819
+ setProp(div, "popovertarget", "menu");
820
+ expect(div.getAttribute("popovertarget")).toBe("menu");
821
+ });
822
+
823
+ test("keeps IDL semantics for writable props like checked", () => {
824
+ const input = claim([], "input") as HTMLInputElement;
825
+ input.type = "checkbox";
826
+ setProp(input, "checked", true);
827
+ expect(input.checked).toBe(true);
828
+ expect(input.hasAttribute("checked")).toBe(false);
829
+ });
808
830
  });
809
831
 
810
832
  describe("claimText", () => {
@@ -1166,24 +1188,23 @@ describe("hydrateKeyedList", () => {
1166
1188
  const pool: ChildNode[] = [open, li1, li2, close];
1167
1189
  const items = [{ id: "a" }, { id: "b" }];
1168
1190
 
1191
+ // compiled row bodies claim their element from the pool
1169
1192
  const result = hydrateKeyedList(
1170
1193
  pool,
1171
1194
  items,
1172
1195
  (item) => item.id,
1173
- (item) => {
1174
- const li = document.createElement("li");
1175
- li.textContent = item.id;
1176
- return li;
1177
- },
1196
+ () => claim(pool, "li"),
1178
1197
  );
1179
1198
 
1180
1199
  expect(result.anchor).toBe(open);
1181
1200
  expect(result.keyMap.size).toBe(2);
1182
1201
  expect(result.keyMap.get("a")).toBe(li1);
1183
1202
  expect(result.keyMap.get("b")).toBe(li2);
1184
- // Pool lost anchor (g) via claimComment.
1185
- // li nodes + /g remain in pool array (walk removes from DOM only)
1186
- 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]);
1187
1208
  });
1188
1209
 
1189
1210
  test("handles SSR/client order mismatch by reconciling", () => {
@@ -1209,11 +1230,7 @@ describe("hydrateKeyedList", () => {
1209
1230
  pool,
1210
1231
  items,
1211
1232
  (item) => item.id,
1212
- (item) => {
1213
- const li = document.createElement("li");
1214
- li.textContent = item.id;
1215
- return li;
1216
- },
1233
+ () => claim(pool, "li"),
1217
1234
  );
1218
1235
 
1219
1236
  // keyMap should have the claimed nodes (mapped by SSR position)
@@ -1885,3 +1902,53 @@ describe("transition directives (runtime)", () => {
1885
1902
  expect(el.parentNode).toBeNull();
1886
1903
  });
1887
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
  }
@@ -629,27 +683,188 @@ export function claim(
629
683
  }
630
684
 
631
685
  /**
632
- * Write a JSX prop to an element. On HTML elements this is the classic blind
633
- * `el[name] = value` (preserves IDL semantics for value/checked/etc). On
634
- * SVG-namespace elements it goes through setAttribute instead — IDL property
635
- * assignment throws on real SVG elements (`svg.width = 15` hits a
636
- * getter-only property and aborts the whole render).
686
+ * Write a JSX prop to an element. On HTML elements this is `el[name] = value`
687
+ * (preserves IDL semantics for value/checked/etc) whenever the element has a
688
+ * writable property of that name. Names with no writable property go through
689
+ * setAttribute instead, like SSR does: some IDL attributes are getter-only
690
+ * (`input.list`, `input.form`, `button.form`), and assigning them throws and
691
+ * aborts the whole render; unknown names would otherwise land as a bare JS
692
+ * expando the DOM never sees. On SVG-namespace elements everything is
693
+ * attribute-routed — IDL property assignment throws there too (`svg.width`).
637
694
  */
638
- const SVG_ATTR_FIX: Record<string, string> = {
695
+ // Never attribute-routed, even inside SVG.
696
+ const SVG_PROP_ONLY = new Set(["textContent", "innerHTML", "outerHTML"]);
697
+
698
+ // Per-prototype cache of "can `el[name] = v` be assigned?".
699
+ const writableCache = new WeakMap<object, Map<string, boolean>>();
700
+
701
+ function isWritableProp(el: Element, name: string): boolean {
702
+ const proto = Object.getPrototypeOf(el) as object;
703
+ let cache = writableCache.get(proto);
704
+ if (!cache) writableCache.set(proto, (cache = new Map()));
705
+ let writable = cache.get(name);
706
+ if (writable === undefined) {
707
+ writable = false;
708
+ for (let o: object | null = proto; o; o = Object.getPrototypeOf(o)) {
709
+ const d = Object.getOwnPropertyDescriptor(o, name);
710
+ if (d) {
711
+ writable = !!(d.set || d.writable);
712
+ break;
713
+ }
714
+ }
715
+ cache.set(name, writable);
716
+ }
717
+ // Own properties (expandos set earlier) are always assignable.
718
+ return writable || Object.hasOwn(el, name);
719
+ }
720
+
721
+ // JSX prop name → attribute name, where they differ.
722
+ const PROP_ATTR: Record<string, string> = {
639
723
  className: "class",
640
724
  htmlFor: "for",
641
725
  };
642
726
 
643
- // Never attribute-routed, even inside SVG.
644
- const SVG_PROP_ONLY = new Set(["textContent", "innerHTML", "outerHTML"]);
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
+ */
732
+ function setAttr(el: Element, attr: string, value: unknown): void {
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));
830
+ }
645
831
 
646
832
  export function setProp(el: Element, name: string, value: unknown): void {
647
- if (el.namespaceURI === SVG_NS && !SVG_PROP_ONLY.has(name)) {
648
- const attr = SVG_ATTR_FIX[name] ?? name;
649
- if (value == null || value === false) el.removeAttribute(attr);
650
- else el.setAttribute(attr, value === true ? "" : String(value));
833
+ if (name === "style") setStyle(el, value);
834
+ else if (el.namespaceURI === SVG_NS) {
835
+ if (SVG_PROP_ONLY.has(name))
836
+ (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;
651
841
  } else {
652
- (el as unknown as Record<string, unknown>)[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
+ }
653
868
  }
654
869
  }
655
870
 
@@ -694,11 +909,16 @@ export function claimComment(
694
909
  }
695
910
 
696
911
  /**
697
- * Hydrate a keyed list: claim the comment anchor, walk its sibling elements
698
- * 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.
699
914
  *
700
- * SSR renders items as flat siblings. Hydration claims them by position,
701
- * 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.
702
922
  */
703
923
  export function hydrateKeyedList<T>(
704
924
  nodesVar: ChildNode[],
@@ -707,38 +927,42 @@ export function hydrateKeyedList<T>(
707
927
  createNode: (item: T) => Node,
708
928
  parent?: Element,
709
929
  ): { anchor: Comment; keyMap: Map<unknown, Node> } {
710
- // 1. Claim the <!--g--> opening delimiter as anchor
711
- const anchor = claimComment(nodesVar, "g", parent);
712
-
713
- // 2. Walk siblings after anchor, collect elements up to <!--/g-->, build keyMap
714
930
  const keyMap = new Map<unknown, Node>();
715
- const anchorParent = anchor.parentNode;
716
- if (anchorParent && signalItems.length > 0) {
717
- const claimed: Element[] = [];
718
- let next = anchor.nextSibling;
719
- while (next) {
720
- const current = next;
721
- next = next.nextSibling;
722
- // Stop at closing <!--/g--> delimiter and consume it
723
- if (current instanceof Comment && current.data === "/g") {
724
- current.remove();
725
- break;
726
- }
727
- if (current instanceof Element) {
728
- current.remove();
729
- claimed.push(current);
730
- }
731
- }
732
- // Map claimed elements to keys by index
733
- for (let i = 0; i < claimed.length && i < signalItems.length; i++) {
734
- //@ts-expect-error i is always in bounds
735
- const key = getKey(signalItems[i], i);
736
- //@ts-expect-error i is always in bounds
737
- keyMap.set(key, claimed[i]);
738
- }
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 };
940
+ }
941
+
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);
739
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
+ );
740
965
 
741
- // 3. Reconcile — keyMap is pre-populated with claimed nodes
742
966
  reconcile(anchor, keyMap, signalItems, getKey, createNode);
743
967
  return { anchor, keyMap };
744
968
  }
@@ -792,11 +1016,14 @@ export function hydrateSlot(
792
1016
  insert(parent, value);
793
1017
  return;
794
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.
795
1021
  let close = open + 1;
796
- while (close < nodes.length) {
1022
+ for (let depth = 0; close < nodes.length; close++) {
797
1023
  const node = nodes[close];
798
- if (node instanceof Comment && node.data === "/g") break;
799
- close++;
1024
+ if (!(node instanceof Comment)) continue;
1025
+ if (node.data === "g") depth++;
1026
+ else if (node.data === "/g" && depth-- === 0) break;
800
1027
  }
801
1028
  const inner = nodes.slice(open + 1, close);
802
1029
  // Drop the whole run so later siblings can't claim slot content.
@@ -809,6 +1036,112 @@ export function hydrateSlot(
809
1036
  }
810
1037
  }
811
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
+
812
1145
  /**
813
1146
  * Realize a slot value that already has SSR markup in the DOM: run thunks,
814
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.7",
5
+ "version": "0.9.11",
6
6
  "description": "Runtime for the Sigil framework",
7
7
  "private": false,
8
8
  "devDependencies": {