Summary
stx ships two independent reactivity systems that do not observe each other, and nothing in the type system, the export surface or the docs indicates which one you are using. State created with one and read inside an effect from the other silently never updates — no error, no warning, just a value frozen at first render.
The two systems
signals-api.ts | reactivity.ts | |
|---|---|---|
| create | state() :187, derived() :283 | ref(), reactive() :293 |
| observe | effect() :385 | watch() :485, watchEffect() :664 |
| tracking var | its own | private currentEffect :119 |
| reaches the client runtime | yes — signals.ts:11 imports state, derived, effect, batch, onMount, onDestroy… from it | no |
reactive() subscribes whatever is in reactivity.ts's module-private currentEffect (set only by that module's own watchEffect, :392). effect() from signals-api never sets it. Neither module imports the other — both have zero imports — so there is no bridge.
Why this is not theoretical
Both are re-exported from the package entry, so import { reactive, effect } from 'stx' gives you one of each, and the combination is inert:
import { effect, reactive } from 'stx'
const s = reactive({ n: 0 })
effect(() => console.log(s.n)) // logs 0
s.n = 1 // logs nothing. ever.The client runtime only ships signals-api's effect (signals.ts:11), so every template binding is on the signals-api side. Anything an app author builds with reactive() — the more discoverable, Vue-shaped API — cannot drive a template.
Found while fixing #1856: defineForm returned plain objects, and the obvious repair was to wrap them in reactive(). That would have looked correct in review, passed a watchEffect-based test, and still never updated a single template. The fix had to use state() from signals-api instead, and the reasoning is now a 12-line comment in forms-validation.ts because nothing else records it.
Ask
- Decide which system is canonical for template-facing state. Given the runtime already ships
signals-api, that is presumably it. - Make the other one either bridge or refuse:
- bridge — have
reactive()/ref()track the same effect context assignals-api'seffect(), so the two compose; or - fence — keep them separate but make
reactive()warn in dev when read inside a foreign effect, and say so in its docblock.
- bridge — have
- Whichever is chosen, document it at both call sites. Today
reactive()'s docblock (reactivity.ts:283-292) shows a plainstate.count++example with no hint that a template will not see it. - Consider not exporting both under undifferentiated names from the same entry — the current surface actively invites the broken combination.
Refs #1856