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 +59 -16
- package/dist/core/dev.d.ts +0 -21
- package/dist/core/dev.js +1 -150
- package/dist/core/for.d.ts +42 -3
- package/dist/core/for.js +223 -69
- package/package.json +1 -1
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`
|
|
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` (
|
|
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
|
|
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
|
-
|
|
303
|
-
createList
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
package/dist/core/dev.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
}
|
package/dist/core/for.d.ts
CHANGED
|
@@ -1,3 +1,42 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
13
|
-
return index
|
|
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 = (
|
|
20
|
-
|
|
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 ===
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
208
|
+
itemsToUpdate.add(key);
|
|
64
209
|
existing.value = item;
|
|
65
210
|
}
|
|
66
211
|
nextItems.push(existing);
|
|
67
212
|
}
|
|
68
213
|
else {
|
|
69
|
-
|
|
214
|
+
itemsToUpdate.add(key);
|
|
70
215
|
nextItems.push({
|
|
71
216
|
key,
|
|
72
217
|
value: item,
|
|
73
|
-
start: document.createComment(`for
|
|
74
|
-
end: document.createComment(`for
|
|
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
|
-
|
|
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
|
|
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
|
|
89
|
-
const
|
|
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 (
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
}
|