what-devtools 0.8.4 → 0.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "what-devtools",
3
- "version": "0.8.4",
3
+ "version": "0.11.0",
4
4
  "description": "Dev tools for What Framework — signal inspector, component tree, effect graph",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -20,7 +20,7 @@
20
20
  "inspector"
21
21
  ],
22
22
  "peerDependencies": {
23
- "what-core": "^0.8.4"
23
+ "what-core": "^0.11.0"
24
24
  },
25
25
  "author": "ZVN DEV (https://zvndev.com)",
26
26
  "license": "MIT",
package/src/DevPanel.jsx CHANGED
@@ -20,12 +20,25 @@
20
20
  */
21
21
 
22
22
  import { signal, effect, onCleanup } from 'what-core';
23
- import { subscribe, getSnapshot, getErrors, installDevTools } from './index.js';
23
+ import { subscribe, getSnapshot, getErrors, installDevTools, _suppressDevtools } from './index.js';
24
24
 
25
25
  export function DevPanel() {
26
26
  // Auto-install devtools if not already done
27
27
  installDevTools();
28
28
 
29
+ // The panel's ENTIRE body runs with devtools registration suppressed —
30
+ // the panel must not appear in its own signal/effect lists. More
31
+ // importantly: if panel-internal effects registered, every panel re-render
32
+ // would emit effect:created events, the subscribe() callback below would
33
+ // write `snapshot`, that write would re-render the panel, creating more
34
+ // effects → an unbounded feedback loop that crashes the page.
35
+ // The two bindings that (re)create DOM subtrees after mount (the isOpen
36
+ // panel toggle and the tab-content switch) carry their own _suppressDevtools
37
+ // wrappers, because their re-runs happen outside this bracket.
38
+ return _suppressDevtools(() => DevPanelBody());
39
+ }
40
+
41
+ function DevPanelBody() {
29
42
  const isOpen = signal(false);
30
43
  const activeTab = signal('overview');
31
44
  const snapshot = signal(getSnapshot());
@@ -291,7 +304,11 @@ export function DevPanel() {
291
304
  };
292
305
 
293
306
  return (
294
- <>
307
+ // display:contents wrapper instead of a fragment — the babel plugin
308
+ // currently miscompiles top-level fragments whose element children carry
309
+ // event handlers (it references _el$N bindings it never emits). The
310
+ // wrapper is layout-neutral; both children are position:fixed anyway.
311
+ <div style="display:contents">
295
312
  {/* Toggle button with health indicator */}
296
313
  <button
297
314
  onclick={() => isOpen((v) => !v)}
@@ -312,9 +329,11 @@ export function DevPanel() {
312
329
  W
313
330
  </button>
314
331
 
315
- {/* Panel -- conditionally rendered */}
332
+ {/* Panel -- conditionally rendered. _suppressDevtools: opening the
333
+ panel instantiates this whole subtree (dozens of bindings) — none
334
+ of them may register with devtools (see DevPanel docblock). */}
316
335
  {() =>
317
- isOpen() ? (
336
+ _suppressDevtools(() => isOpen() ? (
318
337
  <div style={PANEL_STYLE}>
319
338
  {/* Header */}
320
339
  <div style="display:flex;align-items:center;justify-content:space-between;padding:8px 12px;border-bottom:1px solid #2a2a4a;background:#16163a;">
@@ -373,9 +392,10 @@ export function DevPanel() {
373
392
  </button>
374
393
  </div>
375
394
 
376
- {/* Content */}
395
+ {/* Content. _suppressDevtools: tab switches and snapshot
396
+ updates rebuild this subtree — keep it out of devtools. */}
377
397
  <div style="overflow-y:auto;flex:1;">
378
- {() => {
398
+ {() => _suppressDevtools(() => {
379
399
  const tab = activeTab();
380
400
  if (tab === 'overview') return renderOverview();
381
401
  if (tab === 'signals') return renderSignals();
@@ -383,12 +403,12 @@ export function DevPanel() {
383
403
  if (tab === 'components') return renderComponents();
384
404
  if (tab === 'errors') return renderErrors();
385
405
  return renderOverview();
386
- }}
406
+ })}
387
407
  </div>
388
408
  </div>
389
- ) : null
409
+ ) : null)
390
410
  }
391
- </>
411
+ </div>
392
412
  );
393
413
  }
394
414
 
package/src/index.js CHANGED
@@ -19,6 +19,11 @@ let signalId = 0;
19
19
  let effectId = 0;
20
20
  let componentId = 0;
21
21
 
22
+ // what-core's installSignalReadGuardrail, captured when the core module is
23
+ // wired up in installDevTools(). Dev-only: warns when a signal is coerced
24
+ // to a string/number without being called (e.g. `Total: ${count}`).
25
+ let coreSignalReadGuardrail = null;
26
+
22
27
  // Registries
23
28
  const signals = new Map(); // id → { name, ref, createdAt, internal }
24
29
  const effects = new Map(); // id → { name, createdAt, depSignalIds, runCount, lastRunAt }
@@ -27,6 +32,86 @@ const components = new Map(); // id → { name, element, mountedAt, parentId }
27
32
  // Reverse lookup: subscriber Set → signal ID (O(1) dep resolution)
28
33
  const subsToSignalId = new WeakMap();
29
34
 
35
+ // Set of known component function names (added on registerComponent, kept
36
+ // forever even after unmount so post-mortem signals still attribute).
37
+ // Used by attributeToComponent() to recognise component frames in stack traces.
38
+ const knownComponentNames = new Set();
39
+
40
+ // name -> Set<componentId>. When multiple components share a name we cannot
41
+ // disambiguate via stack; the heuristic falls back to the most-recently-mounted.
42
+ const componentsByName = new Map();
43
+
44
+ /**
45
+ * Heuristic attribution: parse new Error().stack to find the most recently
46
+ * called component frame. Returns a componentId or null.
47
+ *
48
+ * Why a stack heuristic instead of a hook:
49
+ * - We cannot add bracketing hooks to packages/core (compiler agent owns it).
50
+ * - `onComponentMount` fires before the component body runs; there's no
51
+ * `onComponentRendered` paired event.
52
+ * - Signal/effect creation happens DURING the component body, so its
53
+ * stack always contains a frame named after the component function.
54
+ *
55
+ * Limitations:
56
+ * - Anonymous components (no function name) cannot be matched.
57
+ * - Arrow components bound to a const get the const name in V8 stacks.
58
+ * - In production builds without source maps, function names may be mangled
59
+ * — but installDevTools should only run in dev anyway.
60
+ */
61
+ function attributeToComponent() {
62
+ if (knownComponentNames.size === 0) return null;
63
+ let stack;
64
+ try { throw new Error(); } catch (e) { stack = e.stack; }
65
+ if (!stack) return null;
66
+ // Walk stack from innermost to outermost. The FIRST component frame we
67
+ // hit is the deepest (currently-running) component.
68
+ const lines = stack.split('\n');
69
+ for (const rawLine of lines) {
70
+ const line = rawLine.trim();
71
+ if (!line.startsWith('at ')) continue;
72
+ // Extract function name token. Handles `at Foo`, `at Object.Foo`,
73
+ // `at new Foo`, `at Foo.bar`, `at Foo (file:line:col)`.
74
+ const m = line.match(/^at\s+(?:new\s+)?([\w$.]+)/);
75
+ if (!m) continue;
76
+ const tokens = m[1].split('.');
77
+ for (const tok of tokens) {
78
+ if (knownComponentNames.has(tok)) {
79
+ const ids = componentsByName.get(tok);
80
+ if (ids && ids.size > 0) {
81
+ // Most-recently-mounted wins.
82
+ let max = -1;
83
+ for (const id of ids) if (id > max) max = id;
84
+ return max;
85
+ }
86
+ }
87
+ }
88
+ }
89
+ return null;
90
+ }
91
+
92
+ // --- Self-tracking suppression ---
93
+ // Devtools UI rendered INSIDE the inspected app (the DevPanel) must not
94
+ // register its own signals/effects: panel-internal registrations emit
95
+ // devtools events, the panel reacts by updating its snapshot signal, the
96
+ // resulting re-render creates more effects, which emit again — an unbounded
97
+ // feedback loop that wedges and crashes the page. Re-entrant (counter).
98
+ let suppressDepth = 0;
99
+
100
+ /**
101
+ * Run fn with devtools registration suppressed (exception-safe).
102
+ * Signals/effects/components created inside fn are invisible to devtools.
103
+ * Does NOT affect what-core's reactive dependency tracking.
104
+ * @internal Used by DevPanel; exported for devtools-adjacent UIs and tests.
105
+ */
106
+ export function _suppressDevtools(fn) {
107
+ suppressDepth++;
108
+ try {
109
+ return fn();
110
+ } finally {
111
+ suppressDepth--;
112
+ }
113
+ }
114
+
30
115
  // Error log (capped at 100)
31
116
  const errors = [];
32
117
  const MAX_ERRORS = 100;
@@ -127,7 +212,7 @@ export function safeSerialize(value, depth = 0, seen) {
127
212
  * Called from reactive.js __DEV__ hooks.
128
213
  */
129
214
  export function registerSignal(sig, name) {
130
- if (!installed) return;
215
+ if (!installed || suppressDepth > 0) return;
131
216
  const id = ++signalId;
132
217
  const entry = {
133
218
  id,
@@ -135,11 +220,19 @@ export function registerSignal(sig, name) {
135
220
  ref: sig,
136
221
  createdAt: Date.now(),
137
222
  internal: false,
223
+ // P1-6: attribute to the component currently executing, so what_explain
224
+ // can show component-local signals instead of always returning [].
225
+ componentId: attributeToComponent(),
138
226
  };
139
227
  signals.set(id, entry);
140
228
  sig._devId = id;
141
229
  // Reverse lookup for O(1) effect dep resolution
142
230
  if (sig._subs) subsToSignalId.set(sig._subs, id);
231
+ // Dev guardrail: warn when this signal is string/number-coerced without
232
+ // being called (catches `Total: ${count}` — should be `${count()}`).
233
+ if (coreSignalReadGuardrail) {
234
+ try { coreSignalReadGuardrail(sig, entry.name); } catch {}
235
+ }
143
236
  emit('signal:created', entry);
144
237
  return id;
145
238
  }
@@ -172,7 +265,7 @@ export function unregisterSignal(sig) {
172
265
  * Register an effect with the devtools.
173
266
  */
174
267
  export function registerEffect(e, name) {
175
- if (!installed) return;
268
+ if (!installed || suppressDepth > 0) return;
176
269
  const id = ++effectId;
177
270
  const entry = {
178
271
  id,
@@ -181,6 +274,8 @@ export function registerEffect(e, name) {
181
274
  depSignalIds: [],
182
275
  runCount: 0,
183
276
  lastRunAt: null,
277
+ // P1-6: attribute to the component currently executing.
278
+ componentId: attributeToComponent(),
184
279
  };
185
280
  effects.set(id, entry);
186
281
  e._devId = id;
@@ -241,9 +336,26 @@ function captureError(err, context) {
241
336
 
242
337
  /**
243
338
  * Register a component mount.
339
+ *
340
+ * TODO(P2-7) — stable component IDs across remounts.
341
+ * Currently `componentId` is a monotonic counter, so a view switch (or any
342
+ * conditional render) produces fresh IDs even when the same component
343
+ * remounts in the same slot. Agents that cache IDs across calls get burned.
344
+ *
345
+ * A stable scheme would key by (parent stable id + position + name), but
346
+ * that requires:
347
+ * - Tracking position within parent (currently only parentDevId is known).
348
+ * - Resolving collisions when two siblings have the same name.
349
+ * - Deciding how recursion (component-renders-itself) is handled.
350
+ * - Migrating every code path that assumes monotonic numeric IDs (devtools
351
+ * panel, registries, MCP bridge serialization, snapshot diffing).
352
+ *
353
+ * Verdict: the registry rewrite is invasive enough to warrant a dedicated
354
+ * change. For now, the MCP tool descriptions document the ephemerality
355
+ * (see what_components / what_explain) so agents re-query before using IDs.
244
356
  */
245
357
  export function registerComponent(name, element, parentDevId) {
246
- if (!installed) return;
358
+ if (!installed || suppressDepth > 0) return;
247
359
  const id = ++componentId;
248
360
  const entry = {
249
361
  id,
@@ -253,6 +365,13 @@ export function registerComponent(name, element, parentDevId) {
253
365
  mountedAt: Date.now(),
254
366
  };
255
367
  components.set(id, entry);
368
+ // Index by name so attributeToComponent() can match stack frames.
369
+ if (name) {
370
+ knownComponentNames.add(name);
371
+ let set = componentsByName.get(name);
372
+ if (!set) { set = new Set(); componentsByName.set(name, set); }
373
+ set.add(id);
374
+ }
256
375
  emit('component:mounted', entry);
257
376
  return id;
258
377
  }
@@ -262,7 +381,17 @@ export function registerComponent(name, element, parentDevId) {
262
381
  */
263
382
  export function unregisterComponent(id) {
264
383
  if (!installed) return;
384
+ const entry = components.get(id);
265
385
  components.delete(id);
386
+ if (entry?.name) {
387
+ const set = componentsByName.get(entry.name);
388
+ if (set) {
389
+ set.delete(id);
390
+ // Keep knownComponentNames populated even after unmount — a re-mount
391
+ // of the same component should still attribute correctly, and
392
+ // attribution costs nothing when no live components match.
393
+ }
394
+ }
266
395
  emit('component:unmounted', { id });
267
396
  }
268
397
 
@@ -290,6 +419,7 @@ export function getSnapshot(opts = {}) {
290
419
  id,
291
420
  name: entry.name,
292
421
  value: entry.ref.peek(),
422
+ componentId: entry.componentId || null,
293
423
  });
294
424
  }
295
425
 
@@ -301,6 +431,7 @@ export function getSnapshot(opts = {}) {
301
431
  depSignalIds: entry.depSignalIds || [],
302
432
  runCount: entry.runCount || 0,
303
433
  lastRunAt: entry.lastRunAt || null,
434
+ componentId: entry.componentId || null,
304
435
  });
305
436
  }
306
437
 
@@ -357,15 +488,65 @@ export function installDevTools(core) {
357
488
  };
358
489
 
359
490
  // Wire into what-core's reactive system
491
+ function installInto(mod) {
492
+ if (!mod) return;
493
+ // Capture the guardrail BEFORE hooks go live so the very first
494
+ // registrations (and the pre-install drain below) get wrapped too.
495
+ if (typeof mod.installSignalReadGuardrail === 'function') {
496
+ coreSignalReadGuardrail = mod.installSignalReadGuardrail;
497
+ }
498
+ if (mod.__setDevToolsHooks) mod.__setDevToolsHooks(hooks);
499
+ if (typeof window !== 'undefined') window.__WHAT_CORE__ = mod;
500
+
501
+ // P1-9: drain anything created BEFORE installDevTools was called
502
+ // (e.g. module-scope signals in store.js imported before app.js).
503
+ // The placeholder hooks in reactive.js buffered creations; we now
504
+ // register them with the live devtools so what_signals can see them.
505
+ if (typeof mod.__drainPreinstallBuffer === 'function') {
506
+ try {
507
+ const drained = mod.__drainPreinstallBuffer();
508
+ for (const sig of drained.signals || []) {
509
+ // Avoid double-register if the placeholder somehow already passed
510
+ // through to hooks (defensive — should be impossible given the
511
+ // ordering in reactive.js).
512
+ if (sig._devId == null) registerSignal(sig);
513
+ }
514
+ for (const e of drained.effects || []) {
515
+ if (e._devId == null) {
516
+ registerEffect(e);
517
+ // The effect already ran once before install; populate its deps
518
+ // now so what_dependency_graph shows the edges immediately,
519
+ // instead of waiting for the next run to re-track.
520
+ const entry = effects.get(e._devId);
521
+ if (entry && Array.isArray(e.deps)) {
522
+ const depSignalIds = [];
523
+ for (const subSet of e.deps) {
524
+ const sigId = subsToSignalId.get(subSet);
525
+ if (sigId != null) depSignalIds.push(sigId);
526
+ }
527
+ entry.depSignalIds = depSignalIds;
528
+ entry.runCount = 1;
529
+ entry.lastRunAt = Date.now();
530
+ }
531
+ }
532
+ }
533
+ for (const ctx of drained.components || []) {
534
+ if (ctx._devId == null) hooks.onComponentMount(ctx);
535
+ }
536
+ } catch (err) {
537
+ // Non-fatal — pre-install drain is best-effort.
538
+ if (typeof console !== 'undefined') {
539
+ console.warn('[what-devtools] pre-install drain failed:', err);
540
+ }
541
+ }
542
+ }
543
+ }
544
+
360
545
  if (core && core.__setDevToolsHooks) {
361
- core.__setDevToolsHooks(hooks);
362
- if (typeof window !== 'undefined') window.__WHAT_CORE__ = core;
546
+ installInto(core);
363
547
  } else {
364
548
  try {
365
- import('what-core').then(mod => {
366
- if (mod.__setDevToolsHooks) mod.__setDevToolsHooks(hooks);
367
- if (typeof window !== 'undefined') window.__WHAT_CORE__ = mod;
368
- }).catch(() => {});
549
+ import('what-core').then(installInto).catch(() => {});
369
550
  } catch {}
370
551
  }
371
552