Skip to content

react-router - Add preloadQuery.waitForStaticResult() API for awaiting partial query data in loaders#545

Open
LordPikaChu wants to merge 10 commits into
apollographql:mainfrom
LordPikaChu:react-router-awaitable
Open

react-router - Add preloadQuery.waitForStaticResult() API for awaiting partial query data in loaders#545
LordPikaChu wants to merge 10 commits into
apollographql:mainfrom
LordPikaChu:react-router-awaitable

Conversation

@LordPikaChu

@LordPikaChu LordPikaChu commented Mar 26, 2026

Copy link
Copy Markdown

Add preloadQuery.awaitable() API for selectively awaiting partial query data in loaders.

This new method returns { queryRef, resolveWhen } — the queryRef works identically to the standard preloadQuery() for streaming, while resolveWhen(predicate) returns a promise that resolves with data as soon as the predicate returns true against an incoming result. This enables use cases like populating React Router's meta() function with query data without blocking the full streaming response, and works naturally with @defer for incremental delivery.

This solves the issue described in #332, while preserving both the original intent of library authors and streaming capabilities

Summary by CodeRabbit

  • New Features

    • Added preloadQuery.waitForStaticResult() (with predicate overloads) and exported StaticResult type for awaiting non-loading or predicate-matching query results; loaders can await these to produce static metadata.
  • Documentation

    • Added guide and TypeScript examples showing waitForStaticResult() usage in loaders and how it interacts with incremental/deferred results.
  • Tests

    • Added end-to-end test covering loader await behavior, document title set from early result, and staged loading → final value transitions.

@LordPikaChu LordPikaChu requested a review from a team as a code owner March 26, 2026 23:00
@netlify

netlify Bot commented Mar 26, 2026

Copy link
Copy Markdown

👷 Deploy request for apollo-client-nextjs-docmodel pending review.

Visit the deploys page to approve it

Name Link
🔨 Latest commit 3134704

@changeset-bot

changeset-bot Bot commented Mar 26, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3134704

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

This PR includes changesets to release 5 packages
Name Type
@apollo/client-react-streaming Minor
@apollo/client-integration-react-router Minor
@apollo/client-integration-nextjs Minor
@apollo/client-integration-tanstack-start Minor
@apollo/experimental-nextjs-app-support 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 Mar 26, 2026

Copy link
Copy Markdown

@mateuszhajdziony is attempting to deploy a commit to the Apollo Client - Next package - integration tests Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Mar 26, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a new preloadQuery.waitForStaticResult API (with predicate overloads) to the streaming client, integrates it into React Router loaders, updates types/exports, provides a loader example and Playwright test, and includes documentation and a changeset for a minor release.

Changes

Cohort / File(s) Summary
Core API Implementation
packages/client-react-streaming/src/transportedQueryRef.ts, packages/client-react-streaming/src/index.shared.ts
Implements waitForStaticResult on the transported query preloader, introduces StaticResult<TData>, stores observables in a WeakMap, refactors preload factory into prepareQuery/preload, and re-exports the new type.
React Router Integration
packages/react-router/src/preloader.tsx
Extends PreloadQueryFn with waitForStaticResult delegating to the preloader; loader callback args now include client: ApolloClient alongside preloadQuery.
Integration Test & Example Route
integration-test/playwright/src/preloadQuery.test.ts, integration-test/react-router/app/routes/preloadQuery.awaitable.tsx
Adds a Playwright test covering staged deferred results and a React Router route demonstrating loader usage of preloadQuery.waitForStaticResult to set meta() while returning queryRef for component consumption.
Documentation & Release Notes
packages/react-router/README.md, .changeset/add-preload-query-wait-for-static-result.md
Documents preloadQuery.waitForStaticResult usage (including predicate behavior and example) and adds a changeset describing the new minor-version API.

Sequence Diagram

sequenceDiagram
    participant Loader as React Router<br/>Loader
    participant Preloader as PreloadQuery<br/>Handler
    participant Observable as Apollo<br/>Observable
    participant Promise as Promise<br/>Resolution

    Loader->>Preloader: waitForStaticResult(queryRef, predicate?)
    Preloader->>Observable: subscribe(observableByRef.get(queryRef))
    Observable->>Preloader: emit({ data: {...}, loading: false? / incremental payloads })
    Preloader->>Preloader: evaluate predicate(result)?
    alt Predicate matches OR no predicate & non-loading
        Preloader->>Promise: resolve(StaticResult)
        Promise->>Loader: result returned (for meta())
        Preloader->>Observable: unsubscribe / cleanup
    else keep waiting until match or completion/error
        Observable->>Preloader: emit(nextResult) or complete/error
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately describes the main change: adding a preloadQuery.waitForStaticResult() API for awaiting partial query data in loaders.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🧹 Nitpick comments (1)
integration-test/react-router/app/routes/preloadQuery.awaitable.tsx (1)

46-49: Consider using nullish coalescing for rating display.

The current || operator would show "loading..." for a rating value of 0. While unlikely in practice, using ?? is more precise for null/undefined checks.

♻️ Suggested change
           Rating:{" "}
           <span data-testid={`rating-${id}`}>
-            {rating?.value || "loading..."}
+            {rating?.value != null ? `${rating.value}/5` : "loading..."}
           </span>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@integration-test/react-router/app/routes/preloadQuery.awaitable.tsx` around
lines 46 - 49, The JSX uses the logical OR operator for rendering the rating
({rating?.value || "loading..."}) which treats 0 as falsy; change it to the
nullish coalescing operator so only null/undefined fall back to "loading..." —
update the expression referencing rating?.value inside the span with data-testid
`rating-${id}` to use ?? instead of ||.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/react-router/README.md`:
- Around line 195-206: The Products example references an undefined product
variable; use the data returned from useReadQuery(queryRef) instead. Update the
Products component to read the product from the useReadQuery result (e.g.,
replace product.title and product.rating with the corresponding properties on
data, such as data.product or data?.product depending on the shape) so that all
usages reference the defined symbol returned by useReadQuery(queryRef) rather
than the nonexistent product variable.
- Around line 208-209: The note block marker `> [!NOTE]` is missing a separating
newline so the content isn't rendered as a block note; update the markdown
around the `> [!NOTE]` marker so there is a blank line (or use a new line
containing just `>`) after the marker and ensure the following line(s) starting
with the content about `resolveWhen` are prefixed as blockquote lines (e.g.,
start with `>`), so the note renders correctly.

---

Nitpick comments:
In `@integration-test/react-router/app/routes/preloadQuery.awaitable.tsx`:
- Around line 46-49: The JSX uses the logical OR operator for rendering the
rating ({rating?.value || "loading..."}) which treats 0 as falsy; change it to
the nullish coalescing operator so only null/undefined fall back to "loading..."
— update the expression referencing rating?.value inside the span with
data-testid `rating-${id}` to use ?? instead of ||.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cac3322f-2f22-4762-9f2e-c5cfcd751611

📥 Commits

Reviewing files that changed from the base of the PR and between 43b0a18 and df6b368.

📒 Files selected for processing (7)
  • .changeset/add-preload-query-awaitable.md
  • integration-test/playwright/src/preloadQuery.test.ts
  • integration-test/react-router/app/routes/preloadQuery.awaitable.tsx
  • packages/client-react-streaming/src/index.shared.ts
  • packages/client-react-streaming/src/transportedQueryRef.ts
  • packages/react-router/README.md
  • packages/react-router/src/preloader.tsx
📜 Review details
🔇 Additional comments (9)
packages/client-react-streaming/src/index.shared.ts (1)

8-13: LGTM!

The new AwaitablePreloadResult type is correctly re-exported alongside the related preloader types, making it available for consumers of the public API.

integration-test/playwright/src/preloadQuery.test.ts (1)

180-205: LGTM!

The test correctly validates the preloadQuery.awaitable() behavior:

  1. Document title is set immediately from data resolved via resolveWhen (before deferred content arrives)
  2. Rating element shows "loading..." initially, then updates to "5/5" after the @defer fragment streams in

The 5s timeout appropriately accounts for the 1000ms delayDeferred plus network variability.

.changeset/add-preload-query-awaitable.md (1)

1-8: LGTM!

The changeset correctly documents the new API with a minor version bump for both affected packages. The description clearly explains the use case and behavior.

packages/react-router/src/preloader.tsx (2)

27-42: LGTM!

The AwaitablePreloadQueryResult type is well-documented and correctly mirrors the streaming library's AwaitablePreloadResult structure while using React Router's unstable_SerializesTo wrapper for the queryRef.


88-101: LGTM!

The implementation correctly wraps preloader.awaitable() to convert the stream to a promiscade for turbo-stream compatibility while passing resolveWhen through unchanged. This maintains consistency with the existing preloadQuery behavior.

integration-test/react-router/app/routes/preloadQuery.awaitable.tsx (1)

8-23: LGTM!

The loader correctly demonstrates the preloadQuery.awaitable() pattern:

  1. Destructures queryRef and resolveWhen from the awaitable call
  2. Awaits only the critical data needed for meta() via the predicate
  3. Returns both the streaming queryRef and the extracted title

This allows the document title to be set immediately while deferred ratings stream in later.

packages/client-react-streaming/src/transportedQueryRef.ts (3)

89-109: LGTM!

The type definitions for awaitable and AwaitablePreloadResult are well-documented with clear JSDoc explaining the behavior, including the rejection conditions. The prepareForReuse limitation is correctly documented.

Also applies to: 116-131


166-206: LGTM!

Good refactoring to extract prepareQuery as a shared helper. This reduces duplication between preload and preload.awaitable while keeping the logic for creating the transported query ref and watch query options in one place.


237-293: LGTM!

The awaitable implementation is well-structured:

  1. Guard clause (lines 243-247): Correctly throws for unsupported prepareForReuse mode
  2. Observable creation (line 254): Creates once, allowing multiple resolveWhen calls with different predicates
  3. Subscription lifecycle (lines 260-285): Properly unsubscribes on resolve, reject, or error
  4. Predicate logic (lines 263-276): Correctly handles:
    • Match found → resolve with data
    • Query completes without match (!result.loading) → reject
    • Still loading → wait for next incremental result
    • Predicate throws → reject

The implementation works naturally with @defer since each incremental result triggers the predicate check.

Comment thread packages/react-router/README.md
Comment thread packages/react-router/README.md Outdated
@Marce1ina

Copy link
Copy Markdown

omg we absolutely NEED this feature merged!

Comment thread packages/react-router/README.md Outdated
Comment on lines +177 to +182
const { queryRef, resolveWhen } = preloadQuery.awaitable(MY_QUERY);

// Await only the product title — `rating` will stream in later via @defer
const data = await resolveWhen(
(data) => data?.product?.title !== null
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hi, just looking at this now :)

We already have preloadQuery.toPromise(queryRef) - could we do this in a similar shape?

Suggested change
const { queryRef, resolveWhen } = preloadQuery.awaitable(MY_QUERY);
// Await only the product title — `rating` will stream in later via @defer
const data = await resolveWhen(
(data) => data?.product?.title !== null
);
const queryRef = preloadQuery(MY_QUERY);
// Await only the product title — `rating` will stream in later via @defer
const result = await preloadQuery.waitForStaticResult(
queryRef,
(result) => result.data?.product?.title !== null
);

signatures would be something like

// returns a promise that resolves to the final result
waitForStaticResult<TData>(queryRef): Promise<Result<TData>>
// returns a promise that could resolve sooner if the predicate is satisfied
// throws if the predicate is not satisfied when the query is fully resolved
waitForStaticResult<TData, TResult extends Result<TData>>(queryRef: PreloadedQuery<T>, predicate: (result: Result<TData>) => result is TResult): Promise<TResult>;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ah got it, so you'd like this to be a static method callable separately with the queryRef. Can do probably, but I'd have to investigate if this method, being separate, would still have access to the original stream. I'll try to prepare another proposal today evening or tomorrow if its possible. But otherwise do you agree with this API and is it "mergeable"?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If we can get it to the flow I laid out, I think we could get it in, yes. The static in the name will hopefully make it clear that it shouldn't be used for data anyone would consider "changing" during a page render.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@phryneas done 🫡

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@phryneas please review, the API is now reworked according to your proposal

@LordPikaChu LordPikaChu force-pushed the react-router-awaitable branch from 8d4cef4 to a267962 Compare April 10, 2026 15:46
@LordPikaChu LordPikaChu changed the title react-router - Add preloadQuery.awaitable() API for awaiting partial query data in loaders react-router - Add preloadQuery.waitForStaticResult() API for awaiting partial query data in loaders Apr 10, 2026

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/client-react-streaming/src/transportedQueryRef.ts`:
- Around line 228-241: The prepareForReuse branch returns a wrapped ref without
registering an observable, but later code (e.g., waitForStaticResult) expects
observableByRef to contain an entry for transportedQueryRef and throws; fix by
ensuring the prepareForReuse path populates observableByRef the same way the
non-reuse path does: after obtaining internalQueryRef via getInternalQueryRef
and wrapQueryRef, create or obtain the underlying observable (e.g., from the
internalQueryRef or by calling client.watchQuery with the same
watchQueryOptions) and set observableByRef.set(transportedQueryRef, observable)
before returning the wrapped ref; alternatively, change waitForStaticResult to
defensively handle missing observableByRef entries, but prefer populating
observableByRef in the prepareForReuse branch (symbols: prepareForReuse,
transportedQueryRef, observableByRef, getInternalQueryRef, wrapQueryRef,
client.watchQuery, waitForStaticResult).
- Around line 270-275: The predicate check is incorrectly gated by `result.data
!= null` so nullable payloads never get evaluated; update the block in
transportedQueryRef where `if (predicate) { if (result.data != null &&
predicate(staticResult)) { ... } else if (!result.loading) { ... } }` to call
`predicate(staticResult)` regardless of `result.data` (i.e., remove the
`result.data != null` guard) so the predicate can pass for null payloads,
keeping the unsubscribe/resolve on success and the unsubscribe/reject when
`!result.loading` unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5f9ff39c-0825-4492-a1b9-5ef3f2a806ac

📥 Commits

Reviewing files that changed from the base of the PR and between 8d4cef4 and 31a37a5.

📒 Files selected for processing (7)
  • .changeset/add-preload-query-wait-for-static-result.md
  • integration-test/playwright/src/preloadQuery.test.ts
  • integration-test/react-router/app/routes/preloadQuery.awaitable.tsx
  • packages/client-react-streaming/src/index.shared.ts
  • packages/client-react-streaming/src/transportedQueryRef.ts
  • packages/react-router/README.md
  • packages/react-router/src/preloader.tsx
✅ Files skipped from review due to trivial changes (3)
  • .changeset/add-preload-query-wait-for-static-result.md
  • packages/react-router/README.md
  • integration-test/playwright/src/preloadQuery.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/client-react-streaming/src/index.shared.ts
  • integration-test/react-router/app/routes/preloadQuery.awaitable.tsx
📜 Review details
🔇 Additional comments (2)
packages/react-router/src/preloader.tsx (2)

61-64: Good API extension with client in loader args.

Adding client on Line 63 makes loader-side integrations more flexible without changing existing preloadQuery behavior.


78-85: Delegation keeps await logic centralized.

Line 80–83 forwards directly to the underlying preloader implementation, which is the right way to prevent behavior drift across packages.

Comment thread packages/client-react-streaming/src/transportedQueryRef.ts Outdated
Comment thread packages/client-react-streaming/src/transportedQueryRef.ts Outdated
LordPikaChu and others added 2 commits April 13, 2026 15:09
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

♻️ Duplicate comments (1)
packages/client-react-streaming/src/transportedQueryRef.ts (1)

228-237: ⚠️ Potential issue | 🟠 Major

prepareForReuse refs still can't use waitForStaticResult.

The reuse path returns on Line 234 without registering the observable, so every reused ref will hit the "no observable found" throw on Line 257. Either populate observableByRef for reused refs too, or fail fast with an explicit unsupported-mode error instead of exposing a broken API path.

Also applies to: 255-260

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/client-react-streaming/src/transportedQueryRef.ts` around lines 228
- 237, The reuse path for prepareForReuse returns a wrapped ref without
registering its observable, causing waitForStaticResult to throw; update the
prepareForReuse branch in transportedQueryRef creation (the code using
getInternalQueryRef, transportedQueryRef, and wrapQueryRef) to register the
underlying observable into observableByRef for the returned ref (or
alternatively throw a clear unsupported-mode error), and apply the same fix to
the analogous reuse logic near the other branch (the code around the other
returned/wrapped ref) so reused refs either have observableByRef populated or
fail fast with an explicit unsupported-mode message instead of leaving a broken
API path.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/client-react-streaming/src/transportedQueryRef.ts`:
- Around line 239-245: The subscribe callbacks currently close over the
subscription variable before it's assigned, risking a ReferenceError; fix by
declaring the subscription binding before calling subscribe and ensuring
callbacks reference that pre-declared variable (e.g., let subscription:
ReturnType<typeof observable.subscribe> | null = null; then assign subscription
= observable.subscribe(...); and use subscription?.unsubscribe() in the
next/error handlers). Apply the same change in the waitForStaticResult code path
so both uses of client.watchQuery / observableByRef and transportedQueryRef
avoid closing over an uninitialized subscription.

---

Duplicate comments:
In `@packages/client-react-streaming/src/transportedQueryRef.ts`:
- Around line 228-237: The reuse path for prepareForReuse returns a wrapped ref
without registering its observable, causing waitForStaticResult to throw; update
the prepareForReuse branch in transportedQueryRef creation (the code using
getInternalQueryRef, transportedQueryRef, and wrapQueryRef) to register the
underlying observable into observableByRef for the returned ref (or
alternatively throw a clear unsupported-mode error), and apply the same fix to
the analogous reuse logic near the other branch (the code around the other
returned/wrapped ref) so reused refs either have observableByRef populated or
fail fast with an explicit unsupported-mode message instead of leaving a broken
API path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6c38b96e-72b9-4f26-acfc-40a04785342c

📥 Commits

Reviewing files that changed from the base of the PR and between 31a37a5 and c13a747.

📒 Files selected for processing (1)
  • packages/client-react-streaming/src/transportedQueryRef.ts
📜 Review details
🔇 Additional comments (1)
packages/client-react-streaming/src/transportedQueryRef.ts (1)

165-216: Nice prepareQuery extraction.

Centralizing the option sanitization and watchQueryOptions construction makes the preload flow much easier to audit.

Comment thread packages/client-react-streaming/src/transportedQueryRef.ts
@LordPikaChu LordPikaChu requested a review from phryneas April 13, 2026 13:19
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.

4 participants