Decision records
ADR 0068 — Fine-grained selector-subscription on ProjectionRuntime
- Status: Accepted (2026-07-21)
- Date: 2026-07-21
- Builds on: the reactive-graph resumability verdict (ADR 0067), live-query as a read-model (ADR 0040), plugin packaging (ADR 0043).
- Part of: the kernel/1P/3P triage discipline (P10 — bias outward). KERNEL_ABI unchanged (1.1.0);
EVENT_SCHEMA unchanged (1.17.0); no
@loomwork/speccapability version bump (additive method on an unversioned framework-agnostic mechanism interface).
Context
ADR 0067 rejected Qwik-style serialize-the-graph
signals, and identified exactly one framework-agnostic residue worth building when a second live
rendering surface arrives: the “wake me only when my bound slice changed” logic, which today lives
trapped in the React useWidgetValue hook (plugins/renderer-react/src/hooks/useWidgetValue.ts).
Every other live surface — a streaming terminal renderer, a VS Code / editor webview (Gemini
Enterprise), a Claude Code plugin — would otherwise reimplement that path-diffing itself.
That second consumer has now arrived: the GenUI project targets terminal, editor-webview, and
Claude-Code surfaces, all live. Per ADR 0067’s D3 evidence gate (and the finding$/rotate$
“promote on a second consumer” precedent), the generic primitive is now demand-justified rather
than speculative.
ProjectionRuntime.subscribe (packages/projection-runtime) notifies on every projection change
and hands over the whole state; slice-granularity was a per-renderer concern. This ADR lifts it into
the shared, framework-agnostic read-model runtime.
Decision
D1 — Add subscribeSelector to ProjectionRuntime (generic, memoized, change-only)
subscribeSelector<TState, TSlice>(
projectionId: string,
executionId: string,
selector: (state: TState) => TSlice,
handler: (slice: TSlice) => void,
isEqual?: (a: TSlice, b: TSlice) => boolean // default Object.is
): () => void
The runtime memoizes selector(state) per subscription and invokes handler only when the slice
changes under isEqual. It takes a selector function, not a path string — so
projection-runtime stays UI-agnostic (no path/binding semantics leak in); the caller supplies the
projection: dot-path resolution via the newly-exported resolvePath from @loomwork/blueprint-runtime.
Semantics mirror subscribe: change-only (no call on subscribe; last seeded from current
state at subscribe time), and it participates in coalesceNotifications (a burst collapses to one
call per changed slice per microtask). Whole-state subscribe keeps a zero-overhead fast path — the
selector registry is a separate map, untouched when unused (G2).
D2 — This is a packages/* mechanism, not kernel and not a plugin
projection-runtime is a framework-agnostic read-model mechanism (a cache of the log, never an
authority — ADR 0040/0058). Fine-grained subscription is generic mechanism, so it stays in
packages/* (not a plugin-*, which are vendor/framework adapters — ADR 0043). It touches no
syscall, no event kind, no fold — replay is untouched by construction (a read-model never feeds
the fold). Consistent with ADR 0067’s “no kernel/event-schema change.”
D3 — React renderer migrates onto it as the proof consumer
useWidgetValue and useDeferredWidgetValue now subscribe slice-scoped, so onStoreChange /
setValue fire only when the widget’s own slice changes — an unrelated slice’s update on the same
projection no longer schedules React work at all (a real reduction, not just relocation). The
equality guard moves from the hooks into the runtime; useWidgetValue keeps its stable-ref
getSnapshot cache as required by useSyncExternalStore. Behavior is unchanged (existing
renderer-react tests stay green). Future surfaces (terminal, editor-webview, Claude Code) consume
subscribeSelector + resolvePath directly and inherit slice-granular reactivity with no
per-surface diffing.
Consequences
- New public method on
ProjectionRuntime; both implementors updated (InMemoryProjectionRuntime,BroadcastingProjectionRuntime— a one-line delegation). resolvePath(state, path)exported from@loomwork/blueprint-runtimeas the generic, null-safe dot-path selector helper for surface authors (extracted from the existingresolveWidgetValue).KERNEL_ABI_VERSIONstays 1.1.0;EVENT_SCHEMA_VERSIONstays 1.17.0; the generated maturity table is unchanged. Covered by unit tests (slice isolation, change-only, customisEqual, coalescing, unsubscribe). The real guarantee is slice isolation: a change to an unrelated slice invokes zero of the other slices’ handlers — so downstream work drops from W handler invocations to (changed slices). That is an invocation-count property (asserted by the tests), not a runtime wall-time speedup: at the runtime level the selector path does slightly more work (it evaluates W selectors to filter), and the payoff is the O(W) downstream re-renders it spares the consumer. The dispatch micro-bench inprojectionStorm.bench.tsdocuments that the filtering overhead stays bounded.- The generic seam now exists for the GenUI multi-surface renderers; none of those renderers are built here.
Source: docs/adr/0068-projection-selector-subscription.md in the loom repository.