fix(render): await stream.allReady before reading renderToReadableStream output#3091
fix(render): await stream.allReady before reading renderToReadableStream output#3091aplr wants to merge 10 commits intoresend:canaryfrom
Conversation
…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>
|
@aplr is attempting to deploy a commit to the resend Team on Vercel. A member of the Team first needs to authorize it. |
🦋 Changeset detectedLatest commit: 3c2a00d The changes in this PR will be included in the next version bump. This PR includes changesets to release 3 packages
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 |
commit: |
There was a problem hiding this comment.
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.allReadybefore reading fromrenderToReadableStreamin 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.
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>
There was a problem hiding this comment.
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.
…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>
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 (<!--$?-->,$RChydration scripts) instead of fully resolved HTML, producing broken, blank emails in clients that don't execute JavaScript.Root cause:
renderToReadableStreamreturns a stream immediately with the shell HTML, then fills in pending Suspense boundaries asynchronously via inline<script>tags. Without awaitstream.allReady, the HTML is captured before those boundaries resolve.Fix: Add await
stream.allReadybefore reading the stream in both the edge render and the node render'srenderToReadableStreamfallback path. This is therenderToReadableStreamequivalent ofonAllReadyinrenderToPipeableStream.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$RCfunction 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.allReadyin everyrender(), it could be added to bothreadStream()implementations.Type of change
Checklist
renderToReadableStreampaths: edge, browser, nodeSummary 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.stream.allReadybefore reading in edge, NoderenderToReadableStreamfallback, and browser.$RC,<!--$?-->) from leaking; aligns withonAllReady.renderToReadableStream: move intoWritableStreamblock, mockreact-dom/servertoreact-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.@react-email/renderpatch release.Written for commit 3c2a00d. Summary will update on new commits.