Skip to content

useObservable mutates shared _immutableStatus snapshot, corrupting concurrent renders #736

Description

@tyler-reitz

Bug

useObservable applies initialData by mutating the object returned by getSnapshot() in-place (lines 130-132 of src/useObservable.ts):

const update = useSyncExternalStore(subscribe, getSnapshot);

if (!observable.hasValue && hasData) {
  update.data = config?.initialData ?? config?.startWithValue;
  update.status = 'success';
  update.hasEmitted = true;
}

getSnapshot() returns observable.immutableStatus, which is SuspenseSubject._immutableStatus — a single shared object reference for all components using the same observableId. Mutating it in-place means one component's render corrupts the snapshot for every other component sharing that observable.

Concrete failure scenario

Two components both call useUser() for the same auth instance (same observableId → same SuspenseSubject → same _immutableStatus object):

  1. Component A has initialData set (e.g., auth.currentUser is truthy after fix: seed useUser with auth.currentUser to prevent undefined on initial render #731). It mutates _immutableStatus.status = 'success' and .data = user.
  2. Component B calls getSnapshot() — it gets the same already-mutated object and renders status: 'success' with a user, even though it passed no initialData and the observable hasn't emitted yet.

useSyncExternalStore's tearing detection does not catch this: Object.is() on the same reference always returns true, so React treats the store as unchanged.

_updateImmutableStatus() (called on each observable emission) creates a new object — so the mutation is eventually overwritten. But between mount and first emission, the shared snapshot is wrong.

Why this surfaced now

This was a pre-existing bug, but it was only triggered when callers explicitly passed initialData. PR #731 seeds initialData from auth.currentUser on every mount of a signed-in user, making this path hit far more frequently.

Fix

Replace the in-place mutation with a new object:

if (!observable.hasValue && hasData) {
  return {
    ...update,
    data: config?.initialData ?? config?.startWithValue,
    status: 'success',
    hasEmitted: true,
  };
}
return update;

This keeps the SuspenseSubject._immutableStatus object untouched and gives each render its own snapshot.

Affected versions

All versions that support initialData / startWithValue. Worsened in 4.2.4+ after #731.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions