Skip to content

Adopt reactivity via @preact/signals-core, incrementally (ADR-0001) #88

Description

@BorisTyshkevich

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):

  • tabs + activeTabId (done in spike)
  • sidePanel (done in spike)
  • resultView (drives renderResults; 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

  • 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)

  • Land @preact/signals-core + the two converted slices on main.
  • Update CLAUDE.md rule 4 ("two bundled runtime dependencies" → three).
  • THIRD-PARTY-NOTICES.md + build/build.mjs comments updated (done in spike).

Acceptance (per slice PR)

  • State field is a signal; mutators just assign .value.
  • Manual renderX() invalidation for that slice is removed; an effect()
    repaints it.
  • npm test green at the per-file coverage gate.
  • No unrelated slice's behavior changes.

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions