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 +132 -14
- 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/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +1 -1
- package/dist/core/watch-testing.d.ts +13 -0
- package/dist/core/watch-testing.js +16 -0
- package/dist/core/watch.d.ts +67 -5
- package/dist/core/watch.js +89 -13
- package/dist/internal/watch-testing.d.ts +1 -0
- package/dist/internal/watch-testing.js +8 -0
- package/package.json +7 -1
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`
|
|
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` (
|
|
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
|
-
|
|
228
|
-
createList
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
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
|
-
}
|
package/dist/core/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export * from "./scope.js";
|
|
2
2
|
export * from "./signal.js";
|
|
3
|
-
export
|
|
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";
|
package/dist/core/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export * from "./scope.js";
|
|
2
2
|
export * from "./signal.js";
|
|
3
|
-
export
|
|
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
|
+
}
|
package/dist/core/watch.d.ts
CHANGED
|
@@ -1,19 +1,81 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
-
*
|
|
5
|
-
* 1)
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
};
|
package/dist/core/watch.js
CHANGED
|
@@ -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
|
|
27
|
-
*
|
|
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
|
-
*
|
|
120
|
+
* watch(node, fn)
|
|
109
121
|
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
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,
|
|
181
|
+
// If empty, delete the entry from WeakMap to free the Set immediately
|
|
164
182
|
if (entries.size === 0)
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/package.json
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dalila",
|
|
3
|
-
"version": "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",
|