| name | arrow-js-obsidian-patterns |
| description | Use when building reactive Obsidian plugin UI with @arrow-js/core beyond basic templates โ icons (Obsidian uses Lucide; the data-icon sweep since setIcon can't run inside templates), CSS scoping/specificity against Obsidian's global rules, component mount/unmount lifecycle in an ItemView, organizing shared reactive state, and imperative-DOM/floating-UI work (popovers, menus, positioning, focus, measuring). Complements arrow-js-obsidian-templates (syntax) with integration patterns. For the full CSS decision hierarchy (Obsidian classes โ oas-* utilities โ custom CSS) and token/class reference, see obsidian-arrow-css. |
Arrow.js + Obsidian integration patterns
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"
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: [] })
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 in reactive(). Resolve a
trigger in the handler via e.target.closest("โฆ"); type handlers (e: Event).
requestAnimationFrame is 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-hidden class rather than
conditionally creating them. A conditionally-rendered nested html\`may not be committed whennextTick fires (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
watch or a
side-effect-only slot. Both watch(() => state.x, cb) and a trailing
${() => { fx(); return "" }} slot were observed not to re-run. A class/style
getter 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's getBoundingClientRect(), 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>`
for (const el of Array.from(container.querySelectorAll<HTMLElement>("[data-icon]"))) {
const name = el.getAttribute("data-icon")
if (name) setIcon(el, name)
}
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 obsidian module). Use icon(name) from
src/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-backed setIcon
shim (aliased as obsidian) and swap the icon() calls to setIcon.
import { icon } from "./icons";
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.
.my-action { background: var(--interactive-accent); }
.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.