Repository: preactjs/preact-render-to-string
Pull request #417 (2025-02-07 04:15:08)

fix: Only abort pipeablestream if not already closed
Remix/React-Router seems to [always call `abort()` after a short timeout](https://github.com/remix-run/react-router/blob/5a1ca081ef49475ac9e23b6ea6ca6f365db58597/packages/react-router-dev/config/defaults/entry.server.node.tsx#L64-L66). This reports the following "error":

```
Error: The render was aborted by the server without a reason.
    at Timeout.abort [as _onTimeout] (file:///home/ryun/Projects/tmp-react-router-preact/node_modules/preact-render-to-string/src/stream-node.js:75:4)
    at listOnTimeout (node:internal/timers:594:17)
    at processTimers (node:internal/timers:529:7)
```

To my understanding, this is something we can ignore if the stream has already been closed. [React does something along these lines too](https://github.com/facebook/react/blob/a0fdb6306043b9f049106e58dcec107d8dbed2b1/packages/react-server/src/ReactFizzServer.js#L5352-L5353), checking to ensure there's tasks that can be aborted before throwing and cleaning up further.

cc @sbesh91

changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: d2e5b2c1f4fe5f84e727b02c972307c120331af0

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/preactjs/preact-render-to-string/new/fix/pipeable-abort?filename=.changeset/breezy-cars-press.md&value=---%0A%22preact-render-to-string%22%3A%20patch%0A---%0A%0Afix%3A%20Only%20abort%20pipeablestream%20if%20not%20already%20closed%0A)



Patch:
diff --git a/.changeset/yellow-hounds-jog.md b/.changeset/yellow-hounds-jog.md
new file mode 100644
index 0000000..2b3b40a
--- /dev/null
+++ b/.changeset/yellow-hounds-jog.md
@@ -0,0 +1,5 @@
+---
+'preact-render-to-string': patch
+---
+
+Only abort/report errors from `renderToPipeableStream()` if the stream hasn't already been closed
diff --git a/src/stream-node.js b/src/stream-node.js
index bffa20e..e39da31 100644
--- a/src/stream-node.js
+++ b/src/stream-node.js
@@ -60,7 +60,14 @@ export function renderToPipeableStream(vnode, options, context) {
 		/**
 		 * @param {unknown} [reason]
 		 */
-		abort(reason = new Error('The render was aborted by the server without a reason.')) {
+		abort(
+			reason = new Error(
+				'The render was aborted by the server without a reason.'
+			)
+		) {
+			// Remix/React-Router will always call abort after a timeout, even on success
+			if (stream.closed) return;
+
 			controller.abort();
 			stream.destroy();
 			if (options.onError) {


Test patch:
diff --git a/test/compat/stream-node.test.js b/test/compat/stream-node.test.js
index 5783887..159e0e2 100644
--- a/test/compat/stream-node.test.js
+++ b/test/compat/stream-node.test.js
@@ -73,4 +73,19 @@ describe('renderToPipeableStream', () => {
 			'</div>'
 		]);
 	});
+
+	it('should not error if the stream has already been closed', async () => {
+		let error;
+		const sink = createSink();
+		const { pipe, abort } = renderToPipeableStream(<div class="foo">bar</div>, {
+			onAllReady: () => {
+				pipe(sink.stream);
+			},
+			onError: (e) => (error = e)
+		});
+		await sink.promise;
+		abort();
+
+		expect(error).to.be.undefined;
+	});
 });
diff --git a/test/compat/stream.test.js b/test/compat/stream.test.js
index 09f6134..f2378a6 100644
--- a/test/compat/stream.test.js
+++ b/test/compat/stream.test.js
@@ -82,10 +82,10 @@ describe('renderToReadableStream', () => {
 		const result = await sink.promise;
 
 		expect(result).to.deep.equal([
-			'<div><!--preact-island:54-->loading...<!--/preact-island:54--></div>',
+			'<div><!--preact-island:56-->loading...<!--/preact-island:56--></div>',
 			'<div hidden>',
 			createInitScript(),
-			createSubtree('54', '<p>it works</p>'),
+			createSubtree('56', '<p>it works</p>'),
 			'</div>'
 		]);
 	});