Guide

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.

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 (loadingdata / 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.

ValidatorAcceptsOn 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.

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 elementWhy
<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 every on* handler is dropped case-insensitively.
  • href, ping, action, and formaction go through the href sanitiser; src and poster through the image sanitiser. A namespace prefix is stripped first, so xlink:href cannot skip the check.
  • Blocked outright: srcset, imagesrcset, srcdoc, data, background, manifest, http-equiv, html, innerhtml, outerhtml, textcontent, is.
  • A target other than _self/_parent/_top forces rel to carry both noopener and noreferrer, preserving any rel you 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