what-devtools 0.8.4 → 0.10.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.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/src/index.js +149 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "what-devtools",
3
- "version": "0.8.4",
3
+ "version": "0.10.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.10.0"
24
24
  },
25
25
  "author": "ZVN DEV (https://zvndev.com)",
26
26
  "license": "MIT",
package/src/index.js CHANGED
@@ -27,6 +27,63 @@ const components = new Map(); // id → { name, element, mountedAt, parentId }
27
27
  // Reverse lookup: subscriber Set → signal ID (O(1) dep resolution)
28
28
  const subsToSignalId = new WeakMap();
29
29
 
30
+ // Set of known component function names (added on registerComponent, kept
31
+ // forever even after unmount so post-mortem signals still attribute).
32
+ // Used by attributeToComponent() to recognise component frames in stack traces.
33
+ const knownComponentNames = new Set();
34
+
35
+ // name -> Set<componentId>. When multiple components share a name we cannot
36
+ // disambiguate via stack; the heuristic falls back to the most-recently-mounted.
37
+ const componentsByName = new Map();
38
+
39
+ /**
40
+ * Heuristic attribution: parse new Error().stack to find the most recently
41
+ * called component frame. Returns a componentId or null.
42
+ *
43
+ * Why a stack heuristic instead of a hook:
44
+ * - We cannot add bracketing hooks to packages/core (compiler agent owns it).
45
+ * - `onComponentMount` fires before the component body runs; there's no
46
+ * `onComponentRendered` paired event.
47
+ * - Signal/effect creation happens DURING the component body, so its
48
+ * stack always contains a frame named after the component function.
49
+ *
50
+ * Limitations:
51
+ * - Anonymous components (no function name) cannot be matched.
52
+ * - Arrow components bound to a const get the const name in V8 stacks.
53
+ * - In production builds without source maps, function names may be mangled
54
+ * — but installDevTools should only run in dev anyway.
55
+ */
56
+ function attributeToComponent() {
57
+ if (knownComponentNames.size === 0) return null;
58
+ let stack;
59
+ try { throw new Error(); } catch (e) { stack = e.stack; }
60
+ if (!stack) return null;
61
+ // Walk stack from innermost to outermost. The FIRST component frame we
62
+ // hit is the deepest (currently-running) component.
63
+ const lines = stack.split('\n');
64
+ for (const rawLine of lines) {
65
+ const line = rawLine.trim();
66
+ if (!line.startsWith('at ')) continue;
67
+ // Extract function name token. Handles `at Foo`, `at Object.Foo`,
68
+ // `at new Foo`, `at Foo.bar`, `at Foo (file:line:col)`.
69
+ const m = line.match(/^at\s+(?:new\s+)?([\w$.]+)/);
70
+ if (!m) continue;
71
+ const tokens = m[1].split('.');
72
+ for (const tok of tokens) {
73
+ if (knownComponentNames.has(tok)) {
74
+ const ids = componentsByName.get(tok);
75
+ if (ids && ids.size > 0) {
76
+ // Most-recently-mounted wins.
77
+ let max = -1;
78
+ for (const id of ids) if (id > max) max = id;
79
+ return max;
80
+ }
81
+ }
82
+ }
83
+ }
84
+ return null;
85
+ }
86
+
30
87
  // Error log (capped at 100)
31
88
  const errors = [];
32
89
  const MAX_ERRORS = 100;
@@ -135,6 +192,9 @@ export function registerSignal(sig, name) {
135
192
  ref: sig,
136
193
  createdAt: Date.now(),
137
194
  internal: false,
195
+ // P1-6: attribute to the component currently executing, so what_explain
196
+ // can show component-local signals instead of always returning [].
197
+ componentId: attributeToComponent(),
138
198
  };
139
199
  signals.set(id, entry);
140
200
  sig._devId = id;
@@ -181,6 +241,8 @@ export function registerEffect(e, name) {
181
241
  depSignalIds: [],
182
242
  runCount: 0,
183
243
  lastRunAt: null,
244
+ // P1-6: attribute to the component currently executing.
245
+ componentId: attributeToComponent(),
184
246
  };
185
247
  effects.set(id, entry);
186
248
  e._devId = id;
@@ -241,6 +303,23 @@ function captureError(err, context) {
241
303
 
242
304
  /**
243
305
  * Register a component mount.
306
+ *
307
+ * TODO(P2-7) — stable component IDs across remounts.
308
+ * Currently `componentId` is a monotonic counter, so a view switch (or any
309
+ * conditional render) produces fresh IDs even when the same component
310
+ * remounts in the same slot. Agents that cache IDs across calls get burned.
311
+ *
312
+ * A stable scheme would key by (parent stable id + position + name), but
313
+ * that requires:
314
+ * - Tracking position within parent (currently only parentDevId is known).
315
+ * - Resolving collisions when two siblings have the same name.
316
+ * - Deciding how recursion (component-renders-itself) is handled.
317
+ * - Migrating every code path that assumes monotonic numeric IDs (devtools
318
+ * panel, registries, MCP bridge serialization, snapshot diffing).
319
+ *
320
+ * Verdict: the registry rewrite is invasive enough to warrant a dedicated
321
+ * change. For now, the MCP tool descriptions document the ephemerality
322
+ * (see what_components / what_explain) so agents re-query before using IDs.
244
323
  */
245
324
  export function registerComponent(name, element, parentDevId) {
246
325
  if (!installed) return;
@@ -253,6 +332,13 @@ export function registerComponent(name, element, parentDevId) {
253
332
  mountedAt: Date.now(),
254
333
  };
255
334
  components.set(id, entry);
335
+ // Index by name so attributeToComponent() can match stack frames.
336
+ if (name) {
337
+ knownComponentNames.add(name);
338
+ let set = componentsByName.get(name);
339
+ if (!set) { set = new Set(); componentsByName.set(name, set); }
340
+ set.add(id);
341
+ }
256
342
  emit('component:mounted', entry);
257
343
  return id;
258
344
  }
@@ -262,7 +348,17 @@ export function registerComponent(name, element, parentDevId) {
262
348
  */
263
349
  export function unregisterComponent(id) {
264
350
  if (!installed) return;
351
+ const entry = components.get(id);
265
352
  components.delete(id);
353
+ if (entry?.name) {
354
+ const set = componentsByName.get(entry.name);
355
+ if (set) {
356
+ set.delete(id);
357
+ // Keep knownComponentNames populated even after unmount — a re-mount
358
+ // of the same component should still attribute correctly, and
359
+ // attribution costs nothing when no live components match.
360
+ }
361
+ }
266
362
  emit('component:unmounted', { id });
267
363
  }
268
364
 
@@ -290,6 +386,7 @@ export function getSnapshot(opts = {}) {
290
386
  id,
291
387
  name: entry.name,
292
388
  value: entry.ref.peek(),
389
+ componentId: entry.componentId || null,
293
390
  });
294
391
  }
295
392
 
@@ -301,6 +398,7 @@ export function getSnapshot(opts = {}) {
301
398
  depSignalIds: entry.depSignalIds || [],
302
399
  runCount: entry.runCount || 0,
303
400
  lastRunAt: entry.lastRunAt || null,
401
+ componentId: entry.componentId || null,
304
402
  });
305
403
  }
306
404
 
@@ -357,15 +455,60 @@ export function installDevTools(core) {
357
455
  };
358
456
 
359
457
  // Wire into what-core's reactive system
458
+ function installInto(mod) {
459
+ if (!mod) return;
460
+ if (mod.__setDevToolsHooks) mod.__setDevToolsHooks(hooks);
461
+ if (typeof window !== 'undefined') window.__WHAT_CORE__ = mod;
462
+
463
+ // P1-9: drain anything created BEFORE installDevTools was called
464
+ // (e.g. module-scope signals in store.js imported before app.js).
465
+ // The placeholder hooks in reactive.js buffered creations; we now
466
+ // register them with the live devtools so what_signals can see them.
467
+ if (typeof mod.__drainPreinstallBuffer === 'function') {
468
+ try {
469
+ const drained = mod.__drainPreinstallBuffer();
470
+ for (const sig of drained.signals || []) {
471
+ // Avoid double-register if the placeholder somehow already passed
472
+ // through to hooks (defensive — should be impossible given the
473
+ // ordering in reactive.js).
474
+ if (sig._devId == null) registerSignal(sig);
475
+ }
476
+ for (const e of drained.effects || []) {
477
+ if (e._devId == null) {
478
+ registerEffect(e);
479
+ // The effect already ran once before install; populate its deps
480
+ // now so what_dependency_graph shows the edges immediately,
481
+ // instead of waiting for the next run to re-track.
482
+ const entry = effects.get(e._devId);
483
+ if (entry && Array.isArray(e.deps)) {
484
+ const depSignalIds = [];
485
+ for (const subSet of e.deps) {
486
+ const sigId = subsToSignalId.get(subSet);
487
+ if (sigId != null) depSignalIds.push(sigId);
488
+ }
489
+ entry.depSignalIds = depSignalIds;
490
+ entry.runCount = 1;
491
+ entry.lastRunAt = Date.now();
492
+ }
493
+ }
494
+ }
495
+ for (const ctx of drained.components || []) {
496
+ if (ctx._devId == null) hooks.onComponentMount(ctx);
497
+ }
498
+ } catch (err) {
499
+ // Non-fatal — pre-install drain is best-effort.
500
+ if (typeof console !== 'undefined') {
501
+ console.warn('[what-devtools] pre-install drain failed:', err);
502
+ }
503
+ }
504
+ }
505
+ }
506
+
360
507
  if (core && core.__setDevToolsHooks) {
361
- core.__setDevToolsHooks(hooks);
362
- if (typeof window !== 'undefined') window.__WHAT_CORE__ = core;
508
+ installInto(core);
363
509
  } else {
364
510
  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(() => {});
511
+ import('what-core').then(installInto).catch(() => {});
369
512
  } catch {}
370
513
  }
371
514