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.
| Situation | Default behaviour | Extra diagnostic under strict |
|---|---|---|
| Unknown identifier | Evaluates to null; interpolates as an empty string. | Yes — one console.warn per name. |
Trailing {…} matching no parameter of a user component | Forwarded as a positional argument. | Yes — one console.warn per component and key set. |
| A handler wrote a DOM attribute the render does not reproduce | Reverted on the next commit. | Yes — one console.warn per program. |
| Unknown component name | Renders a Skeleton placeholder. | No. |
Non-callable callee ($x() where $x is a number) | Returns null. | No. |
| Rejected inline style value | Dropped from the style attribute. | No — see Security. |
Missing $i18n key | Returns the key itself. | No. |
| Unknown prop, bad enum value, too many positionals | Reported — 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.
$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:
| Diagnostic | Fires | De-duplicated by |
|---|---|---|
Unknown identifier resolved to null | The first time each name fails to resolve. | Identifier name, per program. |
| Named→positional flip on a user component | When 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 write | When 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 error banner
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:
- Safety-budget aborts report
{ line: 0, column: 0, message }, with the message the runtime built. - The same abort can dispatch on more than one render pass, so
de-duplicate on
messagebefore you page somebody.
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.
| Prop | Type | Behaviour |
|---|---|---|
children | Node[] | Positional. The guarded subtree. |
fallback | Node | Rendered instead of the children. Omit it for the built-in card. |
onError | callable | onError(err), fired once when the failure is detected. |
showDetails | boolean | Adds the message to the built-in card only. Default false. |
onRetry | callable | Set 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.
| Symptom | Cause | What 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.
| Layer | Catches | Shows the user |
|---|---|---|
ErrorBoundary around a risky subtree | A render that threw. | A card in place of the region, optionally with Retry. |
$util.onError in the program | A throw inside an action or effect. | Whatever you render — usually $toast.error(…). |
The host error event | Parse errors, schema errors, budget aborts. | Nothing by default — you decide. |
showerrors on the host | The 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.