@sigil-dev/runtime 0.7.5 → 0.7.7

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.ts CHANGED
@@ -1,220 +1,385 @@
1
1
  export { Boundary } from "./boundary";
2
+ export {
3
+ easeInCubic,
4
+ easeInOutCubic,
5
+ easeInOutQuad,
6
+ easeInOutQuart,
7
+ easeInOutQuint,
8
+ easeInOutSine,
9
+ easeInQuad,
10
+ easeInQuart,
11
+ easeInQuint,
12
+ easeInSine,
13
+ easeOutCubic,
14
+ easeOutQuad,
15
+ easeOutQuart,
16
+ easeOutQuint,
17
+ easeOutSine,
18
+ linear,
19
+ } from "./easing.ts";
20
+ export { ReactiveMap } from "./reactivity/map.ts";
21
+ export { ReactiveSet } from "./reactivity/set.ts";
2
22
 
3
23
  export const SIGIL_SAFE = Symbol.for("sigil.safe");
4
24
  export class SafeHtml {
5
- [SIGIL_SAFE] = true;
6
- constructor(public v: string) {}
7
- toString() {
8
- return this.v;
9
- }
25
+ [SIGIL_SAFE] = true;
26
+ constructor(public v: string) { }
27
+ toString() {
28
+ return this.v;
29
+ }
10
30
  }
11
31
 
12
-
13
32
  let currentEffect: (() => void) | null = null;
14
33
  let currentDeps: Set<Set<() => void>> | null = null;
15
34
 
16
35
  let batchDepth = 0;
36
+ let scheduledFlush = false;
17
37
  const pendingEffects = new Set<() => void>();
38
+ const pendingPreEffects = new Set<() => void>();
39
+
40
+ function flush() {
41
+ scheduledFlush = false;
42
+ // biome-ignore lint/suspicious/useIterableCallbackReturn: effects returning cleanup is intended
43
+ pendingPreEffects.forEach((e) => e());
44
+ pendingPreEffects.clear();
45
+ // biome-ignore lint/suspicious/useIterableCallbackReturn: effects returning cleanup is intended
46
+ pendingEffects.forEach((e) => e());
47
+ pendingEffects.clear();
48
+ }
49
+
50
+ function scheduleEffect(fn: () => void) {
51
+ const target = (fn as any).__sigil_pre ? pendingPreEffects : pendingEffects;
52
+ target.add(fn);
53
+ if (batchDepth === 0 && !scheduledFlush) {
54
+ scheduledFlush = true;
55
+ queueMicrotask(flush);
56
+ }
57
+ }
18
58
 
19
59
  const targetDepsMap = new WeakMap<
20
- object,
21
- Map<string | symbol, Set<() => void>>
60
+ object,
61
+ Map<string | symbol, Set<() => void>>
22
62
  >();
23
63
 
24
64
  function getDeps(target: object, key: string | symbol): Set<() => void> {
25
- let deps = targetDepsMap.get(target);
26
- if (!deps) {
27
- deps = new Map();
28
- targetDepsMap.set(target, deps);
29
- }
30
- let set = deps.get(key);
31
- if (!set) {
32
- set = new Set();
33
- deps.set(key, set);
34
- }
35
- return set;
65
+ let deps = targetDepsMap.get(target);
66
+ if (!deps) {
67
+ deps = new Map();
68
+ targetDepsMap.set(target, deps);
69
+ }
70
+ let set = deps.get(key);
71
+ if (!set) {
72
+ set = new Set();
73
+ deps.set(key, set);
74
+ }
75
+ return set;
36
76
  }
37
77
 
38
78
  function track(target: object, key: string | symbol) {
39
- if (currentEffect && currentDeps) {
40
- const deps = getDeps(target, key);
41
- deps.add(currentEffect);
42
- currentDeps.add(deps); // Set, deduplicates automatically
43
- }
79
+ if (currentEffect && currentDeps) {
80
+ const deps = getDeps(target, key);
81
+ deps.add(currentEffect);
82
+ currentDeps.add(deps); // Set, deduplicates automatically
83
+ }
44
84
  }
45
85
 
46
86
  function trigger(target: object, key: string | symbol) {
47
- const deps = targetDepsMap.get(target);
48
- if (!deps) return;
49
- const set = deps.get(key);
50
- if (!set) return;
51
- for (const fn of [...set]) {
52
- if (batchDepth > 0) pendingEffects.add(fn);
53
- else fn();
54
- }
87
+ const deps = targetDepsMap.get(target);
88
+ if (!deps) return;
89
+ const set = deps.get(key);
90
+ if (!set) return;
91
+ for (const fn of [...set]) {
92
+ scheduleEffect(fn);
93
+ }
55
94
  }
56
95
 
57
96
  const proxyCache = new WeakMap<object, object>();
97
+ const proxyToTarget = new WeakMap<object, object>();
58
98
  const reactiveTargets = new WeakSet<object>();
99
+ const ARRAY_MUTATORS = new Set([
100
+ "push", "pop", "shift", "unshift", "splice", "sort", "reverse", "fill"
101
+ ]);
59
102
 
60
103
  function createReactiveProxy<T extends object>(target: T): T {
61
- const existing = proxyCache.get(target);
62
- if (existing) return existing as T;
63
-
64
- const proxy = new Proxy(target, {
65
- get(target, key, receiver) {
66
- if (key === "set") {
67
- return (newValue: T) => {
68
- const oldKeys = new Set(Object.keys(target));
69
- const newKeys = new Set(Object.keys(newValue));
70
- for (const k of oldKeys) {
71
- if (!newKeys.has(k)) {
72
- delete (target as any)[k];
73
- trigger(target, k);
74
- }
75
- }
76
- for (const k of newKeys) {
77
- (target as any)[k] = (newValue as any)[k];
78
- trigger(target, k);
79
- }
80
- if (Array.isArray(target)) {
81
- trigger(target, "length");
82
- }
83
- };
84
- }
85
-
86
- if (key === "peek") {
87
- return () => target;
88
- }
89
-
90
- if (typeof key === "symbol") {
91
- return Reflect.get(target, key, receiver);
92
- }
93
-
94
- track(target, key);
95
- const value = target[key as keyof T];
96
-
97
- if (typeof value === "object" && value !== null) {
98
- return createReactiveProxy(value);
99
- }
100
-
101
- return value;
102
- },
103
-
104
- set(target, key, newValue, receiver) {
105
- const oldValue = target[key as keyof T];
106
- const result = Reflect.set(target, key, newValue, receiver);
107
- if (oldValue !== newValue) {
108
- trigger(target, key as string | symbol);
109
- if (
110
- Array.isArray(target) &&
111
- typeof key === "string" &&
112
- !Number.isNaN(Number(key))
113
- ) {
114
- trigger(target, "length");
115
- }
116
- }
117
- return result;
118
- },
119
-
120
- deleteProperty(target, key) {
121
- const result = Reflect.deleteProperty(target, key);
122
- trigger(target, key as string | symbol);
123
- return result;
124
- },
125
- });
126
-
127
- proxyCache.set(target, proxy);
128
- reactiveTargets.add(target);
129
- return proxy as T;
104
+ const existing = proxyCache.get(target);
105
+ if (existing) return existing as T;
106
+
107
+ const proxy = new Proxy(target, {
108
+ get(target, key, receiver) {
109
+ if (Array.isArray(target) && typeof key === "string" && ARRAY_MUTATORS.has(key)) {
110
+ return (...args: any[]) => {
111
+ const result = (target as any)[key](...args);
112
+ trigger(target, "length");
113
+ // trigger all numeric keys that changed — for splice/sort
114
+ // triggering length is sufficient for most reactive patterns
115
+ return result;
116
+ };
117
+ }
118
+ if (key === "set") {
119
+ return (newValue: T) => {
120
+ const oldKeys = new Set(Object.keys(target));
121
+ const newKeys = new Set(Object.keys(newValue));
122
+ for (const k of oldKeys) {
123
+ if (!newKeys.has(k)) {
124
+ delete (target as any)[k];
125
+ trigger(target, k);
126
+ }
127
+ }
128
+ for (const k of newKeys) {
129
+ (target as any)[k] = (newValue as any)[k];
130
+ trigger(target, k);
131
+ }
132
+ if (Array.isArray(target)) {
133
+ trigger(target, "length");
134
+ }
135
+ };
136
+ }
137
+
138
+ if (key === "peek") {
139
+ return () => target;
140
+ }
141
+
142
+ if (typeof key === "symbol") {
143
+ return Reflect.get(target, key, receiver);
144
+ }
145
+
146
+ track(target, key);
147
+ const value = target[key as keyof T];
148
+
149
+ if (typeof value === "object" && value !== null) {
150
+ return createReactiveProxy(value);
151
+ }
152
+
153
+ return value;
154
+ },
155
+
156
+ set(target, key, newValue, receiver) {
157
+ const oldValue = target[key as keyof T];
158
+ const result = Reflect.set(target, key, newValue, receiver);
159
+ if (oldValue !== newValue) {
160
+ trigger(target, key as string | symbol);
161
+ if (
162
+ Array.isArray(target) &&
163
+ typeof key === "string" &&
164
+ !Number.isNaN(Number(key))
165
+ ) {
166
+ trigger(target, "length");
167
+ }
168
+ }
169
+ return result;
170
+ },
171
+
172
+ deleteProperty(target, key) {
173
+ const result = Reflect.deleteProperty(target, key);
174
+ trigger(target, key as string | symbol);
175
+ return result;
176
+ },
177
+ });
178
+
179
+ proxyCache.set(target, proxy);
180
+ proxyToTarget.set(proxy, target);
181
+ reactiveTargets.add(target);
182
+ return proxy as T;
130
183
  }
131
184
 
132
185
  // ─── createSignal (branches on type) ───
133
186
 
134
187
  export type Signal<T> = {
135
- (): T;
136
- set: (next: T) => void;
137
- peek: () => T;
188
+ (): T;
189
+ set: (next: T) => void;
190
+ peek: () => T;
138
191
  };
139
192
 
140
193
  export function batch(fn: () => void) {
141
- batchDepth++;
142
- fn();
143
- batchDepth--;
144
- if (batchDepth === 0) {
145
- // biome-ignore lint/suspicious/useIterableCallbackReturn: effects returing cleanup is intended
146
- pendingEffects.forEach((e) => e());
147
- pendingEffects.clear();
148
- }
194
+ batchDepth++;
195
+ fn();
196
+ batchDepth--;
197
+ if (batchDepth === 0) {
198
+ scheduledFlush = false;
199
+ flush();
200
+ }
201
+ }
202
+
203
+ export function tick(): Promise<void> {
204
+ return new Promise((resolve) => queueMicrotask(resolve));
205
+ }
206
+
207
+ function createPrimitiveSignal<T>(initial: T): Signal<T> {
208
+ const subs = new Set<() => void>();
209
+ let value = initial;
210
+
211
+ const getter = () => {
212
+ if (currentEffect && currentDeps) {
213
+ subs.add(currentEffect);
214
+ currentDeps.add(subs);
215
+ }
216
+ return value;
217
+ };
218
+
219
+ getter.set = (next: T) => {
220
+ value = next;
221
+ for (const fn of [...subs]) {
222
+ scheduleEffect(fn);
223
+ }
224
+ };
225
+
226
+ getter.peek = () => value;
227
+
228
+ return getter;
149
229
  }
150
230
 
151
231
  export function createSignal<T>(initial: T): Signal<T> {
152
- if (typeof initial === "object" && initial !== null) {
153
- const proxy = createReactiveProxy(initial);
154
- const getter = () => proxy;
155
- getter.set = (next: T) => (proxy as any).set(next);
156
- getter.peek = () => initial;
157
- return getter;
158
- }
159
-
160
- const subs = new Set<() => void>();
161
- let value = initial;
162
-
163
- const getter = () => {
164
- if (currentEffect && currentDeps) {
165
- subs.add(currentEffect);
166
- currentDeps.add(subs);
167
- }
168
- return value;
169
- };
170
-
171
- getter.set = (next: T) => {
172
- value = next;
173
- for (const fn of [...subs]) {
174
- if (batchDepth > 0) pendingEffects.add(fn);
175
- else fn();
176
- }
177
- };
178
-
179
- getter.peek = () => value;
180
-
181
- return getter;
232
+ if (typeof initial === "object" && initial !== null) {
233
+ const proxy = createReactiveProxy(initial);
234
+ const getter = () => {
235
+ // array signals: auto-track length so push/pop/splice re-run effects
236
+ if (Array.isArray(initial)) {
237
+ track(initial, "length");
238
+ }
239
+ return proxy;
240
+ };
241
+ getter.set = (next: T) => (proxy as any).set(next);
242
+ getter.peek = () => initial;
243
+ return getter;
244
+ }
245
+
246
+ return createPrimitiveSignal(initial);
247
+ }
248
+
249
+ // A1: $state.raw — create signal without Proxy wrapping for objects/arrays
250
+ export function createRawSignal<T>(initial: T): Signal<T> {
251
+ return createPrimitiveSignal(initial);
252
+ }
253
+
254
+ // A2: $state.snapshot — deep un-proxy a signal value
255
+ function deepUnproxy<T>(value: T): T {
256
+ if (typeof value !== "object" || value === null) return value;
257
+ const raw = proxyToTarget.get(value as object);
258
+ if (!raw) return value; // not a proxy, return as-is
259
+ if (Array.isArray(raw)) {
260
+ return raw.map(deepUnproxy) as T;
261
+ }
262
+ const result: Record<string, unknown> = {};
263
+ for (const key of Object.keys(raw)) {
264
+ result[key] = deepUnproxy((raw as Record<string, unknown>)[key]);
265
+ }
266
+ return result as T;
267
+ }
268
+
269
+ export function snapshot<T>(value: T): T {
270
+ return deepUnproxy(value);
271
+ }
272
+
273
+ // A3: $effect.tracking() — check if inside reactive context
274
+ export function tracking(): boolean {
275
+ return currentEffect !== null;
276
+ }
277
+
278
+ // A4: $inspect — dev-only effect logging
279
+ export function createInspectEffect(
280
+ getValues: () => unknown[],
281
+ cb?: (vals: unknown[]) => void,
282
+ ): void {
283
+ if (typeof process !== "undefined" && process.env.NODE_ENV === "production")
284
+ return;
285
+ createEffect(() => {
286
+ const vals = getValues();
287
+ if (cb) cb(vals);
288
+ else console.log(...vals);
289
+ });
290
+ }
291
+
292
+ // A5: $effect.pre — effect that flushes before DOM update
293
+ export function createEffectPre(fn: () => void | (() => void)): () => void {
294
+ let cleanup: (() => void) | void;
295
+ const ownDeps = new Set<Set<() => void>>();
296
+
297
+ const effect = () => {
298
+ if (cleanup) cleanup();
299
+ for (const depSet of ownDeps) depSet.delete(effect);
300
+ ownDeps.clear();
301
+
302
+ const prevEffect = currentEffect;
303
+ const prevDeps = currentDeps;
304
+ currentEffect = effect;
305
+ currentDeps = ownDeps;
306
+ try {
307
+ cleanup = fn();
308
+ } finally {
309
+ currentEffect = prevEffect;
310
+ currentDeps = prevDeps;
311
+ }
312
+ };
313
+
314
+ (effect as any).__sigil_pre = true;
315
+ effect();
316
+
317
+ const dispose = () => {
318
+ for (const depSet of ownDeps) depSet.delete(effect);
319
+ ownDeps.clear();
320
+ if (cleanup) cleanup();
321
+ };
322
+
323
+ ((globalThis as any).__sigilEffectScope as (() => void)[] | undefined)?.push(
324
+ dispose,
325
+ );
326
+ return dispose;
327
+ }
328
+
329
+ // A9: Profiler effect timing (dev)
330
+ export function createProfilerEffect(
331
+ name: string,
332
+ fn: () => void | (() => void),
333
+ ): () => void {
334
+ return createEffect(() => {
335
+ if (
336
+ typeof process !== "undefined" &&
337
+ process.env.NODE_ENV === "production"
338
+ ) {
339
+ return fn();
340
+ }
341
+ const start = performance.now();
342
+ const cleanup = fn();
343
+ const end = performance.now();
344
+ console.log(`[Profiler] ${name}: ${(end - start).toFixed(2)}ms`);
345
+ return cleanup;
346
+ });
182
347
  }
183
348
 
184
349
  // biome-ignore lint/suspicious/noConfusingVoidType: please shut up its a weird type
185
350
  export function createEffect(fn: () => void | (() => void)): () => void {
186
- let cleanup: (() => void) | void;
187
- const ownDeps = new Set<Set<() => void>>();
188
-
189
- const effect = () => {
190
- if (cleanup) cleanup();
191
- for (const depSet of ownDeps) depSet.delete(effect);
192
- ownDeps.clear();
193
-
194
- const prevEffect = currentEffect;
195
- const prevDeps = currentDeps;
196
- currentEffect = effect;
197
- currentDeps = ownDeps;
198
- try {
199
- cleanup = fn();
200
- } finally {
201
- currentEffect = prevEffect;
202
- currentDeps = prevDeps;
203
- }
204
- };
205
-
206
- effect();
207
-
208
- const dispose = () => {
209
- for (const depSet of ownDeps) depSet.delete(effect);
210
- ownDeps.clear();
211
- if (cleanup) cleanup();
212
- };
213
-
214
- ((globalThis as any).__sigilEffectScope as (() => void)[] | undefined)?.push(
215
- dispose,
216
- );
217
- return dispose;
351
+ let cleanup: (() => void) | void;
352
+ const ownDeps = new Set<Set<() => void>>();
353
+
354
+ const effect = () => {
355
+ if (cleanup) cleanup();
356
+ for (const depSet of ownDeps) depSet.delete(effect);
357
+ ownDeps.clear();
358
+
359
+ const prevEffect = currentEffect;
360
+ const prevDeps = currentDeps;
361
+ currentEffect = effect;
362
+ currentDeps = ownDeps;
363
+ try {
364
+ cleanup = fn();
365
+ } finally {
366
+ currentEffect = prevEffect;
367
+ currentDeps = prevDeps;
368
+ }
369
+ };
370
+
371
+ effect();
372
+
373
+ const dispose = () => {
374
+ for (const depSet of ownDeps) depSet.delete(effect);
375
+ ownDeps.clear();
376
+ if (cleanup) cleanup();
377
+ };
378
+
379
+ ((globalThis as any).__sigilEffectScope as (() => void)[] | undefined)?.push(
380
+ dispose,
381
+ );
382
+ return dispose;
218
383
  }
219
384
 
220
385
  /**
@@ -223,24 +388,24 @@ export function createEffect(fn: () => void | (() => void)): () => void {
223
388
  * Supports nesting: inner scopes don't leak to outer ones.
224
389
  */
225
390
  export function withEffectScope(fn: () => void): () => void {
226
- const prev = (globalThis as any).__sigilEffectScope;
227
- const disposers: (() => void)[] = [];
228
- (globalThis as any).__sigilEffectScope = disposers;
229
- try {
230
- fn();
231
- } finally {
232
- (globalThis as any).__sigilEffectScope = prev;
233
- }
234
- return () => {
235
- for (const d of disposers) d();
236
- disposers.length = 0;
237
- };
391
+ const prev = (globalThis as any).__sigilEffectScope;
392
+ const disposers: (() => void)[] = [];
393
+ (globalThis as any).__sigilEffectScope = disposers;
394
+ try {
395
+ fn();
396
+ } finally {
397
+ (globalThis as any).__sigilEffectScope = prev;
398
+ }
399
+ return () => {
400
+ for (const d of disposers) d();
401
+ disposers.length = 0;
402
+ };
238
403
  }
239
404
 
240
405
  export function createMemo<T>(fn: () => T): Signal<T> {
241
- const inner = createSignal<T>(undefined as T);
242
- createEffect(() => inner.set(fn()));
243
- return inner;
406
+ const inner = createSignal<T>(undefined as T);
407
+ createEffect(() => inner.set(fn()));
408
+ return inner;
244
409
  }
245
410
 
246
411
  /**
@@ -249,23 +414,23 @@ export function createMemo<T>(fn: () => T): Signal<T> {
249
414
  * When falling back to createElement, appends to parent if provided (SPA mode).
250
415
  */
251
416
  export function claim(
252
- nodes: ChildNode[],
253
- tag: string,
254
- parent?: Element,
417
+ nodes: ChildNode[],
418
+ tag: string,
419
+ parent?: Element,
255
420
  ): Element {
256
- for (let i = 0; i < nodes.length; i++) {
257
- const node = nodes[i];
258
- if (
259
- node instanceof Element &&
260
- node.tagName.toLowerCase() === tag.toLowerCase()
261
- ) {
262
- nodes.splice(i, 1);
263
- return node;
264
- }
265
- }
266
- const el = document.createElement(tag);
267
- if (parent) parent.appendChild(el);
268
- return el;
421
+ for (let i = 0; i < nodes.length; i++) {
422
+ const node = nodes[i];
423
+ if (
424
+ node instanceof Element &&
425
+ node.tagName.toLowerCase() === tag.toLowerCase()
426
+ ) {
427
+ nodes.splice(i, 1);
428
+ return node;
429
+ }
430
+ }
431
+ const el = document.createElement(tag);
432
+ if (parent) parent.appendChild(el);
433
+ return el;
269
434
  }
270
435
 
271
436
  /**
@@ -274,16 +439,16 @@ export function claim(
274
439
  * When falling back to createTextNode, appends to parent if provided (SPA mode).
275
440
  */
276
441
  export function claimText(nodes: ChildNode[], parent?: Element): Text {
277
- for (let i = 0; i < nodes.length; i++) {
278
- const node = nodes[i];
279
- if (node instanceof Text) {
280
- nodes.splice(i, 1);
281
- return node;
282
- }
283
- }
284
- const text = document.createTextNode("");
285
- if (parent) parent.appendChild(text);
286
- return text;
442
+ for (let i = 0; i < nodes.length; i++) {
443
+ const node = nodes[i];
444
+ if (node instanceof Text) {
445
+ nodes.splice(i, 1);
446
+ return node;
447
+ }
448
+ }
449
+ const text = document.createTextNode("");
450
+ if (parent) parent.appendChild(text);
451
+ return text;
287
452
  }
288
453
 
289
454
  /**
@@ -292,20 +457,20 @@ export function claimText(nodes: ChildNode[], parent?: Element): Text {
292
457
  * When falling back to createComment, appends to parent if provided (SPA mode).
293
458
  */
294
459
  export function claimComment(
295
- nodes: ChildNode[],
296
- text: string = "",
297
- parent?: Element,
460
+ nodes: ChildNode[],
461
+ text: string = "",
462
+ parent?: Element,
298
463
  ): Comment {
299
- for (let i = 0; i < nodes.length; i++) {
300
- const node = nodes[i];
301
- if (node instanceof Comment && node.data === text) {
302
- nodes.splice(i, 1);
303
- return node;
304
- }
305
- }
306
- const comment = document.createComment(text);
307
- if (parent) parent.appendChild(comment);
308
- return comment;
464
+ for (let i = 0; i < nodes.length; i++) {
465
+ const node = nodes[i];
466
+ if (node instanceof Comment && node.data === text) {
467
+ nodes.splice(i, 1);
468
+ return node;
469
+ }
470
+ }
471
+ const comment = document.createComment(text);
472
+ if (parent) parent.appendChild(comment);
473
+ return comment;
309
474
  }
310
475
 
311
476
  /**
@@ -316,33 +481,33 @@ export function claimComment(
316
481
  * or null to discard.
317
482
  */
318
483
  export function reconcile<T>(
319
- anchor: Partial<Comment>,
320
- keyMap: Map<unknown, Node>,
321
- newItems: T[],
322
- getKey: (item: T) => unknown,
323
- createNode: (item: T) => Node,
324
- // is this meant to be unused? claimNode?: (element: Element, key: unknown) => Element | null,
484
+ anchor: Partial<Comment>,
485
+ keyMap: Map<unknown, Node>,
486
+ newItems: T[],
487
+ getKey: (item: T) => unknown,
488
+ createNode: (item: T) => Node,
489
+ // is this meant to be unused? claimNode?: (element: Element, key: unknown) => Element | null,
325
490
  ): void {
326
- const newKeys = new Set(newItems.map(getKey));
327
-
328
- // Remove nodes no longer in list (or claim them for hydration)
329
- for (const [key, node] of keyMap) {
330
- if (!newKeys.has(key)) {
331
- node.parentNode?.removeChild(node);
332
- keyMap.delete(key);
333
- }
334
- }
335
-
336
- // Insert/move in order — anchor.before() handles both cases
337
- for (const item of newItems) {
338
- const key = getKey(item);
339
- let node = keyMap.get(key);
340
- if (!node) {
341
- node = createNode(item);
342
- keyMap.set(key, node);
343
- }
344
- anchor.before?.(node);
345
- }
491
+ const newKeys = new Set(newItems.map(getKey));
492
+
493
+ // Remove nodes no longer in list (or claim them for hydration)
494
+ for (const [key, node] of keyMap) {
495
+ if (!newKeys.has(key)) {
496
+ node.parentNode?.removeChild(node);
497
+ keyMap.delete(key);
498
+ }
499
+ }
500
+
501
+ // Insert/move in order — anchor.before() handles both cases
502
+ for (const item of newItems) {
503
+ const key = getKey(item);
504
+ let node = keyMap.get(key);
505
+ if (!node) {
506
+ node = createNode(item);
507
+ keyMap.set(key, node);
508
+ }
509
+ anchor.before?.(node);
510
+ }
346
511
  }
347
512
 
348
513
  /**
@@ -353,59 +518,63 @@ export function reconcile<T>(
353
518
  * builds the keyMap, removes from DOM, then reconcile re-inserts in order.
354
519
  */
355
520
  export function hydrateKeyedList<T>(
356
- nodesVar: ChildNode[],
357
- signalItems: T[],
358
- getKey: (item: T) => unknown,
359
- createNode: (item: T) => Node,
360
- parent?: Element,
521
+ nodesVar: ChildNode[],
522
+ signalItems: T[],
523
+ getKey: (item: T) => unknown,
524
+ createNode: (item: T) => Node,
525
+ parent?: Element,
361
526
  ): { anchor: Comment; keyMap: Map<unknown, Node> } {
362
- // 1. Claim the <!--g--> opening delimiter as anchor
363
- const anchor = claimComment(nodesVar, "g", parent);
364
-
365
- // 2. Walk siblings after anchor, collect elements up to <!--/g-->, build keyMap
366
- const keyMap = new Map<unknown, Node>();
367
- const anchorParent = anchor.parentNode;
368
- if (anchorParent && signalItems.length > 0) {
369
- const claimed: Element[] = [];
370
- let next = anchor.nextSibling;
371
- while (next) {
372
- const current = next;
373
- next = next.nextSibling;
374
- // Stop at closing <!--/g--> delimiter and consume it
375
- if (current instanceof Comment && current.data === "/g") {
376
- current.remove();
377
- break;
378
- }
379
- if (current instanceof Element) {
380
- current.remove();
381
- claimed.push(current);
382
- }
383
- }
384
- // Map claimed elements to keys by index
385
- for (let i = 0; i < claimed.length && i < signalItems.length; i++) {
386
- //@ts-expect-error i is always in bounds
387
- const key = getKey(signalItems[i]);
388
- //@ts-expect-error i is always in bounds
389
- keyMap.set(key, claimed[i]);
390
- }
391
- }
392
-
393
- // 3. Reconcile — keyMap is pre-populated with claimed nodes
394
- reconcile(anchor, keyMap, signalItems, getKey, createNode);
395
- return { anchor, keyMap };
527
+ // 1. Claim the <!--g--> opening delimiter as anchor
528
+ const anchor = claimComment(nodesVar, "g", parent);
529
+
530
+ // 2. Walk siblings after anchor, collect elements up to <!--/g-->, build keyMap
531
+ const keyMap = new Map<unknown, Node>();
532
+ const anchorParent = anchor.parentNode;
533
+ if (anchorParent && signalItems.length > 0) {
534
+ const claimed: Element[] = [];
535
+ let next = anchor.nextSibling;
536
+ while (next) {
537
+ const current = next;
538
+ next = next.nextSibling;
539
+ // Stop at closing <!--/g--> delimiter and consume it
540
+ if (current instanceof Comment && current.data === "/g") {
541
+ current.remove();
542
+ break;
543
+ }
544
+ if (current instanceof Element) {
545
+ current.remove();
546
+ claimed.push(current);
547
+ }
548
+ }
549
+ // Map claimed elements to keys by index
550
+ for (let i = 0; i < claimed.length && i < signalItems.length; i++) {
551
+ //@ts-expect-error i is always in bounds
552
+ const key = getKey(signalItems[i]);
553
+ //@ts-expect-error i is always in bounds
554
+ keyMap.set(key, claimed[i]);
555
+ }
556
+ }
557
+
558
+ // 3. Reconcile — keyMap is pre-populated with claimed nodes
559
+ reconcile(anchor, keyMap, signalItems, getKey, createNode);
560
+ return { anchor, keyMap };
396
561
  }
397
562
 
398
563
  export function insert(parent: Element, value: unknown): void {
399
- if (value === false || value === null || value === undefined) return;
400
- if (Array.isArray(value)) {
401
- for (const item of value) insert(parent, item);
402
- return;
403
- }
404
- if (value instanceof Node) {
405
- parent.appendChild(value);
406
- return;
407
- }
408
- parent.append(String(value));
564
+ if (value === false || value === null || value === undefined) return;
565
+ if (typeof value === "function") {
566
+ insert(parent, value());
567
+ return;
568
+ }
569
+ if (Array.isArray(value)) {
570
+ for (const item of value) insert(parent, item);
571
+ return;
572
+ }
573
+ if (value instanceof Node) {
574
+ parent.appendChild(value);
575
+ return;
576
+ }
577
+ parent.append(String(value));
409
578
  }
410
579
 
411
580
  /**
@@ -416,16 +585,16 @@ export function insert(parent: Element, value: unknown): void {
416
585
  export type Directive = (node: Element, params: unknown) => void | (() => void);
417
586
 
418
587
  export function applyDirective(
419
- node: Element,
420
- directive: Directive | [Directive, unknown],
588
+ node: Element,
589
+ directive: Directive | [Directive, unknown],
421
590
  ): void {
422
- const [fn, params] = Array.isArray(directive)
423
- ? directive
424
- : [directive, undefined];
425
- const cleanup = fn(node, params);
426
- if (typeof cleanup === "function") {
427
- createEffect(() => () => cleanup());
428
- }
591
+ const [fn, params] = Array.isArray(directive)
592
+ ? directive
593
+ : [directive, undefined];
594
+ const cleanup = fn(node, params);
595
+ if (typeof cleanup === "function") {
596
+ createEffect(() => () => cleanup());
597
+ }
429
598
  }
430
599
 
431
600
  export type ContextKey<T> = symbol & { __type: T };
@@ -434,23 +603,23 @@ const clientContextStore = new Map<symbol, unknown>();
434
603
 
435
604
  let storeProvider: (() => Map<symbol, unknown>) | null = null;
436
605
  export function __setStoreProvider(fn: () => Map<symbol, unknown>) {
437
- storeProvider = fn;
606
+ storeProvider = fn;
438
607
  }
439
608
 
440
609
  function getStore(): Map<symbol, unknown> {
441
- return storeProvider?.() ?? clientContextStore;
610
+ return storeProvider?.() ?? clientContextStore;
442
611
  }
443
612
 
444
613
  export function createContext<T>(): ContextKey<T> {
445
- return Symbol() as ContextKey<T>;
614
+ return Symbol() as ContextKey<T>;
446
615
  }
447
616
 
448
617
  export function setContext<T>(key: ContextKey<T>, value: T): void {
449
- getStore().set(key as unknown as symbol, value);
618
+ getStore().set(key as unknown as symbol, value);
450
619
  }
451
620
 
452
621
  export function getContext<T>(key: ContextKey<T>): T {
453
- return getStore().get(key as unknown as symbol) as T;
622
+ return getStore().get(key as unknown as symbol) as T;
454
623
  }
455
624
 
456
625
  const _hydrationStack: ChildNode[][] = [];