From b53f368b498e99b102c3fd832251ac56fff054ac Mon Sep 17 00:00:00 2001
From: Jovi De Croock <decroockjovi@gmail.com>
Date: Sat, 13 Jul 2024 10:13:41 +0200
Subject: [PATCH 1/3] Denote suspenseful components with comment markers

---
 src/index.js               |  41 ++++++++++--
 test/compat/async.test.jsx | 126 +++++++++++++++++++++++++++++++++++--
 2 files changed, 156 insertions(+), 11 deletions(-)

diff --git a/src/index.js b/src/index.js
index ff4c774e..35e642da 100644
--- a/src/index.js
+++ b/src/index.js
@@ -29,6 +29,8 @@ const EMPTY_ARR = [];
 const isArray = Array.isArray;
 const assign = Object.assign;
 const EMPTY_STR = '';
+const BEGIN_SUSPENSE_DENOMINATOR = '<!-- $s -->';
+const END_SUSPENSE_DENOMINATOR = '<!-- /$s -->';
 
 // Global state for the current render pass
 let beforeDiff, afterDiff, renderHook, ummountHook;
@@ -372,7 +374,12 @@ function _renderToString(
 
 					if (renderHook) renderHook(vnode);
 
-					rendered = type.call(component, props, cctx);
+					try {
+						rendered = type.call(component, props, cctx);
+					} catch (e) {
+						if (asyncMode) vnode._suspended = true;
+						throw e;
+					}
 				}
 				component[DIRTY] = true;
 			}
@@ -403,6 +410,7 @@ function _renderToString(
 						selectValue,
 						vnode,
 						asyncMode,
+						false,
 						renderer
 					);
 				} catch (err) {
@@ -475,6 +483,21 @@ function _renderToString(
 
 			if (options.unmount) options.unmount(vnode);
 
+			if (vnode._suspended) {
+				if (typeof str === 'string') {
+					return BEGIN_SUSPENSE_DENOMINATOR + str + END_SUSPENSE_DENOMINATOR;
+				} else if (isArray(str)) {
+					str.unshift(BEGIN_SUSPENSE_DENOMINATOR);
+					str.push(END_SUSPENSE_DENOMINATOR);
+					return str;
+				}
+
+				return str.then(
+					(resolved) =>
+						BEGIN_SUSPENSE_DENOMINATOR + resolved + END_SUSPENSE_DENOMINATOR
+				);
+			}
+
 			return str;
 		} catch (error) {
 			if (!asyncMode && renderer && renderer.onError) {
@@ -503,7 +526,7 @@ function _renderToString(
 
 			const renderNestedChildren = () => {
 				try {
-					return _renderToString(
+					const result = _renderToString(
 						rendered,
 						context,
 						isSvgMode,
@@ -512,12 +535,15 @@ function _renderToString(
 						asyncMode,
 						renderer
 					);
+					return vnode._suspended
+						? BEGIN_SUSPENSE_DENOMINATOR + result + END_SUSPENSE_DENOMINATOR
+						: result;
 				} catch (e) {
 					if (!e || typeof e.then != 'function') throw e;
 
 					return e.then(
-						() =>
-							_renderToString(
+						() => {
+							const result = _renderToString(
 								rendered,
 								context,
 								isSvgMode,
@@ -525,8 +551,11 @@ function _renderToString(
 								vnode,
 								asyncMode,
 								renderer
-							),
-						renderNestedChildren
+							)
+							return vnode._suspended
+								? BEGIN_SUSPENSE_DENOMINATOR + result + END_SUSPENSE_DENOMINATOR
+								: result;
+						}, renderNestedChildren
 					);
 				}
 			};
diff --git a/test/compat/async.test.jsx b/test/compat/async.test.jsx
index 12e9ac5a..bf1792bf 100644
--- a/test/compat/async.test.jsx
+++ b/test/compat/async.test.jsx
@@ -3,6 +3,7 @@ import { h, Fragment } from 'preact';
 import { Suspense, useId, lazy, createContext } from 'preact/compat';
 import { expect } from 'chai';
 import { createSuspender } from '../utils.jsx';
+const wait = (ms) => new Promise((r) => setTimeout(r, ms));
 
 describe('Async renderToString', () => {
 	it('should render JSX after a suspense boundary', async () => {
@@ -16,7 +17,30 @@ describe('Async renderToString', () => {
 			</Suspense>
 		);
 
-		const expected = `<div class="foo">bar</div>`;
+		const expected = `<!-- $s --><div class="foo">bar</div><!-- /$s -->`;
+
+		suspended.resolve();
+
+		const rendered = await promise;
+
+		expect(rendered).to.equal(expected);
+	});
+
+	it('should correctly denote null returns of suspending components', async () => {
+		const { Suspender, suspended } = createSuspender();
+
+		const Analytics = () => null;
+
+		const promise = renderToStringAsync(
+			<Suspense fallback={<div>loading...</div>}>
+				<Suspender>
+					<Analytics />
+				</Suspender>
+				<div class="foo">bar</div>
+			</Suspense>
+		);
+
+		const expected = `<!-- $s --><!-- /$s --><div class="foo">bar</div>`;
 
 		suspended.resolve();
 
@@ -49,7 +73,7 @@ describe('Async renderToString', () => {
 			</ul>
 		);
 
-		const expected = `<ul><li>one</li><li>two</li><li>three</li></ul>`;
+		const expected = `<ul><!-- $s --><li>one</li><!-- $s --><li>two</li><!-- /$s --><li>three</li><!-- /$s --></ul>`;
 
 		suspendedOne.resolve();
 		suspendedTwo.resolve();
@@ -85,7 +109,7 @@ describe('Async renderToString', () => {
 			</ul>
 		);
 
-		const expected = `<ul><li>one</li><li>two</li><li>three</li></ul>`;
+		const expected = `<ul><!-- $s --><li>one</li><!-- $s --><li>two</li><!-- /$s --><li>three</li><!-- /$s --></ul>`;
 
 		suspendedOne.resolve();
 		suspendedTwo.resolve();
@@ -95,6 +119,98 @@ describe('Async renderToString', () => {
 		expect(rendered).to.equal(expected);
 	});
 
+	it('should render JSX with nested suspense boundaries containing multiple suspending components', async () => {
+		const {
+			Suspender: SuspenderOne,
+			suspended: suspendedOne
+		} = createSuspender();
+		const {
+			Suspender: SuspenderTwo,
+			suspended: suspendedTwo
+		} = createSuspender();
+		const {
+			Suspender: SuspenderThree,
+			suspended: suspendedThree
+		} = createSuspender('three');
+
+		const promise = renderToStringAsync(
+			<ul>
+				<Suspense fallback={null}>
+					<SuspenderOne>
+						<li>one</li>
+						<Suspense fallback={null}>
+							<SuspenderTwo>
+								<li>two</li>
+							</SuspenderTwo>
+							<SuspenderThree>
+								<li>three</li>
+							</SuspenderThree>
+						</Suspense>
+						<li>four</li>
+					</SuspenderOne>
+				</Suspense>
+			</ul>
+		);
+
+		const expected = `<ul><!-- $s --><li>one</li><!-- $s --><li>two</li><!-- /$s --><!-- $s --><li>three</li><!-- /$s --><li>four</li><!-- /$s --></ul>`;
+
+		suspendedOne.resolve();
+		suspendedTwo.resolve();
+		await wait(0);
+		suspendedThree.resolve();
+
+		const rendered = await promise;
+
+		expect(rendered).to.equal(expected);
+	});
+
+	it.only('should render JSX with deeply nested suspense boundaries', async () => {
+		const {
+			Suspender: SuspenderOne,
+			suspended: suspendedOne
+		} = createSuspender();
+		const {
+			Suspender: SuspenderTwo,
+			suspended: suspendedTwo
+		} = createSuspender();
+		const {
+			Suspender: SuspenderThree,
+			suspended: suspendedThree
+		} = createSuspender();
+
+		const promise = renderToStringAsync(
+			<ul>
+				<Suspense fallback={null}>
+					<SuspenderOne>
+						<li>one</li>
+						<Suspense fallback={null}>
+							<SuspenderTwo>
+								<li>two</li>
+								<Suspense fallback={null}>
+									<SuspenderThree>
+										<li>three</li>
+									</SuspenderThree>
+								</Suspense>
+							</SuspenderTwo>
+						</Suspense>
+						<li>four</li>
+					</SuspenderOne>
+				</Suspense>
+			</ul>
+		);
+
+		const expected = `<ul><!-- $s --><li>one</li><!-- $s --><li>two</li><!-- $s --><li>three</li><!-- /$s --><!-- /$s --><li>four</li><!-- /$s --></ul>`;
+
+		suspendedOne.resolve();
+		suspendedTwo.resolve();
+		await wait(0);
+		suspendedThree.resolve();
+
+		const rendered = await promise;
+
+		expect(rendered).to.equal(expected);
+	});
+
 	it('should render JSX with multiple suspended direct children within a single suspense boundary', async () => {
 		const {
 			Suspender: SuspenderOne,
@@ -127,7 +243,7 @@ describe('Async renderToString', () => {
 			</ul>
 		);
 
-		const expected = `<ul><li>one</li><li>two</li><li>three</li></ul>`;
+		const expected = `<ul><!-- $s --><li>one</li><!-- /$s --><!-- $s --><li>two</li><!-- /$s --><!-- $s --><li>three</li><!-- /$s --></ul>`;
 
 		suspendedOne.resolve();
 		suspendedTwo.resolve();
@@ -187,7 +303,7 @@ describe('Async renderToString', () => {
 
 		suspended.resolve();
 		const rendered = await promise;
-		expect(rendered).to.equal('<p>ok</p>');
+		expect(rendered).to.equal('<!-- $s --><p>ok</p><!-- /$s -->');
 	});
 
 	it('should work with an in-render suspension', async () => {

From 6017e2d0233e5b903958680ea13781349b63b01f Mon Sep 17 00:00:00 2001
From: Jovi De Croock <decroockjovi@gmail.com>
Date: Sun, 14 Jul 2024 09:00:59 +0200
Subject: [PATCH 2/3] Add changeset

---
 .changeset/happy-peas-type.md | 5 +++++
 1 file changed, 5 insertions(+)
 create mode 100644 .changeset/happy-peas-type.md

diff --git a/.changeset/happy-peas-type.md b/.changeset/happy-peas-type.md
new file mode 100644
index 00000000..fa929705
--- /dev/null
+++ b/.changeset/happy-peas-type.md
@@ -0,0 +1,5 @@
+---
+'preact-render-to-string': minor
+---
+
+Insert comment markers for suspended trees, only in renderToStringAsync

From 0da840e2b95445a863609aad8a0948ec9d0d5d33 Mon Sep 17 00:00:00 2001
From: jdecroock <decroockjovi@gmail.com>
Date: Mon, 15 Jul 2024 11:09:34 +0200
Subject: [PATCH 3/3] use shorter notation

---
 src/index.js               | 38 +++++++++++++++++++-------------------
 test/compat/async.test.jsx | 23 +++++++++++++----------
 2 files changed, 32 insertions(+), 29 deletions(-)

diff --git a/src/index.js b/src/index.js
index 35e642da..f6530f4f 100644
--- a/src/index.js
+++ b/src/index.js
@@ -29,8 +29,8 @@ const EMPTY_ARR = [];
 const isArray = Array.isArray;
 const assign = Object.assign;
 const EMPTY_STR = '';
-const BEGIN_SUSPENSE_DENOMINATOR = '<!-- $s -->';
-const END_SUSPENSE_DENOMINATOR = '<!-- /$s -->';
+const BEGIN_SUSPENSE_DENOMINATOR = '<!--$s-->';
+const END_SUSPENSE_DENOMINATOR = '<!--/$s-->';
 
 // Global state for the current render pass
 let beforeDiff, afterDiff, renderHook, ummountHook;
@@ -377,7 +377,9 @@ function _renderToString(
 					try {
 						rendered = type.call(component, props, cctx);
 					} catch (e) {
-						if (asyncMode) vnode._suspended = true;
+						if (asyncMode) {
+							vnode._suspended = true;
+						}
 						throw e;
 					}
 				}
@@ -541,22 +543,20 @@ function _renderToString(
 				} catch (e) {
 					if (!e || typeof e.then != 'function') throw e;
 
-					return e.then(
-						() => {
-							const result = _renderToString(
-								rendered,
-								context,
-								isSvgMode,
-								selectValue,
-								vnode,
-								asyncMode,
-								renderer
-							)
-							return vnode._suspended
-								? BEGIN_SUSPENSE_DENOMINATOR + result + END_SUSPENSE_DENOMINATOR
-								: result;
-						}, renderNestedChildren
-					);
+					return e.then(() => {
+						const result = _renderToString(
+							rendered,
+							context,
+							isSvgMode,
+							selectValue,
+							vnode,
+							asyncMode,
+							renderer
+						);
+						return vnode._suspended
+							? BEGIN_SUSPENSE_DENOMINATOR + result + END_SUSPENSE_DENOMINATOR
+							: result;
+					}, renderNestedChildren);
 				}
 			};
 
diff --git a/test/compat/async.test.jsx b/test/compat/async.test.jsx
index bf1792bf..0ca78352 100644
--- a/test/compat/async.test.jsx
+++ b/test/compat/async.test.jsx
@@ -17,7 +17,7 @@ describe('Async renderToString', () => {
 			</Suspense>
 		);
 
-		const expected = `<!-- $s --><div class="foo">bar</div><!-- /$s -->`;
+		const expected = `<!--$s--><div class="foo">bar</div><!--/$s-->`;
 
 		suspended.resolve();
 
@@ -40,7 +40,7 @@ describe('Async renderToString', () => {
 			</Suspense>
 		);
 
-		const expected = `<!-- $s --><!-- /$s --><div class="foo">bar</div>`;
+		const expected = `<!--$s--><!--/$s--><div class="foo">bar</div>`;
 
 		suspended.resolve();
 
@@ -73,7 +73,7 @@ describe('Async renderToString', () => {
 			</ul>
 		);
 
-		const expected = `<ul><!-- $s --><li>one</li><!-- $s --><li>two</li><!-- /$s --><li>three</li><!-- /$s --></ul>`;
+		const expected = `<ul><!--$s--><li>one</li><!--$s--><li>two</li><!--/$s--><li>three</li><!--/$s--></ul>`;
 
 		suspendedOne.resolve();
 		suspendedTwo.resolve();
@@ -109,7 +109,7 @@ describe('Async renderToString', () => {
 			</ul>
 		);
 
-		const expected = `<ul><!-- $s --><li>one</li><!-- $s --><li>two</li><!-- /$s --><li>three</li><!-- /$s --></ul>`;
+		const expected = `<ul><!--$s--><li>one</li><!--$s--><li>two</li><!--/$s--><li>three</li><!--/$s--></ul>`;
 
 		suspendedOne.resolve();
 		suspendedTwo.resolve();
@@ -152,7 +152,7 @@ describe('Async renderToString', () => {
 			</ul>
 		);
 
-		const expected = `<ul><!-- $s --><li>one</li><!-- $s --><li>two</li><!-- /$s --><!-- $s --><li>three</li><!-- /$s --><li>four</li><!-- /$s --></ul>`;
+		const expected = `<ul><!--$s--><li>one</li><!--$s--><li>two</li><!--/$s--><!--$s--><li>three</li><!--/$s--><li>four</li><!--/$s--></ul>`;
 
 		suspendedOne.resolve();
 		suspendedTwo.resolve();
@@ -164,7 +164,7 @@ describe('Async renderToString', () => {
 		expect(rendered).to.equal(expected);
 	});
 
-	it.only('should render JSX with deeply nested suspense boundaries', async () => {
+	it('should render JSX with deeply nested suspense boundaries', async () => {
 		const {
 			Suspender: SuspenderOne,
 			suspended: suspendedOne
@@ -199,7 +199,7 @@ describe('Async renderToString', () => {
 			</ul>
 		);
 
-		const expected = `<ul><!-- $s --><li>one</li><!-- $s --><li>two</li><!-- $s --><li>three</li><!-- /$s --><!-- /$s --><li>four</li><!-- /$s --></ul>`;
+		const expected = `<ul><!--$s--><li>one</li><!--$s--><li>two</li><!--$s--><li>three</li><!--/$s--><!--/$s--><li>four</li><!--/$s--></ul>`;
 
 		suspendedOne.resolve();
 		suspendedTwo.resolve();
@@ -243,7 +243,7 @@ describe('Async renderToString', () => {
 			</ul>
 		);
 
-		const expected = `<ul><!-- $s --><li>one</li><!-- /$s --><!-- $s --><li>two</li><!-- /$s --><!-- $s --><li>three</li><!-- /$s --></ul>`;
+		const expected = `<ul><!--$s--><li>one</li><!--/$s--><!--$s--><li>two</li><!--/$s--><!--$s--><li>three</li><!--/$s--></ul>`;
 
 		suspendedOne.resolve();
 		suspendedTwo.resolve();
@@ -303,7 +303,7 @@ describe('Async renderToString', () => {
 
 		suspended.resolve();
 		const rendered = await promise;
-		expect(rendered).to.equal('<!-- $s --><p>ok</p><!-- /$s -->');
+		expect(rendered).to.equal('<!--$s--><p>ok</p><!--/$s-->');
 	});
 
 	it('should work with an in-render suspension', async () => {
@@ -340,7 +340,10 @@ describe('Async renderToString', () => {
 			</Context.Provider>
 		);
 
-		expect(rendered).to.equal(`<div>2</div>`);
+		// Before we get to the actual DOM this suspends twice
+		expect(rendered).to.equal(
+			`<!--$s--><!--$s--><div>2</div><!--/$s--><!--/$s-->`
+		);
 	});
 
 	describe('dangerouslySetInnerHTML', () => {