feat: add useObservablePromise for Suspense-ready data fetching - #460
Conversation
🦋 Changeset detectedLatest commit: 04b0f46 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
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
useObservablePromiseandpreloadObservablePromise, 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.
There was a problem hiding this comment.
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
preloadObservablePromisetakingoptions?: {ttl?: number}, but the public API actually exportsPreloadObservablePromiseOptions(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>
There was a problem hiding this comment.
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'
There was a problem hiding this comment.
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
reactare 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 RxJSshare({resetOnRefCountZero: () => timer(entry.retentionMs)})delay is captured at the moment refCount hits 0 and is not affected by laterensure()calls while there are no subscribers. This can tear down the shared connection earlier than the latestttl, 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>)
There was a problem hiding this comment.
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: trueon 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
…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>
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>
76c8706 to
321fc51
Compare
There was a problem hiding this comment.
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'
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>
There was a problem hiding this comment.
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 thereact-rximport, 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'
…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>
Co-authored-by: Cody Olsen <stipsan@users.noreply.github.com>
There was a problem hiding this comment.
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'
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ 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.
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>
There was a problem hiding this comment.
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
preloadObservablePromisesignature documentsoptionsas an inline{ttl?: number}object, but the public API actually exportsPreloadObservablePromiseOptions. 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'

Summary
Adds
useObservablePromiseandpreloadObservablePromiseso RxJS data fetching can activate React Suspense / Activity pre-rendering viause()-compatible instrumented promises — without a provider.useObservablecannot activate Suspense or participate in Activity pre-fetch (only a promise read withuse()can). This hook returns a Promise subclass (withstatus/value/reason) that consumers pass toReact.use().Rebased onto latest
current. Docs cover all four hooks; theuseObservablePromisedemo lives at/examples/data-fetchingsince/examples/suspenseis the hook-comparison demo from #459.API
Behavior
firstValueFromsemantics)useDeferredValue(promise)+memocatchErrordocumented for customization)ttlretention; extending ttl after unmount re-arms both eviction and share grace (so long-lived sources stay connected for the new window)ttleviction only affects future consumersdisabled: truefully prevents fetching (stronger thanuseObservable's warm-up probe)preloadObservablePromisefor hover/route-loader cache warm-up (starts immediately; hung sources stay pending until settle — prefer RxJStimeout)Docs & examples
useObservablePromise+preloadObservablePromise/examples/data-fetchingand/examples/activityOut of scope
useActionState/useOptimistic) — follow-upDemo
use-observable-promise-examples-demo.mp4
Suspense example
Activity hover preload
Test plan
useDeferredValuetime-slicing interleaving testcurrent; lint/knip/tsc/build + 268 tests greenTo show artifacts inline, enable in settings.