Rules for writing html\`` templates that render correctly in the browser and
inside an Obsidian plugin. Several of these are hard runtime errors, not style.
Reactive vs static — the core rule
import { html, reactive } from "@arrow-js/core"
const data = reactive({ count: 0 })
html`<span>${data.count}</span>` // ❌ renders ONCE — never updates
html`<span>${() => data.count}</span>` // ✅ tracked — updates only this slot
${value} is read once at mount. ${() => value} is tracked: Arrow records the
reactive reads inside the function and re-runs only that slot when they
change. Forgetting () => is the #1 "why isn't it updating" bug.
Attributes
An attribute expression must be the entire attribute value:
html`<div class="${() => (active() ? "tab is-active" : "tab")}">` // ✅
html`<div class="tab ${() => active() ? "is-active" : ""}">` // ❌ THROWS
Partial values ("static ${…}") are not registered as placeholders and throw
Invalid HTML position. Build the full string in one expression.
Returning false from an attribute expression removes the attribute (vs ""
which keeps it present-but-empty) — the clean way to toggle disabled/hidden:
html`<button disabled="${() => !canSubmit()}">Save</button>`
Properties, events, lists
html`<input .value="${() => data.text}" />` // .prop = IDL property
html`<button @click="${() => data.count++}">+</button>` // @event
html`${() => data.items.map((i) => html`<li>${i.text}</li>`.key(i.id))}` // keyed list
Keyed lists preserve DOM across reorders; mutate item fields in place for fine-grained updates instead of replacing the whole array.
Async sections
import { boundary } from "@arrow-js/framework"
const Card = component(
async () => { const d = await load(); return html`<div>${d.label}</div>` },
{ fallback: html`<div>Loading…</div>` } // shown while pending
)
html`${boundary(Card())}`
Works client-side with no SSR. boundary() only takes { idPrefix }; the
visible loading state comes from the async component's fallback option.
Event handler typing
An @event handler must be assignable to (e: Event) => void. A handler typed
with a narrowed subtype fails (parameter contravariance) — tsc reports
TS2345 … not assignable to 'ArrowExpression'. Type the param Event and
narrow inside; no-arg handlers are always fine.
// ❌ TS2345 — narrowed param
html`<div @mousedown="${(e: MouseEvent) => resize(e)}">`
// ✅ widen, narrow inside
html`<div @mousedown="${(e: Event) => resize(e as MouseEvent)}">`
(document.addEventListener handlers are unaffected — they're DOM-lib typed.)
Hard footguns
Render-time (pass tsc, fail only at render — always verify in a browser):
- No literal HTML comments inside templates. Arrow uses HTML comments as
expression-slot markers, so a literal
<!-- … -->inflates the slot count and throwsInvalid HTML position. Use JS//comments outside the template. - No partial attribute values — the expression must be the whole value
(see Attributes above), else
Invalid HTML position.
Type-time (caught by tsc):
- No narrowed
Eventsubtype in@eventhandlers (see Event handler typing above).
CI guards all three: test/template-footguns.test.mjs scans for <!-- and for
inline handlers typed with a narrowed Event subtype; tsc covers the rest.
Type-time (silently passes tsc, fails at render — CI cannot catch):
-
as unknown as ArrowTemplate/as unknown as ArrowExpressiondouble-cast. Casting throughunknownto the Arrow template type silencestscwhile producing a value that renders as escaped text or[object Object]at runtime. This exact pattern is the standard "fix the types later" shortcut, but for a template library it converts a correctness bug into a passing build:// ❌ passes tsc, renders as "[object Object]" at runtime return ({ strings: [...] }) as unknown as ArrowTemplate; // ✓ use a real template expression or a type-guard conversion return html`${realExpression}`;If a genuine narrowing is needed, prefer a helper function with an explicit type guard over a double-cast through
unknown. Treat anyas unknown astargeting the Arrow template types as a red flag in code review.
Beyond templates — runtime/lifecycle footguns
The footguns above are about the html\`template itself. There is a second class of trap in **reactivity + imperative DOM** (floating UI, positioning, focus) that also passestscand only bites at render — no DOM nodes inreactive(); requestAnimationFrameis paused in background tabs (usenextTick); always-mount floating elements and toggle is-hiddenrather than conditionally creating them; drive open/close side-effects from a class getter, notwatch. These live in the **arrow-js-obsidian-patterns** skill and in [docs/arrow-notes.md`](../../docs/arrow-notes.md).
Read them before building popovers, menus, or anything positioned.