Skip to content

fix(react-router): stop the RSC HTML stream when it is cancelled during flush - #15612

Open
Mheaus wants to merge 5 commits into
remix-run:mainfrom
Mheaus:fix/rsc-html-stream-cancel-during-flush
Open

Mheaus wants to merge 5 commits into
remix-run:mainfrom
Mheaus:fix/rsc-html-stream-cancel-during-flush

Conversation

@Mheaus

@Mheaus Mheaus commented Oct 9, 2026

Copy link
Copy Markdown

Fixes #15611

Follow-up to #15286. When the readable side is cancelled while flush() is in progress, the Streams spec does not call the transformer's cancel(), so cancelled stays false. If the flush timer is still pending, it then enqueues into a closed stream, and the unhandled rejection stops the Node process. #15611 has the details and a reproduction app.

Changes in injectRSCPayload:

  • The body of cancel() moves to a stop() helper. cancel() calls it, as before.
  • Each enqueue goes through tryEnqueue(). If the enqueue throws, it calls stop(). A failed enqueue is the one signal available in this window.
  • The timer and flush() return early after a stop. stop() resolves flightDataPromise, so flush() cannot wait forever.
  • If writeRSCStream fails after a stop, the catch returns instead of calling controller.error().

Testing:

I added a patch change file. Thanks for reviewing!

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

I ran a variant of the new test where the RSC stream is still open when flush() starts: timer fires, one RSC chunk written, then writer.close(), reader.cancel(), one more RSC chunk.

No unhandled rejection, but the RSC stream is never cancelled. After the enqueue throws, the finally in writeRSCStream releases the reader lock, so rscReader.cancel() in stop() rejects and the .catch swallows it.

In stop(), using rscReader.cancel() only when rscStream.locked, else rscStream.cancel(), cancelled it in my run. The rsc tests still pass.

@Mheaus

Mheaus commented Oct 10, 2026 •

Copy link
Copy Markdown
Author

Thank you very much for testing that variant.

Applied your suggestion in 82fb88e: stop() now cancels through rscReader only when rscStream.locked, and calls rscStream.cancel() otherwise. I also added your scenario as a test: "cancels the RSC stream when the readable side is cancelled during flush while the RSC payload is still streaming". It fails without the change (rscCancelled stays false) and passes with it.

I also looked at the other paths that release the lock:

  • writeRSCStream returns early after isCancelled(): stop() already ran while the reader was locked, so the reader cancelled the stream before the release.
  • The RSC stream ends or errors: cancel() has no effect on a closed or errored stream, with either branch.

The RSC tests pass (13/13), and Prettier, ESLint and tsc are clean.

The comment on `tryEnqueue` said that a closed stream "can only be
detected here". That claimed too much: the close is detected only at the
next enqueue, and the RSC stream stays open until then. The comment now
states that limit.

The other comments keep the same reasons in shorter sentences.
@Mheaus

Mheaus commented Oct 10, 2026

Copy link
Copy Markdown
Author

Two more changes after a closer look at the cancel paths:

  • 839cad8: stop() now resolves flightDataPromise before it cancels the RSC stream. In production the RSC stream is a tee branch (serverResponse.clone()), and the cancel of one branch settles only after the other branch is cancelled or the source closes. Before this change, an abort during flush() kept flush() and reader.cancel() pending until the RSC render ended. The new test "does not wait for the RSC payload to finish when the readable side is cancelled during flush" times out without it. The same commit removes a try/catch around controller.error(), which does not throw, and adds an early return in flush() after a failed buffered flush.
  • The first test now asserts that flush() was in progress when the reader was cancelled (the RSC stream is still open right after the cancel). Without the pending read, that assertion fails, so the test cannot pass through the regular cancel() path by accident.

One limit remains, and the comment on tryEnqueue now states it: if the RSC stream is open but sends nothing, a cancel during flush() is seen only at the next enqueue. Until then the RSC stream stays open. It closes correctly after that, with no unhandled rejection.

RSC tests: 14/14. Prettier, ESLint and tsc are clean.

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

Re-checked at c801cd3; the locked branch covers my case.

  • With server.ts from main, the 3 new tests fail; with the PR all pass.
  • pipeThrough variant, RSC stream open, cancel during flush(), then one RSC chunk (text, and invalid UTF-8): on main reader.cancel() rejects with Unable to enqueue and RSC is never cancelled; with the PR it settles and RSC is cancelled.

Note: removing any one of the four new if (cancelled) return checks changes no test result.

@Mheaus

Mheaus commented Oct 10, 2026

Copy link
Copy Markdown
Author

Thank you for re-checking, and for the pipeThrough variant with invalid UTF-8.

You are right about the four checks: none of them changes an outcome, because the code around them already covers each case.

  • In the timer, after a failed flush: stop() calls rscStream.cancel() before its first await, so the stream is already closed. The reader that starts next reads done at once.
  • In the .catch of writeRSCStream: controller.error() has no effect on a stream that is already closed.
  • The two checks in flush(): the trailer goes through tryEnqueue, which catches the error and calls stop() again. stop() is safe to call more than once.

So 905bf97 removes them, and the .catch is again the same line as on main. The diff is now only stop(), tryEnqueue(), and the two enqueues that use it.

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.

RSC: an aborted document request can still stop the server when it happens while flush() waits for the RSC payload (follow-up to #15275)

2 participants