dalila 1.2.0 โ†’ 1.3.0

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/README.md CHANGED
@@ -14,18 +14,18 @@ Dalila is a **SPA**, **DOM-first**, **HTML natural** framework based on **signal
14
14
  - ๐Ÿ›ฃ๏ธ **SPA router** - Basic routing with loaders and AbortSignal
15
15
  - ๐Ÿ“ฆ **Context system** - Reactive dependency injection
16
16
  - ๐Ÿ”ง **Scheduler & batching** - Group updates into a single frame
17
- - ๐Ÿ“š **List rendering** - `createList` for keyed lists
17
+ - ๐Ÿ“š **List rendering** - `createList` with keyed diffing for efficient updates
18
18
  - ๐Ÿงฑ **Resources** - Async data helpers with AbortSignal and scope cleanup
19
19
 
20
20
  ### Experimental
21
21
  - ๐ŸŽจ **Natural HTML bindings** - Only in the example dev-server (not in core)
22
- - ๐Ÿ“Š **Virtualization** - Virtual lists/tables are experimental
23
22
  - ๐Ÿ” **DevTools (console)** - Warnings and FPS monitor only
24
- - ๐Ÿงช **Low-level list API** - `forEach` (experimental, prefer `createList`)
23
+ - ๐Ÿงช **Low-level list API** - `forEach` (advanced control, use when you need fine-grained behavior like reactive index; `createList` is the default)
25
24
 
26
25
  ### Planned / Roadmap
27
26
  - ๐Ÿงฐ **DevTools UI** - Visual inspection tooling
28
27
  - ๐Ÿงฉ **HTML bindings runtime/compiler** - First-class template binding
28
+ - ๐Ÿ“Š **Virtualization** - Virtual lists/tables for very large datasets (10k+ items)
29
29
 
30
30
  ## ๐Ÿ“ฆ Installation
31
31
 
@@ -55,7 +55,7 @@ export function createController() {
55
55
  }
56
56
  ```
57
57
 
58
- > Note: HTML bindings in this example are provided by the example dev-server,
58
+ > Note: HTML bindings in this example are provided by the example dev-server (`npm run serve`),
59
59
  > not by the core runtime.
60
60
 
61
61
  ## ๐Ÿงช Local Demo Server
@@ -103,8 +103,8 @@ it's **attaching listeners, timers, and async work to real DOM nodes**.
103
103
 
104
104
  Dalila's rule is simple:
105
105
 
106
- - **Inside a scope** โ†’ cleanup is automatic on `scope.dispose()`
107
- - **Outside a scope** โ†’ you must call `dispose()` manually (Dalila warns once)
106
+ - **Inside a scope** โ†’ cleanup is automatic (best-effort) on `scope.dispose()`
107
+ - **Outside a scope** โ†’ you must call `dispose()` manually unless a primitive explicitly auto-disposes on DOM removal (Dalila warns in dev mode)
108
108
 
109
109
  #### `watch(node, fn)` โ€” DOM lifecycle primitive
110
110
 
@@ -298,18 +298,61 @@ mutate(() => {
298
298
  - This allows reading updated values inside the batch while coalescing UI updates
299
299
 
300
300
  ### List Rendering with Keys
301
+
302
+ Dalila provides efficient list rendering with keyed diffing, similar to React's `key` prop or Vue's `:key`.
303
+
304
+ **Basic usage:**
301
305
  ```typescript
302
- // Primary API (stable)
303
- createList(
304
- todos,
305
- (todo) => div(todo.text)
306
+ import { signal } from 'dalila';
307
+ import { createList } from 'dalila/core';
308
+
309
+ const todos = signal([
310
+ { id: 1, text: 'Learn Dalila', done: true },
311
+ { id: 2, text: 'Build app', done: false }
312
+ ]);
313
+
314
+ const listFragment = createList(
315
+ () => todos(),
316
+ (todo) => {
317
+ const li = document.createElement('li');
318
+ li.textContent = todo.text;
319
+ return li;
320
+ },
321
+ (todo) => todo.id.toString() // Key function
306
322
  );
307
323
 
308
- // Experimental low-level API
309
- forEach(
310
- items,
311
- (item) => div(item.name),
312
- (item) => item.id // Key function
324
+ document.body.append(listFragment);
325
+ ```
326
+
327
+ **Key function best practices:**
328
+ - โœ… Always provide a keyFn for dynamic lists
329
+ - โœ… Use stable, unique identifiers (IDs, not indices)
330
+ - โœ… Avoid using object references as keys
331
+ - โš ๏ธ Without keyFn, items use index as key (re-renders on reorder)
332
+
333
+ **How it works:**
334
+ - Only re-renders items whose value changed (not items that only moved)
335
+ - Preserves DOM nodes for unchanged items (maintains focus, scroll, etc)
336
+ - Efficient for lists up to ~1000 items (informal guideline, not yet benchmarked)
337
+ - Each item gets its own scope for automatic cleanup
338
+ - Outside a scope, the list auto-disposes when removed from the DOM (or call `fragment.dispose()`)
339
+ > **Note:** In `createList`, `index` is a snapshot (not reactive). Reorder moves nodes without re-render. Use `forEach()` if you need a reactive index.
340
+
341
+ **Performance:**
342
+ ```typescript
343
+ // Bad: re-creates all items on every change
344
+ effect(() => {
345
+ container.innerHTML = '';
346
+ todos().forEach(todo => {
347
+ container.append(createTodoItem(todo));
348
+ });
349
+ });
350
+
351
+ // Good: only updates changed items
352
+ const list = createList(
353
+ () => todos(),
354
+ (todo) => createTodoItem(todo),
355
+ (todo) => todo.id.toString()
313
356
  );
314
357
  ```
315
358
 
@@ -596,7 +639,7 @@ Dalila is built around these core principles:
596
639
  | Rendering | Virtual DOM diffing | Direct DOM manipulation |
597
640
  | Performance | Manual optimization | Runtime scheduling (best-effort) |
598
641
  | State management | Hooks + deps arrays | Signals + automatic tracking |
599
- | Side effects | `useEffect` + deps | `effect()` + automatic cleanup |
642
+ | Side effects | `useEffect` + deps | `effect()` + automatic cleanup (best-effort) |
600
643
  | Bundle size | ~40KB | Not yet measured |
601
644
 
602
645
  ## ๐Ÿ“ Project Structure
@@ -1,23 +1,2 @@
1
1
  export declare function setDevMode(enabled: boolean): void;
2
2
  export declare function isInDevMode(): boolean;
3
- export declare function warnIfAsyncEffectWithoutAbort(): void;
4
- export declare function warnIfLayoutReadInMutate(): void;
5
- export declare function warnIfSignalUsedOutsideScope(): void;
6
- export declare function checkLayoutThrashing(): Promise<void>;
7
- export declare function monitorPerformance(): void;
8
- export declare function detectMemoryLeaks(): void;
9
- export declare function initDevTools(): Promise<void>;
10
- export declare function inspectSignal<T>(signal: () => T): {
11
- value: T;
12
- subscribers: number;
13
- };
14
- export declare function trackEffect(fn: Function): () => void;
15
- export declare function getActiveEffects(): Array<{
16
- id: number;
17
- fn: Function;
18
- stack: string;
19
- }>;
20
- export declare function getCurrentScopeInfo(): {
21
- cleanupCount: number;
22
- effectCount: number;
23
- };
package/dist/core/dev.js CHANGED
@@ -1,5 +1,4 @@
1
- import { getCurrentScope } from './scope.js';
2
- import { measure, mutate } from './scheduler.js';
1
+ // dev.ts
3
2
  let isDevMode = true;
4
3
  export function setDevMode(enabled) {
5
4
  isDevMode = enabled;
@@ -7,151 +6,3 @@ export function setDevMode(enabled) {
7
6
  export function isInDevMode() {
8
7
  return isDevMode;
9
8
  }
10
- // Dev warnings for common performance issues
11
- export function warnIfAsyncEffectWithoutAbort() {
12
- if (!isDevMode)
13
- return;
14
- console.warn('โš ๏ธ Async effect without AbortSignal: ' +
15
- 'This may cause race conditions. Use effectAsync with signal parameter instead.');
16
- }
17
- export function warnIfLayoutReadInMutate() {
18
- if (!isDevMode)
19
- return;
20
- console.warn('โš ๏ธ Layout read in mutate phase: ' +
21
- 'This may cause forced reflows. Use measure() for reads and mutate() for writes.');
22
- }
23
- export function warnIfSignalUsedOutsideScope() {
24
- if (!isDevMode)
25
- return;
26
- console.warn('โš ๏ธ Signal used outside scope: ' +
27
- 'Signals should be created and used within a scope for proper cleanup.');
28
- }
29
- // Check for layout thrashing
30
- export async function checkLayoutThrashing() {
31
- if (!isDevMode)
32
- return;
33
- // For ES modules, we can't override imports directly
34
- // Instead, we'll provide alternative functions that users can opt into
35
- console.log('โš ๏ธ Layout thrashing detection is disabled in ES modules.');
36
- // Export alternative functions that can be used instead
37
- window.measureWithTracking = function (fn) {
38
- let isMeasuring = false;
39
- let isMutating = false;
40
- // Check if we're in a mutate phase
41
- if (isMutating) {
42
- warnIfLayoutReadInMutate();
43
- }
44
- isMeasuring = true;
45
- try {
46
- return measure(fn);
47
- }
48
- finally {
49
- isMeasuring = false;
50
- }
51
- };
52
- window.mutateWithTracking = function (fn) {
53
- let isMeasuring = false;
54
- let isMutating = false;
55
- // Check if we're in a measure phase
56
- if (isMeasuring) {
57
- warnIfLayoutReadInMutate();
58
- }
59
- isMutating = true;
60
- try {
61
- mutate(fn);
62
- }
63
- finally {
64
- isMutating = false;
65
- }
66
- };
67
- console.log('๐Ÿ“Š Use window.measureWithTracking() and window.mutateWithTracking() for layout thrashing detection.');
68
- }
69
- // Performance monitoring
70
- export function monitorPerformance() {
71
- if (!isDevMode)
72
- return;
73
- let lastFrameTime = performance.now();
74
- let frameCount = 0;
75
- let fps = 0;
76
- function calculateFPS() {
77
- const now = performance.now();
78
- const delta = now - lastFrameTime;
79
- if (delta >= 1000) {
80
- fps = Math.round((frameCount * 1000) / delta);
81
- frameCount = 0;
82
- lastFrameTime = now;
83
- if (fps < 30) {
84
- console.warn(`โš ๏ธ Low FPS: ${fps}. Consider optimizing your effects.`);
85
- }
86
- }
87
- frameCount++;
88
- requestAnimationFrame(calculateFPS);
89
- }
90
- calculateFPS();
91
- }
92
- // Memory leak detection
93
- export function detectMemoryLeaks() {
94
- if (!isDevMode)
95
- return;
96
- const originalScopeDispose = getCurrentScope()?.dispose;
97
- if (originalScopeDispose) {
98
- getCurrentScope().dispose = function () {
99
- const beforeCleanupCount = this.cleanups?.length || 0;
100
- originalScopeDispose.call(this);
101
- const afterCleanupCount = this.cleanups?.length || 0;
102
- if (afterCleanupCount > 0) {
103
- console.warn(`โš ๏ธ Potential memory leak: ${afterCleanupCount} cleanups remained after scope dispose.`);
104
- }
105
- };
106
- }
107
- }
108
- // Initialize dev tools
109
- export async function initDevTools() {
110
- if (!isDevMode)
111
- return;
112
- await checkLayoutThrashing();
113
- monitorPerformance();
114
- detectMemoryLeaks();
115
- console.log('๐Ÿ”ง Dalila DevTools enabled');
116
- }
117
- // Simple signal inspection
118
- export function inspectSignal(signal) {
119
- if (!isDevMode)
120
- return { value: signal(), subscribers: 0 };
121
- const value = signal();
122
- const subscribers = signal.subscribers?.size || 0;
123
- return { value, subscribers };
124
- }
125
- // Effect tracking
126
- let effectIdCounter = 0;
127
- const activeEffects = new Map();
128
- export function trackEffect(fn) {
129
- if (!isDevMode)
130
- return () => { };
131
- const effectId = effectIdCounter++;
132
- const stack = new Error().stack || 'No stack trace';
133
- activeEffects.set(effectId, { fn, stack });
134
- return () => {
135
- activeEffects.delete(effectId);
136
- };
137
- }
138
- export function getActiveEffects() {
139
- if (!isDevMode)
140
- return [];
141
- return Array.from(activeEffects.entries()).map(([id, { fn, stack }]) => ({
142
- id,
143
- fn,
144
- stack
145
- }));
146
- }
147
- // Scope inspection
148
- export function getCurrentScopeInfo() {
149
- const scope = getCurrentScope();
150
- if (!scope || !isDevMode) {
151
- return { cleanupCount: 0, effectCount: 0 };
152
- }
153
- return {
154
- cleanupCount: scope.cleanups?.length || 0,
155
- effectCount: getActiveEffects().length
156
- };
157
- }
@@ -1,3 +1,42 @@
1
- export declare function forEach<T>(items: () => T[], template: (item: T, index: () => number) => Node | Node[], keyFn?: (item: T) => string): DocumentFragment;
2
- export declare function createList<T>(items: () => T[], template: (item: T, index: number) => Node | Node[], keyFn?: (item: T) => string): DocumentFragment;
3
- export declare function createKeyedElement<K extends keyof HTMLElementTagNameMap>(tag: K, key: string, children?: Node | Node[] | string, attrs?: Record<string, any>): HTMLElementTagNameMap[K];
1
+ interface DisposableFragment extends DocumentFragment {
2
+ dispose(): void;
3
+ }
4
+ /**
5
+ * Low-level keyed list rendering with fine-grained reactivity.
6
+ *
7
+ * Uses keyed diffing to efficiently update only changed items.
8
+ * Each item gets its own scope for automatic cleanup.
9
+ *
10
+ * @param items - Signal or function returning array of items
11
+ * @param template - Function that renders each item (receives item and reactive index)
12
+ * @param keyFn - Optional function to extract unique key from item (defaults to index)
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * const todos = signal([
17
+ * { id: 1, text: 'Learn Dalila' },
18
+ * { id: 2, text: 'Build app' }
19
+ * ]);
20
+ *
21
+ * forEach(
22
+ * () => todos(),
23
+ * (todo, index) => {
24
+ * const li = document.createElement('li');
25
+ * li.textContent = `${index()}: ${todo.text}`;
26
+ * return li;
27
+ * },
28
+ * (todo) => todo.id.toString()
29
+ * );
30
+ * ```
31
+ *
32
+ * @internal Prefer createList() for most use cases
33
+ */
34
+ export declare function forEach<T>(items: () => T[], template: (item: T, index: () => number) => Node | Node[], keyFn?: (item: T, index: number) => string): DisposableFragment;
35
+ /**
36
+ * Stable API for rendering keyed lists.
37
+ *
38
+ * Renders a reactive list with automatic updates when items change.
39
+ * Only re-renders items that actually changed (keyed diffing).
40
+ */
41
+ export declare function createList<T>(items: () => T[], template: (item: T, index: number) => Node | Node[], keyFn?: (item: T, index: number) => string): DisposableFragment;
42
+ export {};
package/dist/core/for.js CHANGED
@@ -1,27 +1,117 @@
1
1
  import { effect, signal } from './signal.js';
2
- // Experimental: low-level keyed list diff. Prefer createList for stable API.
2
+ import { isInDevMode } from './dev.js';
3
+ import { createScope, withScope, getCurrentScope } from './scope.js';
4
+ const autoDisposeByDocument = new WeakMap();
5
+ const getMutationObserverCtor = (doc) => {
6
+ if (doc.defaultView?.MutationObserver)
7
+ return doc.defaultView.MutationObserver;
8
+ if (typeof MutationObserver !== 'undefined')
9
+ return MutationObserver;
10
+ return null;
11
+ };
12
+ const registerAutoDispose = (start, end, cleanup) => {
13
+ const doc = start.ownerDocument;
14
+ const MutationObserverCtor = getMutationObserverCtor(doc);
15
+ if (!MutationObserverCtor)
16
+ return () => { };
17
+ let ctx = autoDisposeByDocument.get(doc);
18
+ if (!ctx) {
19
+ const entries = new Set();
20
+ const observer = new MutationObserverCtor(() => {
21
+ entries.forEach(entry => {
22
+ const connected = entry.start.isConnected && entry.end.isConnected;
23
+ if (!entry.attached && connected) {
24
+ entry.attached = true;
25
+ return;
26
+ }
27
+ if (entry.attached && !connected)
28
+ entry.cleanup();
29
+ });
30
+ if (entries.size === 0) {
31
+ observer.disconnect();
32
+ autoDisposeByDocument.delete(doc);
33
+ }
34
+ });
35
+ observer.observe(doc, { childList: true, subtree: true });
36
+ ctx = { observer, entries };
37
+ autoDisposeByDocument.set(doc, ctx);
38
+ }
39
+ const entry = {
40
+ start,
41
+ end,
42
+ cleanup,
43
+ attached: start.isConnected && end.isConnected
44
+ };
45
+ ctx.entries.add(entry);
46
+ return () => {
47
+ ctx?.entries.delete(entry);
48
+ if (ctx && ctx.entries.size === 0) {
49
+ ctx.observer.disconnect();
50
+ autoDisposeByDocument.delete(doc);
51
+ }
52
+ };
53
+ };
54
+ /**
55
+ * Low-level keyed list rendering with fine-grained reactivity.
56
+ *
57
+ * Uses keyed diffing to efficiently update only changed items.
58
+ * Each item gets its own scope for automatic cleanup.
59
+ *
60
+ * @param items - Signal or function returning array of items
61
+ * @param template - Function that renders each item (receives item and reactive index)
62
+ * @param keyFn - Optional function to extract unique key from item (defaults to index)
63
+ *
64
+ * @example
65
+ * ```ts
66
+ * const todos = signal([
67
+ * { id: 1, text: 'Learn Dalila' },
68
+ * { id: 2, text: 'Build app' }
69
+ * ]);
70
+ *
71
+ * forEach(
72
+ * () => todos(),
73
+ * (todo, index) => {
74
+ * const li = document.createElement('li');
75
+ * li.textContent = `${index()}: ${todo.text}`;
76
+ * return li;
77
+ * },
78
+ * (todo) => todo.id.toString()
79
+ * );
80
+ * ```
81
+ *
82
+ * @internal Prefer createList() for most use cases
83
+ */
3
84
  export function forEach(items, template, keyFn) {
4
85
  const start = document.createComment('for:start');
5
86
  const end = document.createComment('for:end');
6
87
  let currentItems = [];
7
- let currentIndex = signal(0);
88
+ let disposeEffect = null;
89
+ const parentScope = getCurrentScope();
90
+ let disposed = false;
91
+ let stopAutoDispose = null;
8
92
  const getKey = (item, index) => {
9
- if (keyFn) {
10
- return keyFn(item);
11
- }
12
- // Default key is index if no key function provided
13
- return index.toString();
93
+ if (keyFn)
94
+ return keyFn(item, index);
95
+ // Using index as key is an anti-pattern for dynamic lists
96
+ // but we allow it as a fallback. Items will be re-rendered on reorder.
97
+ return `__idx_${index}`;
14
98
  };
15
99
  const removeNode = (node) => {
16
100
  if (node.parentNode)
17
101
  node.parentNode.removeChild(node);
18
102
  };
19
- const removeRange = (startNode, endNode) => {
20
- let node = startNode;
103
+ const removeRange = (keyedItem) => {
104
+ // Dispose scope first to cleanup effects/listeners
105
+ if (keyedItem.scope) {
106
+ keyedItem.scope.dispose();
107
+ keyedItem.scope = null;
108
+ }
109
+ // Remove DOM nodes (including markers)
110
+ let node = keyedItem.start;
21
111
  while (node) {
22
112
  const next = node.nextSibling;
23
113
  removeNode(node);
24
- if (node === endNode)
114
+ if (node === keyedItem.end)
25
115
  break;
26
116
  node = next;
27
117
  }
@@ -38,120 +128,184 @@ export function forEach(items, template, keyFn) {
38
128
  const parent = referenceNode.parentNode;
39
129
  if (!parent)
40
130
  return;
41
- const fragment = document.createDocumentFragment();
42
131
  let node = startNode;
43
132
  while (node) {
44
133
  const next = node.nextSibling;
45
- fragment.appendChild(node);
134
+ parent.insertBefore(node, referenceNode);
46
135
  if (node === endNode)
47
136
  break;
48
137
  node = next;
49
138
  }
50
- parent.insertBefore(fragment, referenceNode);
51
139
  };
140
+ let hasValidatedOnce = false;
141
+ const throwDuplicateKey = (key, scheduleFatal) => {
142
+ const error = new Error(`[Dalila] Duplicate key "${key}" detected in forEach. ` +
143
+ `Keys must be unique within the same list. Check your keyFn implementation.`);
144
+ if (scheduleFatal) {
145
+ queueMicrotask(() => {
146
+ throw error;
147
+ });
148
+ }
149
+ throw error;
150
+ };
151
+ const validateNoDuplicateKeys = (arr) => {
152
+ if (!isInDevMode())
153
+ return;
154
+ const scheduleFatal = hasValidatedOnce;
155
+ hasValidatedOnce = true;
156
+ const seenKeys = new Set();
157
+ arr.forEach((item, index) => {
158
+ const key = getKey(item, index);
159
+ if (seenKeys.has(key)) {
160
+ throwDuplicateKey(key, scheduleFatal);
161
+ }
162
+ seenKeys.add(key);
163
+ });
164
+ };
165
+ const disposeItemScope = (item) => {
166
+ if (!item.scope)
167
+ return;
168
+ item.scope.dispose();
169
+ item.scope = null;
170
+ };
171
+ const cleanup = () => {
172
+ if (disposed)
173
+ return;
174
+ disposed = true;
175
+ stopAutoDispose?.();
176
+ stopAutoDispose = null;
177
+ disposeEffect?.();
178
+ disposeEffect = null;
179
+ currentItems.forEach(item => {
180
+ removeRange(item);
181
+ });
182
+ currentItems = [];
183
+ removeNode(start);
184
+ removeNode(end);
185
+ };
186
+ // Validate first render synchronously (let throw escape in dev)
187
+ validateNoDuplicateKeys(items());
52
188
  const update = () => {
189
+ if (disposed)
190
+ return;
53
191
  const newItems = items();
192
+ // Validate again on updates (will be caught by effect error handler)
193
+ validateNoDuplicateKeys(newItems);
54
194
  const oldMap = new Map();
55
195
  currentItems.forEach(item => oldMap.set(item.key, item));
56
196
  const nextItems = [];
57
- const needsUpdate = new Set();
197
+ const itemsToUpdate = new Set();
198
+ const seenNextKeys = new Set();
199
+ // Phase 1: Build next list + detect updates/new
58
200
  newItems.forEach((item, index) => {
59
201
  const key = getKey(item, index);
202
+ if (seenNextKeys.has(key))
203
+ return; // prod-mode: ignore dup keys silently
204
+ seenNextKeys.add(key);
60
205
  const existing = oldMap.get(key);
61
206
  if (existing) {
62
207
  if (existing.value !== item) {
63
- needsUpdate.add(key);
208
+ itemsToUpdate.add(key);
64
209
  existing.value = item;
65
210
  }
66
211
  nextItems.push(existing);
67
212
  }
68
213
  else {
69
- needsUpdate.add(key);
214
+ itemsToUpdate.add(key);
70
215
  nextItems.push({
71
216
  key,
72
217
  value: item,
73
- start: document.createComment(`for:item:start:${key}`),
74
- end: document.createComment(`for:item:end:${key}`)
218
+ start: document.createComment(`for:${key}:start`),
219
+ end: document.createComment(`for:${key}:end`),
220
+ scope: null,
221
+ indexSignal: signal(index)
75
222
  });
76
223
  }
77
224
  });
78
- const nextKeys = new Set(nextItems.map(item => item.key));
225
+ // Phase 2: Remove items no longer present
226
+ const nextKeys = new Set(nextItems.map(i => i.key));
79
227
  currentItems.forEach(item => {
80
- if (!nextKeys.has(item.key)) {
81
- removeRange(item.start, item.end);
82
- }
228
+ if (!nextKeys.has(item.key))
229
+ removeRange(item);
83
230
  });
231
+ // Phase 3: Move/insert items to correct positions
84
232
  const parent = end.parentNode;
85
233
  if (parent) {
86
234
  let cursor = start;
87
235
  nextItems.forEach(item => {
88
- const inDom = item.start.parentNode && item.end.parentNode;
89
- const referenceNode = cursor.nextSibling || end;
236
+ const nextSibling = cursor.nextSibling;
237
+ const inDom = item.start.parentNode === parent;
90
238
  if (!inDom) {
239
+ const referenceNode = nextSibling || end;
91
240
  referenceNode.before(item.start, item.end);
92
241
  }
93
- else if (referenceNode !== item.start) {
242
+ else if (nextSibling !== item.start) {
243
+ const referenceNode = nextSibling || end;
94
244
  moveRangeBefore(item.start, item.end, referenceNode);
95
245
  }
96
246
  cursor = item.end;
97
247
  });
98
248
  }
99
- nextItems.forEach((item, index) => {
100
- if (!needsUpdate.has(item.key))
249
+ // Phase 4: Dispose scopes and clear content for changed items
250
+ nextItems.forEach(item => {
251
+ if (!itemsToUpdate.has(item.key))
101
252
  return;
253
+ disposeItemScope(item);
102
254
  clearBetween(item.start, item.end);
103
- const templateResult = template(item.value, () => index);
104
- const nodes = Array.isArray(templateResult) ? templateResult : [templateResult];
105
- item.end.before(...nodes);
255
+ });
256
+ // Phase 5: Update reactive indices for ALL items
257
+ nextItems.forEach((item, index) => {
258
+ item.indexSignal.set(index);
259
+ });
260
+ // Phase 6: Render changed items
261
+ nextItems.forEach(item => {
262
+ if (!itemsToUpdate.has(item.key))
263
+ return;
264
+ item.scope = createScope();
265
+ withScope(item.scope, () => {
266
+ const indexGetter = () => item.indexSignal();
267
+ const templateResult = template(item.value, indexGetter);
268
+ const nodes = Array.isArray(templateResult) ? templateResult : [templateResult];
269
+ item.end.before(...nodes);
270
+ });
106
271
  });
107
272
  currentItems = nextItems;
108
273
  };
109
- // Initial render
110
- effect(() => {
111
- update();
112
- });
274
+ // IMPORTANT: append markers BEFORE creating the effect,
275
+ // so a synchronous effect run can still render into the fragment safely.
113
276
  const frag = document.createDocumentFragment();
114
277
  frag.append(start, end);
278
+ frag.dispose = cleanup;
279
+ // Run update reactively and capture dispose
280
+ disposeEffect = effect(() => {
281
+ if (disposed)
282
+ return;
283
+ update();
284
+ });
285
+ // Cleanup on parent scope disposal
286
+ if (parentScope) {
287
+ parentScope.onCleanup(cleanup);
288
+ }
289
+ else {
290
+ stopAutoDispose = registerAutoDispose(start, end, cleanup);
291
+ if (isInDevMode()) {
292
+ console.warn('[Dalila] forEach() called outside of a scope. ' +
293
+ 'The effect will not be tied to a scope. ' +
294
+ 'It will auto-dispose when removed from the DOM, ' +
295
+ 'or call fragment.dispose() for manual cleanup if needed.');
296
+ }
297
+ }
115
298
  return frag;
116
299
  }
117
- // Simplified version for common use case
300
+ /**
301
+ * Stable API for rendering keyed lists.
302
+ *
303
+ * Renders a reactive list with automatic updates when items change.
304
+ * Only re-renders items that actually changed (keyed diffing).
305
+ */
118
306
  export function createList(items, template, keyFn) {
119
307
  return forEach(items, (item, index) => {
120
308
  const idx = index();
121
309
  return template(item, idx);
122
310
  }, keyFn);
123
311
  }
124
- // DOM element with key support for list items
125
- export function createKeyedElement(tag, key, children, attrs) {
126
- const element = document.createElement(tag);
127
- element.setAttribute('data-key', key);
128
- // Set attributes
129
- if (attrs) {
130
- Object.entries(attrs).forEach(([key, value]) => {
131
- if (key === 'class') {
132
- element.className = value;
133
- }
134
- else if (key === 'style' && typeof value === 'object') {
135
- Object.assign(element.style, value);
136
- }
137
- else if (key.startsWith('on') && typeof value === 'function') {
138
- const eventName = key.slice(2).toLowerCase();
139
- element.addEventListener(eventName, value);
140
- }
141
- else {
142
- element.setAttribute(key, String(value));
143
- }
144
- });
145
- }
146
- // Add children
147
- if (children !== undefined) {
148
- if (typeof children === 'string') {
149
- element.textContent = children;
150
- }
151
- else {
152
- const nodes = Array.isArray(children) ? children : [children];
153
- element.append(...nodes);
154
- }
155
- }
156
- return element;
157
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dalila",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "DOM-first reactive framework based on signals",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",