Skip to content

Commit 180565b

Browse files
authored
Merge pull request #457 from lemonmade/fix/async-suspense-array-rendering
fix: renderToStringAsync produces commas for suspended components with complex children
2 parents 449c455 + ce6ef71 commit 180565b

3 files changed

Lines changed: 102 additions & 15 deletions

File tree

.changeset/witty-goats-repair.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"preact-render-to-string": patch
3+
---
4+
5+
fix: renderToStringAsync produces commas for suspended components with complex children

src/index.js

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,25 @@ const EMPTY_STR = '';
3434
const BEGIN_SUSPENSE_DENOMINATOR = '<!--$s-->';
3535
const END_SUSPENSE_DENOMINATOR = '<!--/$s-->';
3636

37+
/**
38+
* Wraps a render result with suspense boundary markers, handling all possible
39+
* return types from _renderToString: string, Array, or Promise.
40+
* @param {string | Array | Promise} result
41+
* @returns {string | Array | Promise}
42+
*/
43+
function wrapWithSuspenseMarkers(result) {
44+
if (typeof result === 'string') {
45+
return BEGIN_SUSPENSE_DENOMINATOR + result + END_SUSPENSE_DENOMINATOR;
46+
} else if (isArray(result)) {
47+
result.unshift(BEGIN_SUSPENSE_DENOMINATOR);
48+
result.push(END_SUSPENSE_DENOMINATOR);
49+
return result;
50+
} else if (result && typeof result.then === 'function') {
51+
return result.then(wrapWithSuspenseMarkers);
52+
}
53+
return BEGIN_SUSPENSE_DENOMINATOR + result + END_SUSPENSE_DENOMINATOR;
54+
}
55+
3756
// Global state for the current render pass
3857
let beforeDiff, afterDiff, renderHook, ummountHook;
3958

@@ -498,18 +517,7 @@ function _renderToString(
498517
if (options.unmount) options.unmount(vnode);
499518

500519
if (vnode._suspended) {
501-
if (typeof str === 'string') {
502-
return BEGIN_SUSPENSE_DENOMINATOR + str + END_SUSPENSE_DENOMINATOR;
503-
} else if (isArray(str)) {
504-
str.unshift(BEGIN_SUSPENSE_DENOMINATOR);
505-
str.push(END_SUSPENSE_DENOMINATOR);
506-
return str;
507-
}
508-
509-
return str.then(
510-
(resolved) =>
511-
BEGIN_SUSPENSE_DENOMINATOR + resolved + END_SUSPENSE_DENOMINATOR
512-
);
520+
return wrapWithSuspenseMarkers(str);
513521
}
514522

515523
return str;
@@ -556,9 +564,7 @@ function _renderToString(
556564
asyncMode,
557565
renderer
558566
);
559-
return vnode._suspended
560-
? BEGIN_SUSPENSE_DENOMINATOR + result + END_SUSPENSE_DENOMINATOR
561-
: result;
567+
return vnode._suspended ? wrapWithSuspenseMarkers(result) : result;
562568
} catch (e) {
563569
if (!e || typeof e.then != 'function') throw e;
564570

test/compat/async.test.jsx

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,82 @@ describe('Async renderToString', () => {
360360
);
361361
});
362362

363+
it('should not produce commas or [object Promise] when cascading suspensions produce array results', async () => {
364+
// Regression: when component A suspends then re-renders with children
365+
// that also suspend at multiple levels (B→C→D), the .then() handler
366+
// wrapping suspended output receives an Array instead of a string.
367+
// String concatenation with an Array calls Array.toString(), injecting
368+
// commas into the HTML.
369+
let aResolved = false;
370+
const aPromise = new Promise((r) =>
371+
setTimeout(() => {
372+
aResolved = true;
373+
r();
374+
}, 5)
375+
);
376+
377+
let cResolved = false;
378+
const cPromise = new Promise((r) =>
379+
setTimeout(() => {
380+
cResolved = true;
381+
r();
382+
}, 15)
383+
);
384+
385+
let dResolved = false;
386+
const dPromise = new Promise((r) =>
387+
setTimeout(() => {
388+
dResolved = true;
389+
r();
390+
}, 25)
391+
);
392+
393+
function A() {
394+
if (!aResolved) throw aPromise;
395+
return <B />;
396+
}
397+
398+
function B() {
399+
return (
400+
<div>
401+
<p>b-content</p>
402+
<C />
403+
<p>b-footer</p>
404+
</div>
405+
);
406+
}
407+
408+
function C() {
409+
if (!cResolved) throw cPromise;
410+
return (
411+
<Fragment>
412+
<span>c-before</span>
413+
<D />
414+
<span>c-after</span>
415+
</Fragment>
416+
);
417+
}
418+
419+
function D() {
420+
if (!dResolved) throw dPromise;
421+
return <em>d-content</em>;
422+
}
423+
424+
const rendered = await renderToStringAsync(
425+
<Suspense fallback={null}>
426+
<A />
427+
</Suspense>
428+
);
429+
430+
expect(rendered).not.to.contain(',');
431+
expect(rendered).not.to.contain('[object Promise]');
432+
expect(rendered).to.contain('<p>b-content</p>');
433+
expect(rendered).to.contain('<span>c-before</span>');
434+
expect(rendered).to.contain('<em>d-content</em>');
435+
expect(rendered).to.contain('<span>c-after</span>');
436+
expect(rendered).to.contain('<p>b-footer</p>');
437+
});
438+
363439
describe('dangerouslySetInnerHTML', () => {
364440
it('should support dangerouslySetInnerHTML', async () => {
365441
// some invalid HTML to make sure we're being flakey:

0 commit comments

Comments
 (0)