dalila 1.3.1 → 1.3.2

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.
@@ -1,3 +1,27 @@
1
+ /** Tracks disposed scopes without mutating the public interface. */
2
+ const disposedScopes = new WeakSet();
3
+ const scopeCreateListeners = new Set();
4
+ const scopeDisposeListeners = new Set();
5
+ /**
6
+ * Subscribe to scope creation events.
7
+ * Returns an unsubscribe function.
8
+ */
9
+ export function onScopeCreate(fn) {
10
+ scopeCreateListeners.add(fn);
11
+ return () => scopeCreateListeners.delete(fn);
12
+ }
13
+ /**
14
+ * Subscribe to scope disposal events.
15
+ * Returns an unsubscribe function.
16
+ */
17
+ export function onScopeDispose(fn) {
18
+ scopeDisposeListeners.add(fn);
19
+ return () => scopeDisposeListeners.delete(fn);
20
+ }
21
+ /** Returns true if the given scope has been disposed. */
22
+ export function isScopeDisposed(scope) {
23
+ return disposedScopes.has(scope);
24
+ }
1
25
  /**
2
26
  * Creates a new Scope instance.
3
27
  *
@@ -7,13 +31,13 @@
7
31
  * in the same dispose pass (because we snapshot via `splice(0)`).
8
32
  * - Parent is captured from the current scope context (set by withScope).
9
33
  */
10
- export function createScope() {
34
+ export function createScope(parentOverride) {
11
35
  const cleanups = [];
12
- const parent = currentScope;
13
- let disposed = false;
36
+ const parent = parentOverride === undefined ? currentScope : parentOverride === null ? null : parentOverride;
14
37
  const runCleanupSafely = (fn) => {
15
38
  try {
16
- return fn();
39
+ fn();
40
+ return undefined;
17
41
  }
18
42
  catch (err) {
19
43
  return err;
@@ -21,7 +45,7 @@ export function createScope() {
21
45
  };
22
46
  const scope = {
23
47
  onCleanup(fn) {
24
- if (disposed) {
48
+ if (isScopeDisposed(scope)) {
25
49
  const error = runCleanupSafely(fn);
26
50
  if (error) {
27
51
  console.error('[Dalila] cleanup registered after dispose() threw:', error);
@@ -31,9 +55,9 @@ export function createScope() {
31
55
  cleanups.push(fn);
32
56
  },
33
57
  dispose() {
34
- if (disposed)
58
+ if (isScopeDisposed(scope))
35
59
  return;
36
- disposed = true;
60
+ disposedScopes.add(scope);
37
61
  const snapshot = cleanups.splice(0);
38
62
  const errors = [];
39
63
  for (const fn of snapshot) {
@@ -44,12 +68,28 @@ export function createScope() {
44
68
  if (errors.length > 0) {
45
69
  console.error('[Dalila] scope.dispose() had cleanup errors:', errors);
46
70
  }
71
+ for (const listener of scopeDisposeListeners) {
72
+ try {
73
+ listener(scope);
74
+ }
75
+ catch (err) {
76
+ console.error('[Dalila] scope.dispose() listener threw:', err);
77
+ }
78
+ }
47
79
  },
48
80
  parent,
49
81
  };
50
82
  if (parent) {
51
83
  parent.onCleanup(() => scope.dispose());
52
84
  }
85
+ for (const listener of scopeCreateListeners) {
86
+ try {
87
+ listener(scope);
88
+ }
89
+ catch (err) {
90
+ console.error('[Dalila] scope.create() listener threw:', err);
91
+ }
92
+ }
53
93
  return scope;
54
94
  }
55
95
  /**
@@ -61,6 +101,18 @@ let currentScope = null;
61
101
  export function getCurrentScope() {
62
102
  return currentScope;
63
103
  }
104
+ /**
105
+ * Returns the current scope hierarchy, from current scope up to the root.
106
+ */
107
+ export function getCurrentScopeHierarchy() {
108
+ const scopes = [];
109
+ let current = currentScope;
110
+ while (current) {
111
+ scopes.push(current);
112
+ current = current.parent;
113
+ }
114
+ return scopes;
115
+ }
64
116
  /**
65
117
  * Sets the current active scope.
66
118
  * Prefer using `withScope()` unless you are implementing low-level internals.
@@ -76,6 +128,9 @@ export function setCurrentScope(scope) {
76
128
  * - `effect()` can auto-dispose when the scope ends
77
129
  */
78
130
  export function withScope(scope, fn) {
131
+ if (isScopeDisposed(scope)) {
132
+ throw new Error('[Dalila] withScope() cannot enter a disposed scope.');
133
+ }
79
134
  const prevScope = currentScope;
80
135
  currentScope = scope;
81
136
  try {
@@ -5,9 +5,16 @@
5
5
  */
6
6
  export declare function setEffectErrorHandler(handler: (error: Error, source: string) => void): void;
7
7
  export interface Signal<T> {
8
+ /** Read the current value (with dependency tracking if inside an effect). */
8
9
  (): T;
10
+ /** Set a new value and notify subscribers. */
9
11
  set(value: T): void;
12
+ /** Update the value using a function. */
10
13
  update(fn: (v: T) => T): void;
14
+ /** Read the current value without creating a dependency (no tracking). */
15
+ peek(): T;
16
+ /** Subscribe to value changes manually (outside of effects). Returns unsubscribe function. */
17
+ on(callback: (value: T) => void): () => void;
11
18
  }
12
19
  /**
13
20
  * Create a signal: a mutable value with automatic dependency tracking.
@@ -64,3 +71,18 @@ export declare function computed<T>(fn: () => T): Signal<T>;
64
71
  * - when disposed, aborts the current run and stops future scheduling
65
72
  */
66
73
  export declare function effectAsync(fn: (signal: AbortSignal) => void): () => void;
74
+ /**
75
+ * Run a function without tracking any signal reads as dependencies.
76
+ *
77
+ * Use this inside an effect when you want to read a signal's value
78
+ * without creating a dependency on it.
79
+ *
80
+ * Example:
81
+ * ```ts
82
+ * effect(() => {
83
+ * const tracked = count(); // This read is tracked
84
+ * const untracked = untrack(() => other()); // This read is NOT tracked
85
+ * });
86
+ * ```
87
+ */
88
+ export declare function untrack<T>(fn: () => T): T;
@@ -158,6 +158,26 @@ export function signal(initialValue) {
158
158
  read.update = (fn) => {
159
159
  read.set(fn(value));
160
160
  };
161
+ read.peek = () => value;
162
+ read.on = (callback) => {
163
+ // Create a lightweight effect-like subscriber for manual subscriptions
164
+ const subscriber = (() => {
165
+ if (subscriber.disposed)
166
+ return;
167
+ callback(value);
168
+ });
169
+ subscriber.disposed = false;
170
+ subscribers.add(subscriber);
171
+ (subscriber.deps ?? (subscriber.deps = new Set())).add(subscribers);
172
+ // Return unsubscribe function
173
+ return () => {
174
+ if (subscriber.disposed)
175
+ return;
176
+ subscriber.disposed = true;
177
+ subscribers.delete(subscriber);
178
+ subscriber.deps?.delete(subscribers);
179
+ };
180
+ };
161
181
  return read;
162
182
  }
163
183
  /**
@@ -306,6 +326,45 @@ export function computed(fn) {
306
326
  read.update = () => {
307
327
  throw new Error('Cannot update a computed signal directly. Computed signals are derived from other signals.');
308
328
  };
329
+ read.peek = () => {
330
+ // For computed, peek still needs to compute if dirty, but without tracking
331
+ if (dirty) {
332
+ cleanupDeps();
333
+ const prevEffect = activeEffect;
334
+ const prevScope = activeScope;
335
+ activeEffect = markDirty;
336
+ activeScope = null;
337
+ try {
338
+ value = fn();
339
+ dirty = false;
340
+ if (markDirty.deps)
341
+ trackedDeps = new Set(markDirty.deps);
342
+ }
343
+ finally {
344
+ activeEffect = prevEffect;
345
+ activeScope = prevScope;
346
+ }
347
+ }
348
+ return value;
349
+ };
350
+ read.on = (callback) => {
351
+ const subscriber = (() => {
352
+ if (subscriber.disposed)
353
+ return;
354
+ // For computed, we need to get the latest value
355
+ callback(read.peek());
356
+ });
357
+ subscriber.disposed = false;
358
+ subscribers.add(subscriber);
359
+ (subscriber.deps ?? (subscriber.deps = new Set())).add(subscribers);
360
+ return () => {
361
+ if (subscriber.disposed)
362
+ return;
363
+ subscriber.disposed = true;
364
+ subscribers.delete(subscriber);
365
+ subscriber.deps?.delete(subscribers);
366
+ };
367
+ };
309
368
  return read;
310
369
  }
311
370
  /**
@@ -363,3 +422,30 @@ export function effectAsync(fn) {
363
422
  owningScope.onCleanup(dispose);
364
423
  return dispose;
365
424
  }
425
+ /**
426
+ * Run a function without tracking any signal reads as dependencies.
427
+ *
428
+ * Use this inside an effect when you want to read a signal's value
429
+ * without creating a dependency on it.
430
+ *
431
+ * Example:
432
+ * ```ts
433
+ * effect(() => {
434
+ * const tracked = count(); // This read is tracked
435
+ * const untracked = untrack(() => other()); // This read is NOT tracked
436
+ * });
437
+ * ```
438
+ */
439
+ export function untrack(fn) {
440
+ const prevEffect = activeEffect;
441
+ const prevScope = activeScope;
442
+ activeEffect = null;
443
+ activeScope = null;
444
+ try {
445
+ return fn();
446
+ }
447
+ finally {
448
+ activeEffect = prevEffect;
449
+ activeScope = prevScope;
450
+ }
451
+ }
package/dist/core/when.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { effect } from './signal.js';
2
- import { createScope, getCurrentScope, withScope } from './scope.js';
2
+ import { createScope, getCurrentScope, isScopeDisposed, withScope } from './scope.js';
3
3
  import { scheduleMicrotask } from './scheduler.js';
4
4
  /**
5
5
  * Conditional DOM rendering with per-branch lifetime.
@@ -38,6 +38,13 @@ export function when(test, thenFn, elseFn) {
38
38
  let pendingCond = undefined;
39
39
  /** Disposal guard to prevent orphan microtasks from touching dead DOM. */
40
40
  let disposed = false;
41
+ /** Parent scope captured at creation time (if any). */
42
+ const parentScope = getCurrentScope();
43
+ const resolveParentScope = () => {
44
+ if (!parentScope)
45
+ return null;
46
+ return isScopeDisposed(parentScope) ? null : parentScope;
47
+ };
41
48
  /** Removes mounted nodes and disposes their branch scope. */
42
49
  const clear = () => {
43
50
  branchScope?.dispose();
@@ -56,7 +63,7 @@ export function when(test, thenFn, elseFn) {
56
63
  /** Clears old branch and mounts the branch for `cond`. */
57
64
  const swap = (cond) => {
58
65
  clear();
59
- const nextScope = createScope();
66
+ const nextScope = createScope(resolveParentScope());
60
67
  try {
61
68
  // Render inside an isolated scope so branch resources are tied to that branch.
62
69
  const result = withScope(nextScope, () => (cond ? thenFn() : elseFn?.()));
@@ -107,7 +114,6 @@ export function when(test, thenFn, elseFn) {
107
114
  });
108
115
  });
109
116
  // If `when()` is created inside a parent scope, dispose branch resources with it.
110
- const parentScope = getCurrentScope();
111
117
  if (parentScope) {
112
118
  parentScope.onCleanup(() => {
113
119
  disposed = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dalila",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "description": "DOM-first reactive framework based on signals",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",