@sigil-dev/runtime 0.9.10 → 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 +58 -13
- package/index.ts +349 -49
- 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
|
-
(
|
|
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
|
-
//
|
|
1207
|
-
//
|
|
1208
|
-
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]);
|
|
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
|
-
(
|
|
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
|
-
|
|
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
|
|
674
|
-
else
|
|
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 (
|
|
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,
|
|
682
|
-
} else if (isWritableProp(el, name)) {
|
|
683
|
-
(
|
|
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
|
|
731
|
-
*
|
|
912
|
+
* Hydrate a keyed list. SSR renders the rows as flat siblings between a
|
|
913
|
+
* `<!--g-->` / `<!--/g-->` pair.
|
|
732
914
|
*
|
|
733
|
-
*
|
|
734
|
-
*
|
|
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
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1022
|
+
for (let depth = 0; close < nodes.length; close++) {
|
|
830
1023
|
const node = nodes[close];
|
|
831
|
-
if (node instanceof Comment
|
|
832
|
-
|
|
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
|