FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

feat(client): add `iterate()` method to subscriptions by tarikermis · Pull Request #7461 · trpc/trpc · GitHub

/ trpc Public

feat(client): add iterate() method to subscriptions - #7461

Open
tarikermis wants to merge 3 commits into
trpc:mainfrom
tarikermis:feat/subscription-iterate
Open

feat(client): add iterate() method to subscriptions#7461
tarikermis wants to merge 3 commits into
trpc:mainfrom
tarikermis:feat/subscription-iterate

Conversation

tarikermis commented Aug 9, 2026
edited
Loading

Copy link
Copy Markdown

Closes #6868

What changed

Subscription procedures on the vanilla client can now be consumed as an AsyncIterable:

for await (const data of client.onPostAdd.iterate()) {
  // ...
}

iterate(input, opts?) uses the existing observableToAsyncIterable helper. It filters connection-state envelopes, yields subscription data with the same output typing as onData, and propagates link errors unchanged. The iterable is cold, so each iterator owns one subscription and tears it down on break, return, or abort.

While adding this, I also fixed a few teardown cases in observableToReadableStream: late emissions after cancellation are ignored, abort closes a pending reader, listeners are detached after settlement, and an already-aborted signal does not start a request.

The vanilla client docs now show both callback subscriptions and iterate().

Verification

  • Added 6 HTTP subscription tests, 3 WebSocket tests, and 5 observable unit tests.
  • Focused Vitest run: 190 passed.
  • The new WebSocket cases pass. Five existing PING/PONG and lazy-mode cases remain timing-flaky and fail the same way on a clean main checkout.
  • TypeScript checks pass for client, server, and tests. ESLint, Prettier, and pnpm build-www also pass.

Notes

  • Slow consumers still buffer values; this is existing stream-helper behavior.
  • Errors are intentionally not wrapped again because links already return TRPCClientError, matching subscribe.
  • No changeset was added because this repository uses lerna and has no .changeset setup.

Checklist

  • I have followed the steps listed in the Contributing guide.
  • If necessary, I have added documentation related to the changes made.
  • I have added or updated the tests related to the changes made.

Best Regards, Tarik

…rdown

- close the stream when the abort signal fires so a pending reader is not
  left hanging if the source goes silent after teardown
- drop source emissions that arrive after the stream was cancelled
  (previously threw 'Controller is already closed')
- detach the abort listener once the stream has settled
- skip subscribing entirely when the signal is already aborted
Expose subscriptions on the vanilla client as an AsyncIterable, e.g. for
use with TanStack Query's streamedQuery:

    for await (const data of client.onPostAdd.iterate()) {
      // ...
    }

The iterable is cold: each new iterator starts a new subscription which
is torn down when iteration stops early. Connection state envelopes
(started/stopped/state) are filtered out and errors reject the iterator.
Aborting the optional signal ends the iteration.

Closes trpc#6868
tarikermis requested review from a team as code owners August 9, 2026 05:47

vercel Bot commented Aug 9, 2026
edited
Loading

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
next-prisma-starter Ready Preview Aug 11, 2026 7:39am
og-image Ready Preview Aug 11, 2026 7:39am

coderabbitai Bot commented Aug 9, 2026
edited
Loading

Copy link
Copy Markdown
Contributor

📝 Walkthrough

Walkthrough

The client now exposes typed subscription results as AsyncIterable values. Iteration supports abort signals, reconnects, early termination, errors, completion, and cleanup. Tests cover HTTP and WebSocket subscriptions, and the vanilla client guide documents both subscription APIs.

Changes

AsyncIterable subscription support

Layer / File(s) Summary
Client iteration API
packages/client/src/createTRPCClient.ts, packages/client/src/internals/TRPCUntypedClient.ts
The typed client adds iterate for subscriptions. The untyped client converts subscription observables into async iterables and handles data, errors, completion, and abort signals.
Observable lifecycle handling
packages/server/src/observable/observable.ts, packages/server/src/observable/observable.test.ts
Observable streams now handle pre-aborted signals, cancellation state, settled-state cleanup, and post-settlement emissions. Tests cover abort, completion, errors, and listener cleanup.
Subscription integration validation
packages/tests/server/httpSubscriptionLink.test.ts, packages/tests/server/websockets.test.ts, www/docs/client/vanilla/setup.mdx
HTTP and WebSocket tests cover iteration, reconnects, aborts, repeated iteration, early termination, errors, and cleanup. The guide documents callback-based and async-iterable subscriptions.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ClientProxy
  participant TRPCUntypedClient
  participant SubscriptionObservable
  ClientProxy->>TRPCUntypedClient: invoke subscription iterate
  TRPCUntypedClient->>SubscriptionObservable: create subscription observable
  SubscriptionObservable-->>TRPCUntypedClient: emit subscription data
  TRPCUntypedClient-->>ClientProxy: yield async-iterable value
  ClientProxy->>TRPCUntypedClient: abort or stop iteration
  TRPCUntypedClient->>SubscriptionObservable: unsubscribe and clean up
Loading

Possibly related PRs

  • trpc/trpc#6806: Consumes client async iterables in localLink.
  • trpc/trpc#7303: Adds server-side subscription input and output inference helpers.

Suggested reviewers: katt

🚥 Pre-merge checks | ✅ 5 ✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR fulfills issue #6868 by exposing vanilla client subscriptions as cold AsyncIterable values with appropriate typing and lifecycle handling.
Out of Scope Changes check ✅ Passed The changes remain within scope, including necessary shared-helper hardening, tests, documentation, and the requested client subscription iteration feature.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed The title clearly and concisely identifies the main change: adding an iterate() method to client subscriptions.
Description check ✅ Passed The description explains the changes, verification, documentation, tests, and checklist, although it uses a different heading from the template.
✨ 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.

pkg-pr-new Bot commented Aug 9, 2026
edited
Loading

Copy link
Copy Markdown

Open in StackBlitz

@trpc/client

npm i https://pkg.pr.new/@trpc/client@7461

@trpc/next

npm i https://pkg.pr.new/@trpc/next@7461

@trpc/openapi

npm i https://pkg.pr.new/@trpc/openapi@7461

@trpc/react-query

npm i https://pkg.pr.new/@trpc/react-query@7461

@trpc/server

npm i https://pkg.pr.new/@trpc/server@7461

@trpc/tanstack-react-query

npm i https://pkg.pr.new/@trpc/tanstack-react-query@7461

@trpc/upgrade

npm i https://pkg.pr.new/@trpc/upgrade@7461

commit: f8c4bda

coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/server/src/observable/observable.ts (1)

165-196: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Do not add an abort listener after synchronous settlement.

If observable.subscribe() completes or errors synchronously, onSettled() runs before the listener registration. Lines 192-196 then add onAbort after settlement, so the listener remains attached to signal.

Only add the listener while the stream is still active.

Proposed fix
-      } else {
+      } else if (active) {
         signal.addEventListener('abort', onAbort, { once: true });
       }

Add coverage for a synchronous completion followed by ac.abort() to verify that no abort listener remains.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/server/src/observable/observable.ts` around lines 165 - 196, Update
the subscription setup around observable.subscribe and the subsequent signal
handling so the abort listener is registered only when the stream remains active
after synchronous completion or error settlement. Preserve immediate onAbort
handling for an already-aborted signal, and add coverage for synchronous
completion followed by ac.abort() verifying no abort listener remains.
🧹 Nitpick comments (2)
packages/client/src/createTRPCClient.ts (1)

164-164: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove the non-null assertion.

Guard the result of pathCopy.pop() before using it. This lets TypeScript narrow clientCallType without !.

Proposed fix
-    const clientCallType = pathCopy.pop()!;
+    const clientCallType = pathCopy.pop();
+    if (!clientCallType) {
+      throw new Error('Missing client call type');
+    }

Confirm that createRecursiveProxy does not intentionally invoke this handler with an empty path. As per coding guidelines, “Avoid non-null assertions (!). Use proper type guards and optional chaining instead of non-null assertions.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/client/src/createTRPCClient.ts` at line 164, Update
createRecursiveProxy to guard the result of pathCopy.pop() before assigning or
using clientCallType, removing the non-null assertion and allowing TypeScript to
narrow the value. Preserve the existing behavior for valid non-empty paths, and
explicitly handle an empty path rather than invoking the handler with an
undefined clientCallType.

Source: Coding guidelines

packages/tests/server/httpSubscriptionLink.test.ts (1)

207-383: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use a scoped test resource in each test.

These tests need both server and client setup. Create the resource in each test with await using ctx = testServerAndClientResource(...). Use ctx.client directly instead of destructuring client.

This keeps resource cleanup lexical and follows the required test isolation pattern. As per coding guidelines, “ALWAYS use await using ctx = testServerAndClientResource(...) in tests that need both server and client setup”, “Use ctx.client from the test resource for making tRPC calls”, and “Avoid overzealous object destructuring; prefer direct property access.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/tests/server/httpSubscriptionLink.test.ts` around lines 207 - 383,
Update each async iterable test to create its own scoped resource with await
using ctx = testServerAndClientResource(...), ensuring the required setup
arguments match the existing test context. Replace destructured client usage
with ctx.client, and rely on lexical resource cleanup and isolation within every
test.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@packages/server/src/observable/observable.ts`:
- Around line 165-196: Update the subscription setup around observable.subscribe
and the subsequent signal handling so the abort listener is registered only when
the stream remains active after synchronous completion or error settlement.
Preserve immediate onAbort handling for an already-aborted signal, and add
coverage for synchronous completion followed by ac.abort() verifying no abort
listener remains.

---

Nitpick comments:
In `@packages/client/src/createTRPCClient.ts`:
- Line 164: Update createRecursiveProxy to guard the result of pathCopy.pop()
before assigning or using clientCallType, removing the non-null assertion and
allowing TypeScript to narrow the value. Preserve the existing behavior for
valid non-empty paths, and explicitly handle an empty path rather than invoking
the handler with an undefined clientCallType.

In `@packages/tests/server/httpSubscriptionLink.test.ts`:
- Around line 207-383: Update each async iterable test to create its own scoped
resource with await using ctx = testServerAndClientResource(...), ensuring the
required setup arguments match the existing test context. Replace destructured
client usage with ctx.client, and rely on lexical resource cleanup and isolation
within every test.

ℹ️ Review info ⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 15272a9e-6776-4215-a7d8-047ea7fcd4ba

📥 Commits

Reviewing files that changed from the base of the PR and between acff823 and c59a1e0.

📒 Files selected for processing (7)
  • packages/client/src/createTRPCClient.ts
  • packages/client/src/internals/TRPCUntypedClient.ts
  • packages/server/src/observable/observable.test.ts
  • packages/server/src/observable/observable.ts
  • packages/tests/server/httpSubscriptionLink.test.ts
  • packages/tests/server/websockets.test.ts
  • www/docs/client/vanilla/setup.mdx

This comment was marked as resolved.

Adds regression coverage for observableToAsyncIterable:
- breaking out of iteration unsubscribes the underlying subscription
  even when the signal is never aborted (the subscriptionAsIterable
  fallback path)
- a consumer-side cancel() detaches the abort listener without the
  signal ever being aborted

Also clarifies why the ReadableStream cancel() handler delegates to
onAbort (which already detaches the listener) rather than onSettled.

Copy link
Copy Markdown
Author

Good questions - I double-checked both.

On early break: teardown doesn't rely on the signal at all. break calls the iterator's return(), which cancels the underlying ReadableStream and unsubscribes the subscription, so the fallback controller being orphaned is harmless. Added a test proving unsubscribe happens with a never-aborted signal.

On cancel() vs onSettled: the stream's cancel() handler delegates to onAbort(), whose first line already detaches the abort listener (and unsubscribes), so the listener is never left attached. There's now a test spying on removeEventListener to prove it, plus a comment in cancel() clarifying why onSettled isn't needed there.

Best Regards, Tarik

This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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.

feat: client subscriber gets an AsyncIterable

2 participants


Back | FazBrowse Home | New Git URL