@sigil-dev/runtime 0.9.3 → 0.9.4

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 CHANGED
@@ -10,6 +10,8 @@ import {
10
10
  createMemo,
11
11
  createProfilerEffect,
12
12
  createRawSignal,
13
+ createResource,
14
+ createRoot,
13
15
  createSignal,
14
16
  easeInCubic,
15
17
  easeInOutCubic,
@@ -34,8 +36,10 @@ import {
34
36
  snapshot,
35
37
  tick,
36
38
  tracking,
39
+ untrack,
37
40
  withEffectScope,
38
41
  } from "./index.ts";
42
+ import { mountTransition, removeAnimated } from "./transition.ts";
39
43
 
40
44
  // Minimal DOM mock for reconcile tests
41
45
  class MockNode {
@@ -233,10 +237,13 @@ describe("Runtime", () => {
233
237
 
234
238
  test("moves nodes to new order", () => {
235
239
  const { parent, anchor, keyMap } = setup();
240
+ const i1 = { id: 1 };
241
+ const i2 = { id: 2 };
242
+ const i3 = { id: 3 };
236
243
  mockReconcile(
237
244
  anchor,
238
245
  keyMap,
239
- [{ id: 1 }, { id: 2 }, { id: 3 }],
246
+ [i1, i2, i3],
240
247
  (i) => i.id,
241
248
  (i) => new MockElement(`n${i.id}`),
242
249
  );
@@ -247,7 +254,7 @@ describe("Runtime", () => {
247
254
  mockReconcile(
248
255
  anchor,
249
256
  keyMap,
250
- [{ id: 3 }, { id: 2 }, { id: 1 }],
257
+ [i3, i2, i1],
251
258
  (i) => i.id,
252
259
  (i) => new MockElement(`n${i.id}`),
253
260
  );
@@ -258,12 +265,38 @@ describe("Runtime", () => {
258
265
  expect(nodeNames(parent)).toEqual(["n3", "n2", "n1"]);
259
266
  });
260
267
 
268
+ test("rebuilds node when item instance changes under same key", () => {
269
+ const { parent, anchor, keyMap } = setup();
270
+ mockReconcile(
271
+ anchor,
272
+ keyMap,
273
+ [{ id: 1, text: "old" }],
274
+ (i) => i.id,
275
+ (i) => new MockElement(i.text),
276
+ );
277
+ const oldNode = keyMap.get(1);
278
+
279
+ // immutable-style update: same key, fresh object → node must swap
280
+ mockReconcile(
281
+ anchor,
282
+ keyMap,
283
+ [{ id: 1, text: "new" }],
284
+ (i) => i.id,
285
+ (i) => new MockElement(i.text),
286
+ );
287
+
288
+ expect(keyMap.get(1)).not.toBe(oldNode);
289
+ expect(nodeNames(parent)).toEqual(["new"]);
290
+ });
291
+
261
292
  test("handles mixed add/remove/reorder", () => {
262
293
  const { parent, anchor, keyMap } = setup();
294
+ const i1 = { id: 1 };
295
+ const i3 = { id: 3 };
263
296
  mockReconcile(
264
297
  anchor,
265
298
  keyMap,
266
- [{ id: 1 }, { id: 2 }, { id: 3 }],
299
+ [i1, { id: 2 }, i3],
267
300
  (i) => i.id,
268
301
  (i) => new MockElement(`n${i.id}`),
269
302
  );
@@ -273,7 +306,7 @@ describe("Runtime", () => {
273
306
  mockReconcile(
274
307
  anchor,
275
308
  keyMap,
276
- [{ id: 4 }, { id: 3 }, { id: 1 }],
309
+ [{ id: 4 }, i3, i1],
277
310
  (i) => i.id,
278
311
  (i) => new MockElement(`n${i.id}`),
279
312
  );
@@ -1616,3 +1649,195 @@ describe("Array reactivity", () => {
1616
1649
  expect(len).toBe(2);
1617
1650
  });
1618
1651
  });
1652
+
1653
+ describe("untrack", () => {
1654
+ test("reading a signal inside untrack does not register a dep", async () => {
1655
+ const tracked = createSignal(0);
1656
+ const untracked = createSignal(0);
1657
+ let runs = 0;
1658
+ createEffect(() => {
1659
+ runs++;
1660
+ untrack(() => untracked());
1661
+ tracked();
1662
+ });
1663
+ await tick();
1664
+ expect(runs).toBe(1);
1665
+ untracked.set(99);
1666
+ await tick();
1667
+ expect(runs).toBe(1); // no re-run — read was untracked
1668
+ tracked.set(1);
1669
+ await tick();
1670
+ expect(runs).toBe(2); // still reacts to tracked reads
1671
+ });
1672
+
1673
+ test("returns fn's value", () => {
1674
+ const sig = createSignal(7);
1675
+ expect(untrack(() => sig())).toBe(7);
1676
+ expect(
1677
+ untrack(() => {
1678
+ expect(tracking()).toBe(false);
1679
+ return "direct";
1680
+ }),
1681
+ ).toBe("direct");
1682
+ });
1683
+ });
1684
+
1685
+ describe("createRoot", () => {
1686
+ test("effects inside survive outer scope disposal", async () => {
1687
+ const count = createSignal(0);
1688
+ let runs = 0;
1689
+ let dispose: () => void = () => {};
1690
+ const outerDispose = withEffectScope(() => {
1691
+ createRoot((d) => {
1692
+ dispose = d;
1693
+ createEffect(() => {
1694
+ count();
1695
+ runs++;
1696
+ });
1697
+ });
1698
+ });
1699
+ const afterOuter = runs;
1700
+ count.set(1);
1701
+ await tick();
1702
+ expect(runs).toBe(afterOuter + 1); // root effect still alive
1703
+ dispose();
1704
+ count.set(2);
1705
+ await tick();
1706
+ expect(runs).toBe(afterOuter + 1); // torn down by dispose()
1707
+ });
1708
+
1709
+ test("detached from enclosing scope entirely", async () => {
1710
+ const count = createSignal(0);
1711
+ let runs = 0;
1712
+ const outerDispose = withEffectScope(() => {
1713
+ createRoot(() => {
1714
+ createEffect(() => {
1715
+ count();
1716
+ runs++;
1717
+ });
1718
+ });
1719
+ // no reference kept — detached root leaks by design
1720
+ });
1721
+ outerDispose();
1722
+ await tick();
1723
+ const before = runs;
1724
+ count.set(1);
1725
+ await tick();
1726
+ expect(runs).toBe(before + 1); // invisible to the disposed outer scope
1727
+ });
1728
+ });
1729
+
1730
+ describe("createResource", () => {
1731
+ test("resolves data and toggles loading", async () => {
1732
+ const res = createResource(async () => "value");
1733
+ expect(res.loading()).toBe(true);
1734
+ await tick();
1735
+ expect(res.data()).toBe("value");
1736
+ expect(res.loading()).toBe(false);
1737
+ expect(res.error()).toBeUndefined();
1738
+ });
1739
+
1740
+ test("source change refetches with new source value", async () => {
1741
+ const src = createSignal(1);
1742
+ const calls: unknown[] = [];
1743
+ const res = createResource(
1744
+ async (s) => {
1745
+ calls.push(s);
1746
+ return s;
1747
+ },
1748
+ () => src(),
1749
+ );
1750
+ await tick();
1751
+ src.set(2);
1752
+ await tick();
1753
+ expect(calls).toEqual([1, 2]);
1754
+ expect(res.data()).toBe(2);
1755
+ });
1756
+
1757
+ test("stale responses are discarded", async () => {
1758
+ const resolvers: Array<(v: number) => void> = [];
1759
+ const src = createSignal(0);
1760
+ const res = createResource(
1761
+ () =>
1762
+ new Promise<number>((r) => {
1763
+ resolvers.push(r);
1764
+ }),
1765
+ () => src(),
1766
+ );
1767
+ await tick(); // request #1 pending
1768
+ src.set(1);
1769
+ await tick(); // request #2 started
1770
+ resolvers[1]!(99); // newer resolves first
1771
+ await tick();
1772
+ expect(res.data()).toBe(99);
1773
+ resolvers[0]!(11); // stale response arrives late
1774
+ await tick();
1775
+ expect(res.data()).toBe(99); // discarded
1776
+ });
1777
+
1778
+ test("rejection captured in error()", async () => {
1779
+ const res = createResource(async () => {
1780
+ throw new Error("boom");
1781
+ });
1782
+ await tick();
1783
+ expect(res.error()?.message).toBe("boom");
1784
+ expect(res.loading()).toBe(false);
1785
+ });
1786
+
1787
+ test("refetch re-runs the fetcher", async () => {
1788
+ let calls = 0;
1789
+ const res = createResource(async () => {
1790
+ calls++;
1791
+ return calls;
1792
+ });
1793
+ await tick();
1794
+ res.refetch();
1795
+ await tick();
1796
+ expect(res.data()).toBe(2);
1797
+ });
1798
+ });
1799
+
1800
+ describe("transition directives (runtime)", () => {
1801
+ test("unregistered node is removed synchronously", () => {
1802
+ const el = document.createElement("div");
1803
+ document.body.appendChild(el);
1804
+ removeAnimated(el);
1805
+ expect(el.parentNode).toBeNull();
1806
+ });
1807
+
1808
+ test("out-animation delays removal until finished", async () => {
1809
+ const { promise, resolve } = Promise.withResolvers<void>();
1810
+ const el = document.createElement("div");
1811
+ document.body.appendChild(el);
1812
+ const cleanup = mountTransition(
1813
+ el,
1814
+ ((_el: unknown, _params: unknown, direction: "in" | "out") => {
1815
+ expect(direction).toBe("out");
1816
+ return { finished: promise.then(() => undefined) };
1817
+ }) as unknown as Parameters<typeof mountTransition>[1],
1818
+ "out",
1819
+ );
1820
+ removeAnimated(el);
1821
+ expect(el.parentNode).not.toBeNull(); // waiting for the animation
1822
+ resolve();
1823
+ await promise;
1824
+ await tick();
1825
+ expect(el.parentNode).toBeNull(); // removed after finish
1826
+ cleanup();
1827
+ });
1828
+
1829
+ test("in kind plays immediately, transition registers and plays", () => {
1830
+ const played: string[] = [];
1831
+ const fn = ((_el: Element, _p: unknown, d: "in" | "out") => {
1832
+ played.push(d);
1833
+ }) as never;
1834
+ mountTransition(document.createElement("div"), fn, "in");
1835
+ expect(played).toEqual(["in"]);
1836
+ const el = document.createElement("div");
1837
+ mountTransition(el, fn, "transition");
1838
+ expect(played).toEqual(["in", "in"]);
1839
+ removeAnimated(el); // registered → out plays, then removal
1840
+ expect(played).toEqual(["in", "in", "out"]);
1841
+ expect(el.parentNode).toBeNull();
1842
+ });
1843
+ });
package/index.ts CHANGED
@@ -20,6 +20,7 @@ export {
20
20
  } from "./easing.ts";
21
21
  export { ReactiveMap } from "./reactivity/map.ts";
22
22
  export { ReactiveSet } from "./reactivity/set.ts";
23
+ export { mountTransition, removeAnimated } from "./transition.ts";
23
24
 
24
25
  export const SIGIL_SAFE = Symbol.for("sigil.safe");
25
26
  export class SafeHtml {
@@ -142,6 +143,9 @@ function createReactiveProxy<T extends object>(target: T): T {
142
143
  trigger(target, k);
143
144
  }
144
145
  if (Array.isArray(target)) {
146
+ // delete leaves holes — length must shrink to the new value's,
147
+ // or iteration (spread, reconcile) sees phantom trailing slots
148
+ target.length = (newValue as unknown[]).length;
145
149
  trigger(target, "length");
146
150
  }
147
151
  };
@@ -287,6 +291,23 @@ export function tracking(): boolean {
287
291
  return currentEffect !== null;
288
292
  }
289
293
 
294
+ /**
295
+ * Run fn() outside of any reactive context: signal reads inside fn() do not
296
+ * register dependencies on the enclosing effect.
297
+ */
298
+ export function untrack<T>(fn: () => T): T {
299
+ const prevEffect = currentEffect;
300
+ const prevDeps = currentDeps;
301
+ currentEffect = null;
302
+ currentDeps = null;
303
+ try {
304
+ return fn();
305
+ } finally {
306
+ currentEffect = prevEffect;
307
+ currentDeps = prevDeps;
308
+ }
309
+ }
310
+
290
311
  // A4: $inspect — dev-only effect logging
291
312
  export function createInspectEffect(
292
313
  getValues: () => unknown[],
@@ -417,12 +438,133 @@ export function withEffectScope(fn: () => void): () => void {
417
438
  };
418
439
  }
419
440
 
441
+ type EffectScopeGlobal = { __sigilEffectScope?: (() => void)[] };
442
+
443
+ /**
444
+ * Run fn() with a detached effect scope. Effects created inside are invisible
445
+ * to enclosing scopes (the scope array is replaced, not merged) and live until
446
+ * dispose() is called — for app bootstrap, routers, and long-lived listeners.
447
+ */
448
+ export function createRoot<T>(fn: (dispose: () => void) => T): T {
449
+ const g = globalThis as EffectScopeGlobal;
450
+ const prev = g.__sigilEffectScope;
451
+ const disposers: (() => void)[] = [];
452
+ g.__sigilEffectScope = disposers;
453
+ const dispose = () => {
454
+ for (const d of disposers.splice(0)) d();
455
+ };
456
+ try {
457
+ return fn(dispose);
458
+ } finally {
459
+ g.__sigilEffectScope = prev;
460
+ }
461
+ }
462
+
463
+ export interface Resource<T> {
464
+ data: () => T | undefined;
465
+ loading: () => boolean;
466
+ error: () => Error | undefined;
467
+ refetch: () => void;
468
+ }
469
+
470
+ /**
471
+ * Async data source with reactive re-fetching. Re-runs when `source()` changes
472
+ * or refetch() is called; stale responses are discarded by request id.
473
+ */
474
+ export function createResource<T>(
475
+ fetcher: (source: unknown, info: { refetch: () => void }) => Promise<T>,
476
+ source?: () => unknown,
477
+ ): Resource<T> {
478
+ const data = createSignal<T | undefined>(undefined);
479
+ const loading = createSignal(true);
480
+ const error = createSignal<Error | undefined>(undefined);
481
+ const version = createSignal(0);
482
+ let requestCount = 0;
483
+ let versionCounter = 0;
484
+
485
+ const refetch = () => {
486
+ versionCounter++;
487
+ version.set(versionCounter);
488
+ };
489
+
490
+ createEffect(() => {
491
+ const src = source?.();
492
+ version(); // track refetch bumps
493
+ const id = ++requestCount;
494
+ loading.set(true);
495
+ error.set(undefined);
496
+ fetcher(src, { refetch }).then(
497
+ (result) => {
498
+ if (id !== requestCount) return; // stale response
499
+ data.set(result);
500
+ loading.set(false);
501
+ },
502
+ (err: unknown) => {
503
+ if (id !== requestCount) return;
504
+ error.set(err instanceof Error ? err : new Error(String(err)));
505
+ loading.set(false);
506
+ },
507
+ );
508
+ });
509
+
510
+ return { data, loading, error, refetch };
511
+ }
512
+
420
513
  export function createMemo<T>(fn: () => T): Signal<T> {
421
514
  const inner = createSignal<T>(undefined as T);
422
515
  createEffect(() => inner.set(fn()));
423
516
  return inner;
424
517
  }
425
518
 
519
+ /**
520
+ * Reconcile a list of keyed DOM nodes.
521
+ * anchor.before(node) handles both insert and move natively.
522
+ * @param claimNode - Optional hydration callback. Called with (element, key) for
523
+ * server-rendered elements removed from the DOM. Returns the element to use,
524
+ * or null to discard.
525
+ */
526
+ const lastItemFor = new WeakMap<object, unknown>();
527
+
528
+ export function reconcile<T>(
529
+ anchor: Partial<Comment>,
530
+ keyMap: Map<unknown, Node>,
531
+ newItems: T[],
532
+ getKey: (item: T, index: number) => unknown,
533
+ createNode: (item: T) => Node,
534
+ // is this meant to be unused? claimNode?: (element: Element, key: unknown) => Element | null,
535
+ ): void {
536
+ const newKeys = new Set(newItems.map((item, i) => getKey(item, i)));
537
+
538
+ // Remove nodes no longer in list (or claim them for hydration)
539
+ for (const [key, node] of keyMap) {
540
+ if (!newKeys.has(key)) {
541
+ node.parentNode?.removeChild(node);
542
+ keyMap.delete(key);
543
+ }
544
+ }
545
+
546
+ // Insert/move in order — anchor.before() handles both cases.
547
+ // A surviving node whose item instance changed is rebuilt: compiled row
548
+ // bodies bake item reads at creation time, so immutable-style updates
549
+ // (new object, same key) must swap the node to show fresh content.
550
+ for (let i = 0; i < newItems.length; i++) {
551
+ const item = newItems[i]!;
552
+ const key = getKey(item, i);
553
+ let node = keyMap.get(key);
554
+ if (node && lastItemFor.has(node) && !Object.is(lastItemFor.get(node), item)) {
555
+ node.parentNode?.removeChild(node);
556
+ keyMap.delete(key);
557
+ node = undefined;
558
+ }
559
+ if (!node) {
560
+ node = createNode(item);
561
+ lastItemFor.set(node, item);
562
+ keyMap.set(key, node);
563
+ }
564
+ anchor.before?.(node);
565
+ }
566
+ }
567
+
426
568
  /**
427
569
  * Claim an existing element from a hydration pool by tag name, or create a new one.
428
570
  * Removes the claimed node from the pool array.
@@ -488,43 +630,6 @@ export function claimComment(
488
630
  return comment;
489
631
  }
490
632
 
491
- /**
492
- * Reconcile a list of keyed DOM nodes.
493
- * anchor.before(node) handles both insert and move natively.
494
- * @param claimNode - Optional hydration callback. Called with (element, key) for
495
- * server-rendered elements removed from the DOM. Returns the element to use,
496
- * or null to discard.
497
- */
498
- export function reconcile<T>(
499
- anchor: Partial<Comment>,
500
- keyMap: Map<unknown, Node>,
501
- newItems: T[],
502
- getKey: (item: T) => unknown,
503
- createNode: (item: T) => Node,
504
- // is this meant to be unused? claimNode?: (element: Element, key: unknown) => Element | null,
505
- ): void {
506
- const newKeys = new Set(newItems.map(getKey));
507
-
508
- // Remove nodes no longer in list (or claim them for hydration)
509
- for (const [key, node] of keyMap) {
510
- if (!newKeys.has(key)) {
511
- node.parentNode?.removeChild(node);
512
- keyMap.delete(key);
513
- }
514
- }
515
-
516
- // Insert/move in order — anchor.before() handles both cases
517
- for (const item of newItems) {
518
- const key = getKey(item);
519
- let node = keyMap.get(key);
520
- if (!node) {
521
- node = createNode(item);
522
- keyMap.set(key, node);
523
- }
524
- anchor.before?.(node);
525
- }
526
- }
527
-
528
633
  /**
529
634
  * Hydrate a keyed list: claim the comment anchor, walk its sibling elements
530
635
  * to build a keyMap, then reconcile with signal data.
@@ -535,7 +640,7 @@ export function reconcile<T>(
535
640
  export function hydrateKeyedList<T>(
536
641
  nodesVar: ChildNode[],
537
642
  signalItems: T[],
538
- getKey: (item: T) => unknown,
643
+ getKey: (item: T, index: number) => unknown,
539
644
  createNode: (item: T) => Node,
540
645
  parent?: Element,
541
646
  ): { anchor: Comment; keyMap: Map<unknown, Node> } {
@@ -564,7 +669,7 @@ export function hydrateKeyedList<T>(
564
669
  // Map claimed elements to keys by index
565
670
  for (let i = 0; i < claimed.length && i < signalItems.length; i++) {
566
671
  //@ts-expect-error i is always in bounds
567
- const key = getKey(signalItems[i]);
672
+ const key = getKey(signalItems[i], i);
568
673
  //@ts-expect-error i is always in bounds
569
674
  keyMap.set(key, claimed[i]);
570
675
  }
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.3",
5
+ "version": "0.9.4",
6
6
  "description": "Runtime for the Sigil framework",
7
7
  "private": false,
8
8
  "devDependencies": {
package/transition.ts ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * transition:/in:/out: directive support.
3
+ *
4
+ * The compiler lowers `<div transition:fade={fade}>` to
5
+ * `mountTransition(el, fade, "transition")` (bare expression or
6
+ * `[fn, params]` tuple), and swaps anchor-mount cleanup's `node.remove()`
7
+ * for `removeAnimated(node)` so out-directions can play before unmount.
8
+ */
9
+
10
+ export type TransitionFn = (
11
+ el: Element,
12
+ params: unknown,
13
+ direction: "in" | "out",
14
+ ) => Animation | undefined | void;
15
+
16
+ const outRegistry = new WeakMap<
17
+ Element,
18
+ { fn: TransitionFn; params: unknown }
19
+ >();
20
+
21
+ /**
22
+ * Play an in-animation immediately (when kind includes it) and register the
23
+ * element for an out-animation on removal (when kind includes that).
24
+ * Returns a cleanup that unregisters the element.
25
+ */
26
+ export function mountTransition(
27
+ el: Element,
28
+ spec: TransitionFn | [TransitionFn, unknown],
29
+ kind: "in" | "out" | "transition",
30
+ ): () => void {
31
+ const [fn, params] = Array.isArray(spec) ? spec : [spec, undefined];
32
+ if (kind === "in" || kind === "transition") {
33
+ fn(el, params, "in");
34
+ }
35
+ if (kind === "out" || kind === "transition") {
36
+ outRegistry.set(el, { fn, params });
37
+ }
38
+ return () => {
39
+ outRegistry.delete(el);
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Remove a node, playing its registered out-animation first when one exists.
45
+ * No registration → immediate removal (plain remove() semantics).
46
+ */
47
+ export function removeAnimated(node: Node): void {
48
+ if (!(node instanceof Element)) {
49
+ node.remove();
50
+ return;
51
+ }
52
+ const entry = outRegistry.get(node);
53
+ if (!entry) {
54
+ node.remove();
55
+ return;
56
+ }
57
+ const animation = entry.fn(node, entry.params, "out");
58
+ if (animation?.finished && typeof animation.finished.finally === "function") {
59
+ animation.finished
60
+ .finally(() => {
61
+ node.remove();
62
+ })
63
+ .catch(() => {
64
+ node.remove();
65
+ });
66
+ } else {
67
+ node.remove();
68
+ }
69
+ }