@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.
- package/index.test.ts +80 -13
- package/index.ts +383 -50
- 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
|
-
(
|
|
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
|
-
//
|
|
1185
|
-
//
|
|
1186
|
-
expect(pool.length).toBe(
|
|
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
|
-
(
|
|
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
|
-
|
|
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
|
|
633
|
-
*
|
|
634
|
-
*
|
|
635
|
-
*
|
|
636
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
644
|
-
|
|
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 (
|
|
648
|
-
|
|
649
|
-
if (
|
|
650
|
-
|
|
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
|
|
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
|
|
698
|
-
*
|
|
912
|
+
* Hydrate a keyed list. SSR renders the rows as flat siblings between a
|
|
913
|
+
* `<!--g-->` / `<!--/g-->` pair.
|
|
699
914
|
*
|
|
700
|
-
*
|
|
701
|
-
*
|
|
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
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
-
|
|
1022
|
+
for (let depth = 0; close < nodes.length; close++) {
|
|
797
1023
|
const node = nodes[close];
|
|
798
|
-
if (node instanceof Comment
|
|
799
|
-
|
|
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
|