Skip to content

fix: resolve switch mutations with null when the stream completes empty#139

Merged
mbret merged 1 commit into
mainfrom
fix/bug-hunt-2026-07-19
Jul 19, 2026
Merged

fix: resolve switch mutations with null when the stream completes empty#139
mbret merged 1 commit into
mainfrom
fix/bug-hunt-2026-07-19

Conversation

@mbret

@mbret mbret commented Jul 19, 2026

Copy link
Copy Markdown
Owner

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: mutateAsync hangs forever and isPending stays true until a subsequent mutation aborts it with a SwitchMutationCancelError.

Observable with:

const { mutateAsync } = useSwitchMutation$({ mutationFn: () => EMPTY })
await mutateAsync() // never resolves, never rejects

Root cause

useSwitchMutation$ builds its stream as:

merge(
  source,
  fromEvent(abort, "abort").pipe(tap(throwCancel), ignoreElements()),
).pipe(first(), defaultIfEmpty(null))

fromEvent(signal, "abort") never completes, so merge() never completes when source completes empty. The completion never reaches first(), and defaultIfEmpty(null) — whose whole purpose is to map an empty mutation to null (the result type is TData | null for 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 emits null at its own completion and first() resolves the mutation. Abort/switch semantics are unchanged: a value (or the injected null) still races the abort event through first().

Verification

New regression test should resolve with null when the mutation stream completes without emitting in useSwitchMutation$.test.tsx:

  • Before the fix: the test times out — the mutateAsync promise never settles (also reproduced the hang with a standalone RxJS script).
  • After the fix: the promise resolves with null, onSuccess receives (null, variables), and all 4 tests in the file pass.

Full gates on the branch: npm run check clean, npm run build (tsc + vite) succeeds, npm run test:ci 129/129 passing.

Other findings (not addressed in this PR)

  • useQuery$.test.tsx > "should return consecutive results" is flaky under full-suite load on clean main (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; since share() resets on error, the useSyncExternalStore subscribe 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

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
@mbret
mbret merged commit 09727ca into main Jul 19, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants