Guide

Error handling & debugging.

Aktion fails safely by design — partial, mid-stream LLM output should never crash the page. That same design hides bugs from a human developer. This guide shows the real messages, the two switches that make them visible, and the failures that stay silent on purpose.

The mental model in one line

By default Aktion swallows problems so a half-streamed program still renders. Add strict while developing to turn some of that silence into console.warns, add showerrors to render the parse-error banner, listen for the host error event to route failures into telemetry, and wrap risky subtrees in ErrorBoundary so a throw shows a card instead of a blank region.

Two audiences, two modes

The runtime serves a streaming LLM, which emits incomplete programs token by token, and a human developer, who wants loud feedback. The default is the safe, quiet behaviour for streaming. Every row below is the runtime’s default, and it is what ships to production unless you change it.

SituationDefault behaviourExtra diagnostic under strict
Unknown identifierEvaluates to null; interpolates as an empty string.Yes — one console.warn per name.
Trailing {…} matching no parameter of a user componentForwarded as a positional argument.Yes — one console.warn per component and key set.
A handler wrote a DOM attribute the render does not reproduceReverted on the next commit.Yes — one console.warn per program.
Unknown component nameRenders a Skeleton placeholder.No.
Non-callable callee ($x() where $x is a number)Returns null.No.
Rejected inline style valueDropped from the style attribute.No — see Security.
Missing $i18n keyReturns the key itself.No.
Unknown prop, bad enum value, too many positionalsReported — the schema validator turns these into errors alongside parse errors.Same.

The last row is the one people miss: prop-level mistakes are not silent. They arrive through the same channel as parse errors, described in Parse errors below.

The demo shows the unknown-component default in action. A reference to a component that does not exist renders a Skeleton instead of crashing — exactly what you want while an LLM is still streaming the function that will define it.

Live — an unknown component falls back to a Skeleton
$app(Column([
  Text("Known components render normally:", { variant: "large-heavy" }),
  Badge("I exist", "success"),
  Text("An unknown component becomes a Skeleton (no crash):"),
  ChartThatDoesNotExist(),
], { gap: "md" }))

Enabling strict mode

Add the strict attribute to the host element. Use it in development and tests; leave it off in the live LLM surface where partial output is expected.

<aktion-app strict showerrors></aktion-app>

Strict mode adds exactly three diagnostics, all console.warns, all de-duplicated so a re-rendering program cannot flood the console:

DiagnosticFiresDe-duplicated by
Unknown identifier resolved to nullThe first time each name fails to resolve.Identifier name, per program.
Named→positional flip on a user componentWhen a trailing object’s keys match none of the declaration’s parameters.Component name plus the sorted key set.
Commit reverted a handler’s DOM writeWhen a MutationObserver sees the reconciler undo an attribute a handler wrote.Once per program.

Here are the first two, verbatim, from a program with a misspelled atom and a renamed parameter:

[aktion] strict: unknown identifier "couunt" resolved to null (line 1, col 16).
  Did you misspell a state atom, action, or component name?

[aktion] strict: object { title, body } passed to <Panel> (line 4, col 6) is being
  forwarded as a positional argument because none of its keys match a parameter
  (heading, note). If you meant named props, check for a renamed/misspelled parameter.

The second message names both sides — the keys you passed and the parameters that exist — which is usually enough to spot the rename without opening the declaration. The third diagnostic is documented with the reconciler it belongs to, in the morph contract.

Strict mode only changes diagnostics. It never changes rendered output, so you can safely toggle it per environment. Note that it does not make prop or enum mistakes louder — those are already reported — and it does not turn the sanitiser drops in Silent by design into warnings.

Parse errors

A parse error carries a line, a column and a message. The parser records the error, recovers to the next line, and keeps going — so the prefix that did parse still renders. Take this program, with a missing closing brace on the options object:

// ❌ the closing brace of the onClick options object is missing
$count = 0
$app(Column([
  Text(`Total: ${$count}`),
  Button("Add", { onClick: () => $count = $count + 1 )
]))

Two errors come back, and they point at the token that could not follow, not at the brace you forgot — a distinction worth internalising, because the fix is usually one line above the reported line:

Line 4: Expected Punctuation "}" but got Punctuation ")"
Line 5: Unexpected token Punctuation "]"

Schema errors arrive through the same list. They read differently because the validator knows what you meant:

Line 1: Unknown prop "colour" on <Button>. Known props: label, onClick, variant,
        type, size, icon, iconPosition, iconOnly, loading, fullWidth, disabled, href.

Line 1: <Button> variant="primaryish" — must be one of "primary", "secondary",
        "outline", "ghost", "link", "danger", "default".

Line 0: The UI root must be a component tree (e.g. `Text(...)`, `Column([...])`),
        not a bare string. Wrap it — `$app(Text(...))` — or render a component.

Line 0 means the error is about the program as a whole rather than a specific token, which is also how safety-budget aborts are reported.

The host renders errors into the DOM only when you opt in with the showerrors attribute (bare, ="true" or ="1"). Without it there is no banner at all — errors still fire the error event, but nothing appears on screen.

<div class="rui-error-banner">
  <div>2 parse issues (rendered partial UI):</div>
  <ul>
    <li>Line 4: Expected Punctuation "}" but got Punctuation ")"</li>
    <li>Line 5: Unexpected token Punctuation "]"</li>
  </ul>
</div>

The banner lives inside the shadow root, immediately before the rendered tree, and carries the hidden attribute when there is nothing to show. It lists at most five messages; the error event always carries the full set. Style it by targeting .rui-error-banner in a theme.

While the program is still streaming

Set the streaming attribute (or the streaming property) while chunks are arriving. The in-flight last line is almost always mid-token, so the banner is suppressed and the error event is deferred until you clear the flag.

const app = document.querySelector("aktion-app");
app.streaming = true;
for await (const delta of tokens) app.appendChunk(delta);
app.streaming = false;   // now the banner renders and `error` fires

Forgetting to clear it means you never see a single error — and forgetting to set it means every partial chunk flashes a banner. See Streaming throughput for why appendChunk is the right method here.

The error event

The host dispatches a composed, bubbling error CustomEvent. Listen for it to route problems into your own logging or telemetry — it fires whether or not showerrors is set.

const app = document.querySelector("aktion-app");
app.addEventListener("error", (e) => {
  for (const { line, column, message } of e.detail.errors) {
    console.error(`[aktion] ${line}:${column} ${message}`);
    // forward to Sentry / your telemetry here
  }
});

The detail shape is { errors: [{ line, column, message }] } for every kind of failure — parse errors, schema errors, and aborted renders alike. Two details matter for a telemetry sink:

The runtime guards

Two guards stop a program from taking the tab down with it. Both are always on; neither depends on strict.

Render-loop guard

If a $name = … write happens during render, the runtime applies the value but does not schedule another render — otherwise the render would re-trigger itself forever — and logs a warning. Seeing it means you are writing reactive state in render position.

The fix is to move the write into an event handler or an effect, or to seed it as component-local state with a PascalCase component or the $state hook. See Reactivity & rendering.

Safety-budget guard

Each render runs under a budget that bounds component recursion depth and total loop iterations. When a limit trips the evaluator throws RuntimeBudgetError, the render aborts, and the previous tick’s DOM is kept so the user still sees something.

Runtime aborted (component-depth): [aktion] runtime aborted at component "Foo":
  component recursion exceeded 150 levels — check for a component that calls
  itself directly or transitively.

The parenthesised kind is component-depth, iterations or array-length, and it tells you what to look for: a component that renders itself, an unbounded loop, or a range with a runaway size. The limits, the exact defaults, and how to change them live in Performance → the per-render safety budget — that page owns those numbers.

ErrorBoundary & $util.onError

A parse error is a problem with the program text. A throw is a problem at run time, and it has two containers depending on whether it happened during render or inside an action.

When a render throws

If a library component’s render throws, the renderer catches it, logs [aktion] failed to render <Name> with the stack, and substitutes a marker node so the rest of the tree still paints:

<div class="rui-render-error">[render error in Table]</div>

That developer text is not something you want a user to read, which is what ErrorBoundary is for. It renders its children, notices the marker, and swaps in a fallback instead.

PropTypeBehaviour
childrenNode[]Positional. The guarded subtree.
fallbackNodeRendered instead of the children. Omit it for the built-in card.
onErrorcallableonError(err), fired once when the failure is detected.
showDetailsbooleanAdds the message to the built-in card only. Default false.
onRetrycallableSet it to get a Retry button on the built-in card. Without it there is no button.
$attempt = 0

$app(ErrorBoundary(RiskyWidget(), {
  showDetails: true,
  onRetry: () => { $attempt = $attempt + 1 },
  onError: (err) => $console.error(err)
}))

Because no fallback is set, a failure in RiskyWidget renders the built-in card — “Something went wrong”, the message (because showDetails is on) and a Retry button (because onRetry is set), wrapped in role="alert". Pass a fallback instead when you want your own copy:

$app(ErrorBoundary(RiskyWidget(), {
  fallback: Callout("This widget is unavailable.", { tone: "danger" })
}))

A custom fallback ignores showDetails and onRetry — you are drawing the whole card, so put your own retry Button in it. Nesting works: the innermost boundary consumes the marker first, so exactly one boundary handles each failure.

When an action throws

A throw inside an onClick, an effect body or any other action is not a render failure, so ErrorBoundary never sees it. Register a program-level sink with $util.onError(fn) instead.

$util.onError(({ error, source }) => {
  $toast.error("Something went wrong")
  $console.error(`${source}: ${error}`)
})

source is the name of the declaration that threw, or "action" for an anonymous body. The hook runs before the default logging and does not swallow the error — it still propagates afterwards. Only one hook is active at a time; a second $util.onError call replaces the first.

Silent by design — symptom to cause

Some values are dropped without any diagnostic at all, because the alternative is worse: a guessed-at substitute, or an error thrown at a user over data an attacker controls. These are the symptoms that send people looking for a bug that is not there.

SymptomCauseWhat to do
x.constructor, x.__proto__ or x.prototype reads as empty. These three property names are unreadable and unwritable from a program on every access path. A dot or computed read yields undefined, a method call yields null, and a write is a no‑op. Nothing — this is deliberate. f.constructor("…")() was arbitrary code execution reachable from any program. See Security.
A $head({ link: […] }) entry never appears in <head>. rel is allow-listed. stylesheet, preload, modulepreload, prefetch, prerender and import are excluded, and an entry whose href fails the URL check is dropped whole. Load web fonts through $theme({ fonts: { import: […] } }); load stylesheets from the host page. See Document head.
$head({ base }) or $head({ htmlAttrs: { style } }) has no effect. base accepts same-origin relative paths only — any scheme is rejected. htmlAttrs is limited to lang, dir, class, translate, id and data-*. Set these on the host page. $head is the one API that writes outside the shadow root, so it is the most tightly allow-listed.
A Styles(…) sheet renders empty. The CSS exceeded 64 KB, contained one of </style, <script, expression(, javascript:, behavior: or @import, or the scope prop was not a plain selector. Check the console — this one always warns and names the reason. A rejected scope drops the whole sheet rather than leaking unscoped rules.
HTMLTag("svg", …) rendered a <div>. The tag is not in HTMLTag’s allow-list. SVG and MathML are excluded on purpose. Use Svg, Icon or Image. A one-time console.warn names the tag.
A role on the universal channel did nothing. role is allow-listed, and a value outside the list is dropped rather than applied. Roles needing owned children or matching ARIA state are excluded. Reach for the component that already implements the pattern. See Accessibility.
A $rows[bigIndex] = … write did nothing. An array index above 1 000 000 is refused so a single write cannot allocate a huge array. Paths containing __proto__, constructor or prototype are refused too. Store sparse data in an object rather than a high-index array. See the bounds table.
$script({ src }) never loads. Either the URL failed the scheme check, or a restricted global access policy is active — in which case $script is disabled outright. Read $s.error; $s.ready stays false so a program that gates on it degrades. See Security.
An uncontrolled TextArea or Select loses what the user did. A known reconciler limitation: only <input> can distinguish “the render asserts nothing” from “the render asserts empty”. Bind value to state. See the morph contract.

The pattern behind the first eight rows is the same one Security & the trust model explains: every URL, attribute, style and markup value a program renders is treated as untrusted data, so a value that fails its check is dropped rather than sanitised into something plausible-but-wrong.

Surfacing errors to end users

A developer wants the message; a user wants a way forward. Layer the four mechanisms so each audience gets what it needs from the same failure.

LayerCatchesShows the user
ErrorBoundary around a risky subtreeA render that threw.A card in place of the region, optionally with Retry.
$util.onError in the programA throw inside an action or effect.Whatever you render — usually $toast.error(…).
The host error eventParse errors, schema errors, budget aborts.Nothing by default — you decide.
showerrors on the hostThe same set, rendered into the shadow root.The developer banner. Not for production UI.

A reasonable production posture is: ErrorBoundary around each independently-failing region, one $util.onError sink wired to a toast, an error listener forwarding to telemetry, and showerrors plus strict on in development and staging only.

When you are debugging a specific symptom rather than building a strategy, Troubleshooting / FAQ is organised the other way round — symptom first, then cause and fix.

Diagnose with DevTools

When a value looks wrong or something re-renders unexpectedly, the DevTools panel is faster than reading messages. It gives you a state inspector, a commit profiler, an effect timeline, and a per-render “why did this render” reason.

Start with the state inspector to confirm the atom holds what you think, then read the commit reason to trace which write triggered the render. Performance → measure first walks through the same panel from the throughput angle.

Typo suggestions with suggestComponent

The runtime renders a Skeleton for an unknown component rather than guessing at a correction. If you are building a lint step, a build check or an editor, suggestComponent(name, library, limit?) gives you the “did you mean” candidates yourself.

import { suggestComponent, defaultLibrary } from "aktion-runtime";

suggestComponent("Buttn", defaultLibrary);       // → ["Button", "Buttons"]
suggestComponent("VirtualLst", defaultLibrary);  // → ["VirtualList", "VirtualGrid"]
suggestComponent("Button", defaultLibrary);      // → ["Button"]  (exact match short-circuits)

It ranks by edit distance and returns at most limit candidates (default 3), with a threshold that scales with the query length — so a short garbage name returns nothing rather than a misleading guess.

Next