dalila 1.8.4 → 1.9.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
@@ -94,6 +94,12 @@ bind(document.getElementById('app')!, ctx);
94
94
  - [Scheduler](./docs/core/scheduler.md) — Batching and coordination
95
95
  - [Keys](./docs/core/key.md) — Cache key encoding
96
96
  - [Dev Mode](./docs/core/dev.md) — Warnings and helpers
97
+ - [Devtools Extension](./devtools-extension/README.md) — Browser panel for reactive graph and scopes
98
+
99
+ Firefox extension workflows:
100
+
101
+ - `npm run devtools:firefox:run` — launch Firefox with extension loaded for dev
102
+ - `npm run devtools:firefox:build` — package extension artifact for submission/signing
97
103
 
98
104
  ## Features
99
105
 
@@ -1,7 +1,16 @@
1
+ import { DevtoolsEvent, DevtoolsRuntimeOptions, DevtoolsSnapshot } from "./devtools.js";
1
2
  export declare function setDevMode(enabled: boolean): void;
2
3
  export declare function isInDevMode(): boolean;
4
+ export interface InitDevToolsOptions extends DevtoolsRuntimeOptions {
5
+ }
6
+ export declare function setDevtoolsEnabled(enabled: boolean, options?: DevtoolsRuntimeOptions): void;
7
+ export declare function isDevtoolsEnabled(): boolean;
8
+ export declare function configureDevtools(options: DevtoolsRuntimeOptions): void;
9
+ export declare function getDevtoolsSnapshot(): DevtoolsSnapshot;
10
+ export declare function onDevtoolsEvent(listener: (event: DevtoolsEvent) => void): () => void;
11
+ export declare function resetDevtools(): void;
3
12
  /**
4
- * Initialize dev tools. Currently just enables dev mode.
13
+ * Initialize dev tools runtime bridge for graph inspection.
5
14
  * Returns a promise for future async initialization support.
6
15
  */
7
- export declare function initDevTools(): Promise<void>;
16
+ export declare function initDevTools(options?: InitDevToolsOptions): Promise<void>;
package/dist/core/dev.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { configure, getSnapshot, isEnabled, reset, setEnabled, subscribe, } from "./devtools.js";
1
2
  let isDevMode = true;
2
3
  export function setDevMode(enabled) {
3
4
  isDevMode = enabled;
@@ -5,10 +6,33 @@ export function setDevMode(enabled) {
5
6
  export function isInDevMode() {
6
7
  return isDevMode;
7
8
  }
9
+ export function setDevtoolsEnabled(enabled, options) {
10
+ setEnabled(enabled, options);
11
+ }
12
+ export function isDevtoolsEnabled() {
13
+ return isEnabled();
14
+ }
15
+ export function configureDevtools(options) {
16
+ configure(options);
17
+ }
18
+ export function getDevtoolsSnapshot() {
19
+ return getSnapshot();
20
+ }
21
+ export function onDevtoolsEvent(listener) {
22
+ return subscribe(listener);
23
+ }
24
+ export function resetDevtools() {
25
+ reset();
26
+ }
8
27
  /**
9
- * Initialize dev tools. Currently just enables dev mode.
28
+ * Initialize dev tools runtime bridge for graph inspection.
10
29
  * Returns a promise for future async initialization support.
11
30
  */
12
- export async function initDevTools() {
31
+ export async function initDevTools(options = {}) {
13
32
  setDevMode(true);
33
+ setEnabled(true, {
34
+ exposeGlobalHook: options.exposeGlobalHook ?? true,
35
+ dispatchEvents: options.dispatchEvents ?? true,
36
+ maxEvents: options.maxEvents,
37
+ });
14
38
  }
@@ -0,0 +1,65 @@
1
+ export type DevtoolsNodeType = "scope" | "signal" | "computed" | "effect" | "effectAsync";
2
+ export type DevtoolsEdgeKind = "dependency" | "ownership";
3
+ export interface DevtoolsNode {
4
+ id: number;
5
+ type: DevtoolsNodeType;
6
+ label: string;
7
+ disposed: boolean;
8
+ scopeId: number | null;
9
+ parentScopeId: number | null;
10
+ reads: number;
11
+ writes: number;
12
+ runs: number;
13
+ lastValue: string;
14
+ lastRunAt: number;
15
+ createdAt: number;
16
+ }
17
+ export interface DevtoolsEdge {
18
+ from: number;
19
+ to: number;
20
+ kind: DevtoolsEdgeKind;
21
+ }
22
+ export interface DevtoolsEvent {
23
+ type: string;
24
+ at: number;
25
+ payload: Record<string, unknown>;
26
+ }
27
+ export interface DevtoolsSnapshot {
28
+ enabled: boolean;
29
+ nodes: DevtoolsNode[];
30
+ edges: DevtoolsEdge[];
31
+ events: DevtoolsEvent[];
32
+ }
33
+ export interface DevtoolsRuntimeOptions {
34
+ maxEvents?: number;
35
+ exposeGlobalHook?: boolean;
36
+ dispatchEvents?: boolean;
37
+ }
38
+ export interface DevtoolsHighlightOptions {
39
+ durationMs?: number;
40
+ }
41
+ export declare function configure(options?: DevtoolsRuntimeOptions): void;
42
+ export declare function setEnabled(next: boolean, options?: DevtoolsRuntimeOptions): void;
43
+ export declare function isEnabled(): boolean;
44
+ export declare function reset(): void;
45
+ export declare function subscribe(listener: (event: DevtoolsEvent) => void): () => void;
46
+ export declare function getSnapshot(): DevtoolsSnapshot;
47
+ export declare function registerScope(scopeRef: object, parentScopeRef: object | null): void;
48
+ export declare function withDevtoolsDomTarget<T>(element: Element | null, fn: () => T): T;
49
+ export declare function linkScopeToDom(scopeRef: object, element: Element, label?: string): void;
50
+ export declare function disposeScope(scopeRef: object): void;
51
+ export declare function registerSignal(signalRef: object, type: "signal" | "computed", options?: {
52
+ scopeRef?: object | null;
53
+ initialValue?: unknown;
54
+ }): void;
55
+ export declare function registerEffect(effectRef: object, type: "effect" | "effectAsync", scopeRef: object | null): void;
56
+ export declare function aliasEffectToNode(effectRef: object, targetRef: object): void;
57
+ export declare function linkSubscriberSetToSignal(subscriberSetRef: object, signalRef: object): void;
58
+ export declare function trackSignalRead(signalRef: object): void;
59
+ export declare function trackSignalWrite(signalRef: object, nextValue: unknown): void;
60
+ export declare function trackEffectRun(effectRef: object): void;
61
+ export declare function trackEffectDispose(effectRef: object): void;
62
+ export declare function trackDependency(signalRef: object, effectRef: object): void;
63
+ export declare function untrackDependencyBySet(subscriberSetRef: object, effectRef: object): void;
64
+ export declare function clearHighlights(): void;
65
+ export declare function highlightNode(nodeId: number, options?: DevtoolsHighlightOptions): boolean;
@@ -0,0 +1,452 @@
1
+ const DEFAULT_MAX_EVENTS = 500;
2
+ const GLOBAL_HOOK_KEY = "__DALILA_DEVTOOLS__";
3
+ const GLOBAL_EVENT_NAME = "dalila:devtools:event";
4
+ let enabled = false;
5
+ let maxEvents = DEFAULT_MAX_EVENTS;
6
+ let exposeGlobalHook = false;
7
+ let dispatchEvents = false;
8
+ let nextId = 1;
9
+ let refToId = new WeakMap();
10
+ const nodes = new Map();
11
+ const dependencyEdges = new Map();
12
+ const ownershipEdges = new Map();
13
+ let subscriberSetToSignalId = new WeakMap();
14
+ let effectAliasToNodeId = new WeakMap();
15
+ const listeners = new Set();
16
+ let events = [];
17
+ const nodeDomTargets = new Map();
18
+ const highlightTimers = new WeakMap();
19
+ let currentDomTarget = null;
20
+ const HIGHLIGHT_ATTR = "data-dalila-devtools-highlight";
21
+ const HIGHLIGHT_LABEL_ATTR = "data-dalila-devtools-label";
22
+ const HIGHLIGHT_STYLE_ID = "dalila-devtools-highlight-style";
23
+ function canUseGlobalDispatch() {
24
+ return typeof globalThis.dispatchEvent === "function";
25
+ }
26
+ function canUseDOM() {
27
+ return typeof document !== "undefined" && typeof Element !== "undefined";
28
+ }
29
+ function clearHighlightOnElement(element) {
30
+ const timer = highlightTimers.get(element);
31
+ if (timer !== undefined && typeof globalThis.clearTimeout === "function") {
32
+ globalThis.clearTimeout(timer);
33
+ highlightTimers.delete(element);
34
+ }
35
+ element.removeAttribute(HIGHLIGHT_ATTR);
36
+ element.removeAttribute(HIGHLIGHT_LABEL_ATTR);
37
+ }
38
+ function ensureHighlightStyle() {
39
+ if (!canUseDOM())
40
+ return;
41
+ if (document.getElementById(HIGHLIGHT_STYLE_ID))
42
+ return;
43
+ const style = document.createElement("style");
44
+ style.id = HIGHLIGHT_STYLE_ID;
45
+ style.textContent = `
46
+ [${HIGHLIGHT_ATTR}="1"] {
47
+ outline: 2px solid #0f6d67 !important;
48
+ outline-offset: 2px !important;
49
+ box-shadow: 0 0 0 3px rgba(15, 109, 103, 0.22) !important;
50
+ transition: outline-color 120ms ease, box-shadow 120ms ease;
51
+ }`;
52
+ (document.head || document.documentElement).append(style);
53
+ }
54
+ function resolveNodeDomTargets(nodeId) {
55
+ const direct = nodeDomTargets.get(nodeId);
56
+ if (direct && direct.size > 0)
57
+ return Array.from(direct);
58
+ const node = nodes.get(nodeId);
59
+ if (!node)
60
+ return [];
61
+ if (node.type !== "scope" && node.scopeId !== null) {
62
+ const scopeTargets = nodeDomTargets.get(node.scopeId);
63
+ if (scopeTargets && scopeTargets.size > 0)
64
+ return Array.from(scopeTargets);
65
+ }
66
+ return [];
67
+ }
68
+ function addDomTarget(nodeId, element) {
69
+ let targets = nodeDomTargets.get(nodeId);
70
+ if (!targets) {
71
+ targets = new Set();
72
+ nodeDomTargets.set(nodeId, targets);
73
+ }
74
+ targets.add(element);
75
+ }
76
+ function previewValue(value) {
77
+ const type = typeof value;
78
+ if (type === "string") {
79
+ const quoted = JSON.stringify(value);
80
+ return quoted.length > 120 ? `${quoted.slice(0, 117)}...` : quoted;
81
+ }
82
+ if (value === null ||
83
+ type === "number" ||
84
+ type === "boolean" ||
85
+ type === "undefined" ||
86
+ type === "bigint" ||
87
+ type === "symbol") {
88
+ return String(value);
89
+ }
90
+ if (type === "function") {
91
+ const fn = value;
92
+ return `[Function ${fn.name || "anonymous"}]`;
93
+ }
94
+ try {
95
+ const json = JSON.stringify(value);
96
+ if (json === undefined)
97
+ return Object.prototype.toString.call(value);
98
+ return json.length > 120 ? `${json.slice(0, 117)}...` : json;
99
+ }
100
+ catch {
101
+ return Object.prototype.toString.call(value);
102
+ }
103
+ }
104
+ function getNodeId(ref) {
105
+ const alias = effectAliasToNodeId.get(ref);
106
+ if (alias)
107
+ return alias;
108
+ const existing = refToId.get(ref);
109
+ if (existing)
110
+ return existing;
111
+ const id = nextId++;
112
+ refToId.set(ref, id);
113
+ return id;
114
+ }
115
+ function edgeKey(from, to, kind) {
116
+ return `${kind}:${from}->${to}`;
117
+ }
118
+ function emit(type, payload) {
119
+ if (!enabled)
120
+ return;
121
+ const event = {
122
+ type,
123
+ at: Date.now(),
124
+ payload,
125
+ };
126
+ events.push(event);
127
+ if (events.length > maxEvents) {
128
+ events = events.slice(events.length - maxEvents);
129
+ }
130
+ for (const listener of listeners) {
131
+ try {
132
+ listener(event);
133
+ }
134
+ catch (error) {
135
+ console.error("[Dalila] Devtools listener threw:", error);
136
+ }
137
+ }
138
+ if (dispatchEvents && canUseGlobalDispatch() && typeof CustomEvent !== "undefined") {
139
+ try {
140
+ globalThis.dispatchEvent(new CustomEvent(GLOBAL_EVENT_NAME, {
141
+ detail: event,
142
+ }));
143
+ }
144
+ catch {
145
+ // Ignore environments without full event support.
146
+ }
147
+ }
148
+ }
149
+ function createNode(ref, type, label, options) {
150
+ const id = getNodeId(ref);
151
+ if (nodes.has(id))
152
+ return id;
153
+ const scopeId = options?.scopeRef ? getNodeId(options.scopeRef) : null;
154
+ const parentScopeId = options?.parentScopeRef ? getNodeId(options.parentScopeRef) : null;
155
+ const registeredScopeId = scopeId !== null && nodes.has(scopeId) ? scopeId : null;
156
+ const registeredParentScopeId = parentScopeId !== null && nodes.has(parentScopeId) ? parentScopeId : null;
157
+ nodes.set(id, {
158
+ id,
159
+ type,
160
+ label,
161
+ disposed: false,
162
+ scopeId: registeredScopeId,
163
+ parentScopeId: registeredParentScopeId,
164
+ reads: 0,
165
+ writes: 0,
166
+ runs: 0,
167
+ lastValue: options && "initialValue" in options ? previewValue(options.initialValue) : "",
168
+ lastRunAt: 0,
169
+ createdAt: Date.now(),
170
+ });
171
+ if (registeredScopeId !== null) {
172
+ addOwnershipEdge(registeredScopeId, id);
173
+ }
174
+ if (type === "scope" && registeredParentScopeId !== null) {
175
+ addOwnershipEdge(registeredParentScopeId, id);
176
+ }
177
+ emit("node.create", { id, type, label, scopeId: registeredScopeId, parentScopeId: registeredParentScopeId });
178
+ return id;
179
+ }
180
+ function addOwnershipEdge(from, to) {
181
+ if (!nodes.has(from) || !nodes.has(to))
182
+ return;
183
+ const key = edgeKey(from, to, "ownership");
184
+ if (ownershipEdges.has(key))
185
+ return;
186
+ const edge = { from, to, kind: "ownership" };
187
+ ownershipEdges.set(key, edge);
188
+ emit("edge.add", { from: edge.from, to: edge.to, kind: edge.kind });
189
+ }
190
+ function upsertDependencyEdge(from, to) {
191
+ const key = edgeKey(from, to, "dependency");
192
+ if (dependencyEdges.has(key))
193
+ return;
194
+ const edge = { from, to, kind: "dependency" };
195
+ dependencyEdges.set(key, edge);
196
+ emit("edge.add", { from: edge.from, to: edge.to, kind: edge.kind });
197
+ }
198
+ function removeDependencyEdge(from, to) {
199
+ const key = edgeKey(from, to, "dependency");
200
+ const existing = dependencyEdges.get(key);
201
+ if (!existing)
202
+ return;
203
+ dependencyEdges.delete(key);
204
+ emit("edge.remove", { from: existing.from, to: existing.to, kind: existing.kind });
205
+ }
206
+ function markDisposed(ref) {
207
+ if (!enabled)
208
+ return;
209
+ const node = nodes.get(getNodeId(ref));
210
+ if (!node || node.disposed)
211
+ return;
212
+ node.disposed = true;
213
+ emit("node.dispose", { id: node.id, type: node.type });
214
+ }
215
+ function markRead(ref) {
216
+ if (!enabled)
217
+ return;
218
+ const node = nodes.get(getNodeId(ref));
219
+ if (!node)
220
+ return;
221
+ node.reads += 1;
222
+ }
223
+ function markWrite(ref, nextValue) {
224
+ if (!enabled)
225
+ return;
226
+ const node = nodes.get(getNodeId(ref));
227
+ if (!node)
228
+ return;
229
+ node.writes += 1;
230
+ node.lastValue = previewValue(nextValue);
231
+ emit("signal.write", { id: node.id, type: node.type, nextValue: node.lastValue });
232
+ }
233
+ function markRun(ref) {
234
+ if (!enabled)
235
+ return;
236
+ const node = nodes.get(getNodeId(ref));
237
+ if (!node)
238
+ return;
239
+ node.runs += 1;
240
+ node.lastRunAt = Date.now();
241
+ }
242
+ function installGlobalHook() {
243
+ if (!exposeGlobalHook)
244
+ return;
245
+ const host = globalThis;
246
+ if (host[GLOBAL_HOOK_KEY])
247
+ return;
248
+ host[GLOBAL_HOOK_KEY] = {
249
+ version: 1,
250
+ getSnapshot,
251
+ subscribe,
252
+ reset,
253
+ setEnabled,
254
+ configure,
255
+ highlightNode,
256
+ clearHighlights,
257
+ };
258
+ }
259
+ export function configure(options = {}) {
260
+ if (typeof options.maxEvents === "number" && Number.isFinite(options.maxEvents) && options.maxEvents > 0) {
261
+ maxEvents = Math.floor(options.maxEvents);
262
+ if (events.length > maxEvents) {
263
+ events = events.slice(events.length - maxEvents);
264
+ }
265
+ }
266
+ if (typeof options.exposeGlobalHook === "boolean") {
267
+ exposeGlobalHook = options.exposeGlobalHook;
268
+ }
269
+ if (typeof options.dispatchEvents === "boolean") {
270
+ dispatchEvents = options.dispatchEvents;
271
+ }
272
+ if (enabled)
273
+ installGlobalHook();
274
+ }
275
+ export function setEnabled(next, options) {
276
+ if (options)
277
+ configure(options);
278
+ if (next) {
279
+ enabled = true;
280
+ installGlobalHook();
281
+ emit("devtools.enabled", { enabled: true });
282
+ }
283
+ else {
284
+ if (enabled) {
285
+ emit("devtools.enabled", { enabled: false });
286
+ }
287
+ enabled = false;
288
+ }
289
+ }
290
+ export function isEnabled() {
291
+ return enabled;
292
+ }
293
+ export function reset() {
294
+ // Identity maps must be reset together with graph state. Otherwise existing
295
+ // refs keep stale ids and can generate edges pointing to missing nodes.
296
+ nextId = 1;
297
+ refToId = new WeakMap();
298
+ subscriberSetToSignalId = new WeakMap();
299
+ effectAliasToNodeId = new WeakMap();
300
+ dependencyEdges.clear();
301
+ ownershipEdges.clear();
302
+ nodes.clear();
303
+ nodeDomTargets.clear();
304
+ currentDomTarget = null;
305
+ events = [];
306
+ if (enabled) {
307
+ emit("devtools.reset", {});
308
+ }
309
+ }
310
+ export function subscribe(listener) {
311
+ listeners.add(listener);
312
+ return () => listeners.delete(listener);
313
+ }
314
+ export function getSnapshot() {
315
+ return {
316
+ enabled,
317
+ nodes: Array.from(nodes.values()).map((node) => ({ ...node })),
318
+ edges: [...Array.from(ownershipEdges.values()), ...Array.from(dependencyEdges.values())],
319
+ events: events.map((event) => ({ ...event, payload: { ...event.payload } })),
320
+ };
321
+ }
322
+ export function registerScope(scopeRef, parentScopeRef) {
323
+ if (!enabled)
324
+ return;
325
+ createNode(scopeRef, "scope", "scope", {
326
+ parentScopeRef,
327
+ });
328
+ }
329
+ export function withDevtoolsDomTarget(element, fn) {
330
+ if (!element || !canUseDOM())
331
+ return fn();
332
+ if (!(element instanceof Element))
333
+ return fn();
334
+ const prev = currentDomTarget;
335
+ currentDomTarget = element;
336
+ try {
337
+ return fn();
338
+ }
339
+ finally {
340
+ currentDomTarget = prev;
341
+ }
342
+ }
343
+ export function linkScopeToDom(scopeRef, element, label) {
344
+ if (!enabled || !canUseDOM())
345
+ return;
346
+ if (!(element instanceof Element))
347
+ return;
348
+ const scopeId = getNodeId(scopeRef);
349
+ const node = nodes.get(scopeId);
350
+ if (!node)
351
+ return;
352
+ addDomTarget(scopeId, element);
353
+ if (label && node.label === "scope") {
354
+ node.label = label;
355
+ }
356
+ }
357
+ export function disposeScope(scopeRef) {
358
+ markDisposed(scopeRef);
359
+ }
360
+ export function registerSignal(signalRef, type, options) {
361
+ if (!enabled)
362
+ return;
363
+ createNode(signalRef, type, type, {
364
+ scopeRef: options?.scopeRef ?? null,
365
+ initialValue: options?.initialValue,
366
+ });
367
+ }
368
+ export function registerEffect(effectRef, type, scopeRef) {
369
+ if (!enabled)
370
+ return;
371
+ const id = createNode(effectRef, type, type, {
372
+ scopeRef,
373
+ });
374
+ if (currentDomTarget) {
375
+ addDomTarget(id, currentDomTarget);
376
+ }
377
+ }
378
+ export function aliasEffectToNode(effectRef, targetRef) {
379
+ if (!enabled)
380
+ return;
381
+ const targetId = getNodeId(targetRef);
382
+ effectAliasToNodeId.set(effectRef, targetId);
383
+ }
384
+ export function linkSubscriberSetToSignal(subscriberSetRef, signalRef) {
385
+ if (!enabled)
386
+ return;
387
+ subscriberSetToSignalId.set(subscriberSetRef, getNodeId(signalRef));
388
+ }
389
+ export function trackSignalRead(signalRef) {
390
+ markRead(signalRef);
391
+ }
392
+ export function trackSignalWrite(signalRef, nextValue) {
393
+ markWrite(signalRef, nextValue);
394
+ }
395
+ export function trackEffectRun(effectRef) {
396
+ markRun(effectRef);
397
+ }
398
+ export function trackEffectDispose(effectRef) {
399
+ markDisposed(effectRef);
400
+ }
401
+ export function trackDependency(signalRef, effectRef) {
402
+ if (!enabled)
403
+ return;
404
+ const from = getNodeId(signalRef);
405
+ const to = getNodeId(effectRef);
406
+ if (!nodes.has(from) || !nodes.has(to))
407
+ return;
408
+ upsertDependencyEdge(from, to);
409
+ }
410
+ export function untrackDependencyBySet(subscriberSetRef, effectRef) {
411
+ if (!enabled)
412
+ return;
413
+ const from = subscriberSetToSignalId.get(subscriberSetRef);
414
+ if (!from)
415
+ return;
416
+ const to = getNodeId(effectRef);
417
+ removeDependencyEdge(from, to);
418
+ }
419
+ export function clearHighlights() {
420
+ if (!canUseDOM())
421
+ return;
422
+ for (const targets of nodeDomTargets.values()) {
423
+ for (const element of targets) {
424
+ clearHighlightOnElement(element);
425
+ }
426
+ }
427
+ }
428
+ export function highlightNode(nodeId, options = {}) {
429
+ if (!enabled || !canUseDOM())
430
+ return false;
431
+ const targets = resolveNodeDomTargets(nodeId);
432
+ if (targets.length === 0)
433
+ return false;
434
+ ensureHighlightStyle();
435
+ const node = nodes.get(nodeId);
436
+ const label = node ? `#${node.id} ${node.label || node.type}` : `#${nodeId}`;
437
+ const durationMs = typeof options.durationMs === "number" && Number.isFinite(options.durationMs) && options.durationMs > 0
438
+ ? Math.floor(options.durationMs)
439
+ : 650;
440
+ for (const element of targets) {
441
+ clearHighlightOnElement(element);
442
+ element.setAttribute(HIGHLIGHT_ATTR, "1");
443
+ element.setAttribute(HIGHLIGHT_LABEL_ATTR, label);
444
+ if (typeof globalThis.setTimeout === "function") {
445
+ const timer = globalThis.setTimeout(() => {
446
+ clearHighlightOnElement(element);
447
+ }, durationMs);
448
+ highlightTimers.set(element, timer);
449
+ }
450
+ }
451
+ return true;
452
+ }
@@ -1,3 +1,4 @@
1
+ import { disposeScope, registerScope } from "./devtools.js";
1
2
  /** Tracks disposed scopes without mutating the public interface. */
2
3
  const disposedScopes = new WeakSet();
3
4
  const scopeCreateListeners = new Set();
@@ -73,6 +74,7 @@ export function createScope(parentOverride) {
73
74
  if (errors.length > 0) {
74
75
  console.error('[Dalila] scope.dispose() had cleanup errors:', errors);
75
76
  }
77
+ disposeScope(scope);
76
78
  for (const listener of scopeDisposeListeners) {
77
79
  try {
78
80
  listener(scope);
@@ -84,6 +86,7 @@ export function createScope(parentOverride) {
84
86
  },
85
87
  parent,
86
88
  };
89
+ registerScope(scope, parent);
87
90
  if (parent) {
88
91
  parent.onCleanup(() => scope.dispose());
89
92
  }
@@ -1,5 +1,6 @@
1
1
  import { getCurrentScope, withScope } from './scope.js';
2
2
  import { scheduleMicrotask, isBatching, queueInBatch } from './scheduler.js';
3
+ import { aliasEffectToNode, linkSubscriberSetToSignal, registerEffect, registerSignal, trackDependency, trackEffectDispose, trackEffectRun, trackSignalRead, trackSignalWrite, untrackDependencyBySet, } from './devtools.js';
3
4
  /**
4
5
  * Optional global error handler for reactive execution.
5
6
  *
@@ -114,9 +115,12 @@ function scheduleEffect(eff) {
114
115
  * - signals do not "own" subscriber lifetimes; they only maintain the set
115
116
  */
116
117
  export function signal(initialValue) {
118
+ const owningScope = getCurrentScope();
117
119
  let value = initialValue;
118
120
  const subscribers = new Set();
121
+ let signalRef;
119
122
  const read = () => {
123
+ trackSignalRead(signalRef);
120
124
  if (activeEffect && !activeEffect.disposed) {
121
125
  // Scope-aware subscription guard (best effort):
122
126
  // - if the active effect is not scoped, allow subscription
@@ -125,6 +129,7 @@ export function signal(initialValue) {
125
129
  if (!subscribers.has(activeEffect)) {
126
130
  subscribers.add(activeEffect);
127
131
  (activeEffect.deps ?? (activeEffect.deps = new Set())).add(subscribers);
132
+ trackDependency(signalRef, activeEffect);
128
133
  }
129
134
  }
130
135
  else {
@@ -133,6 +138,7 @@ export function signal(initialValue) {
133
138
  if (!subscribers.has(activeEffect)) {
134
139
  subscribers.add(activeEffect);
135
140
  (activeEffect.deps ?? (activeEffect.deps = new Set())).add(subscribers);
141
+ trackDependency(signalRef, activeEffect);
136
142
  }
137
143
  }
138
144
  }
@@ -149,6 +155,7 @@ export function signal(initialValue) {
149
155
  return;
150
156
  // State updates are immediate even inside `batch()`.
151
157
  value = nextValue;
158
+ trackSignalWrite(signalRef, nextValue);
152
159
  // Notify now, or defer into the batch queue.
153
160
  if (isBatching())
154
161
  queueInBatch(notify);
@@ -178,6 +185,12 @@ export function signal(initialValue) {
178
185
  subscriber.deps?.delete(subscribers);
179
186
  };
180
187
  };
188
+ signalRef = read;
189
+ registerSignal(signalRef, 'signal', {
190
+ scopeRef: owningScope,
191
+ initialValue,
192
+ });
193
+ linkSubscriberSetToSignal(subscribers, signalRef);
181
194
  return read;
182
195
  }
183
196
  /**
@@ -199,8 +212,10 @@ export function effect(fn) {
199
212
  const cleanupDeps = () => {
200
213
  if (!run.deps)
201
214
  return;
202
- for (const depSet of run.deps)
215
+ for (const depSet of run.deps) {
203
216
  depSet.delete(run);
217
+ untrackDependencyBySet(depSet, run);
218
+ }
204
219
  run.deps.clear();
205
220
  };
206
221
  const dispose = () => {
@@ -209,10 +224,12 @@ export function effect(fn) {
209
224
  run.disposed = true;
210
225
  cleanupDeps();
211
226
  pendingEffects.delete(run);
227
+ trackEffectDispose(run);
212
228
  };
213
229
  const run = (() => {
214
230
  if (run.disposed)
215
231
  return;
232
+ trackEffectRun(run);
216
233
  // Dynamic deps: unsubscribe from previous reads.
217
234
  cleanupDeps();
218
235
  const prevEffect = activeEffect;
@@ -231,6 +248,7 @@ export function effect(fn) {
231
248
  activeScope = prevScope;
232
249
  }
233
250
  });
251
+ registerEffect(run, 'effect', owningScope);
234
252
  scheduleEffect(run);
235
253
  if (owningScope)
236
254
  owningScope.onCleanup(dispose);
@@ -252,9 +270,11 @@ export function effect(fn) {
252
270
  * - other effects can subscribe to the computed like a normal signal
253
271
  */
254
272
  export function computed(fn) {
273
+ const owningScope = getCurrentScope();
255
274
  let value;
256
275
  let dirty = true;
257
276
  const subscribers = new Set();
277
+ let signalRef;
258
278
  // Dep sets that `markDirty` is currently registered in (so we can unsubscribe on recompute).
259
279
  let trackedDeps = new Set();
260
280
  /**
@@ -271,19 +291,23 @@ export function computed(fn) {
271
291
  markDirty.disposed = false;
272
292
  markDirty.sync = true;
273
293
  const cleanupDeps = () => {
274
- for (const depSet of trackedDeps)
294
+ for (const depSet of trackedDeps) {
275
295
  depSet.delete(markDirty);
296
+ untrackDependencyBySet(depSet, markDirty);
297
+ }
276
298
  trackedDeps.clear();
277
299
  if (markDirty.deps)
278
300
  markDirty.deps.clear();
279
301
  };
280
302
  const read = () => {
303
+ trackSignalRead(signalRef);
281
304
  // Allow effects to subscribe to this computed (same rules as signal()).
282
305
  if (activeEffect && !activeEffect.disposed) {
283
306
  if (!activeScope) {
284
307
  if (!subscribers.has(activeEffect)) {
285
308
  subscribers.add(activeEffect);
286
309
  (activeEffect.deps ?? (activeEffect.deps = new Set())).add(subscribers);
310
+ trackDependency(signalRef, activeEffect);
287
311
  }
288
312
  }
289
313
  else {
@@ -292,6 +316,7 @@ export function computed(fn) {
292
316
  if (!subscribers.has(activeEffect)) {
293
317
  subscribers.add(activeEffect);
294
318
  (activeEffect.deps ?? (activeEffect.deps = new Set())).add(subscribers);
319
+ trackDependency(signalRef, activeEffect);
295
320
  }
296
321
  }
297
322
  }
@@ -365,6 +390,13 @@ export function computed(fn) {
365
390
  subscriber.deps?.delete(subscribers);
366
391
  };
367
392
  };
393
+ signalRef = read;
394
+ registerSignal(signalRef, 'computed', {
395
+ scopeRef: owningScope,
396
+ });
397
+ linkSubscriberSetToSignal(subscribers, signalRef);
398
+ aliasEffectToNode(markDirty, signalRef);
399
+ registerEffect(markDirty, 'effect', owningScope);
368
400
  return read;
369
401
  }
370
402
  /**
@@ -381,8 +413,10 @@ export function effectAsync(fn) {
381
413
  const cleanupDeps = () => {
382
414
  if (!run.deps)
383
415
  return;
384
- for (const depSet of run.deps)
416
+ for (const depSet of run.deps) {
385
417
  depSet.delete(run);
418
+ untrackDependencyBySet(depSet, run);
419
+ }
386
420
  run.deps.clear();
387
421
  };
388
422
  const dispose = () => {
@@ -393,10 +427,12 @@ export function effectAsync(fn) {
393
427
  controller = null;
394
428
  cleanupDeps();
395
429
  pendingEffects.delete(run);
430
+ trackEffectDispose(run);
396
431
  };
397
432
  const run = (() => {
398
433
  if (run.disposed)
399
434
  return;
435
+ trackEffectRun(run);
400
436
  // Abort previous run (if any), then create a new controller for this run.
401
437
  controller?.abort();
402
438
  controller = new AbortController();
@@ -417,6 +453,7 @@ export function effectAsync(fn) {
417
453
  activeScope = prevScope;
418
454
  }
419
455
  });
456
+ registerEffect(run, 'effectAsync', owningScope);
420
457
  scheduleEffect(run);
421
458
  if (owningScope)
422
459
  owningScope.onCleanup(dispose);
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { effect, createScope, withScope, isInDevMode, signal } from '../core/index.js';
10
10
  import { WRAPPED_HANDLER } from '../form/form.js';
11
+ import { linkScopeToDom, withDevtoolsDomTarget } from '../core/devtools.js';
11
12
  // ============================================================================
12
13
  // Utilities
13
14
  // ============================================================================
@@ -81,6 +82,26 @@ function warn(message) {
81
82
  console.warn(`[Dalila] ${message}`);
82
83
  }
83
84
  }
85
+ function describeBindRoot(root) {
86
+ const explicit = root.getAttribute('data-component') ||
87
+ root.getAttribute('data-devtools-label') ||
88
+ root.getAttribute('aria-label') ||
89
+ root.getAttribute('id');
90
+ if (explicit)
91
+ return String(explicit);
92
+ const className = root.getAttribute('class');
93
+ if (className) {
94
+ const first = className.split(/\s+/).find(Boolean);
95
+ if (first)
96
+ return `${root.tagName.toLowerCase()}.${first}`;
97
+ }
98
+ return root.tagName.toLowerCase();
99
+ }
100
+ function bindEffect(target, fn) {
101
+ withDevtoolsDomTarget(target ?? null, () => {
102
+ effect(fn);
103
+ });
104
+ }
84
105
  const expressionCache = new Map();
85
106
  const templateInterpolationPlanCache = new Map();
86
107
  const TEMPLATE_PLAN_CACHE_MAX_ENTRIES = 250;
@@ -964,7 +985,7 @@ function bindTextNodeFromPlan(node, plan, ctx, benchSession) {
964
985
  applyResult(evalExpressionAst(parsed.ast, ctx));
965
986
  // Only schedule reactive updates when expression depends on reactive sources.
966
987
  if (expressionDependsOnReactiveSource(parsed.ast, ctx)) {
967
- effect(() => {
988
+ bindEffect(node.parentElement, () => {
968
989
  applyResult(evalExpressionAst(parsed.ast, ctx));
969
990
  });
970
991
  }
@@ -1055,7 +1076,7 @@ function bindWhen(root, ctx, cleanups) {
1055
1076
  const initialValue = !!resolve(binding);
1056
1077
  htmlEl.style.display = initialValue ? '' : 'none';
1057
1078
  // Then create reactive effect to keep it updated
1058
- effect(() => {
1079
+ bindEffect(htmlEl, () => {
1059
1080
  const value = !!resolve(binding);
1060
1081
  htmlEl.style.display = value ? '' : 'none';
1061
1082
  });
@@ -1107,7 +1128,7 @@ function bindMatch(root, ctx, cleanups) {
1107
1128
  // Apply initial state
1108
1129
  applyMatch();
1109
1130
  // Then create reactive effect to keep it updated
1110
- effect(() => {
1131
+ bindEffect(el, () => {
1111
1132
  applyMatch();
1112
1133
  });
1113
1134
  }
@@ -1321,7 +1342,7 @@ function bindEach(root, ctx, cleanups) {
1321
1342
  }
1322
1343
  if (isSignal(binding)) {
1323
1344
  // Effect owned by templateScope — no manual stop needed
1324
- effect(() => {
1345
+ bindEffect(el, () => {
1325
1346
  const value = binding();
1326
1347
  renderList(Array.isArray(value) ? value : []);
1327
1348
  });
@@ -1373,7 +1394,7 @@ function bindIf(root, ctx, cleanups) {
1373
1394
  comment.parentNode?.insertBefore(htmlEl, comment);
1374
1395
  }
1375
1396
  // Then create reactive effect to keep it updated
1376
- effect(() => {
1397
+ bindEffect(htmlEl, () => {
1377
1398
  const value = !!resolve(binding);
1378
1399
  if (value) {
1379
1400
  if (!htmlEl.parentNode) {
@@ -1409,7 +1430,7 @@ function bindHtml(root, ctx, cleanups) {
1409
1430
  }
1410
1431
  const htmlEl = el;
1411
1432
  if (isSignal(binding)) {
1412
- effect(() => {
1433
+ bindEffect(htmlEl, () => {
1413
1434
  const v = binding();
1414
1435
  const html = v == null ? '' : String(v);
1415
1436
  if (isInDevMode() && /<script[\s>]|javascript:|onerror\s*=/i.test(html)) {
@@ -1482,7 +1503,7 @@ function bindAttrs(root, ctx, cleanups) {
1482
1503
  }
1483
1504
  el.removeAttribute(attr.name);
1484
1505
  if (isSignal(binding)) {
1485
- effect(() => {
1506
+ bindEffect(el, () => {
1486
1507
  applyAttr(el, attrName, binding());
1487
1508
  });
1488
1509
  }
@@ -1589,7 +1610,7 @@ function bindField(root, ctx, cleanups) {
1589
1610
  }
1590
1611
  // Setup reactive aria-invalid based on error state
1591
1612
  if ('error' in form && typeof form.error === 'function') {
1592
- effect(() => {
1613
+ bindEffect(htmlEl, () => {
1593
1614
  // Read current path from DOM attribute inside effect
1594
1615
  // This allows the effect to see updated paths after array reorder
1595
1616
  const currentPath = htmlEl.getAttribute('data-field-path') || htmlEl.getAttribute('name') || fieldPath;
@@ -1644,7 +1665,7 @@ function bindError(root, ctx, cleanups) {
1644
1665
  htmlEl.setAttribute('role', 'alert');
1645
1666
  htmlEl.setAttribute('aria-live', 'polite');
1646
1667
  // Reactive error display
1647
- effect(() => {
1668
+ bindEffect(htmlEl, () => {
1648
1669
  // Read current path from DOM attribute inside effect
1649
1670
  // This allows the effect to see updated paths after array reorder
1650
1671
  const currentPath = htmlEl.getAttribute('data-error-path') || fieldPath;
@@ -1689,7 +1710,7 @@ function bindFormError(root, ctx, cleanups) {
1689
1710
  htmlEl.setAttribute('role', 'alert');
1690
1711
  htmlEl.setAttribute('aria-live', 'polite');
1691
1712
  // Reactive form error display
1692
- effect(() => {
1713
+ bindEffect(htmlEl, () => {
1693
1714
  const errorMsg = form.formError();
1694
1715
  if (errorMsg) {
1695
1716
  htmlEl.textContent = errorMsg;
@@ -1978,7 +1999,7 @@ function bindArray(root, ctx, cleanups) {
1978
1999
  }
1979
2000
  }
1980
2001
  // Reactive rendering
1981
- effect(() => {
2002
+ bindEffect(el, () => {
1982
2003
  renderList();
1983
2004
  });
1984
2005
  // Bind array operation buttons
@@ -2080,6 +2101,7 @@ export function bind(root, ctx, options = {}) {
2080
2101
  // Create a scope for this template binding
2081
2102
  const templateScope = createScope();
2082
2103
  const cleanups = [];
2104
+ linkScopeToDom(templateScope, root, describeBindRoot(root));
2083
2105
  // Run all bindings within the template scope
2084
2106
  withScope(templateScope, () => {
2085
2107
  // 1. Form setup — must run very early to register form instances
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dalila",
3
- "version": "1.8.4",
3
+ "version": "1.9.0",
4
4
  "description": "DOM-first reactive framework based on signals",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -103,7 +103,9 @@
103
103
  "test": "npm run build && node --test",
104
104
  "test:e2e": "npm run build && playwright test",
105
105
  "test:watch": "jest --watch",
106
- "clean": "rm -rf dist"
106
+ "clean": "rm -rf dist",
107
+ "devtools:firefox:run": "npx web-ext run --source-dir devtools-extension --devtools",
108
+ "devtools:firefox:build": "npx web-ext build --source-dir devtools-extension --artifacts-dir devtools-extension/dist --overwrite-dest"
107
109
  },
108
110
  "keywords": [
109
111
  "framework",