Change:
When using for e.g. Cloudflare Workers runtimes, I'm noticing this error on the server: I'm assuming this results in the following malformed chunk being sent to the stream: Which results in the client failing: I put together a reproduction here, powered by for the Workers runtime: Could this be related to the "fundamentally flawed" nature of Web Streams implementations in e.g. Cloudflare Workers runtime? Is something going on with corking/uncorking, as per Maybe something's wrong with Miniflare's polyfill and this is a non-issue, coming from ? Finally, it's possible something is wrong with the Vite version of the example I've put together. I could try rebuilding with webpack if it's determined none of these items apply.
Have you looked at what chain of assumptions leads to this? It seems like it would be good to understand what different state machines are involved and why something's happening in CF that doesn't happen in the browser.
Have you looked at what chain of assumptions leads to this? after lots of digging, here's what we've discovered: The difference between Cloudflare/Oxygen Workers runtimes and the browser comes down to how the is consumed after being passed to . In the browser and , this is how the stream (via ) is consumed: This presumably results in a single . In the worker runtime, is read through an : Importantly, this results in getting called multiple times as more data is enqueued and as the reader sees fit. This is an assumption on my part, so I could be wrong here. When combined with Suspense boundaries, callbacks trigger . At the same time, repeated requests from the stream reader also call . This results in the following error on the server: You can also reproduce this directly in the browser by attempting to read the stream with . Here is a new fixture with a reproduction of the issue, including a couple test cases that show the different errors you'll see based on the number of Suspense boundaries you have: If this seems like a legitimate bug to you, a couple ideas I had: Should there be some sort of check like in ? Should we splice completed boundaries earlier to account for concurrency so they are removed from the array and not re-flushed? Here are some other resources and scratch notes:
~We'll fix this reentrancy bug somehow. However, right now it's a bit lower priority because CloudFlare technically don't support ReadableStreams and so you have to go through the polyfill and any subtle differences might be due to the polyfill. To really know if we fixed this issue we should apply this to native CloudFlare ReadableStreams... which I'm told is coming. So that's how we know if it actually worked. Until then, the support for CloudFlare workers is... partial.~ On a second thought this should probably fix it. I think I finally understand how it's supposed to work now.
Please take another look and see if this fixes all the issues you've noticed.
works like a charm! Thanks for taking the time to look at it and get it fixed

Discussion:
} } <ins> function makeDelayedText(Model) { let error, _resolve, _reject; let promise = new Promise((resolve, reject) => { _resolve = () => { promise = null; resolve(); }; _reject = e => { error = e; promise = null; reject(e); }; }); function DelayedText({children}, data) { if (promise) { throw promise; } if (error) { throw error; } return <Model>{children}</Model>; } return [DelayedText, _resolve, _reject]; } </ins> it('should resolve HTML using W3C streams', async () => { function Text({children}) { return <span>{children}</span>;

return children; } <del> function makeDelayedText() { let error, _resolve, _reject; let promise = new Promise((resolve, reject) => { _resolve = () => { promise = null; resolve(); }; _reject = e => { error = e; promise = null; reject(e); }; }); function DelayedText({children}, data) { if (promise) { throw promise; } if (error) { throw error; } return <Text>{children}</Text>; } return [DelayedText, _resolve, _reject]; } const [Friends, resolveFriends] = makeDelayedText(); const [Name, resolveName] = makeDelayedText(); const [Posts, resolvePosts] = makeDelayedText(); const [Photos, resolvePhotos] = makeDelayedText(); const [Games, , rejectGames] = makeDelayedText(); </del> <ins> const [Friends, resolveFriends] = makeDelayedText(Text); const [Name, resolveName] = makeDelayedText(Text); const [Posts, resolvePosts] = makeDelayedText(Text); const [Photos, resolvePhotos] = makeDelayedText(Text); const [Games, , rejectGames] = makeDelayedText(Text); </ins> // View function ProfileDetails({avatar}) {

expect(reportedErrors).toEqual([]); }); <ins> it('should close the stream upon completion when rendering to W3C streams', async () => { const {Suspense} = React; // Model function Text({children}) { return children; } const [Friends, resolveFriends] = makeDelayedText(Text); const [Name, resolveName] = makeDelayedText(Text); const [Posts, resolvePosts] = makeDelayedText(Text); const [Photos, resolvePhotos] = makeDelayedText(Text); // View function ProfileDetails({avatar}) { return ( <div> <Name>:name:</Name> {avatar} </div> ); } function ProfileSidebar({friends}) { return ( <div> <Photos>:photos:</Photos> {friends} </div> ); } function ProfilePosts({posts}) { return <div>{posts}</div>; } function ProfileContent() { return ( <Suspense fallback="(loading everything)"> <ProfileDetails avatar={<Text>:avatar:</Text>} /> <Suspense fallback={<p>(loading sidebar)</p>}> <ProfileSidebar friends={<Friends>:friends:</Friends>} /> </Suspense> <Suspense fallback={<p>(loading posts)</p>}> <ProfilePosts posts={<Posts>:posts:</Posts>} /> </Suspense> </Suspense> ); } const model = { rootContent: <ProfileContent />, }; const stream = ReactServerDOMWriter.renderToReadableStream( model, webpackMap, ); const reader = stream.getReader(); const decoder = new TextDecoder(); let flightResponse = ''; let isDone = false; reader.read().then(function progress({done, value}) { if (done) { isDone = true; return; } flightResponse += decoder.decode(value); return reader.read().then(progress); }); // Advance time enough to trigger a nested fallback. jest.advanceTimersByTime(500); await act(async () => {}); expect(flightResponse).toContain('(loading everything)'); expect(flightResponse).toContain('(loading sidebar)'); expect(flightResponse).toContain('(loading posts)'); expect(flightResponse).not.toContain(':friends:'); expect(flightResponse).not.toContain(':name:'); await act(async () => { resolveFriends(); }); expect(flightResponse).toContain(':friends:'); await act(async () => { resolveName(); }); expect(flightResponse).toContain(':name:'); await act(async () => { resolvePhotos(); }); expect(flightResponse).toContain(':photos:'); await act(async () => { resolvePosts(); }); expect(flightResponse).toContain(':posts:'); // Final pending chunk is written; stream should be closed. expect(isDone).toBeTruthy(); }); </ins> });

if (request.status === CLOSED) { return; } <ins> if (request.destination !== null) { // We're already flowing. return; } </ins> request.destination = destination; try { flushCompletedQueues(request, destination);

if (request.status === CLOSED) { return; } <ins> if (request.destination !== null) { // We're already flowing. return; } </ins> request.destination = destination; try { flushCompletedChunks(request, destination);