diff --git a/.changeset/witty-goats-repair.md b/.changeset/witty-goats-repair.md new file mode 100644 index 0000000..f84d89d --- /dev/null +++ b/.changeset/witty-goats-repair.md @@ -0,0 +1,5 @@ +--- +"preact-render-to-string": patch +--- + +fix: renderToStringAsync produces commas for suspended components with complex children diff --git a/src/index.js b/src/index.js index 8437d7e..001565a 100644 --- a/src/index.js +++ b/src/index.js @@ -34,6 +34,25 @@ const EMPTY_STR = ''; const BEGIN_SUSPENSE_DENOMINATOR = ''; const END_SUSPENSE_DENOMINATOR = ''; +/** + * Wraps a render result with suspense boundary markers, handling all possible + * return types from _renderToString: string, Array, or Promise. + * @param {string | Array | Promise} result + * @returns {string | Array | Promise} + */ +function wrapWithSuspenseMarkers(result) { + if (typeof result === 'string') { + return BEGIN_SUSPENSE_DENOMINATOR + result + END_SUSPENSE_DENOMINATOR; + } else if (isArray(result)) { + result.unshift(BEGIN_SUSPENSE_DENOMINATOR); + result.push(END_SUSPENSE_DENOMINATOR); + return result; + } else if (result && typeof result.then === 'function') { + return result.then(wrapWithSuspenseMarkers); + } + return BEGIN_SUSPENSE_DENOMINATOR + result + END_SUSPENSE_DENOMINATOR; +} + // Global state for the current render pass let beforeDiff, afterDiff, renderHook, ummountHook; @@ -498,18 +517,7 @@ 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 wrapWithSuspenseMarkers(str); } return str; @@ -556,9 +564,7 @@ function _renderToString( asyncMode, renderer ); - return vnode._suspended - ? BEGIN_SUSPENSE_DENOMINATOR + result + END_SUSPENSE_DENOMINATOR - : result; + return vnode._suspended ? wrapWithSuspenseMarkers(result) : result; } catch (e) { if (!e || typeof e.then != 'function') throw e; diff --git a/test/compat/async.test.jsx b/test/compat/async.test.jsx index 615c6fd..bc877e1 100644 --- a/test/compat/async.test.jsx +++ b/test/compat/async.test.jsx @@ -360,6 +360,82 @@ describe('Async renderToString', () => { ); }); + it('should not produce commas or [object Promise] when cascading suspensions produce array results', async () => { + // Regression: when component A suspends then re-renders with children + // that also suspend at multiple levels (B→C→D), the .then() handler + // wrapping suspended output receives an Array instead of a string. + // String concatenation with an Array calls Array.toString(), injecting + // commas into the HTML. + let aResolved = false; + const aPromise = new Promise((r) => + setTimeout(() => { + aResolved = true; + r(); + }, 5) + ); + + let cResolved = false; + const cPromise = new Promise((r) => + setTimeout(() => { + cResolved = true; + r(); + }, 15) + ); + + let dResolved = false; + const dPromise = new Promise((r) => + setTimeout(() => { + dResolved = true; + r(); + }, 25) + ); + + function A() { + if (!aResolved) throw aPromise; + return ; + } + + function B() { + return ( +
b-content
+b-footer
+b-content
'); + expect(rendered).to.contain('c-before'); + expect(rendered).to.contain('d-content'); + expect(rendered).to.contain('c-after'); + expect(rendered).to.contain('b-footer
'); + }); + describe('dangerouslySetInnerHTML', () => { it('should support dangerouslySetInnerHTML', async () => { // some invalid HTML to make sure we're being flakey: