Repository: preactjs/preact-render-to-string
Pull request #411 (2025-01-12 07:26:27)

Ensure use id works
CC @f0x52 

Resolves https://github.com/preactjs/preact-render-to-string/issues/407

This avoids us nullifying the `parent` pointer which leads us to have unique id across streaming boundaries.

Tangent: I did think about https://github.com/preactjs/preact/issues/4442 and how currently in Preact we might not wait for a boundary to finish streaming in. Which might fallback to normal rendering if the JS bundle loads faster than the boundary streaming in, we might need to fix this as part of hydration 2.0 as well.


Patch:
diff --git a/.changeset/friendly-numbers-hang.md b/.changeset/friendly-numbers-hang.md
index 764bcd8..4e8fe4b 100644
--- a/.changeset/friendly-numbers-hang.md
+++ b/.changeset/friendly-numbers-hang.md
@@ -2,4 +2,5 @@
 'preact-render-to-string': patch
 ---
 
-Fix issue where subtree re-render for Suspense boundaries caused a circular reference in the VNode's parent
+Ensure that the `_parent` is kept around across multiple suspensions and avoid circular references.
+In doing so our `useId` hook should always output unique ids during renderingToString.
diff --git a/package-lock.json b/package-lock.json
index e4d3c80..6e40cb4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
 	"name": "preact-render-to-string",
-	"version": "6.5.11",
+	"version": "6.5.12",
 	"lockfileVersion": 3,
 	"requires": true,
 	"packages": {
 		"": {
 			"name": "preact-render-to-string",
-			"version": "6.5.11",
+			"version": "6.5.12",
 			"license": "MIT",
 			"devDependencies": {
 				"@babel/plugin-transform-react-jsx": "^7.12.12",
@@ -14,7 +14,7 @@
 				"@babel/register": "^7.12.10",
 				"@changesets/changelog-github": "^0.4.1",
 				"@changesets/cli": "^2.18.0",
-				"baseline-rts": "npm:preact-render-to-string@6.5.11",
+				"baseline-rts": "npm:preact-render-to-string@latest",
 				"benchmarkjs-pretty": "^2.0.1",
 				"chai": "^4.2.0",
 				"check-export-map": "^1.3.1",
diff --git a/src/index.js b/src/index.js
index 4c7fe19..ff4c774 100644
--- a/src/index.js
+++ b/src/index.js
@@ -443,7 +443,6 @@ function _renderToString(
 					return EMPTY_STR;
 				} finally {
 					if (afterDiff) afterDiff(vnode);
-					vnode[PARENT] = null;
 
 					if (ummountHook) ummountHook(vnode);
 				}
@@ -473,7 +472,6 @@ function _renderToString(
 
 			if (afterDiff) afterDiff(vnode);
 			// when we are dealing with suspense we can't do this...
-			vnode[PARENT] = null;
 
 			if (options.unmount) options.unmount(vnode);
 
@@ -688,9 +686,6 @@ function _renderToString(
 
 	if (afterDiff) afterDiff(vnode);
 
-	// TODO: this was commented before
-	vnode[PARENT] = null;
-
 	if (ummountHook) ummountHook(vnode);
 
 	// Emit self-closing tag for empty void elements:


Test patch:
diff --git a/test/compat/render-chunked.test.js b/test/compat/render-chunked.test.js
index 575e0f9..502507f 100644
--- a/test/compat/render-chunked.test.js
+++ b/test/compat/render-chunked.test.js
@@ -145,7 +145,48 @@ describe('renderToChunks', () => {
 			'<div><p>id: P0-0</p><!--preact-island:24-->loading...<!--/preact-island:24--></div>',
 			'<div hidden>',
 			createInitScript(1),
-			createSubtree('24', '<p>id: P0-0</p>'),
+			createSubtree('24', '<p>id: P0-1</p>'),
+			'</div>'
+		]);
+	});
+
+	it('should support using multiple useId hooks inside multiple suspense boundaries', async () => {
+		const { Suspender, suspended } = createSuspender();
+		const { Suspender: Suspender2, suspended: suspended2 } = createSuspender();
+
+		function ComponentWithId() {
+			const id = useId();
+			return <p>id: {id}</p>;
+		}
+
+		const result = [];
+		const promise = renderToChunks(
+			<div>
+				<ComponentWithId />
+				<Suspense fallback="loading...">
+					<Suspender>
+						<ComponentWithId />
+					</Suspender>
+				</Suspense>
+				<Suspense fallback="loading...">
+					<Suspender2>
+						<ComponentWithId />
+					</Suspender2>
+				</Suspense>
+			</div>,
+			{ onWrite: (s) => result.push(s) }
+		);
+
+		suspended.resolve();
+		suspended2.resolve();
+		await promise;
+
+		expect(result).to.deep.equal([
+			'<div><p>id: P0-0</p><!--preact-island:33-->loading...<!--/preact-island:33--><!--preact-island:36-->loading...<!--/preact-island:36--></div>',
+			'<div hidden>',
+			createInitScript(1),
+			createSubtree('33', '<p>id: P0-1</p>'),
+			createSubtree('36', '<p>id: P0-2</p>'),
 			'</div>'
 		]);
 	});
diff --git a/test/compat/stream-node.test.js b/test/compat/stream-node.test.js
index a415d36..5783887 100644
--- a/test/compat/stream-node.test.js
+++ b/test/compat/stream-node.test.js
@@ -66,10 +66,10 @@ describe('renderToPipeableStream', () => {
 		const result = await sink.promise;
 
 		expect(result).to.deep.equal([
-			'<div><!--preact-island:33-->loading...<!--/preact-island:33--></div>',
+			'<div><!--preact-island:47-->loading...<!--/preact-island:47--></div>',
 			'<div hidden>',
 			createInitScript(),
-			createSubtree('33', '<p>it works</p>'),
+			createSubtree('47', '<p>it works</p>'),
 			'</div>'
 		]);
 	});
diff --git a/test/compat/stream.test.js b/test/compat/stream.test.js
index c3b0df3..09f6134 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:40-->loading...<!--/preact-island:40--></div>',
+			'<div><!--preact-island:54-->loading...<!--/preact-island:54--></div>',
 			'<div hidden>',
 			createInitScript(),
-			createSubtree('40', '<p>it works</p>'),
+			createSubtree('54', '<p>it works</p>'),
 			'</div>'
 		]);
 	});