@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 +229 -4
- package/index.ts +144 -39
- package/package.json +1 -1
- package/transition.ts +69 -0
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
|
-
[
|
|
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
|
-
[
|
|
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
|
-
[
|
|
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 },
|
|
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
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
|
+
}
|