Index · Previous: Testing · Next: Performance
Experimental. This package is new and its shape is still moving. The names, the returned structures and the panel will change without a major version while that is true, and nothing else in the framework depends on it.
Fine-grained reactivity is pleasant in the small and opaque in the large. Small means you can hold the graph in your head. Large means somebody asks why a button is disabled, and the honest answer is “something wrote something”.
npm install --save-dev @firsthandjs/devtools
// main.tsx, above everything else
import { attach } from '@firsthandjs/devtools';
if (import.meta.env.DEV) {
attach();
}
That is the whole setup.
When the page loads it says so, once:
[firsthand] devtools attached — press Ctrl+Shift+F for the panel,
or call __FIRSTHAND__.panel()
Ctrl+Shift+F opens and closes the panel. __FIRSTHAND__.panel() does the
same from the console, and __FIRSTHAND__.panel($0) opens it on whatever the
Elements panel has selected.
A panel opens in the corner of the page. Press Pick, click the element that is wrong, and the answer is drawn:
App › OrderPage › SaveButton;Two more tabs: Timeline, every update in the page with a bar for its reach — click one to see what it woke — and Queries, what the cache has been doing.
It draws itself in a shadow root with all: initial, so the page’s stylesheet
cannot reach it and its own cannot reach the page — an inspector that changes
what it is inspecting is worse than none. The panel’s code is behind a dynamic
import, so a session that never opens it never downloads it.
You cannot write import { chain } from '@firsthandjs/devtools' in a browser
console: a bare specifier has no resolver there, and in a bundled application
the module is inside the bundle. So attach() puts the whole API on
globalThis instead, spelled to pair with $0 — the element the Elements
panel has selected:
__FIRSTHAND__.chain($0); // the chain for whatever you clicked in Elements
__FIRSTHAND__.panel($0); // or open the panel on it
__FIRSTHAND__.queries(); // what the cache did
This is plain JavaScript in the console: no types, no autocompletion for the
arguments. The panel is the better tool for looking around; the console is the
better one when you know exactly what you want, or want to filter the result.
The same functions are importable in a test or a module, where the types do apply:
import { chain } from '@firsthandjs/devtools';
__FIRSTHAND__.chain($0);
status (order.ts:12)
↓
computed(isEditable)
↓
button.disabled
Read it bottom-up: the disabled property of that button is written by a part,
the part reads a computed called isEditable, and that computed reads a signal
called status, declared on line 12. Three links, and the question is
answered.
chain draws one path — the longest — because a chain is a story. When a part
reads several sources, inspect has the rest.
__FIRSTHAND__.causeOf($0); // 'status (order.ts:12)'
What changed to make the part run. null means it has not run since anything
changed — which is itself an answer, and usually the surprising one.
This is the only thing devtools record rather than read. The graph keeps no history, because nothing needs it once the update is over.
__FIRSTHAND__.inspect($0);
// [{ kind: 'part', name: 'button.disabled', value: true,
// dependencies: [{ kind: 'computed', name: 'isEditable', … }],
// dependents: [] }]
Each entry is a part that writes the node, with what it reads, and what reads
that, as far as you ask (inspect(node, 12)). Both directions are there:
dependents answers “what else would change if I touched this”.
cells() is the same shape for everything currently alive, walked from the
roots through the owner tree.
Under a development server the compiler labels each cell with the variable that
holds it and the line it was written on — v (main.tsx:9). That is not
cosmetic. A runtime cannot see the name at all, and new Error().stack reports
a position in the compiled module, because browsers do not apply source
maps to error.stack. The compiler knows both, so it says both, and a
production build emits none of it.
The compiler also produces a source map now, so a debugger shows the JSX that was written rather than the templates and protocol calls it became.
The call stack in the panel is read back through that same map. A frame arrives
as a position in the compiled module — main.tsx:8:5, a line the author never
wrote — and the panel fetches the module, decodes the map it already carries,
and shows main.tsx:5:3 instead: the line you would find by opening the file.
A frame it cannot resolve is shown as it came, rather than guessed at.
| You see | It is |
|---|---|
button.disabled |
A part writing that property of that element |
p.text |
A part writing text into that element |
computed(isEditable) |
A computed whose function has a name |
user.address.city |
A property of deep state |
todos.length |
An array’s length, inside deep state |
keys |
Whether the set of keys changed — Object.keys, for…in |
status (order.ts:12) |
A signal, named by the compiler after the variable |
order.ts:12:19 |
Created where the compiler could not see it |
The last row is the fallback: a runtime cannot see that const count =
signal(0) is called count, so a cell created before attach() ran, or
outside a compiled module, is named by where it was created instead. The panel
reads that position back through the source map, the same as a stack frame. In
the data it is kept whole — the served URL and the compiled position — because
that is what resolving it needs.
Naming a computed or an effect is therefore worth the keystrokes when you
expect to be debugging it:
const isEditable = computed(function isEditable() {
return order.status.value === 'draft';
});
| What you see | What it means |
|---|---|
| A blue SIGNAL box | A source: someone writes it |
| A purple COMPUTED box | Derived, and memoised |
| An amber PART box | The DOM write at the end of the path |
| An outlined box | This is what triggered the last run |
| A blue bar in a timeline row | How much that write woke |
| A grey bar in a timeline row | That write woke nothing at all |
| A dim position after the bar | Where the write came from |
The path runs top to bottom, because that is the direction the value travels.
The bar is how many parts the write woke, drawn against the busiest update in view — so it compares writes to each other rather than to an absolute, and a filtered timeline redraws to its own scale. Hovering says the two numbers outright. It is there to separate, at a glance, the write that rebuilt half the page from the one that woke nothing.
__FIRSTHAND__.stack($0); // ['App', 'OrderPage', 'SaveButton']
Outermost first. The owner tree already has the shape — a component’s scope is the parent of everything its setup created — so this is a walk rather than a recording; the DOM layer only contributes the name, which it knows at the moment an instance is created.
The panel shows it above the chain, so picking an element tells you where in the application you are as well as what feeds it.
__FIRSTHAND__.timeline(); // everything, oldest first
__FIRSTHAND__.timeline($0); // only the updates that ran this node's part
[{ at: 1843, source: 'status (order.ts:12)', ran: ['button.disabled', 'p.text'] },
{ at: 2044, source: 'draft (order.ts:31)', ran: [] }]
Each entry is one write and everything that ran because of it, in order. The graph answers “what depends on this”; the timeline answers “what happened”, which is the question when something updated and nobody expected it to — or when nothing did.
That second entry is the case worth knowing: a write with an empty ran woke
nothing at all. In the panel its bar is grey, and it is very often the answer to
“why is the screen not changing?” — because nothing was reading that signal.
The last 100 updates, so a page left open overnight is still a debugging tool rather than a leak.
Invalidation is the one part of the framework whose behaviour is not in the reactive graph. A tag match is a decision, not an edge — so an invalidation that matched nothing looks exactly like one that was never sent.
__FIRSTHAND__.queries().filter((e) => e.event === 'invalidated');
// [{ event: 'invalidated', key: 'order|…', tags: ['order(id: 7)'] }]
Created, invalidated and dropped, oldest first, with the tags each resource carries. When an action does not refresh what you expected, this is where the answer is: either the invalidation is not there, or its tags did not match the ones the resource carries.
The last 200 events, so that a long session does not turn a debugging tool into a memory leak.
Properties of a deepSignal are named by their
path, because a version cell on its own says nothing — a hundred objects all
have a name.
const state = deepSignal({ user: { address: { city: 'Cambridge' } } });
// reading state.user.address.city subscribes to `user.address.city`
One thing surprises people, and devtools show it plainly: reading
state.todos.length subscribes to two things — the todos property of the
root, and the array’s own length. Both appear in dependencies, which is the
answer to “why did this update when I replaced the whole array?”.
attach() works anywhere, so an assertion about the graph is an ordinary test:
import { attach, detach, inspect } from '@firsthandjs/devtools';
it('subscribes to exactly one property', () => {
attach();
render(() => <p>{state.user.name}</p>, host);
expect(inspect(host.querySelector('p'))[0]?.dependencies.map((d) => d.name)).toEqual([
'user.name',
]);
detach();
});
That is a real use, not a demonstration: “this part reads more than it should” is a performance bug that is otherwise invisible until the profile is taken.
The framework’s side of it ships nothing. The hooks devtools read live in modules the production build replaces with empty functions, so a shipped bundle contains neither that code nor its message strings — whether or not you use devtools at all.
This package is not stripped, though. It is an ordinary module, so an
unconditional import puts it in your production bundle. Import it behind a
dev-only guard if you do not want that:
// src/devtools.ts — imported first by the entry module
if (import.meta.env.DEV) {
const { attach } = await import('@firsthandjs/devtools');
attach();
}
Its own module, and imported first, because imports are hoisted: attach()
written among the imports of your entry module would run after every one of
them had been evaluated, and anything created during that evaluation would be
recorded without a name.
Of the package, an import pulls in 1.96 kB gzip; the panel is a further 4.83 kB, and only if it is opened.
The measured exception, because this documentation does not round numbers away:
the calls to those empty functions cost 32 bytes minified and 9 gzip across
the whole runtime, since a bundler cannot prove a call has no effect. Nothing
measurable beyond that: bench:ic still reports mixed cell shapes at 1.02× a
single shape, and mounting 2000 components takes the same 14.00 ms as a build
with the call sites removed outright.
In development, nothing until attach(). Naming every cell costs a
WeakMap write, and a hundred thousand rows would feel that — which is exactly
the size at which devtools matter most, so the cost is opt-in rather than
merely small.
Call it before the application creates anything. A cell created earlier still works and still appears; it is simply unnamed.
Next: Performance — what is fast by construction, what is not, and how to measure it.