dalila 1.1.1 โ†’ 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
@@ -10,21 +10,22 @@ Dalila is a **SPA**, **DOM-first**, **HTML natural** framework based on **signal
10
10
  - ๐Ÿš€ **Signals-based reactivity** - Automatic dependency tracking
11
11
  - ๐ŸŽฏ **DOM-first rendering** - Direct DOM manipulation, no Virtual DOM
12
12
  - ๐Ÿ”„ **Scope-based lifecycle** - Automatic cleanup (best effort)
13
+ - ๐Ÿงฟ **DOM lifecycle watchers** โ€” `watch()` + helpers (`useEvent`, `useInterval`, `useTimeout`, `useFetch`) with scope-based cleanup
13
14
  - ๐Ÿ›ฃ๏ธ **SPA router** - Basic routing with loaders and AbortSignal
14
15
  - ๐Ÿ“ฆ **Context system** - Reactive dependency injection
15
16
  - ๐Ÿ”ง **Scheduler & batching** - Group updates into a single frame
16
- - ๐Ÿ“š **List rendering** - `createList` for keyed lists
17
+ - ๐Ÿ“š **List rendering** - `createList` with keyed diffing for efficient updates
17
18
  - ๐Ÿงฑ **Resources** - Async data helpers with AbortSignal and scope cleanup
18
19
 
19
20
  ### Experimental
20
21
  - ๐ŸŽจ **Natural HTML bindings** - Only in the example dev-server (not in core)
21
- - ๐Ÿ“Š **Virtualization** - Virtual lists/tables are experimental
22
22
  - ๐Ÿ” **DevTools (console)** - Warnings and FPS monitor only
23
- - ๐Ÿงช **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)
24
24
 
25
25
  ### Planned / Roadmap
26
26
  - ๐Ÿงฐ **DevTools UI** - Visual inspection tooling
27
27
  - ๐Ÿงฉ **HTML bindings runtime/compiler** - First-class template binding
28
+ - ๐Ÿ“Š **Virtualization** - Virtual lists/tables for very large datasets (10k+ items)
28
29
 
29
30
  ## ๐Ÿ“ฆ Installation
30
31
 
@@ -54,7 +55,7 @@ export function createController() {
54
55
  }
55
56
  ```
56
57
 
57
- > 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`),
58
59
  > not by the core runtime.
59
60
 
60
61
  ## ๐Ÿงช Local Demo Server
@@ -95,6 +96,80 @@ effectAsync(async (signal) => {
95
96
  });
96
97
  ```
97
98
 
99
+ ### Lifecycle / Cleanup (Scopes + DOM)
100
+
101
+ Dalila is DOM-first. That means a lot of your "lifecycle" work is not React-like rendering โ€”
102
+ it's **attaching listeners, timers, and async work to real DOM nodes**.
103
+
104
+ Dalila's rule is simple:
105
+
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
+
109
+ #### `watch(node, fn)` โ€” DOM lifecycle primitive
110
+
111
+ `watch()` runs a reactive function while a DOM node is connected.
112
+ When the node disconnects, the effect is disposed. If the node reconnects later, it starts again.
113
+ It's the primitive that enables DOM lifecycle without a VDOM.
114
+
115
+ ```ts
116
+ import { watch, signal } from "dalila";
117
+
118
+ const count = signal(0);
119
+
120
+ const dispose = watch(someNode, () => {
121
+ // Reactive while connected because watch() runs this inside an effect()
122
+ someNode.textContent = String(count());
123
+ });
124
+
125
+ // later (optional if inside a scope)
126
+ dispose();
127
+ ```
128
+
129
+ #### Lifecycle helpers
130
+
131
+ Built on the same mental model, Dalila provides small helpers that always return an **idempotent** `dispose()`:
132
+ `useEvent`, `useInterval`, `useTimeout`, `useFetch`.
133
+
134
+ **Inside a scope (recommended)**
135
+
136
+ ```ts
137
+ import { createScope, withScope, useEvent, useInterval, useFetch } from "dalila";
138
+
139
+ const scope = createScope();
140
+
141
+ withScope(scope, () => {
142
+ useEvent(button, "click", onClick);
143
+ useInterval(tick, 1000);
144
+
145
+ const user = useFetch("/api/user");
146
+
147
+ // Optional manual cleanup:
148
+ // user.dispose();
149
+ });
150
+
151
+ scope.dispose(); // stops listener, interval, and aborts fetch
152
+ ```
153
+
154
+ Disposing the scope stops listeners/timers and aborts in-flight async work created inside the scope.
155
+
156
+ **Outside a scope (manual cleanup)**
157
+
158
+ ```ts
159
+ import { useInterval } from "dalila";
160
+
161
+ const dispose = useInterval(() => console.log("tick"), 1000);
162
+
163
+ // later...
164
+ dispose(); // required
165
+ ```
166
+
167
+ #### Why helpers vs native APIs?
168
+
169
+ You *can* use `addEventListener` / `setTimeout` / `setInterval` directly, but then cleanup becomes "manual discipline".
170
+ In a DOM-first app, listeners/timers are the #1 source of silent leaks when the UI changes.
171
+ Scopes make cleanup a **default**, not a convention โ€” so UI changes don't silently leak listeners, timers, or in-flight async work.
172
+
98
173
  ### Conditional Rendering
99
174
 
100
175
  Dalila provides two primitives for branching UI:
@@ -223,18 +298,61 @@ mutate(() => {
223
298
  - This allows reading updated values inside the batch while coalescing UI updates
224
299
 
225
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:**
226
305
  ```typescript
227
- // Primary API (stable)
228
- createList(
229
- todos,
230
- (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
231
322
  );
232
323
 
233
- // Experimental low-level API
234
- forEach(
235
- items,
236
- (item) => div(item.name),
237
- (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()
238
356
  );
239
357
  ```
240
358
 
@@ -521,7 +639,7 @@ Dalila is built around these core principles:
521
639
  | Rendering | Virtual DOM diffing | Direct DOM manipulation |
522
640
  | Performance | Manual optimization | Runtime scheduling (best-effort) |
523
641
  | State management | Hooks + deps arrays | Signals + automatic tracking |
524
- | Side effects | `useEffect` + deps | `effect()` + automatic cleanup |
642
+ | Side effects | `useEffect` + deps | `effect()` + automatic cleanup (best-effort) |
525
643
  | Bundle size | ~40KB | Not yet measured |
526
644
 
527
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
- }
@@ -1,6 +1,6 @@
1
1
  export * from "./scope.js";
2
2
  export * from "./signal.js";
3
- export * from "./watch.js";
3
+ export { watch, onMount, onCleanup, useEvent, useInterval, useTimeout, useFetch } from "./watch.js";
4
4
  export * from "./when.js";
5
5
  export * from "./match.js";
6
6
  export * from "./for.js";
@@ -1,6 +1,6 @@
1
1
  export * from "./scope.js";
2
2
  export * from "./signal.js";
3
- export * from "./watch.js";
3
+ export { watch, onMount, onCleanup, useEvent, useInterval, useTimeout, useFetch } from "./watch.js";
4
4
  export * from "./when.js";
5
5
  export * from "./match.js";
6
6
  export * from "./for.js";
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Testing utilities for watch.ts
3
+ *
4
+ * This file is NOT exported in the public API (src/index.ts).
5
+ * It should only be imported directly by test files.
6
+ *
7
+ * @internal
8
+ */
9
+ /**
10
+ * Reset all warning flags to their initial state.
11
+ * Use this in tests to ensure deterministic warning behavior.
12
+ */
13
+ export declare function resetWarnings(): void;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Testing utilities for watch.ts
3
+ *
4
+ * This file is NOT exported in the public API (src/index.ts).
5
+ * It should only be imported directly by test files.
6
+ *
7
+ * @internal
8
+ */
9
+ import { __resetWarningsForTests } from './watch.js';
10
+ /**
11
+ * Reset all warning flags to their initial state.
12
+ * Use this in tests to ensure deterministic warning behavior.
13
+ */
14
+ export function resetWarnings() {
15
+ __resetWarningsForTests();
16
+ }
@@ -1,19 +1,81 @@
1
1
  /**
2
- * Watch a DOM node and run an effect while it's connected.
2
+ * Reset warning flags (used by src/internal/watch-testing.ts for deterministic tests).
3
+ * @internal
4
+ */
5
+ export declare function __resetWarningsForTests(): void;
6
+ /**
7
+ * watch(node, fn)
8
+ *
9
+ * Runs `fn` inside a reactive effect while `node` is connected to the document.
10
+ * - Signal reads inside `fn` are tracked (because `fn` runs inside effect()).
11
+ * - When the node disconnects, the effect is disposed.
12
+ * - If the node reconnects later, the effect may start again (best-effort, based on DOM mutations).
3
13
  *
4
- * Fixes applied:
5
- * 1) Late-start effects now run inside the original scope (captured on watch()).
6
- * 2) watches uses WeakMap<Node,...> so we don't keep detached nodes alive.
7
- * 3) Observer disconnect uses ctx.watchCount (since WeakMap has no .size).
14
+ * Implementation notes / fixes:
15
+ * 1) Scope capture: the scope at watch() time is stored on the entry so effects that start later
16
+ * still get created "inside" the original scope (fixes late-start effects created outside scope).
17
+ * 2) Memory safety: watches uses WeakMap<Node, ...> so detached nodes are not kept alive.
18
+ * 3) Observer lifecycle: we track ctx.watchCount (WeakMap has no .size) to know when to disconnect.
8
19
  */
9
20
  export declare function watch(node: Node, fn: () => void): () => void;
21
+ /**
22
+ * onMount(fn)
23
+ *
24
+ * Executes fn immediately. Minimal semantic helper to document mount-time logic in DOM-first code.
25
+ * Unlike React, there's no deferred execution or batching โ€” it runs synchronously.
26
+ */
10
27
  export declare function onMount(fn: () => void): void;
28
+ /**
29
+ * onCleanup(fn)
30
+ *
31
+ * Registers fn to run when the current scope is disposed.
32
+ * If called outside a scope, fn is never called (no-op).
33
+ */
11
34
  export declare function onCleanup(fn: () => void): void;
35
+ /**
36
+ * useEvent(target, type, handler, options?)
37
+ *
38
+ * Attaches an event listener and returns an idempotent dispose() function.
39
+ * - Inside a scope: listener is removed automatically on scope.dispose().
40
+ * - Outside a scope: you must call dispose() manually (warns once).
41
+ */
12
42
  export declare function useEvent<T extends EventTarget>(target: T, type: string, handler: (event: Event) => void, options?: AddEventListenerOptions): () => void;
43
+ /**
44
+ * useInterval(fn, ms)
45
+ *
46
+ * Starts an interval and returns an idempotent dispose() function.
47
+ * - Inside a scope: interval is cleared automatically on scope.dispose().
48
+ * - Outside a scope: you must call dispose() manually (warns once).
49
+ */
13
50
  export declare function useInterval(fn: () => void, ms: number): () => void;
51
+ /**
52
+ * useTimeout(fn, ms)
53
+ *
54
+ * Starts a timeout and returns an idempotent dispose() function.
55
+ * - Inside a scope: timeout is cleared automatically on scope.dispose().
56
+ * - Outside a scope: you must call dispose() manually (warns once).
57
+ */
14
58
  export declare function useTimeout(fn: () => void, ms: number): () => void;
59
+ /**
60
+ * useFetch(url, options)
61
+ *
62
+ * Small convenience for "fetch + reactive state + abort" built on effectAsync().
63
+ * Returns { data, loading, error, dispose }.
64
+ *
65
+ * Behavior:
66
+ * - Runs the fetch inside effectAsync, which provides an AbortSignal.
67
+ * - If url is a function, it tracks signal reads (reactive).
68
+ * - Calling dispose() aborts the in-flight request (via effectAsync's signal).
69
+ * - Inside a scope: auto-disposed on scope.dispose().
70
+ * - Outside a scope: you must call dispose() manually (warns once).
71
+ *
72
+ * Limitations:
73
+ * - No refresh(), no caching, no invalidation.
74
+ * - For those features, use createResource / query().
75
+ */
15
76
  export declare function useFetch<T>(url: string | (() => string), options?: RequestInit): {
16
77
  data: () => T | null;
17
78
  loading: () => boolean;
18
79
  error: () => Error | null;
80
+ dispose: () => void;
19
81
  };
@@ -10,6 +10,14 @@ const documentWatchers = new WeakMap();
10
10
  */
11
11
  let hasWarnedNoScope = false;
12
12
  const warnedFunctions = new Set();
13
+ /**
14
+ * Reset warning flags (used by src/internal/watch-testing.ts for deterministic tests).
15
+ * @internal
16
+ */
17
+ export function __resetWarningsForTests() {
18
+ warnedFunctions.clear();
19
+ hasWarnedNoScope = false;
20
+ }
13
21
  /**
14
22
  * Walk a subtree recursively, calling visitor for each node.
15
23
  * Supports any Node type (Element, Text, Comment, etc.) via childNodes.
@@ -23,8 +31,12 @@ function walkSubtree(root, visitor) {
23
31
  }
24
32
  }
25
33
  /**
26
- * Start a watch entry effect in the entry's captured scope (if any).
27
- * This fixes the critical bug where late-start effects were created outside scope.
34
+ * Start a watch entry effect inside the entry's captured scope (if any).
35
+ *
36
+ * Why:
37
+ * - `effect()` captures getCurrentScope() at creation time.
38
+ * - watch entries can start later (when a node becomes connected), so we must re-enter
39
+ * the original scope to preserve cleanup semantics.
28
40
  */
29
41
  function startEntryEffect(entry) {
30
42
  if (entry.effectStarted || entry.cleanup)
@@ -105,12 +117,18 @@ function getDocumentWatchContext(doc) {
105
117
  return ctx;
106
118
  }
107
119
  /**
108
- * Watch a DOM node and run an effect while it's connected.
120
+ * watch(node, fn)
109
121
  *
110
- * Fixes applied:
111
- * 1) Late-start effects now run inside the original scope (captured on watch()).
112
- * 2) watches uses WeakMap<Node,...> so we don't keep detached nodes alive.
113
- * 3) Observer disconnect uses ctx.watchCount (since WeakMap has no .size).
122
+ * Runs `fn` inside a reactive effect while `node` is connected to the document.
123
+ * - Signal reads inside `fn` are tracked (because `fn` runs inside effect()).
124
+ * - When the node disconnects, the effect is disposed.
125
+ * - If the node reconnects later, the effect may start again (best-effort, based on DOM mutations).
126
+ *
127
+ * Implementation notes / fixes:
128
+ * 1) Scope capture: the scope at watch() time is stored on the entry so effects that start later
129
+ * still get created "inside" the original scope (fixes late-start effects created outside scope).
130
+ * 2) Memory safety: watches uses WeakMap<Node, ...> so detached nodes are not kept alive.
131
+ * 3) Observer lifecycle: we track ctx.watchCount (WeakMap has no .size) to know when to disconnect.
114
132
  */
115
133
  export function watch(node, fn) {
116
134
  const currentScope = getCurrentScope();
@@ -160,9 +178,9 @@ export function watch(node, fn) {
160
178
  const entries = ctx.watches.get(node);
161
179
  if (entries) {
162
180
  entries.delete(entry);
163
- // If empty, clear to drop strong refs to entries; key removal is handled by WeakMap GC.
181
+ // If empty, delete the entry from WeakMap to free the Set immediately
164
182
  if (entries.size === 0)
165
- entries.clear();
183
+ ctx.watches.delete(node);
166
184
  }
167
185
  // Decrement active watch count and disconnect observer if none left
168
186
  ctx.watchCount--;
@@ -177,14 +195,33 @@ export function watch(node, fn) {
177
195
  }
178
196
  return dispose;
179
197
  }
198
+ /**
199
+ * onMount(fn)
200
+ *
201
+ * Executes fn immediately. Minimal semantic helper to document mount-time logic in DOM-first code.
202
+ * Unlike React, there's no deferred execution or batching โ€” it runs synchronously.
203
+ */
180
204
  export function onMount(fn) {
181
205
  fn();
182
206
  }
207
+ /**
208
+ * onCleanup(fn)
209
+ *
210
+ * Registers fn to run when the current scope is disposed.
211
+ * If called outside a scope, fn is never called (no-op).
212
+ */
183
213
  export function onCleanup(fn) {
184
214
  const scope = getCurrentScope();
185
215
  if (scope)
186
216
  scope.onCleanup(fn);
187
217
  }
218
+ /**
219
+ * useEvent(target, type, handler, options?)
220
+ *
221
+ * Attaches an event listener and returns an idempotent dispose() function.
222
+ * - Inside a scope: listener is removed automatically on scope.dispose().
223
+ * - Outside a scope: you must call dispose() manually (warns once).
224
+ */
188
225
  export function useEvent(target, type, handler, options) {
189
226
  const scope = getCurrentScope();
190
227
  if (!scope && !warnedFunctions.has('useEvent')) {
@@ -204,6 +241,13 @@ export function useEvent(target, type, handler, options) {
204
241
  scope.onCleanup(dispose);
205
242
  return dispose;
206
243
  }
244
+ /**
245
+ * useInterval(fn, ms)
246
+ *
247
+ * Starts an interval and returns an idempotent dispose() function.
248
+ * - Inside a scope: interval is cleared automatically on scope.dispose().
249
+ * - Outside a scope: you must call dispose() manually (warns once).
250
+ */
207
251
  export function useInterval(fn, ms) {
208
252
  const scope = getCurrentScope();
209
253
  if (!scope && !warnedFunctions.has('useInterval')) {
@@ -223,6 +267,13 @@ export function useInterval(fn, ms) {
223
267
  scope.onCleanup(dispose);
224
268
  return dispose;
225
269
  }
270
+ /**
271
+ * useTimeout(fn, ms)
272
+ *
273
+ * Starts a timeout and returns an idempotent dispose() function.
274
+ * - Inside a scope: timeout is cleared automatically on scope.dispose().
275
+ * - Outside a scope: you must call dispose() manually (warns once).
276
+ */
226
277
  export function useTimeout(fn, ms) {
227
278
  const scope = getCurrentScope();
228
279
  if (!scope && !warnedFunctions.has('useTimeout')) {
@@ -242,12 +293,34 @@ export function useTimeout(fn, ms) {
242
293
  scope.onCleanup(dispose);
243
294
  return dispose;
244
295
  }
245
- // Async effect with fetch support and automatic abort
296
+ /**
297
+ * useFetch(url, options)
298
+ *
299
+ * Small convenience for "fetch + reactive state + abort" built on effectAsync().
300
+ * Returns { data, loading, error, dispose }.
301
+ *
302
+ * Behavior:
303
+ * - Runs the fetch inside effectAsync, which provides an AbortSignal.
304
+ * - If url is a function, it tracks signal reads (reactive).
305
+ * - Calling dispose() aborts the in-flight request (via effectAsync's signal).
306
+ * - Inside a scope: auto-disposed on scope.dispose().
307
+ * - Outside a scope: you must call dispose() manually (warns once).
308
+ *
309
+ * Limitations:
310
+ * - No refresh(), no caching, no invalidation.
311
+ * - For those features, use createResource / query().
312
+ */
246
313
  export function useFetch(url, options) {
314
+ const scope = getCurrentScope();
315
+ if (!scope && !warnedFunctions.has('useFetch')) {
316
+ warnedFunctions.add('useFetch');
317
+ console.warn('[Dalila] useFetch() called outside scope. ' +
318
+ 'Request will not auto-cleanup. Call the returned dispose() function manually.');
319
+ }
247
320
  const data = signal(null);
248
321
  const loading = signal(false);
249
322
  const error = signal(null);
250
- effectAsync(async (signal) => {
323
+ const dispose = effectAsync(async (signal) => {
251
324
  try {
252
325
  loading.set(true);
253
326
  error.set(null);
@@ -259,7 +332,7 @@ export function useFetch(url, options) {
259
332
  if (!response.ok) {
260
333
  throw new Error(`HTTP error! status: ${response.status}`);
261
334
  }
262
- const result = await response.json();
335
+ const result = (await response.json());
263
336
  data.set(result);
264
337
  }
265
338
  catch (err) {
@@ -273,5 +346,8 @@ export function useFetch(url, options) {
273
346
  }
274
347
  }
275
348
  });
276
- return { data, loading, error };
349
+ // Match the other lifecycle helpers: if we are inside a scope, dispose automatically.
350
+ if (scope)
351
+ scope.onCleanup(dispose);
352
+ return { data, loading, error, dispose };
277
353
  }
@@ -0,0 +1 @@
1
+ export declare function resetWarnings(): void;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Testing utilities for watch.ts
3
+ * @internal
4
+ */
5
+ import { __resetWarningsForTests } from '../core/watch.js';
6
+ export function resetWarnings() {
7
+ __resetWarningsForTests();
8
+ }
package/package.json CHANGED
@@ -1,10 +1,16 @@
1
1
  {
2
2
  "name": "dalila",
3
- "version": "1.1.1",
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",
7
7
  "type": "module",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
8
14
  "scripts": {
9
15
  "build": "tsc",
10
16
  "dev": "tsc --watch",