DevTools.
aktion-runtime/devtools is an in‑page debugger for every
<aktion-app> on the document. You mount it yourself with one
line — there is no browser extension to install — and you get fourteen
tabs over the running program: a component inspector with an element picker, a
live editable $state tree with time travel, a render profiler, an
effect timeline, a network inspector that can mock requests, a console with an
expression REPL, route / data / theme / source explorers, and a test toolkit —
all of it reachable from a command palette on
Ctrl / Cmd + K.
What it is
DevTools is a second, opt‑in entry point of the same package. It renders a panel inside its own shadow root — floating or docked to any edge — so it cannot be restyled by the app it inspects and can still debug a program that is throwing.
| Tab | Answers | The number on the tab |
|---|---|---|
Overview |
Is anything broken, what is expensive, and where should I look? | — |
Inspect |
What component rendered this element, what props / state does it hold, and what does it look like in the box model? | Active prop overrides |
State |
What is $state right now, what just changed, which atom churns most — and what did it hold two commits ago? |
Reactive atoms in the snapshot |
Profiler |
What re‑rendered on this commit, why, how long did each body take, and how much went to the DOM diff? | Commits captured |
Effects |
Which effect ran, what fired it, how long it took, did it throw — and what is mounted but never firing? | Effect events (or errors, if any) |
Network |
What did the app request, what came back — and what happens if that endpoint is slow, broken, or offline? | Requests (or failures, if any) |
Console |
What did the program log, what is the runtime warning about, and what does this expression evaluate to right now? | Errors |
Routes |
Where am I, which arm matched, what routes exist, and how did I get here? | Navigations |
Data |
What is in the query cache, what do the stores and forms hold, and what is in browser storage? | Queries in flight |
Theme |
Which tokens are in effect, what happens if I change one, and does this pair pass contrast? | Token overrides |
Source |
What is the program, where are its problems, what does it declare — and does my edit parse? | Errors |
Test |
Can I turn this bug into a test, is this accessible, what is covered, and what breaks under random clicking? | Recorded steps or audit findings |
Timeline |
What happened, in order, across every subsystem? | — |
Settings |
How much instrumentation do I want to pay for, and where should the panel sit? | — |
Everything you see is the real runtime. State edits go through the genuine reactive pipeline, prop overrides enter the component where the value normally would, the profiler reads the actual per‑instance memoization decisions, the timeline is fed by the real effect runner, and the network tab taps the real HTTP layer. Nothing is simulated.
Enable it
The runtime never installs the panel. You do, from anywhere in your own code, and the whole surface is one function call.
From a bundler
import "aktion-runtime"; // registers <aktion-app>
import { mountDevtools } from "aktion-runtime/devtools";
mountDevtools(); // opens immediately, attaches to every app on the page
That is the whole setup. mountDevtools() installs the global hook,
appends the panel to document.body, and adopts every
<aktion-app> already in the document — including apps that
mounted long before the panel opened.
From a CDN
<script type="module">
import "https://cdn.jsdelivr.net/npm/aktion-runtime/dist/aktion.js";
import { mountDevtools } from "https://cdn.jsdelivr.net/npm/aktion-runtime/dist/devtools.js";
mountDevtools();
</script>
Both bundles are self‑contained ES modules, so no build step is involved. The DevTools bundle talks to the runtime only through the global hook, which is why loading them from two different URLs still works.
Behind a flag or a keyboard shortcut
Because it is a separate entry, a dynamic import() keeps the panel out
of your main chunk entirely until someone asks for it.
let devtools = null;
async function toggleDevtools() {
if (!devtools) {
const { mountDevtools } = await import("aktion-runtime/devtools");
devtools = mountDevtools(); // first press mounts it, already open
} else {
devtools.toggle(); // later presses just show / hide
}
}
// Ctrl+Shift+D, or ?debug in the URL.
addEventListener("keydown", (e) => {
if (e.ctrlKey && e.shiftKey && e.key === "D") toggleDevtools();
});
if (new URLSearchParams(location.search).has("debug")) toggleDevtools();
Aktion ships no keyboard shortcut of its own, so the binding above is yours to
choose. Mount once and keep the controller: every call to
mountDevtools() adds another panel element, even though they all share
one hook and one event stream.
Client only
mountDevtools() needs a live document.
Under SSR or a framework that renders on the server, call it from a
client‑only effect (useEffect, onMounted, a
<script> in the browser bundle), never at module scope in shared
code.
Try it live
The app below is an ordinary <aktion-app> rendered from the program
on the left. Press Launch DevTools to open the real panel over this
page, then interact with the app and watch it fill up. Press
Ctrl / Cmd + K inside it for the
command palette, or ? for the shortcuts.
$count = 0
$seconds = 0
$user = { name: "Ada", role: "engineer", prefs: { notify: true } }
// An interval effect: one `run` event per second, so the Effect timeline fills live.
$effect(() => { $seconds = $seconds + 1 }, ["every(1000)"])
// A user component: its own instance, and its own memo record, in the profiler.
function Stat(label, value, tone) {
return Card([Column([
Text(label, { variant: "small", tone: "muted" }),
Text(value, { variant: "title" }),
Badge(tone, { tone: tone })
], { gap: "xs" })])
}
$app(Column([
CardHeader("DevTools demo", { subtitle: "Open the inspector, then interact." }),
Row([Stat("Count", `${$count}`, "info"), Stat("Uptime", `${$seconds}s`, "success")], { grow: true }),
Button("Increment", { onClick: () => $count = $count + 1, variant: "primary" }),
Text(`Signed in as ${$user.name} (${$user.role})`, { variant: "small", tone: "muted" })
], { gap: "md" }))
Three things to look at, in order:
-
State — expand
user, thenprefs, clicknotify, typefalse, press Enter. The write lands in the real store and the app re‑renders. - Profiler — press Increment a few times. Each press adds one blue bar to the commit strip; the first bar of the session is green.
-
Effects — the
every(1000)effect adds arunevent per second, so its lane grows while you read this.
The command palette, and the keyboard
Fourteen tabs is more surface than anyone wants to hunt through. Press Ctrl / Cmd + K anywhere on the page — the panel does not need focus — and type: the palette fuzzy‑matches every tab and every action the panel can perform, so pick an element, arm a break, reset the theme, or export the session are one phrase away instead of three clicks inside a tab you have not opened.
- Type a word that names a tab —
theme,network— and the first result takes you there. - ↑ / ↓ move, Enter runs, Escape closes.
- Matching is on the action, its group, and its synonyms, so
curlfinds the copy button andslowfinds the delay rule.
Press ? for the full list of shortcuts at any time:
| Key | Does |
|---|---|
| Ctrl / Cmd + K | Open the command palette. |
| Ctrl + Shift + P | Arm the element picker. |
| ? | Show / hide this list. |
| Alt + 1…9 | Jump straight to a tab. |
| Alt + [ / ] | Previous / next tab. |
| / | Focus the current tab's filter box. |
| Ctrl + F | Find — in the Source tab, search the program. |
| Escape | Close the palette, the shortcut sheet, or disarm the element picker. |
The palette, the picker, and the Alt tab shortcuts work anywhere on the page — you are usually clicking the app, not the panel, when you want to change what the panel is showing — and they never fire while you are typing in the app's own fields. The rest need the panel focused; click it once.
Typing is never interrupted
The panel re‑renders on every runtime event, which for a busy app is several times a second. Every field declares a stable key, so your focus, your caret position, and the scroll offset of whatever list you are reading survive those re‑renders — including the Enter that runs a REPL expression and grows the history above the input.
Watching an app while you use it
Four things the panel can do continuously, so you can put it in a corner and go use the app rather than clicking back and forth. All four are switches in Settings → While you work, and each is off by default because each costs something.
-
Watch expressions. Pin an Aktion expression —
$todos.length,$user.role,$cart.items[0].qty— in the Console tab, or with the eye button on any REPL result. It is re‑evaluated on every render, so the value moves as you click. Watches are remembered across a page reload, and the Overview tab shows them too. - Highlight re‑renders. Flash a box over every component that actually re‑rendered in the last commit — memoized instances are not flashed — which is the fastest way to see that one keystroke is repainting a whole page.
-
Break on change. Arm an atom from the State tab (the
●button beside it) and the browser pauses in the debugger the moment anything writes to it, with the write still on the stack. Needs devtools open in the browser, the way anydebuggerstatement does. -
Browser performance marks. Mirror every commit into
performance.measure(), so Aktion commits appear as named measures in the browser's own performance profiler, lined up with layout, paint, and your own marks.
Alongside them, the panel reports main‑thread blocking it
observed (via PerformanceObserver's long‑task entries) on the
Overview tab, so a janky interaction points at the frame that caused it.
Paused means paused
The Rec button in the header stops the panel ingesting events, which
is how you read a moving timeline. Those events are dropped, not queued, so
the button counts what pausing has cost you — Paused · 34 —
rather than looking like a hung panel.
Component inspector
The Inspect tab is the one that changes how you debug an Aktion app. It shows the component‑instance tree the renderer actually built — not the DOM, and not the source — and lets you change what any instance received and what it holds.
The element picker
Press ◎ Pick and the page gets a crosshair. Hover anything and the real box model is drawn over it — margin, border, padding, content, each labelled with its measured value — with a tooltip naming the component, the element, and its size. Click to select; Esc cancels.
The picker reaches inside the app's shadow root. A naive implementation
cannot: document.elementFromPoint stops at the
<aktion-app> host, so every hover would resolve to the same element.
This one descends through each shadow root until it reaches a leaf, then walks back up
to the nearest component instance — so clicking the text inside a Badge selects
the Badge, not an anonymous <span>. Hovering the panel
itself is ignored, and clicking it does not cancel the pick you are halfway through.
Reading the component tree
Every row is one instance: the component name (your function components in
purple, library primitives in grey), the author's key: if there is one, a
memo chip when the last commit skipped its body, its prop count, how many
times it has rendered this session, and its last self time. Hovering highlights it on
the page; clicking selects it. A row marked no dom rendered a fragment with
no host element (a Show / Async / Lazy branch),
so there is nothing to highlight.
The tree comes from the instance keys the renderer derives, which encode the whole
hierarchy — $/0#Page@1:0/1#Card@7:4>0#Button@9:12 is a Button
inside a Card inside a Page. Filtering flattens the tree into a result list, because
keeping the hierarchy would mean showing unmatched ancestors as though they matched.
Given room — a panel wider than about 700 pixels, so any side dock or a
floating panel at its default size — the tab splits: the tree on the left, the
selected instance's detail on the right, both scrolling independently. Narrower than
that, the detail replaces the tree, because two 300‑pixel columns help nobody.
↑ / ↓ walk the rows, ← / →
collapse and expand, and the ⊟ / ⊞ buttons in the
toolbar collapse or expand everything at once.
Every other tab that names a component links here: a bar in the flamegraph, an effect's owner, a timeline row. Following one of those links clears whatever was hiding the row — a collapsed ancestor, a filter that excludes it, the Library toggle when the target is a library component — tells you what it cleared, and scrolls the row into view, so the jump never lands on an empty‑looking tree.
Editing props, hooks, and component state
Selecting an instance opens six panes. Everything editable is edited in place: click
the value, type, Enter to commit, Esc to cancel. Input is parsed
as JSON when it can be (42, true, ["a"]) and as a
plain string otherwise, so Ada is not rejected for being invalid JSON.
| Pane | What it shows | What an edit does |
|---|---|---|
Props |
Every argument the instance received, by its declared name, plus anything on the universal channel (sx, class, aria…). |
A $‑bound prop writes the atom — that is the source of truth, and overriding the prop instead would be reverted on the next commit. Anything else installs a DevTools override that lasts until you clear it. |
State |
Per‑instance $state / $memo / $ref cells by slot index, plus the internal UI state a library component keeps for itself. |
Writes the cell and re‑renders, exactly as the hook's own setter would. A $memo is read‑only: it recomputes from its deps, so an edit would not stick. |
DOM |
The element, its box model, its attributes, and its markup — with copy‑selector, copy‑HTML, and a button that console.logs the live node so you can poke at it. |
Read‑only. An imperative DOM write has no source of truth the next render can reproduce, and the reconciler would undo it. |
Styles |
Computed properties grouped by concern, and every --rui-* theme variable actually in effect on this element, with swatches. |
Read‑only here — change the token in the Theme tab and the whole library follows. |
A11y |
The role and accessible name a screen reader would announce, the ARIA attributes that decide them, and any audit findings in this subtree. | Read‑only; each finding highlights its element and names the fix. |
Source |
The lines around the call site, with the instance's own line highlighted. | Read‑only; “Open in Source tab” jumps to it in the full program. |
The + row at the bottom of the Props pane adds an override for a
prop the call site never passed — which is usually the interesting experiment.
Trying variant: "danger" or sx: { padding: 24 } on one live
component takes a second and leaves no edit behind in the program.
An override is a lie you asked for. While one is active the UI is
showing a DevTools value, not the program's — so the tab shows a banner with a
“clear all” button, each overridden prop carries an
override chip, and clearing restores the authored value on the next
commit. Overrides are per‑instance and are dropped when the program is
re‑planned.
Remount drops the instance's memo, hook cells, and UI‑state slots so it mounts fresh — the fastest way to check that a component's initial state is what you think it is, without reloading the page.
State inspector
The State tab is a live tree of every reactive $state atom, updated on
every state flush — including flushes the render gate decides not to re‑render
for. So the tree mirrors the store even when the screen does not move.
Reading the tree
One row per atom, indented by depth. Values are coloured by type and collapsed to a preview until you expand them.
| You see | It means |
|---|---|
▸ / ▾ | An object or array with at least one entry. Click to expand or collapse. |
• | A leaf — a scalar, an empty object or array, or a function. |
Array(12) | Array preview, with its length. |
{ name, role, prefs } | Object preview: the first three keys, then … if there are more. |
ƒ () | A function-valued atom. Shown, never editable. |
reserved tag | An atom the runtime owns. route is the only one; its value is not editable. |
| Row highlighted | The atom’s root changed in the last 1.1 seconds. The flash is keyed on the root, so every row under user flashes when user.prefs.notify changes. |
Only $‑prefixed reactive atoms appear here. Plain top‑level
let / const bindings are re‑seeded every render and are
not part of the store, so they are never listed — see
Reactivity for the distinction.
Editing a value
Click any leaf value to turn it into an input. Enter commits,
Escape cancels, and clicking away also commits. What you type is
run through JSON.parse first and kept as raw text only if that fails.
| You type | What is written |
|---|---|
42 | The number 42 |
false | The boolean false |
null | null |
{ "a": 1 } | An object — any JSON literal works |
"true" | The string true |
pending | The string pending — not valid JSON, so it is kept verbatim |
A dotted row such as user.prefs.notify writes through the store’s
path API, which rebuilds user immutably: sibling fields survive and
every dependent of user wakes up. The edit is indistinguishable from one
an onClick handler would make.
Containers are not editable
Edit leaves, not branches. An object or array that has entries is a container — expand it and edit the values inside. An empty object or array is a leaf, so you can replace it wholesale by typing a JSON literal over it.
Filtering and reactivity heat
Every top‑level atom carries a heat badge: a bar sized against the busiest atom and a count of how many flushes changed it this session. The toolbar shows the session total.
- Filter atoms… — case‑insensitive substring match on top‑level atom names. Expanded children are not searched.
- Sort by activity — ranks atoms by change count, descending, falling back to alphabetical for ties. Off by default; the default order is alphabetical.
12 atoms · 1.4k changes— atoms in the snapshot, and every counted change across them.
Sort by activity is the fastest way to find the atom your UI actually churns on. An atom with a heat count far above the rest is usually a timer, a scroll position, or a pointer coordinate that belongs in an instance hook rather than the global store.
Time travel
The runtime attaches a $state snapshot to every commit, so the State tab
keeps a bounded history of moments you can recognise — “two clicks
ago” — rather than raw flushes. Drag the scrubber back and the tree shows
that snapshot, read‑only, with a banner saying how long ago it was.
Restore hydrates it back into the live store; Live
follows the app again.
Restoring is a real hydration, not a rewind: effects do not un‑run and requests do not un‑send. It puts the data back so you can re‑drive the UI from a state you have already seen.
Diffing two snapshots
Reading two state trees side by side and spotting the difference is a job for a computer. The State tab's Diff view takes any two of the captured commits and lists only what changed between them — added, removed, or changed, at the leaf, with both values — which is usually three lines instead of two hundred. Coming from a bug report ("it was fine, then I clicked twice"), this is the shortest path from "something changed" to "this changed".
It needs captureSnapshots on (it is, by default) because it reads the same
per‑commit snapshots time travel uses.
Render profiler
Every commit is captured with its trigger, its wall‑clock duration, and one record per component instance. Open the Profiler tab, interact with the app, and read it top‑to‑bottom: summary, strip, insights, commit detail, reactivity, ranked components.
The commit strip
One bar per commit, oldest on the left. Bar height is that commit’s duration relative to the slowest one captured, so the strip is a shape, not an axis. Click a bar to pin it; the Commit detail section below follows your selection instead of the latest commit.
| Bar | Means | Source flag |
|---|---|---|
| Green | The initial mount — the first commit DevTools captured for this app. There is exactly one. | initial: true |
| Amber | A full render: memoization was bypassed and every component body re‑ran. | fullRender: true |
| Blue | An incremental commit — the normal case, where memoized instances were skipped. | neither flag |
Green wins over amber, so the first captured commit reads green even though it is also
a full render. Hover any bar for
#7 · 3.10 ms · 4 rendered / 9 memoized, and look for runs of amber:
repeated full renders are the single most common Aktion performance bug.
The flamegraph
Commit detail lists every instance in the selected commit, in render order, indented by tree depth. Bar width is that instance’s self time relative to the widest one in the same commit. Library components are prefixed with a small square; user components are not.
| Phase | Bar | What happened |
|---|---|---|
mount |
Green | This instance appeared in the tree for the first time. |
update |
Blue | The instance already existed and its body re‑ran. |
memo |
Grey, dimmed | Skipped by per‑instance memoization — the body did not run and the cached tree was reused. Fixed‑width bar; the time column reads memo. |
Hover a bar for the reason the phase was chosen. These strings come straight from the renderer, so they name the exact decision it made:
| Reason | Read it as |
|---|---|
initial mount | First render of this instance; nothing was cached yet. |
positional args changed | A positional argument differs from the memoized call. |
named args changed | A prop in the options object differs. |
state dependency changed | Args matched, but a $state path this body reads was in the commit’s change set. |
no memo (full render) / full render | Memoization was off for this commit, so the body re‑ran regardless. |
memoized (args + deps unchanged) | Both checks passed; the body was skipped. |
mounted / re-rendered | A library component. These two are the only reasons library instances report. |
render threw | The component’s render function raised. Check the console for the error. |
Self time means two different things
Compare user components with user components. For a
function Foo() the number is body‑evaluation time only, with
children excluded. For a library component it is inclusive of the subtree its render
function builds in one call. A memo record always reports zero.
Performance summary and the components table
The stat grid at the top of the tab aggregates every captured commit. Several tiles change colour once they cross a fixed threshold, so you can read the grid without knowing what “good” looks like.
| Tile | What it measures | Turns amber at |
|---|---|---|
commits | Commits captured this session. | — |
total render | Sum of every captured commit’s duration. | — |
avg / commit | Total divided by commit count. | ≥ 8 ms |
slowest | The single longest commit. Click the tile to pin that commit in the strip. | ≥ 16 ms |
memoized | Share of component evaluations that were skipped. | below 20% — green above it |
full renders | Commits that bypassed memoization entirely. | more than half the commits |
commit rate | Commits per second across the captured span. Hidden until the commits span a measurable interval. | ≥ 30/s |
Below the flamegraph, Components — ranked by self time
aggregates the same records by component name across every captured commit. Click any
header to sort by it; clicking the active header flips the direction. It opens sorted
by Total, descending.
| Column | Meaning |
|---|---|
Component | Component name. Instances of the same component are merged into one row. |
Type | user for a function in your program, library for a built‑in. |
Renders | Commits in which a body actually ran. Memoized skips are excluded. |
Memo | Commits in which the instance was skipped. |
Total | Summed self time, with an inline bar against the heaviest row. |
Avg | Total ÷ Renders. |
Max | The worst single render. A high Max with a low Avg is a first‑render or cold‑cache cost. |
A healthy row has Memo comfortably above Renders. A user
component with a large Renders and Memo of zero reads a
$state path that changes on nearly every commit.
Insights and the reactivity panel
Insights is a fixed set of heuristics over the captured commits, capped at six entries. Each one names the component and the number that triggered it, so you can verify the claim in the table below.
- A component averaging ≥ 8 ms per render — split it, or move the work into a
$memo. - A user component that rendered ≥ 12 times, was never memoized, across at least four commits — check what
$stateits body reads. - Three or more non‑initial commits that forced a full re‑render — usually an async resolution, a timer, or an effect calling notify.
- Nothing found, with at least one commit — a single green “no render hot‑spots detected”.
Reactivity — state paths that triggered commits ranks the top
eight $state paths by how many commits they caused. It answers a
different question from the heat badges on the State tab: heat counts every
write, this panel counts only the writes that actually cost you a render.
Flash on commit outlines the inspected element for 140 ms on every commit — the quickest way to tell which of several apps on a page is the one re‑rendering. Clear empties the strip without stopping recording.
Effect timeline
Every transition of every effect, attributed to the trigger that caused it. This is where you take an effect that “runs too often” and find out what is firing it.
Phases and triggers
Five phases, each with its own colour and its own extra payload. They arrive in
lifecycle order, and a re‑run emits cleanup before its
run whenever the previous run registered any cleanup handlers.
| Phase | Chip | Emitted when | Also carries |
|---|---|---|---|
mount |
Green | The declaration was registered and its subscriptions or intervals were wired — before any body runs. | — |
run |
Blue | The body executed. | Body duration |
cleanup |
Purple | Registered cleanup(fn) handlers fired, before a re‑run or on teardown. |
How many handlers ran |
unmount |
Grey | The declaration was torn down — the owning instance left the tree, or the program was re‑planned. | — |
error |
Red | The body threw. The effect stays mounted and will run again on its next trigger. | The message, and the duration up to the throw |
The reason column is the attribution — the answer to “what fired this?”
| Reason | Fired by |
|---|---|
mount | Registration, or a body run triggered by mounting. |
state:count | The $count trigger. It shows the atom you wrote, not the per‑instance alias the runtime subscribed to. |
every(1000) | An interval tick, with the declared period. |
unmount | Teardown — the final cleanup, and the body of an on:unmount effect. |
Alongside the reason, each effect shows its declared trigger list exactly as you wrote
it — [$count, "mount"], [every(1000)],
[debounce(250), $query]. An effect that declares no triggers at all reads
[mount], which is the rule the runtime applies to it.
Timeline versus Log
The Timeline view (the default) gives each effect a lane and places one dot per event on a shared time axis measured from the first event this app emitted. Bursts, overlapping runs, and cleanup→run pairing are obvious at a glance; exact ordering within a burst is not.
Press the toggle for the Log view: a flat, newest‑first list of the last 250 matching events with a relative timestamp, the phase chip, the effect label, and the reason. Use Timeline to find the effect, Log to read the sequence.
The five phase chips in the toolbar filter both views — all on by default. Turn
off run and mount and a noisy timeline collapses to just the
cleanups and errors you care about.
Lanes, summary, and insights
Between the insights and the visualisation, one lane per effect lists its label, its
declared triggers, its run count, and its total run time, sorted by run count. Effects
declared inside a function body carry an instance tag, and
report their unmount when that component leaves the tree.
Effect labels are always derived from source position —
effect @ L3:C1 is the effect declared at line 3, column 1. There is no
way to name an effect, so the position is the identity: move the declaration
and its lane is a new lane.
| Summary tile | What it counts |
|---|---|
effects | Distinct effects seen, counting each component instance separately. |
runs | run events. |
total run / avg run | Summed and mean body duration across those runs. |
cleanups | cleanup events, not individual handlers. |
errors | error events. Green at zero, red above it. |
The Insights list applies three rules per effect, capped at six entries: any error at all is flagged red; ≥ 20 runs is flagged as a hot trigger worth re‑checking the dependency list for; otherwise an average of ≥ 6 ms per run is flagged as heavy work in an effect body.
Network, and request rules
Every request the Aktion HTTP layer issues — $query,
$mutation, Http({...}), and $http — is
listed with its status, method, path, size, duration, and a waterfall bar. The detail
pane has the response body, the request body, both header sets, and a timing breakdown,
plus Copy as curl for reproducing it outside the app.
Rules is the half that makes this a testing tool. A rule matches by URL
(substring, or a glob with *) and optionally by method, and then does one
of four things:
| Action | Effect | Use it for |
|---|---|---|
delay | Adds latency, then proceeds normally. | Seeing your own loading states. A skeleton nobody has ever watched is usually wrong. |
mock | Answers with a canned status + body; the network is never touched. | An endpoint that does not exist yet, an empty list, a 500, a paginated response you cannot easily produce. |
fail | Fails the request with your message. | Error paths, retry logic, onDone handlers. |
offline | Fails everything. | The offline banner, and what an app does when nothing resolves. |
Rules are evaluated in order and the first enabled match wins; each rule shows how many recorded requests it would have claimed, so a rule that matches nothing is visibly different from a rule that is not working. A mocked or blocked row is labelled with the rule that produced it, so a mocked 500 never reads as a real one. Mock this on any recorded request seeds a rule that replays its response.
The program sees exactly what it would see from the network: a mocked response arrives through the same code path as a real one, and a failure surfaces as a transport error. Rules live on the inspected element and are dropped when it unmounts.
Console and the expression REPL
The panel mirrors the page console, which matters more than it sounds: the runtime's own
diagnostics land there — “a reactive $state write happened
during render…”, “failed to render Button”,
“handler rejected” — and they are usually the most direct
explanation of a reactivity bug. Runtime lines carry a runtime chip,
consecutive duplicates collapse into a count, and uncaught errors and unhandled
promise rejections are captured too.
Below it, a REPL that evaluates Aktion expressions — not JavaScript — against the live program scope:
› $user.name
‹ "Ada"
› $todos.filter(t => !t.done).length
‹ 3
› $count = 5 // writes through the real reactive pipeline
‹ 5
The same bindings, the same atoms, the same helpers the program sees. An assignment to a
$ atom is indistinguishable from one a button made. Reads are deliberately
not tracked as render dependencies, so poking at state from the console cannot
change what the app re‑renders on. ↑ / ↓ walk the
history.
Routes and data
Routes lists the patterns the program declares, read statically from its
$router({ … }) arms rather than from observation — a router only
discovers a pattern when it matches, so a history‑based view could only ever show
you where you have already been. Every concrete route is clickable, and the navigation
history records each change with the arm that claimed it. A navigation that matched
nothing is called out: without a default: arm the router renders nothing at
all for that path.
Data covers the three places state hides from the State tab:
-
Queries — the shared
$query/Http({...})cache, keyed by request rather than by atom, with each resource's state, status, data, and age. Refetch or cancel one, or invalidate by key substring the wayinvalidates:does. -
Stores & forms — a
$storekeeps everything in one machine‑named atom (__store_3_10), which is why the State tab shows a name nobody wrote. Here it is named by its declaration site, with its methods listed and callable. -
Storage —
localStorage,sessionStorage, and cookies, read straight from the page so the view is correct even for keys the program has never touched. That is where stale‑value bugs live — and you can write there too: add a key, edit a value in place, or delete one, which is how you reproduce "works for me, broken for the user who has been here before" without hand‑editing storage in another devtools panel.
Theme tokens
Aktion's components take almost no styling props: they read --rui-* custom
properties, and a theme is a map of those. That makes the token editor the highest
leverage surface in the panel — one edit restyles every Button, Card, and Table at
once.
Tokens are grouped the way a designer thinks about them (surfaces, text, brand, status,
typography, spacing, elevation), colour‑valued ones get a picker, and edits are
applied as inline custom properties exactly the way an in‑script
$theme({...}) block applies them. Copy as $theme hands you
the block that reproduces what you just did. A token the program's own
$theme({...}) sets is marked, because that block is re‑applied every
render and wins.
Below the tokens, contrast checks for the pairs the stylesheet actually paints — body text, muted text, text on a surface, primary buttons, accent fills, links, control borders — each with its measured ratio against the WCAG minimum. “Check your contrast” is useless without knowing which pairs matter.
Source
Aktion programs are usually generated, which changes what a source view is for: you are rarely reading code you wrote, you are checking what the model emitted and where the validator disagreed. So this tab leads with diagnostics placed on their lines and an outline of every declaration — components, effects, actions, hooks, atoms, imports — that you can jump through.
Edit turns it into an editor. The draft is parsed and schema‑checked
as you type, so Apply is never a surprise, and mounting goes through the
same path a streamed update takes: $state is preserved across the diff. The
analysis runs the runtime's own parser and validator — the panel deliberately does
not carry a second one, because an inspector that disagreed with the runtime about
whether a program is valid would be worse than no inspector.
Because an edit can break the program badly enough that there is nothing left to edit,
the tab keeps a history: every version that has been mounted this
session, with the diagnostics it produced and a one‑click Revert.
A bad apply is a two‑second undo rather than a reload that loses your
$state.
Ctrl + F searches the program and marks every hit; Ctrl + Enter applies a draft. Large programs stay cheap: the view renders a window of lines around what you are looking at rather than rebuilding every line on every runtime event.
Test toolkit
Five tools that turn “it broke” into something you can commit.
| Tool | What it does |
|---|---|
| Record |
Captures your clicks, typing, selects, Enter/Esc, and
navigations, then emits a runnable aktion-runtime/test file with the
program inlined and the atoms that changed asserted. Typing is coalesced into one
step per field. Queries follow Testing Library priority — test id, then role
+ accessible name, then label, then placeholder, then text — and a step that
had to fall back to a CSS selector is flagged brittle in the list and
commented in the output.
|
| A11y |
Audits the rendered tree for the failures a generated UI actually produces: an icon
button with no accessible name, a field labelled only by its placeholder, a heading
ladder with a hole in it, text below the contrast minimum, focusable content inside
aria-hidden, a positive tabindex, duplicate ids, dangling
aria-labelledby references, nested interactive elements, targets under
24 px. Every finding names the element, states the measurement, and says what
to change in Aktion terms — and clicking it highlights the element and selects
its component.
|
| Coverage |
Real DSL coverage. A .aktion file compiles to a
JSON.parse of its AST, so V8 and Istanbul see one executed line however
much DSL sits behind it — coverage has to come from the interpreter. Start it
(the program re‑plans so its static shape is registered), use the app, and read
lines / functions / branches per file, with the never‑executed lines clickable
through to the Source tab. Exports LCOV.
|
| Queries |
A Testing Library query playground. Type a role, a label, a test id, or a selector
and see what matches, highlighted on the page. The useful outcome is usually
discovering that a query matches three things — which is the failure
you would otherwise meet in CI — and the copy button hands you the exact
getBy* / getAllBy* call.
|
| Chaos | Clicks random controls a few hundred times, with a render between each, and reports every runtime and console error that appeared. Crude, and reliably effective on a generated UI full of paths nobody has clicked. Controls whose accessible name reads destructive (delete, clear, sign out) are skipped, and time travel gets you back if a run leaves the app somewhere odd. |
Timeline
Every commit, effect event, request, navigation, emitted event, log line, and error in one ordered stream, with idle gaps marked. The per‑subsystem tabs each answer their own question well and none of them answers the most common one: what happened when I clicked that? A real interaction is a commit, two effect runs, a request, and a route change inside forty milliseconds, spread across four tabs.
Rows are clickable — a commit opens in the Profiler, an effect in Effects, a request in Network. Export session writes the whole capture (events, state, program, totals) to a JSON file, which is what you attach to a bug report.
Settings, and what you are paying for
Instrumentation is not free, and a debugger that silently changes the timings it reports is a bad debugger. Five switches gate the work inside the runtime, so turning one off makes the app faster, not just the panel:
| Switch | Pays for | Turn it off when |
|---|---|---|
captureProps | Serialising every instance's arguments, every commit. Required by the Props pane. | You are measuring render time on a large tree. |
tagDom | One attribute per rendered element. Required by the element picker and highlighting. | You need the exact production DOM. |
captureSnapshots | A $state clone per commit. Required by time travel. | Your state is large and changes constantly. |
captureNetwork | Request events, and request rules. | Never, really — it is one clock read per request. |
measureDom | A node count after each commit. | The tree is enormous. |
A second group, While you work, holds the continuous aids described under Watching an app while you use it — highlight re‑renders, flash on commit, browser performance marks, console capture. Those cost the panel, not the runtime, and are off by default.
The same tab holds the panel's own preferences — dock to any edge or float, a light theme for a light host page, compact rows for a narrow dock, console capture on or off — and they are remembered between sessions.
API reference
aktion-runtime/devtools is a self‑contained ES module. Importing it
registers the <aktion-devtools> custom element as a side effect, so
you can also drop the tag straight into your HTML instead of calling the mount
function.
| Export | Purpose |
|---|---|
mountDevtools(options?) | Install the hook and mount a panel. Returns a controller. |
isDevtoolsInstalled() | true when a hook exists on this page, whatever installed it. |
isDevtoolsActive() | true only when a hook exists and a frontend is subscribed. This is the flag the runtime gates instrumentation on. |
installDevtoolsHook(version?) | Install the hub without any UI. For an extension or a custom frontend. |
getDevtoolsHook() | The installed hook, or undefined. |
defineDevtoolsElement() | Register <aktion-devtools>. Idempotent, and already called on import. |
AktionDevtoolsElement | The panel class. AktionDevtoolsElement.tagName is "aktion-devtools". |
HOOK_KEY | "__AKTION_DEVTOOLS_HOOK__" — the property the hook lives under on globalThis. |
DEVTOOLS_PROTOCOL_VERSION | Currently 2. Bumped only when the event shapes change incompatibly; everything protocol 2 added is optional, so a v1 frontend still works against a v2 runtime. |
buildPalette(ctx, actions) | The command list the palette shows, and rankCommands / fuzzyScore behind it. Exported so a custom frontend can reuse the same commands. |
exportSessionJson(ctx) | The whole session — events, commits, diagnostics, program history, long tasks — as one JSON document to attach to a bug report. |
diffSnapshots(from, to) | Leaf‑level changes between two $state snapshots. Pure; useful in a test. |
The event shapes themselves are exported as types — DevtoolsEvent,
CommitRecord, ComponentRenderRecord, StateEvent,
EffectEvent, RenderPhase, EffectPhase — so a
custom frontend can be fully typed. See TypeScript.
mountDevtools(options?)
One call does three things: it installs the hook, registers and appends the panel element, and adopts every app on the page. It is idempotent at the hook level but not at the panel level — a second call mounts a second panel.
const devtools = mountDevtools({
container: document.querySelector("#debug-dock"), // default: document.body
appId: "aktion-app-2", // default: the first app that registered
tab: "inspect", // default: last used, else "overview"
dock: "bottom", // "float" | "right" | "bottom" | "left"
open: false // default: true — mounts already open
});
Nothing here is required. mountDevtools() with no arguments is the call
you want almost always — reach for the options only when the panel has to live
somewhere other than document.body, open on a particular tab, or start
hidden.
| Option | Type | Default | Effect |
|---|---|---|---|
container | HTMLElement | document.body | Where the panel element is appended. |
appId | string | First registered app | Pre‑selects an app in the picker. |
tab | TabId | Last used, else "overview" | Opens on a specific tab — "inspect", "state", "profiler", "effects", "network", "console", "routes", "data", "theme", "source", "test", "timeline", "settings". |
dock | DockMode | Last used, else "float" | Anchors the panel to an edge instead of floating. |
open | boolean | true | false mounts the panel hidden. It still subscribes — see what it costs. |
Floating, the panel is position: fixed at a very high z‑index, sized
560×620 and placed 16 px from the bottom‑right corner. Drag the header
to move it, use the corner grip to resize down to 360×260, and the header buttons
to switch dock, pause recording, collapse, or close. Docked, it spans an edge and the
drag/resize handles retire. Position, size, dock, theme, density, and the last tab are
remembered in localStorage.
The controller
mountDevtools() returns a plain object. Keep it: it is the only handle on
that panel.
| Member | Type | Does |
|---|---|---|
element | AktionDevtoolsElement | The live panel element. |
hook | AktionDevtoolsHook | The installed hook — shared by every panel on the page. |
open() | void | Show the panel and resume rendering it. |
close() | void | Hide it. It stays subscribed and keeps recording. |
toggle() | void | Flip hidden state. Bind this to your shortcut. |
selectApp(id) | void | Switch the inspected app. Also clears the pinned commit, instance, and request. |
selectTab(tab) | void | Switch tabs — e.g. open straight on "network" from your own “report a bug” button. |
destroy() | void | Remove the panel element. It unsubscribes, so the runtime goes dormant again — but the hook and its buffered events stay installed. |
close() and destroy() are not interchangeable.
close() is a UI state; destroy() is the one that turns the
instrumentation back off.
The hook and the app record
The hook is the wire between the runtime and any frontend. You rarely touch it directly, but reading it in the console is a fast way to confirm DevTools is wired up at all.
const hook = window.__AKTION_DEVTOOLS_HOOK__;
hook.aktion; // true — the marker that says you found the real hub
hook.protocolVersion; // 2
hook.apps.size; // how many <aktion-app> elements registered
hook.active; // true only while a frontend is subscribed
hook.buffer.length; // recent events, capped at hook.bufferLimit (default 2000)
hook.options; // the instrumentation switches the runtime reads
hook.setOptions({ captureProps: false }); // stop serialising per-instance props
hook.clearBuffer(); // drop the backfill buffer
const stop = hook.subscribe((event) => console.log(event.kind, event));
// ... later
stop();
subscribe(fn) and subscribeApps(fn) both return an
unsubscribe function. A listener that throws is caught and logged, so a broken
frontend can never break the app it inspects.
Events come in eight kinds. commit, state, and
effect are the original three; protocol 2 adds
network (one start plus one terminal event per request),
route (emitted after the render that resolves which arm matched, so it can
report the pattern), emit (every $emit(…), mirrored
before dispatch so a host that stops propagation cannot hide it), log, and
error (a failure the app survived — a plan error, a render throw, a
budget abort).
Each entry in hook.apps is a deliberately narrow handle. It exposes what a
debugger legitimately needs and nothing else — there is no path from it to the raw
runtime internals, values are serialised before they cross, and every write goes through
the same reactive pipeline an event handler uses. The first seven members are the
version‑1 contract and are always present; everything after them is optional, so a
frontend feature‑detects and degrades instead of throwing.
| Member | Gives you |
|---|---|
id | Stable per‑element id, aktion-app-1, aktion-app-2, … |
label | The name in the app picker: data-devtools-label if present, else the element’s id, else the generated id. |
element | The host element, for highlighting and scroll‑into‑view. |
getState() | The current reactive $state snapshot. |
setState(path, value) | Write an atom. Dotted paths are rebuilt immutably. |
getProgram() | The current program source text. |
forceRender() | Force one non‑memoized render. |
| Program & diagnostics | |
setProgram(text) | Hot‑swap the running program, preserving state across the diff. |
getSources() | Per‑module sources of a linked program. |
getDiagnostics() | Structured parse + schema diagnostics from the last plan (line, column, kind, severity). |
analyzeProgram(text?) | Parse, validate, and outline a candidate program without mounting it. |
reload() | Re‑plan and re‑render from the current source. |
| Inspector | |
getRenderRoot() | The element the app paints into — the anchor for DOM inspection. |
getComponentTree() | The instance tree of the last commit, parented and depth‑ordered. |
getInstance(key) | One instance in full: props, hooks, UI state, deps, effects, ancestors, DOM. |
instanceForNode(node) | The instance that rendered a DOM node — the element picker's other half. |
nodeForInstance(key) | The element an instance rendered. |
setInstanceHook(key, slot, value) | Write one per‑instance $state / $ref cell. |
setInstanceUiState(key, slot, value) | Write one useInstanceState slot. |
setPropOverride(key, prop, value) | Force a prop on one instance. |
clearPropOverride(key, prop?) | Drop one override, or all of that instance's. |
listPropOverrides() | Every active override. |
remountInstance(key) | Drop the instance's memo, hooks, and UI state so it mounts fresh. |
| State, effects, data | |
getStateMeta() | Per‑atom metadata: runtime‑owned, derived, declaring module, source position. |
resetState(names?) | Reset atoms to their declared defaults. |
hydrateState(snapshot) | Restore a snapshot — the write half of time travel. |
evaluateExpression(src) | Evaluate an Aktion expression against the live scope. |
getEffects() / runEffect(key) | Every mounted effect with its subscriptions; fire one on demand. |
getQueries(), refetchQuery, cancelQuery, invalidateQueries | The $query cache, and the three things you do to it. |
getStores() / callStoreMethod(atom, method, args?) | $store / $form handles, and their methods. |
| Router, theme, network, stats | |
getRoute() / navigate(path) | Current path, pattern, params, mode, declared routes; and navigation. |
getTheme(), setThemeTokens, clearThemeTokens, setThemeName | Resolved tokens, live overrides, and the theme switcher. |
setNetworkRules(rules) / getNetworkRules() | Install and read the delay / mock / fail / offline rules. |
getStats() | DOM nodes, elements, instances, atoms, effects, queries, stores, program size, JS heap. |
So data-devtools-label is the one authoring hook on the app side. With
several apps on a page, name them — otherwise the picker reads
aktion-app-1, aktion-app-2, and you will pick the wrong one.
<aktion-app data-devtools-label="Checkout form"></aktion-app>
<aktion-app data-devtools-label="Live order feed"></aktion-app>
There is also el.connectDevtools() on the element itself. The panel calls
it on every aktion-app it finds when it opens, which is what makes
late‑attach work; call it yourself for an app the panel cannot reach with a
document query.
How it works
The architecture is the browser‑DevTools split: a backend (the runtime) and a frontend (the panel) that only ever talk through structured, JSON‑serialisable events.
-
The runtime emits to a global hook at
window.__AKTION_DEVTOOLS_HOOK__, exactly like React’s__REACT_DEVTOOLS_GLOBAL_HOOK__. Nothing installs that hook until a frontend does. - The hook buffers. Recent events are kept in a ring buffer, so a panel that opens after an app has already rendered can backfill its timeline instead of starting blank.
-
Panels share one hub.
installDevtoolsHook()is idempotent, so a secondmountDevtools()attaches to the same registry and the same event stream. -
The protocol is transport‑agnostic. No payload contains a DOM
node, a class instance, or a function, so the same events could cross a
postMessageboundary into a browser‑extension panel.
Everything is bounded, so a session can run for hours without the panel’s model growing without limit. When a cap is hit the oldest entries are dropped.
| Cap | Limit |
|---|---|
| Hook event buffer | 2000 events — writable via hook.bufferLimit |
| Commits kept per app | 300 |
| Effect events kept per app | 600 |
| Requests kept per app | 300 |
| Console lines kept per app | 500 — consecutive duplicates collapse into a count |
| Navigations / emitted events / errors | 200 each |
| State snapshots kept for time travel | 60 commits |
| Props recorded per instance | 40 |
| Elements examined by the a11y audit | 4000 — it says when it truncated |
| Rows in a flat log view | 250–400 most recent matches |
| Change‑flash duration | 1100 ms |
What it costs when it is not open
The runtime always attempts to talk to the hook, so the cost question is really about which of three states you are in. Only the third one is not free.
| State | isDevtoolsActive() | What the runtime pays |
|---|---|---|
You never import aktion-runtime/devtools |
false |
One property read per emit site. No profiler records, no event objects, no panel code in the bundle. |
Hook installed, but nothing subscribed — including after destroy() |
false |
The same single property read. Instrumentation stays dormant. |
A panel is mounted — even hidden by close() or open: false |
true |
Per‑commit timing plus one record per component instance, a state snapshot on every flush, and whatever the instrumentation switches are set to. |
The gate is decided once per commit, so the profiler flag stays consistent across a
whole render pass. A hidden panel skips its own rendering, but it is still subscribed
— which is why destroy(), not close(), is what you call
before measuring real performance numbers.
Within the third state, the Settings tab decides how much you pay: prop capture serialises every instance's arguments per commit, DOM tagging writes an attribute per rendered element, and snapshots clone the store per commit. Turning one off makes the app faster, not just the panel — which is what you want when the thing you are measuring is render time itself.
Do not ship it enabled
Gate the mount, not just the panel. Put
mountDevtools() behind a dynamic import() that only a flag,
a shortcut, or a non‑production build can reach. The panel can read and write
any atom in the program, so an always‑mounted DevTools in production is a
debugging surface you did not intend to publish — see
Security.
Troubleshooting
A handful of failures account for almost every “DevTools isn’t working” report. Work down the list in order — each one rules out the ones below it.
No panel appears
-
The import alone does nothing. You have to call
mountDevtools(), or place an<aktion-devtools>element in the document. A bundler may also have tree‑shaken an import whose result you never used. -
You passed
open: false. The panel is mounted and recording, just hidden. Callcontroller.open(). -
It is off‑screen. The corner position is computed once, at
mount, from the viewport size. Shrink the window afterwards and the panel can sit
outside it. Drag the header back, or
destroy()and mount again. -
A
containerancestor traps it. Atransform,filter,backdrop-filter, orcontainon an ancestor establishes a containing block forposition: fixed, which clips or mispositions the panel. Mount intodocument.body, which is the default.
The hook is not installed
Type window.__AKTION_DEVTOOLS_HOOK__ in the console. If it is
undefined, no frontend has run — the runtime never installs the hook
by itself.
- Confirm from code with
isDevtoolsInstalled(), and check whether anything is actually listening withisDevtoolsActive(). - Two realms will not see each other. A panel mounted inside an iframe installs the hook on that iframe’s
globalThis; an app in the parent document emits to the parent’s. Mount the panel in the same document as the app. - Check the load order of the bundles only if the app itself is broken: the hook does not depend on the runtime, and either import may come first.
“No Aktion app detected”
The panel adopts apps two ways: they register themselves when they render, and on open
the panel calls connectDevtools() on every aktion-app it can
find with a document query. If the picker is still empty:
-
The element never upgraded.
import "aktion-runtime"has to run for<aktion-app>to become a custom element. An un‑upgraded tag has noconnectDevtoolsand is skipped silently. -
The app is inside a shadow root or an iframe.
document.querySelectorAllpierces neither. Reach the element yourself and callel.connectDevtools(). -
The tag is created later. An app added to the DOM after the panel
opened registers on its first render, so give it one interaction — or call
connectDevtools()right after you insert it.
The Profiler tab is empty
- Commits are only recorded while a frontend is subscribed. Renders that happened before you opened the panel were never instrumented. Attaching forces one full render per app, so you should see at least one commit immediately — if you see exactly one, that is it.
- Recording is paused. The header button flips to Paused and the dot goes grey. Paused events are dropped by the panel, not queued, so they do not appear when you resume.
- You pressed Clear, which empties the strip and un‑pins the selected commit.
- The app is idle. Nothing has changed, so nothing has committed. Interact with it, or give it an
every(N)effect.
The State tab is empty, or an edit does nothing
- Only
$‑prefixed atoms are state. Plainlet/constbindings are re‑seeded each render and never appear in the tree. - You clicked a container. Only leaf values are editable. Expand the object and edit the value inside.
- The row is tagged
reserved.routeis runtime‑owned; change the URL through the router instead. See Routing. - Your input parsed as a string.
JSON.parseruns first, sotruewrites a boolean butyeswrites the text"yes". Quote it —"true"— when you really want the string. - The value snaps back. Something re‑derives it: an effect, an
$httpresolution, or a$memo. Watch the Effects tab immediately after the edit to see which.
When the DOM reverts instead of the state
Add strict to the app element. If an attribute a handler
wrote onto the live DOM keeps disappearing, that is the reconciler, not DevTools:
<aktion-app strict> arms a guard that logs exactly which attribute
each commit reverted and on which element. See
Troubleshooting.
The Inspect tab is empty, or the picker selects nothing
-
The tree comes from the last commit. An app that has not rendered
since the panel opened has no tree. Attaching forces one render, so this is normally
only a problem right after
hook.clearBuffer(). -
tagDomis off. Without it there is nodata-aktion-instanceattribute to resolve, so the picker cannot map a click to a component and highlighting does nothing. Turn it back on in Settings. -
The row says
no dom. That instance rendered a fragment with no host element — aShow/Async/Lazybranch. Its props and state are still inspectable; there is just nothing to outline. - You picked something the panel is covering. The hit test is positional, so a floating panel over the element wins. Dock the panel to an edge, or scroll the element clear of it.
- The node belongs to no component. A preserved third‑party widget's internals, or part of the host page. The panel says so and still shows the DOM, attributes, and styles.
A prop edit does not stick
-
The prop is
$‑bound. Then the atom is the source of truth and the edit writes it — look for the change in the State tab, and expect other readers of that atom to move too. - The value is not serialisable. A callback, a live resource, or a DOM node is shown read‑only, because an inspector that offered an edit it cannot apply is worse than one that does not offer it.
-
The program was re‑planned. Overrides are keyed by instance and are
dropped on
setResponse,appendChunk, orreload(). -
The instance remounted under a new key. A changed
key:, or a conditional branch flipping, produces a different instance — and the override belonged to the old one.
The Network tab shows nothing
-
Only the Aktion HTTP layer is tapped.
$query,$mutation,Http({...}), and$httpappear here; a barefetch()from a host page does not. Use the browser's own network panel for that. captureNetworkis off in Settings — which also disables request rules.-
A rule is answering everything. A mocked or blocked row is labelled
with the rule that produced it, and each rule shows its match count. An
offlinerule with an empty pattern matches every request.
Next
Performance
Turn a profiler reading into a fix — memoization, virtual lists, and workers.
Read the guide → BehaviourSide effects
Triggers, intervals, and cleanup — the declarations the Effect timeline shows.
Open the guide → GuideTroubleshooting
Symptom→cause→fix for the bugs a profiler reading will not explain.
Read the guide →