Troubleshooting & FAQ.
The questions developers hit most often, with the cause and the fix in one place. Most surprising behaviour comes from Aktion’s streaming-first defaults — once you know the rule, the fix is short.
Find your symptom
- Input loses focus while typing
- Two-way binding isn’t updating
- An effect isn’t re-running
- A component didn’t update
- A list reorders or loses state
- My
$httpdata never appears Map(…)rendered the wrong thing- An inline style was dropped
- My theme isn’t applying
- A framework theme has no custom type
- A translation shows the key
- My cookie gained
SameSite=Lax - A
$head<link>never appears - My
Stylessheet renders empty - My inline SVG lost nodes
HTMLTagrendered a<div>$scripterrors and never loadsx.constructorisundefined- Nothing rendered, no error
- “Reactive write during render” warning
Rendering & reactivity
My Input loses focus on every keystroke / state change
Cause. The morph reconciler preserves focus, selection,
and input values across re-renders — but a subtree rendered through
Portal is re-created rather than morphed, so a focused field
inside a Portal loses focus whenever an unrelated state change re-renders
the tree.
Fix. Keep focused, editable content out of a
Portal. For overlays that must hold a form, prefer
Modal (which traps and restores focus). If you must keep an
input mounted, bind it to a top-level atom so typing only triggers a
path-scoped update rather than a full re-render of the surrounding tree.
For a hand-rolled overlay, FocusTrap and the rest
of the focus primitives are on
Accessibility.
Typing in an Input doesn’t update my state (two-way binding)
Cause. A control writes back only when its
value receives the bare
$variable — a plain reference such as
$name, a member chain rooted at one such as
$form.email, or a $store field such as
cart.draft.
Anything the runtime has to compute first — an interpolated string, a concatenation, a ternary — arrives as a value with no path attached. The control renders it and then has nowhere to write back to.
$name = ""
// ❌ interpolated — the field shows the value, typing goes nowhere
Input("name", { placeholder: "Your name", value: `${$name}` })
// ✅ the bare $variable in `value` IS the binding
Input("name", { label: "Your name", value: $name })
Fix. Pass the bare $variable as
value on Input, TextArea,
Select, Checkbox, Switch,
Slider, MultiSelect, and the other bindable
controls. That is the whole mechanism — there is no separate
binding argument. See Components for each
control’s prop list.
A trailing extra argument is not a binding slot
Do not write Input("Name", { value: $name },
$name). Aktion routes surplus positional arguments into
the next unfilled prop in declaration order, so that third
argument does not re-bind anything — it lands in
placeholder and your placeholder becomes the field’s
current value. On Select the same shape fills
label instead. The two-argument form above already binds
both ways.
My effect isn’t re-running
Cause. $effect(fn, [deps]) only re-runs when
one of its tracked dependencies changes: reactive atoms /
$store fields read in the dependency array, plus the
component lifecycle and timer ticks. A value that isn’t a reactive
read (a plain local, a prop captured by value) won’t re-trigger the
effect when it changes.
// ❌ depends on a plain local — never re-fires when $userId changes
$effect(() => load(id), [])
// ✅ list the reactive atom; the effect re-runs when $userId changes
$effect(() => load($userId), [$userId])
Fix. Put the actual reactive atom(s) the
effect depends on into the dependency array. If you need to react to a
derived value, derive it from atoms (or a $memo) so the
dependency is itself reactive. An empty array [] means
“run once on mount”.
My component didn’t update — it was “memoized away”
Cause. On a fine-grained update, a PascalCase component whose props are unchanged is skipped and its previous DOM is reused. If the component reads state it didn’t receive as a prop and that state changes via a path the component never subscribed to, it can be skipped.
Fix. Read the reactive value inside the component (so it subscribes to the path), or pass it in as a prop so a changed value breaks prop equality and forces a re-render. Use DevTools’ “why did this render” to confirm whether an instance was skipped.
Data, lists & async
My list reorders, animates wrong, or loses per-row input state
Cause. Without a stable key, the morph
reconciler matches list children by position. When the array
reorders, inserts, or filters, DOM nodes (and their focus / input
values) get reused for the wrong row.
// ❌ positional matching — inserting at the top shifts every row's DOM
$items.map(item => Card([Text(item.title)]))
// ✅ key by a stable id so identity follows the data
$items.map(item => Card([Text(item.title)], { key: item.id }))
Fix. Give every item in a mapped list a
key tied to stable data identity (an id, not the array
index). This also lets the memoizer skip unchanged rows — see
Performance.
My $http data never appears (blank where the data should be)
Cause. An $http({...}) call returns a
resource with a lifecycle (loading →
data / error), not the data directly. Rendering
the resource object itself shows nothing useful while it’s still
loading.
$users = $http({ url: "/api/users" })
// ✅ switch on the resource state with Async
Async($users, {
loading: Spinner(),
error: Callout("Couldn't load users", { tone: "danger" }),
empty: EmptyState("No users yet"),
data: List($users.data.map(u => ListItem(u.name))),
})
Fix. Render the resource through
Async(resource, { loading, error, empty, data }) (or read
resource.data / resource.state yourself). See
HTTP.
Common gotchas
Map(...) rendered something unexpected (not a JS Map)
Cause. Map is a built-in component
name in Aktion (the geographic map). A bare Map(…) in
render position resolves to that component, not the JavaScript
Map constructor.
Fix. For a JS hash map use an object literal
({}) or the array/object helpers in $util
($util.keyBy, $util.groupBy). Reserve
Map(…) for the map component.
My inline style was dropped
Cause. Every length and colour value that reaches an
inline style attribute — through sx, through
Container.maxWidth, through chart geometry — passes one
of two validators, and both are allow-lists rather than blocklists.
| Validator | Accepts | On rejection |
|---|---|---|
| Lengths / sizes | Letters, digits, and . % + - * / ( ) , plus spaces. Max 64 characters. So clamp(280px, 40vw, 640px) and var(--rui-spacing-l) both pass. |
The component’s own fallback value. |
| Colours | Letters, digits, and # % . , ( ) + - plus spaces. Max 64 characters. Additionally rejected outright: url(, expression(, javascript:, @import. |
"" — the declaration is omitted entirely. |
Neither validator logs anything, in any mode.
strict does not surface these — there is no console
warning for a rejected length or colour, which is exactly why the symptom
reads as “my style vanished”.
Fix. Log the string you are actually passing.
Quotes, semicolons, and braces are the usual culprits — a value
assembled by concatenation that picks up a stray ; falls
outside both alphabets, and a value over 64 characters is rejected for
length alone regardless of content.
Prefer theme tokens (var(--rui-…)) over
hand-built strings: see sx and
Themes. The Styles
component is louder than this — it warns on every rejection (see
below).
My $theme({...}) override isn’t applying
Cause. Two things commonly bite: (1) the in-program
$theme({...}) form takes grouped keys
(colors.primary), while the host theme attribute
and setTheme() take flat keys
(colorPrimary); and (2) unknown token names are silently
ignored as a typo guard, so a misspelled token does nothing.
// ✅ in-program: grouped keys
$theme({ colors: { primary: "#e11d48" }, radius: { md: "14px" } })
// ✅ host attribute: flat keys (JSON)
// <aktion-app theme='{"colorPrimary":"#e11d48"}'></aktion-app>
Fix. Match the key shape to where you set the theme, and check the token name against the Themes token reference.
A framework theme renders in the system font
Cause. Four themes declare web fonts — Geist for
shadcn, Roboto for mui, Inter for
heroui, IBM Plex Sans + Mono for signal.
Activating one appends
<link rel="stylesheet" href="https://fonts.googleapis.com/css2…">
to the host <head>. If that request is blocked or fails,
each token’s fallback chain takes over and you get
system-ui — with no error, because a font fallback is
not an error.
Fix. Allow
https://fonts.googleapis.com in style-src and
https://fonts.gstatic.com in font-src, then check
the Network panel for the css2 request. A blocked stylesheet
shows up as a CSP violation in the console; a 404 does not. See
the CSP policy and
theme fonts.
One related gotcha: soft names a display family in
its tokens that it does not import, so headings fall back to
the platform stack unless you import it yourself.
And with
$theme({ fonts: { import: ["Inter:400,600"] } }) the family
name must match /^[A-Za-z0-9 ]{1,48}$/ and each weight must be
an integer from 100 to 900. An entry failing either check is dropped from
the constructed URL rather than reported.
A missing translation shows the key instead of text
Cause. When an $i18n key has no entry for the
active locale, the runtime returns the key itself rather than throwing —
so a partially-translated app still renders.
Fix. Add the missing key to the locale table. In strict mode, missing keys are surfaced as console warnings so you can find them during development.
My cookie suddenly has SameSite=Lax
Cause. storage.cookies.set now
always emits a SameSite attribute, and
Lax is the default when you do not pass one. Leaving it off
used to hand cross-site behaviour to per-engine browser defaults; emitting
it makes the behaviour the same everywhere and matches what modern browsers
apply anyway.
// Default — SameSite=Lax; Path=/ is also always emitted
storage.cookies.set("theme", "dark")
// Explicit — a genuine cross-site cookie needs both
storage.cookies.set("sid", $token, { sameSite: "None", secure: true })
Fix. Pass sameSite explicitly.
The value is normalised to Strict, Lax, or
None, and anything else becomes Lax. Browsers
reject SameSite=None without Secure, so set both
together.
Three neighbouring rules in the same writer:
path defaults to / and falls back to
/ when it fails validation; domain is dropped
unless it is a valid hostname of at most 253 characters; and names and
values are percent-encoded on write, decoded per entry on read, so one
malformed cookie cannot break the whole jar.
Allow-lists & sanitisers
Five APIs are allow-listed rather than filtered, because each one writes
somewhere a blocklist cannot safely defend: the host
<head>, a raw stylesheet, raw SVG, raw HTML attributes,
and a remote <script>. When something disappears from one
of them, it is almost always the allow-list.
The trap is that the schema validator does not flag any of this. The allow-lists are runtime behaviour, so your program parses and validates cleanly and then quietly renders less than you wrote. Security is the full reference.
My $head <link rel="stylesheet"> never appears
Cause. $head is the only runtime API that
writes outside the shadow root, into the host
<head> and <html>, so every field it
accepts is allow-listed rather than filtered.
rel must be one of 17 values, and stylesheet is
deliberately not among them — neither are preload,
modulepreload, prefetch, prerender,
or import, each of which can be turned into a script load. An
entry with a disallowed rel, or an href that does
not survive sanitisation, is dropped whole and silently.
$app(Column([
$head({
// ✅ allowed: canonical, alternate, prev, next, author, license, help,
// icon, shortcut icon, apple-touch-icon, apple-touch-icon-precomposed,
// mask-icon, manifest, search, dns-prefetch, preconnect, me
link: [{ rel: "canonical", href: "https://acme.example/invoices" }],
htmlAttrs: { lang: "en", dir: "ltr" }
}),
Text("Invoices")
]))
Fix. For web fonts use
$theme({ fonts: { import: […] } }) — the vetted
path, which builds the fonts.googleapis.com URL itself. For
your own CSS, either link the stylesheet from the host page or use the
Styles component inside the app. See
Document head.
The same shape applies to the other fields.
htmlAttrs accepts only lang, dir,
class, translate, id, and
data-* — not style.
base accepts same-origin relative paths only, and a
meta key is always emitted as a name attribute,
never as http-equiv.
My Styles sheet renders empty
Cause. The Styles component has two
independent gates, and either one produces an empty
<style>. The CSS itself is rejected if it exceeds
64 KB or contains </style, <script,
expression(, javascript:, behavior:,
or @import.
Separately, scope is concatenated into the generated sheet, so
it must be a plain selector: class, id, or tag compounds joined by
descendant, child, or sibling combinators, at most 128 characters.
Check the console. Unlike the SVG and HTML sanitisers,
which drop silently, this one announces every rejection with a
console.warn naming the gate it hit — precisely because
a silently empty <style> is indistinguishable from a
typo’d selector.
$app(Column([
// ❌ dropped: @import is on the blocklist
// Styles("@import url(/theme.css); .panel { padding: 12px }")
// ❌ dropped whole: `scope` is not a plain selector, and an unscoped
// fallback would leak every rule to the shadow root
// Styles(".panel { padding: 12px }", { scope: ".a { } .b" })
// ✅
Styles(".panel { padding: 12px; border-radius: 8px }", { scope: ".panel" }),
Text("Scoped")
]))
Fix. Move an @import to the host
page, or use $theme({ fonts: { import: […] } }) when it
is a web font. Keep scope to a single plain selector. Token
interpolation still works inside the CSS —
padding: {spacing.l} becomes
var(--rui-spacing-l) — so you rarely need anything the
blocklist rejects.
My inline SVG came back with nodes missing
Cause. Markup passed to Svg(…) or
registered through $theme({ icons }) is parsed in an inert
document and then rebuilt from an element and attribute allow-list.
Anything outside the list is dropped, and nothing is logged.
| Dropped element | Why |
|---|---|
<a> | SVG anchors execute javascript: hrefs. |
<style> | CSS injection — @import, background beacons, full-cover overlays. |
<image> | Fetches an external URL (a tracking beacon) and can reference a nested SVG document. |
<foreignObject> | Re-enters the HTML namespace, which makes every HTML element — including <script> — reachable. |
<set> | Assigns an arbitrary attribute value, defeating attribute sanitisation. |
<discard> | Removes elements at a scheduled time. |
Fix. Export flattened SVG. Replace a
<style> block with per-element attributes or
fill="currentColor"; replace an embedded
<image> with the Image component; wrap the
whole Svg in a Link instead of using an SVG
anchor.
Two more rules that surprise people. Inline
style on an SVG node survives only up to 512 characters and
must not contain < > { } @ \ " ', url(,
image-set(, element(, expression(,
behavior:, or -- — so CSS custom properties
do not work there.
And href / xlink:href
survive only as same-document fragments (#gradient-1) and only
on use, mpath, and textPath. Size
bounds are 64 KB of input, 4096 nodes, and depth 32 — see
resource bounds.
HTMLTag("svg", …) rendered a <div>
Cause. HTMLTag’s tag name goes through
an allow-list of structural, text, table, media, and form tags. SVG and
MathML are excluded on purpose — their namespaces need a different
createElement path, so a bare createElement("svg")
would produce an HTML element that merely looks like SVG. An unknown tag
collapses to div and warns once on the console, naming the
tag.
Fix. Use Svg for inline vector
markup, Icon for icons, and Image for a
.svg file loaded by URL.
The attribute rules are worth knowing while you are here:
- Names must match
/^[a-zA-Z][a-zA-Z0-9_:-]*$/, and everyon*handler is dropped case-insensitively. href,ping,action, andformactiongo through the href sanitiser;srcandposterthrough the image sanitiser. A namespace prefix is stripped first, soxlink:hrefcannot skip the check.- Blocked outright:
srcset,imagesrcset,srcdoc,data,background,manifest,http-equiv,html,innerhtml,outerhtml,textcontent,is. - A
targetother than_self/_parent/_topforcesrelto carry bothnoopenerandnoreferrer, preserving anyrelyou supplied.
$script reports an error and never loads
Cause — gate 1, the scheme. src must be
http(s) or a same-origin relative path (/,
./, ../). Protocol-relative
//host/x.js, javascript:, data:, and
blob: are all rejected, and control characters are stripped
before the scheme is read.
Cause — gate 2, the policy. $script is
disabled outright whenever the global-access policy is anything other than
"all", because it downloads and executes remote code —
exactly what a narrowed policy exists to prevent.
Either way the resource comes back with error set and
ready still false. It never throws, so a program
that gates on ready degrades instead of crashing.
$stripe = $script({ src: "https://js.stripe.com/v3/", global: "Stripe" })
$app(Column([
$stripe.error ? Callout("Payment SDK unavailable", { tone: "danger", description: `${$stripe.error}` }) : Spacer(),
$stripe.ready ? Text("SDK loaded") : Spinner()
]))
Fix. Render $stripe.error while
you debug — it names the gate. For the scheme case, use an absolute
https:// URL or a same-origin path. For the policy case,
either fetch what you needed through $http instead of loading
an SDK, or keep that surface on the default "all" policy.
Note the policy is process-global: one
setGlobalAccessPolicy call disables $script for
every program on the page. See
$script and the policy.
Under SSR there is no DOM, so the resource simply stays
un-ready rather than erroring — another reason to branch on
ready instead of assuming the script arrived.
x.constructor is undefined
Cause. Three property names are unreadable from Aktion
on every access path: constructor, __proto__, and
prototype. Dot access, computed access
(x["cons" + "tructor"]), and method-call dispatch each resolve
properties independently, and all three enforce the block.
The reason is that every lambda you write is a real JS function, so
f.constructor would be Function and
f.constructor("…")() would be arbitrary code execution.
Blocking these three names is what makes narrowing the global surface mean
anything at all — see
forbidden property names.
Writes through the same names are silent no-ops.
Fix. There is no opt-out and no policy that
re-enables them. For a type check use typeof or
Array.isArray(x); for a class name from an API response, put
the name in the payload and read it as an ordinary field.
Silent failures & warnings
Nothing rendered and there was no error
Cause. By design, unknown identifiers evaluate to
null, non-callable callees return null, and an
unknown component renders a Skeleton — so mid-stream
partial output never crashes.
Fix. Enable strict mode
(strict attribute on <aktion-app>) to turn
these silent fallbacks into console warnings. See
Error handling & debugging.
I got a “reactive write during render” warning
Cause. A $name = … assignment ran in
render position (commonly a $x = … at the top of a
lowercase function that is invoked to build UI). The runtime applies the
write without scheduling a re-render to avoid an infinite loop, and warns.
Fix. Seed component-local state with a
PascalCase component (so $x = … becomes a
set-once per-instance declaration) or the $state hook, and
only write state from event handlers / effects. See
Reactivity & rendering.
Still stuck?
Two switches surface almost everything: add the strict
attribute to <aktion-app> to turn silent fallbacks into
console warnings (see Error handling), and open
the DevTools panel to inspect live state and
read each commit’s “why did this render” reason.
Neither one reaches an allow-list drop — those are runtime behaviour, not diagnostics. When something you wrote simply is not in the output, read the rules in Security rather than looking for a warning that will never arrive.
If the problem is on the host side instead — an event payload or an
element method coming back as any, or a subpath import that
will not resolve — the typed surface is documented in
TypeScript.
Next
Error handling & debugging
Reading parse/runtime errors, the guards, the error event, and strict mode.
Reactivity & rendering
Why re-renders happen and how to keep them fine-grained.
Read the guide → AdvancedDevTools
Diagnose unexpected re-renders with the commit profiler.
Open the guide →