Skip to content

fix: pace useObservable emission delivery to React's render cycle - #490

Draft
bjoerge wants to merge 2 commits into
currentfrom
pace-emission-delivery
Draft

fix: pace useObservable emission delivery to React's render cycle#490
bjoerge wants to merge 2 commits into
currentfrom
pace-emission-delivery

Conversation

@bjoerge

@bjoerge bjoerge commented Aug 19, 2026

Copy link
Copy Markdown
Member

React restarts an in-flight concurrent render pass whenever a useSyncExternalStore snapshot changes mid-pass, so a source that emits faster than the pass can complete blocks it from ever committing.

useObservable now delivers the first emission immediately and, while a delivered value is still rendering, holds newer emissions and delivers only the latest once the main thread is idle (requestIdleCallback with setTimeout(0) fallback). This bounds restarts to one per commit cycle. Isolated emissions and synchronous first values at mount are unchanged. The paced branch keeps its own snapshot state, since the uSES consistency check re-reads the snapshot mid-pass; it falls back to the live state until the first paced delivery and resets on disconnect.

Contract change: bursts coalesce, so intermediate values may never render through useObservable. useSyncObservable still delivers every emission.


Note

Medium Risk
Changes core react-rx subscription timing and observable delivery semantics for all useObservable consumers; behavior is well-tested but coalescing can surprise code that relied on every emission rendering.

Overview
Fixes concurrent render starvation when observables emit faster than React can finish a pass: each useSyncExternalStore snapshot change mid-pass forces a restart, so rapid emissions could block commits indefinitely.

useObservable now uses a render-paced branch (paceToRenderIdle + requestIdleCallback, with setTimeout(0) fallback): same-microtask bursts still pass through; cross-task emissions while a value may still be rendering are coalesced to the latest and delivered after render-idle. useSyncObservable stays on the live branch with every emission.

The shared cache splits live vs paced snapshot state so uSES consistency checks do not observe newer live values during an in-flight paced delivery; paced state resets on disconnect so remounts do not show stale values.

Contract: intermediate values in cross-task bursts may never render through useObservable — use useSyncObservable when every emission must be synchronous. Minor version bump documents the behavior change.

Reviewed by Cursor Bugbot for commit da82695. Bugbot is set up for automated code reviews on this repo. Configure here.

React restarts an in-flight concurrent render pass whenever a useSyncExternalStore snapshot changes mid-pass, so a source that emits faster than the pass can complete blocks it from ever committing.

useObservable now delivers the first emission immediately and, while a delivered value is still rendering, holds newer emissions and delivers only the latest once the main thread is idle (requestIdleCallback; setTimeout(0) fallback). This bounds restarts to one per commit cycle. Isolated emissions and synchronous first values at mount are unchanged. The paced branch keeps its own snapshot state, since the uSES consistency check re-reads the snapshot mid-pass; it falls back to the live state until the first paced delivery and resets on disconnect.

Contract change: bursts coalesce, so intermediate values may never render through useObservable. useSyncObservable still delivers every emission.
@changeset-bot

changeset-bot Bot commented Aug 19, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: da82695

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
react-rx Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Aug 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
react-rx Ready Ready Preview Aug 19, 2026 5:24pm

Request Review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 9e26daf. Configure here.

Comment thread packages/react-rx/src/cache.ts
A synchronously completing multi-value source (of(a, b)) flashed on
mount: the render-phase warm-up painted the last value, the shared
pipeline reset on complete, and at commit the replay delivered its
first value as the paced leading edge — painting b, then a, then b
again at idle. Caught by Cursor Bugbot on #490.

Pacing within a single task gains nothing: all same-task notifications
produce one scheduled render and React paints only the last value, so
holding same-task emissions exposes an earlier value and delays the
final one. The operator (now hand-rolled, replacing the
exhaustMapWithTrailing composition) delivers emissions synchronously
while in the same microtask as the last delivery and holds only
emissions arriving later — the actual starvation vector. Same-task
behavior (mount replays, event-handler bursts) is identical to unpaced
delivery again, so the earlier test edits accounting for same-task
coalescing are reverted.

Co-Authored-By: Claude <noreply@anthropic.com>
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.

1 participant