Skip to content

fix(render): await stream.allReady before reading renderToReadableStream output#3091

Open
aplr wants to merge 10 commits intoresend:canaryfrom
aplr:fix/issue-3090-readable-stream-all-ready
Open

fix(render): await stream.allReady before reading renderToReadableStream output#3091
aplr wants to merge 10 commits intoresend:canaryfrom
aplr:fix/issue-3090-readable-stream-all-ready

Conversation

@aplr
Copy link

@aplr aplr commented Mar 19, 2026

When using renderToReadableStream (the edge/browser/bun code path), the stream was read immediately after creation without waiting for all Suspense boundaries to resolve. This caused the output to contain streaming HTML markers (<!--$?-->, $RC hydration scripts) instead of fully resolved HTML, producing broken, blank emails in clients that don't execute JavaScript.

Root cause: renderToReadableStream returns a stream immediately with the shell HTML, then fills in pending Suspense boundaries asynchronously via inline <script> tags. Without await stream.allReady, the HTML is captured before those boundaries resolve.

Fix: Add await stream.allReady before reading the stream in both the edge render and the node render's renderToReadableStream fallback path. This is the renderToReadableStream equivalent of onAllReady in renderToPipeableStream.

Tests: The browser render variant even had an existing Suspense test whose inline snapshot explicitly asserted the broken streaming output <!--$?-->, hydration <script> tags, and the $RC function as the expected result. In other words, the test was inadvertently documenting the bug as intended behaviour rather than catching it as a regression. This commit corrects the assertion and adds a dedicated regression test alongside it.

This issue is particularly visible when using @react-email/tailwind, which uses an async Suspense-based setup on first render.

Fixes #3090

Open for discussion

Instead of awaiting stream.allReady in every render(), it could be added to both readStream() implementations.

Type of change

  • Bug fix (non-breaking change which fixes an issue)

Checklist

  • I have performed a self-review of my code
  • I have added tests that cover the Suspense resolution behaviour for all three renderToReadableStream paths: edge, browser, node
  • My changes generate no new warnings
  • My changes don't break any existing tests

Summary by cubic

Wait for Suspense to finish before reading renderToReadableStream, so HTML is fully resolved and emails render in non‑JS clients. Applies to edge, Node fallback, and browser render; fixes #3090 and first‑render issues with @react-email/tailwind.

  • Bug Fixes
    • Await stream.allReady before reading in edge, Node renderToReadableStream fallback, and browser.
    • Prevent streaming markers ($RC, <!--$?-->) from leaking; aligns with onAllReady.
    • Harden tests for renderToReadableStream: move into WritableStream block, mock react-dom/server to react-dom/server.browser, fix browser Suspense assertions, and use a deferred promise; add regression tests for <Tailwind> produces broken streaming HTML when rendering in Bun #3090.
    • Add changeset for @react-email/render patch release.

Written for commit 3c2a00d. Summary will update on new commits.

aplr and others added 2 commits March 19, 2026 12:56
…eam output

When using renderToReadableStream (the edge/Bun path), the stream was read
immediately after creation without waiting for all Suspense boundaries to
resolve. This caused the output to contain streaming HTML markers ($RC,
<!--$?-->) instead of fully resolved HTML — producing broken emails in
clients that don't execute JavaScript.

Fix: await stream.allReady before calling readStream in both the edge render
and the node render's renderToReadableStream fallback path.

Fixes resend#3090

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 19, 2026 12:07
@vercel
Copy link
Contributor

vercel bot commented Mar 19, 2026

@aplr is attempting to deploy a commit to the resend Team on Vercel.

A member of the Team first needs to authorize it.

@changeset-bot
Copy link

changeset-bot bot commented Mar 19, 2026

🦋 Changeset detected

Latest commit: 3c2a00d

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

This PR includes changesets to release 3 packages
Name Type
@react-email/render Patch
@react-email/components Patch
playground Patch

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

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 19, 2026

Open in StackBlitz

npm i https://pkg.pr.new/@react-email/render@3091

commit: 13a1149

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes incomplete HTML output when using React’s renderToReadableStream by awaiting stream.allReady before consuming the stream, ensuring Suspense boundaries fully resolve before the HTML is captured (preventing $RC hydration scripts and <!--$?--> markers from leaking into email HTML).

Changes:

  • Await stream.allReady before reading from renderToReadableStream in both the edge renderer and the node renderer’s readable-stream path.
  • Add coverage asserting Suspense-resolved output for edge and node readable-stream paths.
  • Reduce a couple of Suspense test delays to speed up test execution.

Reviewed changes

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

Show a summary per file
File Description
packages/render/src/node/render.tsx Waits for stream.allReady before reading renderToReadableStream output in node.
packages/render/src/edge/render.tsx Waits for stream.allReady before reading renderToReadableStream output in edge.
packages/render/src/node/render-node.spec.tsx Speeds up an existing Suspense-related test delay.
packages/render/src/node/render-edge.spec.tsx Adds regression test ensuring Suspense resolves before final HTML is returned (no $RC / <!--$?-->).
packages/render/src/edge/render.spec.tsx Adds regression test ensuring Suspense resolves before final HTML is returned (no $RC / <!--$?-->).
packages/render/src/browser/render-web.spec.tsx Speeds up an existing Suspense-related test delay.

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

You can also share your feedback on Copilot code review. Take the survey.

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

No issues found across 6 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

aplr and others added 4 commits March 19, 2026 13:24
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…e renderToReadableStream path

The previous test ran outside the WritableStream availability block,
which meant it went through renderToPipeableStream (which always waits
via onAllReady) rather than the renderToReadableStream path that had
the bug. Move the test inside the block and mock react-dom/server to
forward to react-dom/server.browser so the renderToReadableStream code
path is actually exercised.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The browser render had the same missing await stream.allReady bug as the
edge/node renderToReadableStream path. The existing Suspense test was
asserting the broken streaming output ($RC, <!--$?-->) as expected
behaviour — corrected to assert resolved HTML instead, and added an
explicit regression test for resend#3090.

Fixes resend#3090

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/render/src/node/render-edge.spec.tsx">

<violation number="1" location="packages/render/src/node/render-edge.spec.tsx:172">
P2: Suspense regression test can become a false positive because the async promise may resolve before render actually suspends.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

aplr and others added 2 commits March 19, 2026 16:38
…e regression test

The previous test used a 50ms setTimeout, which created a theoretical
false positive: if the promise resolved before renderToReadableStream
started rendering (e.g. during the internal module import await), React
would never suspend and the test would pass without exercising the
stream.allReady fix.

Replace with a deferred promise that is resolved explicitly after a
setTimeout(0), which flushes the microtask queue and guarantees React
has already suspended on the pending promise before it is resolved.

Co-Authored-By: Claude Sonnet 4.6 <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.

<Tailwind> produces broken streaming HTML when rendering in Bun

2 participants