dalila 1.1.1 → 1.2.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,6 +10,7 @@ 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
@@ -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 on `scope.dispose()`
107
+ - **Outside a scope** → you must call `dispose()` manually (Dalila warns once)
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:
@@ -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.2.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",