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 +2 -2
- package/src/DevPanel.jsx +29 -9
- package/src/index.js +190 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "what-devtools",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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
|
|
362
|
-
if (typeof window !== 'undefined') window.__WHAT_CORE__ = core;
|
|
546
|
+
installInto(core);
|
|
363
547
|
} else {
|
|
364
548
|
try {
|
|
365
|
-
import('what-core').then(
|
|
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
|
|