fix: resolve switch mutations with null when the stream completes empty#139
Merged
Conversation
The abort stream created with fromEvent(signal, "abort") never completes, so the merge() around the mutation source never completes either. The defaultIfEmpty(null) placed after first() could therefore never fire: a mutation observable completing without emitting left the mutation pending forever instead of resolving with null. Move defaultIfEmpty(null) onto the source itself so an empty stream emits null through first() and the mutation settles. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWPsHcRZtDAUYGsBEihBgh
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.
The bug
When a mutation observable passed to
useSwitchMutation$completes without emitting any value (e.g.EMPTY, or a filtered stream), the mutation never settles:mutateAsynchangs forever andisPendingstaystrueuntil a subsequent mutation aborts it with aSwitchMutationCancelError.Observable with:
Root cause
useSwitchMutation$builds its stream as:fromEvent(signal, "abort")never completes, somerge()never completes whensourcecompletes empty. The completion never reachesfirst(), anddefaultIfEmpty(null)— whose whole purpose is to map an empty mutation tonull(the result type isTData | nullfor exactly this reason) — is unreachable dead code. The bug has been present since the hook was introduced (2cb1d4f).The fix
Move
defaultIfEmpty(null)onto the source itself, so an empty source emitsnullat its own completion andfirst()resolves the mutation. Abort/switch semantics are unchanged: a value (or the injectednull) still races the abort event throughfirst().Verification
New regression test
should resolve with null when the mutation stream completes without emittinginuseSwitchMutation$.test.tsx:mutateAsyncpromise never settles (also reproduced the hang with a standalone RxJS script).null,onSuccessreceives(null, variables), and all 4 tests in the file pass.Full gates on the branch:
npm run checkclean,npm run build(tsc + vite) succeeds,npm run test:ci129/129 passing.Other findings (not addressed in this PR)
useQuery$.test.tsx > "should return consecutive results"is flaky under full-suite load on cleanmain(times out at 500 ms when run with the whole suite, passes in isolation and on re-runs). Worth a look at the test timeout or cross-file resource contention.ObservableStore.subscribe(src/lib/binding/useObserve/store.ts) guards against re-subscribing a completed source but not an errored one; sinceshare()resets on error, theuseSyncExternalStoresubscribe re-executes the source observable (duplicating its side effects, e.g. a fetch) after a synchronous error. Not demonstrated end-to-end in a React test, so left alone.Generated by Claude Code