Guides

The DevTools Contract

The __MOTIF_DEVTOOLS__ object turned on by useDevelopment(true); paths, events, the tree, component contents, the reactivity trace, counters, router and error records, write operations.

The core does not include a DevTools UI; it offers a single object that a UI can read and write. This page describes that object. It is for people writing a DevTools UI. A package author who wants to open their package's own state to DevTools reads the DevTools for Packages page; this page is the ground that one stands on.

Turning it on

DevTools is turned on only with app.useDevelopment(true). This call turns on development mode as well. useDevelopment(false) turns both off. A parameter in the address bar or a global written by hand does not turn DevTools on.

While it is on, globalThis.__MOTIF_DEVTOOLS__ holds the contract object; when it is turned off, the object is removed. While it is off, the core records nothing and produces no events. Counters and records are kept from the moment DevTools is turned on.

Module-level stores (export const cart = reactive({ … })) are created during import, that is, before the useDevelopment(true) call. When developing with the @motifx/compiler Vite plugin, every top-level const or let whose value comes from a reactive, useModel, createSignal, createComputed or createLazyComputed call of @motifx/core is named after its variable, in all .ts, .tsx, .js and .jsx files. These stores are listed in getStores() with a name (cart) and a file (src/stores/cart.ts, relative to the Vite root). Nothing is added to a production build.

Early stores held in a field of a component or a service enter the list through the scan done in the getStores() call; only stores held inside a closure and not named by the compiler stay invisible.

import type { MotifDevtools } from "@motifx/core/devtools";

app.useDevelopment(true);
const devtools = (globalThis as any).__MOTIF_DEVTOOLS__ as MotifDevtools;
app.useDevelopment(true);
const devtools = globalThis.__MOTIF_DEVTOOLS__;

version is the version of the contract, 2 today. It goes up when the meaning of a field changes; adding a new field does not change the version. coreVersion is the version of the @motifx/core package the application runs on ('1.2.0').

Paths

Components have no id numbers. The tree is a forest: [] is the virtual Application node that holds all the roots, the first number of a path picks the root, and the rest are controls indices down from that root. The roots, in order, are the application shell that run() builds ([0]), the page layer app.layer (Layer) if it has been created, and the host component wrapping the body of every window opened or connected with app.windows (Window); the windows are in the order of app.windows.list. [0, 2] is the shell's third child, and [1, 0] is the layer's first child. The path of a component that is under no root is null.

A path depends on the component's position in the list. When a component is added in front of it or the list is re-sorted, the path changes. That is why every addition, removal and move in the tree is reported with the tree:changed event; the event carries the current paths of the parent components that need to be read again. When the layer is created, or when a window opens or closes, the list of roots changes; that is reported in the same event with the path [].

Events

on(fn) adds a listener and returns a function that removes it. Every event has the form { type, payload }. The payload of component events always contains { path, type, tag }; for a component that is under no root, path is null.

Event Payload
tree:changed { parents }: the paths of the parent components that changed; [] when the list of roots changed. Changes in the same round arrive in a single event.
documents:changed The list of documents connected to the application changed: {}. The list is read again with getDocuments().
component:built duration: the build time including children (ms).
component:visibility visible; only when the visibility changes.
component:wait waiting; only when the waiting state changes.
component:disposing The component info.
component:disposed duration; path is the path at the moment the dispose started.
reactivity:flush Once at the end of every reactivity round: { id, duration, runs, triggers, effects, operations }; see Operations.
operation:started A piece of work started (see Operations): { id, kind, name?, data?, parent? }.
operation:finished The work finished: { id, duration, error? }; for async work, when the promise settles.
stores:changed When a new store is registered, once per round however many stores are created: {}. The list is read again with getStores().
scopes:changed When a DI scope opens, is committed or closes, once per round: {}. The list is read again with getScopes().
service:created A service instance was created: { scope, token, lifetime, source, instance }. scope is the number in getScopes(), and source is class, factory or value; a transient emits on every resolution.
service:disposed A scope started closing one of its instances: { scope, token, lifetime, instance }.
route:navigation-start { uri }.
route:navigation-end { uri, requested, outcome, reason?, params, duration }; every navigation closes with an outcome.
warning { code, message, details }; on every occurrence.
error:caught { context, error }; on every occurrence.
extension:registered A package registered (see Packages): { name }.
extension:disposed Its registration was removed: { name }.
extension:changed The package wants the UI to read it again: { name, source? }; the panels if there is no source, that source if there is.
extension:reveal The package wants the UI to show something: { name, target }; target has the form { panel, route?, params?, state? }.
ext:<name>:<type> An event emitted by a package (see Packages).

There is no event for the moment a component enters the page. Whether a component is on the page is read from the connected field in the tree.

Tree

tag is the element's tag name; #fragment for a fragment and #text for text.

Component contents

inspect(path) returns the inside of a single component as plain data. No internal object is created on the component while reading, and no user code is called.

Field Content
props A preview of every prop. A function is shown as { kind: 'function', name } and is not called. An array is { kind: 'array', length }, an object { kind: 'object', type, keys }, a component { kind: 'component', path, type }, a DOM node { kind: 'node', tag }, and a signal or computed { kind: 'signal', type: 'signal' \| 'computed', value, stale? } with a preview of its value; stale: true marks a computed that has not calculated its current value yet, and its getter is not run.
fields The component's own fields (for example the class field state = reactive(…)), in the same preview form as props; the internal fields the framework writes on every component are not listed. A field that is a getter appears as { kind: 'getter' } and is not called.
functionProps Only on the root of a function component: the props given to the function, in the same preview form as props.
bindings { kind, property?, member?, updateMode?, active }; kind: property, list, conditional, class, attr, style.
events { name, type, dom, handler, modifiers }; dom: false is a component event.
hooks { name, method, handlers }: whether it is defined as a class method, and how many handlers are attached.
classes { static, reactive: [{ id, classes }] }; the current class set of the reactive sources.
attributes { name, value, reactive, held?, heldValue? }; value is the component's own value. On an attribute that a value is held over with attr.hold, held is true and heldValue gives the value the element shows.
services { scope, requested }: the services the component asked for with getService, and its DI scope.
effects See Reactivity trace.
extensions The output of the packages that provide data for this component.
window Only on a window root: { kind, title, open, components }; kind is popup, pip or attached, and components is the number of components in the window.

readValue(path, source, keys) opens one level of a value inside props, functionProps or fields (source): keys is the path to the value (['stat', 'tags']). The result is { ok: true, value: { value, entries: [{ key, value, getter? }], total } } in the same preview form; at most 100 fields are returned, and total gives the full count. Getters and functions are never called: a getter appears as getter: true, and a path that goes through a getter returns { ok: false, error: 'getter' }. Arrays, plain objects, class instances, Map (with keys as text) and Set (by position) can be opened; a path that goes through a signal or a computed continues into its value. readStore(id, keys) opens the inside of a store in getStores() the same way; for signals and computeds, the inside of the value is opened.

getServices() lists the services registered in the application as { token, lifetime, created }.

getScopes() lists the DI scopes and the services created in each:

Field Content
id, parent The scope's number and the number of its parent scope; parent is null at the root.
kind, name root, navigation (one per navigation) or user (opened with createScope(name)), and the scope's name.
current The scope from which the current page gets its scoped services.
pending A navigation scope that is still being built and has not been committed.
pins The number of components outside the current page that still hold the scope, for example a page kept with keepAlive or a leaving page whose disposal is in progress. A previous navigation scope lives while this number is above zero.
services { token, lifetime, instance, creating? }: singletons at the root and scoped services in every scope, in the same preview form as props; creating: true while an async service is being built.
transients The transient instances the scope holds in order to close them, while enableAutoDisposeTransients() is on.

A scope opened with createScope is listed for as long as it is used, and leaves the list with dispose(). The scopes:changed event says when to read the list again.

readService(scope, token, keys) opens one level of a service instance in the same form as readValue: scope is the number in getScopes(), and token is the name shown in that scope's services list. Singletons are read from the root scope, and scoped services from the scope that created them. Reading never creates the service: a service that has not been created yet returns { ok: false, error: 'no service' }. Only the instance's own fields are listed; getters on the class and #private fields are not.

Reactivity trace

inspect(path).effects lists the effects the component owns. A record looks like this:

const effect = {
  kind: "binding:textContent", runs: 4, lastDuration: 0.1,
  lastTrigger: { target: { kind: "object", type: "Object", keys: 7 }, key: "title" },
  deps: [{ target: { kind: "object", type: "Object", keys: 7 }, key: "title" }],
};
const effect = {
  kind: "binding:textContent", runs: 4, lastDuration: 0.1,
  lastTrigger: { target: { kind: "object", type: "Object", keys: 7 }, key: "title" },
  deps: [{ target: { kind: "object", type: "Object", keys: 7 }, key: "title" }],
};

kind tells where the effect comes from: binding:<property>, list, when, method, ternary, watch, switch, conditional, class, attr:<name>, style, virtual-data, computed. Those created directly with effect() appear as effect and have no owner.

The reactivity:flush event arrives once per round. triggers carries the changes that started that round, with their counts, and effects carries the effects that ran, with the paths of their owning components. If the changed object belongs to a store in getStores(), the trigger also carries a store: { id, path } field: path is the keys from the store's root to the changed object (a change to stats[1].value arrives as { path: ['stats', '1'] } and key: 'value'). For signals and computeds the key is value. A thousand changes to the same value in one round are still a single event.

Every flush has an increasing id. A trigger that happens inside an operation carries an operationId; operations lists the ids of the operations the triggers of that round belong to. Changes to the same value in different operations are counted separately.

Counters

getCounters() returns, in a single call, an object that carries these fields:

Field Meaning
components.live The number of live components
components.inTree The number of components in the application tree
components.notInTree The number of components that are alive but not in the tree
components.byType The live count per component type ({ NoteRow: 12 })
bindings The number of bindings in the tree
listeners.dom / listeners.component DOM listeners / component event listeners
effects.live / effects.owned Live effects / those of them owned by a component
reactivity.runs The number of effect runs
reactivity.flushes / reactivity.flushRuns The number of rounds / the total effects run in rounds
reactivity.lastFlushDuration / reactivity.maxFlushDuration The duration of the last round / of the longest round (ms)

All the fields are numbers; byType is an object from type name to number.

live goes up when a component is built and down when it is disposed. inTree, bindings and listeners are counted by walking the tree at the time of the call. notInTree is the components that are alive but not in the tree; if it does not drop even after leaving the page, those components are being held somewhere. The counters start from zero every time DevTools is turned on.

Router, errors and warnings

Write operations

Every write goes through the public API that application code uses too. The result is { ok: true, value? } or { ok: false, error }; the operations do not throw.

Operation What it does
setClass(path, name, on) class.add / class.remove.
setAttribute(path, name, value) attr.add; value: null deletes. Names starting with on… are not written.
setVisible(path, visible) motif.show() / motif.hide(); transitions are awaited. Roots are not hidden: a single-number path returns ok: false.
writeModel(path, value) bindings.writeModel(value); ok: false if there is no model binding.
setProp(path, keys, value) Writes to the key path on props, then runs the bindings again with setState().
navigate(uri) router.navigate(uri).
setStoreValue(id, keys, value) Writes to a reactive or signal record by its id in getStores(); for a signal with empty keys, it replaces the value itself. A computed cannot be written. Reactivity works as usual.
callExtension(name, panel, command, args) Runs a command a package registered (see Packages); only from a panel whose capabilities grant write permission. The command's return value is awaited and becomes value.

The keys __proto__, prototype and constructor are not written.

Next step

A package that shows its own state in DevTools registers with registerDevtools: DevTools for Packages. For a summary of every exported symbol, go on to API Reference.