fix: subscribe useObservableState to its subject so setState re-renders - #156
Merged
Conversation
The hook returns [value, setState, subject] mirroring React.useState, but nothing connected the BehaviorSubject back to React: setState only called subject.next() and the render read subject.getValue() once, so components never re-rendered and kept displaying the stale value forever. Wire the subject to React with useSyncExternalStore, skipping the BehaviorSubject's synchronous replay emission since useSyncExternalStore reads the initial value through getSnapshot. This also picks up direct subject.next() calls from outside the hook. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHTuazpMJYhEVqdtuVWvz9
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Bug
useObservableState(publicly exported from the package root) returns[value, setState, subject], mirroringReact.useState— its setter is even typed asDispatch<SetStateAction<T>>. But callingsetStatenever triggers a re-render: the component keeps rendering the stale initial value forever.How to observe:
Root cause
The hook only pushed values into its internal
BehaviorSubject(subject.next(...)) and readsubject.getValue()once during render. Nothing subscribed the subject back to React, so React had no reason to re-render — the returnedvaluewas a one-shot snapshot from mount. (The pre-rewrite version of this hook returned the observable itself, so consumers subscribed on their own; the rewrite inabfbbbbchanged the contract touseStateparity but never added the subscription.)Fix
Wire the subject to React with
useSyncExternalStore, following the same pattern the library already uses inuseSignalValue: subscribe withskip(1)(aBehaviorSubjectsynchronously replays its current value, whileuseSyncExternalStorereads the initial value throughgetSnapshot), and usesubject.getValue()as the snapshot. This also makes the hook re-render on directsubject.next()calls from outside the hook, which the returned subject explicitly invites.Verification
Added
src/lib/binding/useObservableState.test.tsx(the hook previously had no tests). Before the fix, 3 of the 5 tests fail onmain:setStateis called with a valuesetStateis called with an updater functionsetStatereceives the current value (already passing)Full gates pass after the fix:
npm run check(biome),npm run build(tsc + vite),npm run test:ci— 24 files / 139 tests green.Other findings (not addressed in this PR)
signal({ default: null })yields a signal whose value isundefined, notnull, despite being typedSignal<null>: the factory insrc/lib/state/Signal.tsbuilds the config withdefault: config.default ?? undefined, and??coerces an explicitnulldefault toundefined. Verified by tracing the exact expression:{ ...{ default: null }, default: null ?? undefined }→default: undefined. Also affectsSIGNAL_RESET, which resets toundefinedinstead of the declarednulldefault.Generated by Claude Code