How to build real components (not just template syntax) for an Obsidian plugin
view with @arrow-js/core. Pairs with arrow-js-obsidian-templates (the
template rules + footguns).
Mount / unmount lifecycle
A view mounts a component imperatively, the same call the sandbox makes:
import { html } from "@arrow-js/core"
// in ItemView.onOpen():
this.contentEl.empty()
html`${MyComponent()}`(this.contentEl)
html\`(container)does **not** reliably return an unmount function — don't gate cleanup on its return value. Alwayscontainer.empty()` before remounting.- Re-rendering on route/state change: clear the container, then mount fresh.
Reactive bindings (
${() => …}) update in place — don't remount the whole tree on every state change; let Arrow patch the slots that read changed state.
Shared reactive state
One reactive() object is the single source of truth; components read it via
getters so they stay in sync without prop-drilling.
import { reactive } from "@arrow-js/core"
export const state = reactive({ model: "", streaming: false, items: [] })
// pass getters into components: Toggle(() => state.streaming, () => { ... })
Reactivity & imperative DOM (floating UI) gotchas
Runtime traps that typecheck clean — hit them building popovers/menus. Full write-up
with code: docs/arrow-notes.md.
- Never put a DOM node (or class instance / function) in
reactive(). It gets deep-proxied, so you get a Proxy of the element back — geometry reads misbehave and positioning silently no-ops. Keep DOM refs (e.g. a popover's anchor/trigger) in a plain closure variable; only put serialisable data inreactive(). Resolve a trigger in the handler viae.target.closest("…"); type handlers(e: Event). requestAnimationFrameis paused in background/unfocused tabs — a popover that positions or wires its dismiss inside a rAF silently never runs (intermittently). Use a microtask (nextTick/queueMicrotask/setTimeout) for any correctness-critical DOM work; reserve rAF for skippable visual animation.- Always-mount floating elements; toggle an
is-hiddenclass rather than conditionally creating them. A conditionally-rendered nestedhtml\`may not be committed whennextTickfires (getElementById→null`); an always-mounted box is committed once, so lookups are reliable and there's no race. (Mirrors the plugin's session popover.) - Drive open/close side-effects from an attribute (class) getter, not
watchor a side-effect-only slot. Bothwatch(() => state.x, cb)and a trailing${() => { fx(); return "" }}slot were observed not to re-run. Aclass/stylegetter re-runs reliably (Arrow must recompute the attribute) — co-locate the effect there, guarded to be idempotent. - Floating controller: one shared controller,
position: fixed, positioned off the trigger'sgetBoundingClientRect(), clamped to bounds, flipped on overflow; reposition on scroll/resize; wire dismiss (click-outside/Escape) synchronously from the positioning nextTick, and ignore clicks on the trigger so a toggle closes cleanly.
Icons (Obsidian uses Lucide)
setIcon(el, name) needs a real DOM element, so it cannot be called inside
an Arrow template expression (the element doesn't exist yet). Two options:
-
In-plugin — the data-icon sweep. Emit a placeholder, then sweep after mount:
html`<span class="svg-icon" data-icon="copy"></span>` // after template(container) — scope the query to the container, not document: for (const el of Array.from(container.querySelectorAll<HTMLElement>("[data-icon]"))) { const name = el.getAttribute("data-icon") if (name) setIcon(el, name) // setIcon from "obsidian" }For sections that open after mount (dropdowns/popovers), run the sweep in a
nextTick(...)after they render, scoped to that section's element. -
In the sandbox (no
obsidianmodule). Useicon(name)fromsrc/components/icons.ts— it returns a glyph string for common Lucide names and is shim-free. Always import from icons.ts; never define a local ICON_MAP in component code. When porting to the plugin, add a Lucide-backedsetIconshim (aliased asobsidian) and swap theicon()calls tosetIcon.import { icon } from "./icons"; // sandbox: glyph string // html`<span>${icon("search")}</span>` → "⌕"Available names:
check,x,chevron-right,chevron-down,loader,search,file,folder,info,warning,error.
CSS scoping & specificity (the big one)
Obsidian's app.css applies global rules like
button:not(.clickable-icon) { background: var(--interactive-normal) } at
specificity (0,1,1). A plain .my-btn selector is (0,1,0) and loses
regardless of cascade order.
/* (0,1,0) — LOSES to Obsidian's global button rule */
.my-action { background: var(--interactive-accent); }
/* (0,2,1) — WINS, and can't leak */
.my-panel button.my-action { background: var(--interactive-accent); }
Rules: prefer Obsidian's own classes (.setting-item, .clickable-icon,
.workspace-leaf, .vertical-tab-*, .modal) and var(--…) tokens first; only
add custom CSS where there's no Obsidian class; always scope custom rules under a
container class + element type.
Component composition analysis
Before extracting any primitive, survey all existing implementations. The CaretPopover
failure pattern — designing from conceptual similarity without studying shared template
structure — produces primitives that don't fit real consumers and must be deleted.
The correct sequence:
- Read every hand-rolled implementation in the area. All of them.
- Find repeated DOM structures and CSS class patterns — not conceptual similarities.
- Design the primitive from those actual shared fragments. If you can't point to two identical code fragments, there is no primitive yet.
- Extract from one consumer, migrate the second immediately in the same session.
- The primitive + consumers must be shorter than the originals. If not, the abstraction is wrong — return to the survey.
What this replaces: "only extract when 2+ consumers are ready." That's a symptom fix. The real fix is doing the survey before designing anything.
Common Obsidian layout classes
.workspace-leaf>.workspace-leaf-content— the pane/tab container that holds a View (WorkspaceLeaf)..view-header/.view-content— pane header + body..setting-item(+.setting-item-info/.setting-item-name/.setting-item-description/.setting-item-control) — settings rows..checkbox-container(+.is-enabled) — toggle..clickable-icon— icon button (escapes the global button background rule)..vertical-tab-header/.vertical-tab-nav-item(+.is-active) — tab nav..modal-container>.modal— modal/popover..mod-cta— primary call-to-action button.