Part of #68 (Roadmap to 1.0.0). Decision recorded in docs/ADR-0001-reactivity.md.
Decision
Adopt @preact/signals-core (signal() / effect() / computed() /
batch() / untracked()) and migrate state one slice at a time, behind
accessor helpers. No UI framework (React/Preact/Solid).
Rationale (full record in the ADR): the growth pain is manual render
invalidation, not a missing component model, and we don't need a virtualized
large-list renderer — the one thing a vDOM framework buys. A maintained,
glitch-free signals core (with lazy computed()) beats both a UI framework and
a hand-rolled primitive.
Measured artifact cost (gzip): @preact/signals-core +1.4 KB · hand-rolled
+0.45 KB · Preact+signals +7.3 KB · React ~+45 KB. signals-core is one small,
zero-transitive-dependency package, still inlined → zero third-party requests.
Per CLAUDE.md rule 4 it's a deliberate dependency (the third, after Chart.js and
dagre).
Evidence (branches spike/signals and spike/signals-core)
Two slices converted end-to-end, full suite + per-file coverage gate green, real
npm run build succeeds:
- tabs (
tabs + activeTabId) — has activeTab() helper → 16 callers
untouched; one behavioral test relocation (repaint moved to a createApp()
effect).
- sidePanel — no helper → every reader changed, but purely mechanical
.value, no assertion rewrites.
A hand-rolled ~70-line primitive was prototyped first (spike/signals); swapping
it for @preact/signals-core was a 5-line diff (same .value API), so the
choice is reversible.
Migration plan (incremental, between feature work)
Per slice: convert the state field(s) to signal(), route reads through an
accessor helper, replace the manual renderX() invalidation with one effect()
in createApp(), and sweep the slice's tests to .value. Each slice ships on
its own, gate green, and never un-converts another.
Suggested order (cheap → higher churn):
Rules
- Give each converted slice an accessor helper (like
activeTab()) to
contain reader churn and localize behavior.
- Keep the editor and the schema/EXPLAIN SVG imperative — reactivity doesn't
help canvas/contenteditable code.
- Multi-signal updates use
batch() to repaint once.
On first adoption (one-time)
Acceptance (per slice PR)
Re-evaluation trigger
Revisit Preact/Solid only if the UI grows many interdependent components with
rich local state, or a genuine large-list / virtualized render need appears.
Part of #68 (Roadmap to 1.0.0). Decision recorded in
docs/ADR-0001-reactivity.md.Decision
Adopt
@preact/signals-core(signal()/effect()/computed()/batch()/untracked()) and migrate state one slice at a time, behindaccessor helpers. No UI framework (React/Preact/Solid).
Rationale (full record in the ADR): the growth pain is manual render
invalidation, not a missing component model, and we don't need a virtualized
large-list renderer — the one thing a vDOM framework buys. A maintained,
glitch-free signals core (with lazy
computed()) beats both a UI framework anda hand-rolled primitive.
Measured artifact cost (gzip): @preact/signals-core +1.4 KB · hand-rolled
+0.45 KB · Preact+signals +7.3 KB · React ~+45 KB. signals-core is one small,
zero-transitive-dependency package, still inlined → zero third-party requests.
Per CLAUDE.md rule 4 it's a deliberate dependency (the third, after Chart.js and
dagre).
Evidence (branches
spike/signalsandspike/signals-core)Two slices converted end-to-end, full suite + per-file coverage gate green, real
npm run buildsucceeds:tabs+activeTabId) — hasactiveTab()helper → 16 callersuntouched; one behavioral test relocation (repaint moved to a
createApp()effect).
.value, no assertion rewrites.A hand-rolled ~70-line primitive was prototyped first (
spike/signals); swappingit for
@preact/signals-corewas a 5-line diff (same.valueAPI), so thechoice is reversible.
Migration plan (incremental, between feature work)
Per slice: convert the state field(s) to
signal(), route reads through anaccessor helper, replace the manual
renderX()invalidation with oneeffect()in
createApp(), and sweep the slice's tests to.value. Each slice ships onits own, gate green, and never un-converts another.
Suggested order (cheap → higher churn):
tabs+activeTabId(done in spike)sidePanel(done in spike)resultView(drivesrenderResults; no helper)running(cross-cutting run/cancel flag; gate the run button via effect)schema/schemaError/schemaFilter(side-panel schema tree)libraryName/libraryDirty(header title)Rules
activeTab()) tocontain reader churn and localize behavior.
help canvas/contenteditable code.
batch()to repaint once.On first adoption (one-time)
@preact/signals-core+ the two converted slices onmain.build/build.mjscomments updated (done in spike).Acceptance (per slice PR)
signal; mutators just assign.value.renderX()invalidation for that slice is removed; aneffect()repaints it.
npm testgreen at the per-file coverage gate.Re-evaluation trigger
Revisit Preact/Solid only if the UI grows many interdependent components with
rich local state, or a genuine large-list / virtualized render need appears.