Skip to content

feat: add useObservablePromise for Suspense-ready data fetching - #460

Merged
stipsan merged 13 commits into
currentfrom
cursor/use-observable-promise-5510
Jul 31, 2026
Merged

feat: add useObservablePromise for Suspense-ready data fetching#460
stipsan merged 13 commits into
currentfrom
cursor/use-observable-promise-5510

Conversation

@stipsan

@stipsan stipsan commented Jul 29, 2026

Copy link
Copy Markdown
Member

Summary

Adds useObservablePromise and preloadObservablePromise so RxJS data fetching can activate React Suspense / Activity pre-rendering via use()-compatible instrumented promises — without a provider.

useObservable cannot activate Suspense or participate in Activity pre-fetch (only a promise read with use() can). This hook returns a Promise subclass (with status / value / reason) that consumers pass to React.use().

Rebased onto latest current. Docs cover all four hooks; the useObservablePromise demo lives at /examples/data-fetching since /examples/suspense is the hook-comparison demo from #459.

API

const promise = useObservablePromise(observable$, { disabled?, ttl? })
const data = use(promise)

preloadObservablePromise(observable$, { ttl? }) // event-driven warm-up

Behavior

  • Suspends until the first emission (firstValueFrom semantics)
  • First emission resolves the pending promise in place — the uSES snapshot is unchanged, so the unblock happens purely via promise resolution (no store-notification re-render, per async-react fix: add "private" property to package.json #3)
  • Later emissions swap to a pre-fulfilled promise and update without re-suspending
  • Expensive subtrees can opt into time-sliced updates with userland useDeferredValue(promise) + memo
  • Errors go to the nearest Error Boundary (catchError documented for customization)
  • Module-level WeakMap cache with configurable ttl retention; extending ttl after unmount re-arms both eviction and share grace (so long-lived sources stay connected for the new window)
  • Mounted components pin their cache entry: ttl eviction only affects future consumers
  • disabled: true fully prevents fetching (stronger than useObservable's warm-up probe)
  • preloadObservablePromise for hover/route-loader cache warm-up (starts immediately; hung sources stay pending until settle — prefer RxJS timeout)

Docs & examples

  • Guide: Data fetching with Suspense (incl. deferring expensive re-renders, observable-swap recipe, preload hang guidance)
  • Reference: useObservablePromise + preloadObservablePromise
  • Sandpack: /examples/data-fetching and /examples/activity

Out of scope

  • Actions (useActionState / useOptimistic) — follow-up
  • RSC / hydration-perfect streaming SSR

Demo

use-observable-promise-examples-demo.mp4

Suspense example
Activity hover preload

Test plan

  • Core Suspense / identity / error / ttl / disabled / preload tests
  • First-emission bailout + Activity eviction-while-hidden regression
  • Concurrent tearing + useDeferredValue time-slicing interleaving test
  • StrictMode / SSR / type-level / WeakRef leak tests
  • ttl-extension after unmount keeps long-lived source connected (Bugbot)
  • Rebased onto latest current; lint/knip/tsc/build + 268 tests green
  • Review comments addressed (preload hang docs + share grace bounce)

To show artifacts inline, enable in settings.

Open in Web Open in Cursor 

@changeset-bot

changeset-bot Bot commented Jul 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 04b0f46

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 Jul 29, 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 Jul 31, 2026 7:53am

Request Review

Comment thread packages/react-rx/src/observablePromiseCache.ts Outdated

Copilot AI 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.

Pull request overview

Adds a new Suspense/Activity-friendly data-fetching API to react-rx by converting an Observable<T> into a React use()-compatible, instrumented promise, plus docs, website examples, and comprehensive tests.

Changes:

  • Introduces useObservablePromise and preloadObservablePromise, backed by a WeakMap cache with TTL retention and “first emission unblocks Suspense” semantics.
  • Adds extensive Vitest coverage (Suspense behavior, identity stability, StrictMode, SSR, Activity pre-render, concurrency/tearing, leak/eviction behavior, and type-level tests).
  • Documents the new APIs and adds website Sandpack examples for Suspense and Activity/preload strategies.

Reviewed changes

Copilot reviewed 23 out of 23 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
website/src/examples/suspense/SuspenseExample.tsx New Suspense example demonstrating promise identity + live updates without re-suspending.
website/src/examples/suspense/index.tsx Wires the Suspense example into Sandpack.
website/src/examples/activity/TabPanel.tsx Activity demo panel that reads observable data via use(useObservablePromise(...)).
website/src/examples/activity/index.tsx Wires the Activity example into Sandpack.
website/src/examples/activity/App.tsx Demo app comparing no-prefetch vs hover preload vs <Activity> pre-render.
website/src/examples/activity/api.ts Example “fetch-like” observable source with simulated latency + caching.
website/src/content/reference.mdx Adds API reference entries for useObservablePromise and preloadObservablePromise.
website/src/content/guide.md Adds a guide section explaining Suspense/Activity usage, semantics, options, and preloading.
website/src/content/examples/suspense.mdx Adds docs page embedding the Suspense example.
website/src/content/examples/activity.mdx Adds docs page embedding the Activity/preload example.
website/src/content/examples/_meta.ts Adds new examples to the examples nav metadata.
packages/react-rx/src/useObservablePromise.ts Implements useObservablePromise + preloadObservablePromise public API and option types.
packages/react-rx/src/observablePromiseCache.ts Implements WeakMap cache entries, TTL retention, shared subscription, and eviction logic.
packages/react-rx/src/observablePromise.ts Adds an instrumented Promise subclass compatible with React’s thenable protocol.
packages/react-rx/src/index.ts Exports the new API from the package entrypoint.
packages/react-rx/src/tests/useObservablePromise.test.tsx Core behavior tests (suspense, identity, errors, ttl, disabled, preload, etc.).
packages/react-rx/src/tests/useObservablePromise.test-d.ts Type-level coverage for return types, narrowing, and options.
packages/react-rx/src/tests/useObservablePromise.strictmode.test.tsx StrictMode double-invoke/identity stability tests.
packages/react-rx/src/tests/useObservablePromise.ssr.test.tsx SSR (renderToString) behavior tests and server snapshot coverage.
packages/react-rx/src/tests/useObservablePromise.leaks.test.tsx Eviction/teardown regression tests for sync termination and TTL.
packages/react-rx/src/tests/useObservablePromise.concurrent.test.tsx Concurrent rendering “no tearing finally” checks (dai-shi port).
packages/react-rx/src/tests/useObservablePromise.activity.test.tsx React 19.2 <Activity> pre-render integration tests.
.changeset/use-observable-promise.md Changeset declaring a minor bump for the new API.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread website/src/content/reference.mdx
Comment thread website/src/content/guide.md
Comment thread packages/react-rx/src/observablePromiseCache.ts

Copilot AI 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.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

website/src/examples/activity/TabPanel.tsx:3

  • This file has two separate imports from 'react' (lines 1 and 3). The other examples keep a single React import (e.g. website/src/examples/fetch/FetchExample.tsx:1), and duplicate imports may trip lint rules like no-duplicate-imports. Merge these into one import and keep React imports grouped at the top.
import {use} from 'react'
import {useObservablePromise} from 'react-rx'
import {useMemo} from 'react'

website/src/content/reference.mdx:101

  • The reference docs show preloadObservablePromise taking options?: {ttl?: number}, but the public API actually exports PreloadObservablePromiseOptions (packages/react-rx/src/useObservablePromise.ts). Align the docs signature with the exported type so consumers can discover/annotate it consistently.
function preloadObservablePromise<T>(
  observable: Observable<T>,
  options?: {ttl?: number},
): ObservablePromise<T>

Copilot AI 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.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

website/src/examples/activity/TabPanel.tsx:5

  • Avoid duplicate React imports in this example. The rest of the website examples consistently import from 'react' in a single statement (e.g. website/src/examples/fetch/FetchExample.tsx:1, website/src/examples/errors/Counter.tsx:1).
import {use} from 'react'
import {useObservablePromise} from 'react-rx'
import {useMemo} from 'react'

Copilot AI review requested due to automatic review settings July 30, 2026 04:02
Comment thread packages/react-rx/src/useObservablePromise.ts

Copilot AI 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.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

website/src/examples/activity/TabPanel.tsx:3

  • The imports from react are split across two separate statements (lines 1 and 3), with another import in between. This is easy to miss during edits and can trip import-order lint rules; it’s also unnecessary since both bindings come from the same module.
import {use} from 'react'
import {useObservablePromise} from 'react-rx'
import {useMemo} from 'react'

packages/react-rx/src/observablePromiseCache.ts:199

  • ensure() claims retention “counts from the last touch” and is “re-armed” when extended, but the underlying RxJS share({resetOnRefCountZero: () => timer(entry.retentionMs)}) delay is captured at the moment refCount hits 0 and is not affected by later ensure() calls while there are no subscribers. This can tear down the shared connection earlier than the latest ttl, which means long-lived sources may stop updating the cached promise during the intended retention window (and the comment is currently inaccurate).
      entry.retentionMs = Math.max(entry.retentionMs, ttl)
      // Retention counts from the last touch (and may have just been extended,
      // e.g. a preload arriving while a hook-settled entry awaits eviction).
      if (entry.evictionTimer !== null) {
        scheduleEviction(entry as CacheEntry<unknown>)

Copilot AI 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.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

website/src/examples/activity/TabPanel.tsx:3

  • React imports are split across two statements and out of order (React → react-rx → React). This is inconsistent with the other website examples (e.g. website/src/examples/context/App.tsx:1) and may violate import-order linting. Combine the React imports into a single statement at the top.
import {use} from 'react'
import {useObservablePromise} from 'react-rx'
import {useMemo} from 'react'

packages/react-rx/src/observablePromiseCache.ts:101

  • Pending cache entries are explicitly never evicted (if (!entry.settled) return). If a consumer creates an entry but never starts the resolver subscription (e.g. disabled: true on mount) and then unmounts, the entry can remain in the module-level WeakMap indefinitely as long as the observable key is strongly retained elsewhere (e.g. a module Map cache), which can lead to unbounded growth over time. Consider a bounded policy for “never-started” entries (or tracking whether an entry was ever started) so they can be collected without relying on the observable key being GC’d.
    // Pending entries are never timed out — a suspended consumer may still be
    // waiting on this promise with no live uSES subscriber yet.
    if (!entry.settled) {
      return

Copilot AI review requested due to automatic review settings July 30, 2026 07:10
cursoragent and others added 3 commits July 30, 2026 20:13
…ion updates

Emission-driven uSES re-renders are scheduled at sync priority and cannot be
time-sliced directly. The new interleaving test (createRoot outside act, DOM
sampled between event-loop turns) proves the userland escape hatch works:
useDeferredValue(promise) + memo keeps the expensive subtree on the old
promise during the synchronous store pass, re-renders it on the deferred lane
(observed lag window), lets an urgent click preempt the in-progress ~100ms
deferred render, and converges afterwards. A negative control (removing
useDeferredValue) fails the lag-window assertion, so the test discriminates.
Documented the pattern (incl. the load-bearing memo) in the guide.

Co-authored-by: Cody Olsen <stipsan@users.noreply.github.com>
Bare `#3` autolinks to this repo's PR #3 on GitHub.

Co-authored-by: Cody Olsen <stipsan@users.noreply.github.com>
When ensure() raises retentionMs while a settled entry has no live
subscribers, share's resetOnRefCountZero timer was already started with
the old retention. Re-arming only the WeakMap eviction timer left
long-lived sources disconnecting early, so emissions during the extended
window were lost. Bounce a no-op subscription so share cancels the
pending reset and starts a fresh grace period with the updated ttl.

Also document that preload starts the source immediately and that hung
observables keep the pending promise/subscription alive until settle
(prefer RxJS timeout).

Co-authored-by: Cody Olsen <stipsan@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 30, 2026 20:13
@cursor
cursor Bot force-pushed the cursor/use-observable-promise-5510 branch from 76c8706 to 321fc51 Compare July 30, 2026 20:13

Copilot AI 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.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

website/src/examples/activity/TabPanel.tsx:3

  • This file imports from 'react' twice (lines 1 and 3). All other examples use a single consolidated React import (e.g. website/src/examples/activity/App.tsx:1), and many linters flag duplicate imports from the same module. Consider combining these into one import to match the repo’s convention and avoid potential lint failures.
import {use} from 'react'
import {useObservablePromise} from 'react-rx'
import {useMemo} from 'react'

Comment thread packages/react-rx/src/observablePromiseCache.ts
Comment thread packages/react-rx/src/observablePromiseCache.ts
Two Bugbot findings on the ttl policy:
- finalize no longer restarts an already-pending eviction timer when share
  disconnects, which was doubling documented retention for long-lived sources
- ensure() bounces share's resetOnRefCountZero on same-ttl preloads/remounts
  (startResolver), not only when retentionMs increases, so renewing a touch
  keeps the multicast connected for the renewed window

Regression tests cover both; negative controls fail without the fixes.

Co-authored-by: Cody Olsen <stipsan@users.noreply.github.com>

Copilot AI 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.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

website/src/examples/activity/TabPanel.tsx:5

  • The React imports are split across two separate from 'react' statements and placed around the react-rx import, which is inconsistent with the existing examples (e.g. website/src/examples/suspense/SuspenseExample.tsx:1). Consolidating them improves readability and keeps import grouping consistent.
import {use} from 'react'
import {useObservablePromise} from 'react-rx'
import {useMemo} from 'react'

import {fetchTab$} from './api'

Comment thread packages/react-rx/src/observablePromiseCache.ts
…clock

ensure() was re-arming eviction (and bouncing share) whenever a timer was
pending. The hook calls ensure on every render, so disabled consumers and
hidden <Activity> trees kept resetting ttl forever — breaking the documented
retention contract for future mounts and leaving long-lived connections open.
Pinning already preserves the mounted value after a real eviction.

Grace renewal is now opt-in via ensure(..., renewGrace) and only
preloadObservablePromise passes it. Idle ensures still adopt max ttl for later
timers without restarting the current window.

Co-authored-by: Cody Olsen <stipsan@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 30, 2026 20:31
Co-authored-by: Cody Olsen <stipsan@users.noreply.github.com>

Copilot AI 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.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

website/src/examples/activity/TabPanel.tsx:3

  • This file has two separate imports from 'react', which is inconsistent with the established pattern in other examples (single consolidated React import). Consolidating avoids duplicate-import linting and keeps imports consistent (e.g. website/src/examples/errors/Counter.tsx:1, website/src/examples/suspense/SuspenseExample.tsx:1).
import {use} from 'react'
import {useObservablePromise} from 'react-rx'
import {useMemo} from 'react'

@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 53ac6fa. Configure here.

Comment thread packages/react-rx/src/observablePromiseCache.ts
Completing without emitting now rejects with RxJS's own EmptyError (the exact
type firstValueFrom throws) instead of a private look-alike, so consumer
`instanceof EmptyError` checks succeed and the documented firstValueFrom
semantics hold. RxJS marks the constructor @deprecated as an internal detail,
but firstValueFrom/first/single throw this type, so mirroring it is
intentional — scoped oxlint-disable documents that. Test now asserts the
thrown error is an EmptyError instance.

Co-authored-by: Cody Olsen <stipsan@users.noreply.github.com>

Copilot AI 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.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.

Suppressed comments (2)

website/src/content/reference.mdx:148

  • The preloadObservablePromise signature documents options as an inline {ttl?: number} object, but the public API actually exports PreloadObservablePromiseOptions. Using the exported type name here makes the docs match the API surface and improves discoverability for TS users.
function preloadObservablePromise<T>(
  observable: Observable<T>,
  options?: {ttl?: number},
): ObservablePromise<T>

website/src/examples/activity/TabPanel.tsx:3

  • Combine the two separate React imports into a single import to avoid redundant module imports and keep import blocks consistent.
import {use} from 'react'
import {useObservablePromise} from 'react-rx'
import {useMemo} from 'react'

@stipsan
stipsan merged commit 4d9b613 into current Jul 31, 2026
12 checks passed
@stipsan
stipsan deleted the cursor/use-observable-promise-5510 branch July 31, 2026 08:15
@squiggler-app squiggler-app Bot mentioned this pull request Jul 31, 2026
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.

3 participants