Guides
DevTools for Packages
registerDevtools for packages (describe, inspect, sources, commands, panels, events, reveal), operations and write records; the core side of a package's own DevTools panel.
A package (a component library, a data layer, a store) can show its own state in DevTools without depending on a DevTools UI: it registers with the core, and the UI reads what it provides. This page covers the core side: what registerDevtools takes, what its handle does, and how a package says which piece of work changed what. The object the UI reads is on The DevTools Contract page. What the @motifx-community/devtools panel draws from panel metadata, its widgets, routes and limits are described in that package's docs/extensions.md file, not here.
registerDevtools for packages
A package opens its own state from the @motifx/core/devtools subpath. describe and inspect provide data; with sources, commands and panels the UI reads when it wants to, sends commands to the page and knows what to draw. In the example, the folder chart from the Wrapping Third-Party Libraries page keeps the charts it builds by component and gives their data to DevTools:
import { registerDevtools } from "@motifx/core/devtools";
import type { ComponentBase } from "@motifx/core";
import type { Chart } from "chart.js/auto";
const charts = new Map<object, Chart>();
const devtools = registerDevtools("folder-chart", {
describe: () => ({ charts: charts.size }),
inspect: (component) => charts.get(component)?.data.datasets[0].data,
sources: {
charts: { read: () => [...charts.values()].map(summaryOf), key: "id", events: { update: "refresh" } },
chart: { read: (params: { id: string }) => detailOf(params.id) },
},
commands: {
redraw: (args: { id: string }) => chartById(args.id)?.update(),
},
panels: [{
id: "charts", title: "Folder charts", icon: "chart", order: 40,
capabilities: { read: true, subscribe: true, write: true },
layout: { /* what the UI draws; its own schema */ },
}],
});
function track(canvas: ComponentBase, chart: Chart) {
charts.set(canvas, chart);
canvas.motif.setDisposable(() => charts.delete(canvas));
}
function updated(canvas: ComponentBase, counts: number[]) {
devtools.emit("update", { path: devtools.pathOf(canvas), counts });
devtools.changed("charts");
}
import { registerDevtools } from "@motifx/core/devtools";
const charts = new Map();
const devtools = registerDevtools("folder-chart", {
describe: () => ({ charts: charts.size }),
inspect: (component) => charts.get(component)?.data.datasets[0].data,
sources: {
charts: { read: () => [...charts.values()].map(summaryOf), key: "id", events: { update: "refresh" } },
chart: { read: (params) => detailOf(params.id) },
},
commands: {
redraw: (args) => chartById(args.id)?.update(),
},
panels: [{
id: "charts", title: "Folder charts", icon: "chart", order: 40,
capabilities: { read: true, subscribe: true, write: true },
layout: { /* what the UI draws; its own schema */ },
}],
});
function track(canvas, chart) {
charts.set(canvas, chart);
canvas.motif.setDisposable(() => charts.delete(canvas));
}
function updated(canvas, counts) {
devtools.emit("update", { path: devtools.pathOf(canvas), counts });
devtools.changed("charts");
}
- Registration can happen at any time; a package can register the moment it is imported.
describe,inspect,sources,commandsandpanelsgiven as a function are called only when the UI asks. - The result of
describe()is read withreadExtension(name), and the result ofinspect(component)insideinspect(path).extensions[name]. A package that returnsundefinedis not listed for that component. - The result of a function that throws becomes
{ error }(describe,inspect) or{ ok: false, error }(sources, commands,panels); the other data is not affected. panelssays what the UI will draw for this package:id,title,icon,order,capabilities, and whatever else the UI's own schema defines (layout,routes). The core stores it as it is and reads onlyidandcapabilities. If it is given as a function, it is called on every read.readExtensionPanels(name)returns{ panels, sources, commands }: the panels, the settings of each source other than itsread(key,events,max), and the command names.sourcesare named functions the UI reads with parameters.readExtensionSource(name, panel, source, params)awaitsread(params)and returns{ ok: true, value }.keynames the field that identifies a row,eventsmaps the package's own event types to what the UI will do with them (upsert,append,remove,refresh), andmaxlimits the number of rows the UI keeps; the core carries these three to the UI and does not use them itself.commandsare named functions the UI runs withcallExtension. A command can return a promise.capabilitiesis read per panel on every read and call:readallowsreadExtensionSource,writeallowscallExtension, andsubscribelets the package's events through. A field that is not written isfalse. Denials have the form{ ok: false, error }and carrynot allowed,no extension,no sourceorno command. Only own keys exist insourcesandcommands;constructorand__proto__are not there.emit(type, payload)emits the event under the nameext:<name>:<type>; it does nothing while DevTools is off, nor when the registration haspanelsand none of them hassubscribe. A registration withoutpanelsemits as before.changed(source?)emitsextension:changedso that the UI reads the panels (without an argument) or that source again;reveal(target)emitsextension:revealwith{ panel, route?, params?, state? }. Both do so only while DevTools is on and while the registration is the current registration for its name.pathOf(component)gives the component's DevTools path.dispose()removes the registration and emitsextension:disposed. A new registration under the same name replaces the old one; the old registration'sdispose()does not delete the new one. A registration made while DevTools is on emitsextension:registered.getExtensions()lists the names of the registered packages.
Everything the core does not read (the shape of a panel apart from id and capabilities, the meaning of key, events and max, the content of a reveal target) is a contract between the UI and the package; the core carries it as it is.
Operations and write records
@motifx/core/devtools also exports what a package needs to say which piece of work changed what: operations and write records. They are for packages that keep history, draw a timeline or explain a change; application code has no need for them.
An operation is the work running at the moment: a click handler, a navigation, a package's action. It is a single value the core holds while a function is running, just as it holds the running effect. It is not tied to the application or the component tree, it is not passed down, and components do not see it. Writes, flush triggers and operation events carry its id.
import { runOperation, startOperation, resumeOperation, endOperation, currentOperation } from "@motifx/core/devtools";
runOperation({ kind: "action", name: "cart/add", data: { id: 7 } }, () => {
cart.items.push(item); // written inside this operation
});
await runOperation({ kind: "action", name: "orders/load" }, async () => {
const operation = currentOperation();
const rows = await api.orders();
resumeOperation(operation, () => { state.orders = rows; });
});
const operation = startOperation({ kind: "action", name: "chat/send" });
resumeOperation(operation, () => { state.sending = true; });
socket.onAnswer(() => resumeOperation(operation, () => {
state.sending = false;
endOperation(operation);
}));
startOperation(info)gives{ id, kind, name?, data?, parent? }. The core provides theid(an increasing number) and theparent(the operation running at that moment); no other field is copied frominfo.kindis any text the package chooses.resumeOperation(operation, fn)makes it the current operation whilefnruns and puts the previous one back;nullrunsfnoutside any operation. It can be called as many times as needed.endOperation(operation, error?)ends it once; later calls do nothing. Resuming an operation that has ended still records under it, and warns once in development (MJX211).runOperation(info, fn)is the three in a single call. Iffnreturns a promise, the operation ends when the promise settles, andrunOperationreturns a promise that follows it: the same value, the same rejection. While DevTools is off, it returns the result offnas it is.- JavaScript carries no value across an
await; after anawaitthere is no current operation, and the code above brings it back withresumeOperation. Do not rely on whatcurrentOperation()gives after anawait. operation:startedcarries the operation, andoperation:finishedcarries{ id, duration, error? }; both only while DevTools is on.startOperation,resumeOperationandendOperationstay in production too; their cost is an assignment.
onWrite(fn) reports every reactive write while DevTools is on, and returns a function that stops it. Every record has a target (the raw object, array, Map, Set or signal) and, if it happened inside an operation, an operationId:
kind |
When | Fields |
|---|---|---|
add |
a new key on an object, an index past the end of an array, a new key in a Map or a new value in a Set |
key, value |
update |
a changed value: an object key, an array index, a growing length, a Map value, signal.value (key: 'value') |
key, previous, value |
delete |
delete obj.key, map.delete(k), set.delete(v) |
key, previous |
clear |
map.clear(), set.clear() |
removed: the map's [key, value] pairs, the set's values |
splice |
push, pop, shift, unshift, splice, sort, reverse, a shrinking length |
method, index, removed, added |
mutate |
signal.mutate(fn); the core cannot see what fn changed |
ā |
- Values arrive as they are stored: references, not copies. A listener that wants a snapshot takes it itself.
- A write that changes nothing (the same value again, a
sortthat keeps the order) is not reported. An array method is a single record; the index writes it makes inside are not reported.items.length = 1reports the cut-off items as asplicewithmethod: 'length'. - Several listeners can be registered; one that throws is skipped. The writes a listener makes itself are not reported, so a listener can keep its records in reactive state.
- The records are for development. Every report in the core is a
@DEV-ONLYstatement, and a production build removes them; when used without the compiler, each one is a single check that is false while DevTools is off. A production history of what the application does belongs to the application itself: a service method and its inverse.
Next step
For a summary of every exported symbol, go on to API Reference.