Skip to content

fix: renderToStringAsync produces commas for suspended components with complex children#457

Merged
JoviDeCroock merged 2 commits intopreactjs:mainfrom
lemonmade:fix/async-suspense-array-rendering
Mar 28, 2026
Merged

fix: renderToStringAsync produces commas for suspended components with complex children#457
JoviDeCroock merged 2 commits intopreactjs:mainfrom
lemonmade:fix/async-suspense-array-rendering

Conversation

@lemonmade
Copy link
Copy Markdown
Contributor

Fix: renderToStringAsync produces commas and [object Promise] for suspended components with complex children

Problem

When using renderToStringAsync with <Suspense> boundaries, certain component trees produce corrupted HTML output containing commas and [object Promise] literals. For example, rendered output may contain:

,,,,,[object Promise],

instead of the expected HTML.

Root cause

Internally, _renderToString can return three types of values:

  • string — when all children resolve to plain text
  • Array — when children contain a mix of strings and pending Promises (e.g. nested elements with further suspensions)
  • Promise — when a child threw a Promise (suspension)

The suspense marker wrapping code at two locations assumed the result was always a string and used + concatenation to wrap it with <!--$s--> / <!--/$s--> markers. When the result is an Array, JavaScript's + operator calls Array.prototype.toString(), which joins elements with commas — injecting comma characters into the HTML. When the result is a Promise, it produces the literal string [object Promise].

How to reproduce

The bug requires multi-level cascading suspensions: component A throws a Promise, and after resolving, its render tree contains components B, C, D that also throw independent Promises at different times. The key conditions:

  1. Component A suspends (throws a Promise) → A._suspended = true
  2. After A resolves, A re-renders and returns a single child B (through a Fragment)
  3. B renders children containing C, which also throws a different Promise
  4. C's catch handler returns a Promise, making str at A's level a Promise
  5. Since A._suspended is true, the code enters str.then((resolved) => N + resolved + q)
  6. When C resolves, its children contain D which also throws, creating arrays with Promises in the HTML element's child list
  7. The resolved value in A's .then() callback is an Array, not a string
  8. N + Array + q calls Array.toString()commas in the HTML output
function A() {
  if (!aResolved) throw aPromise;
  return <B />;
}
function B() {
  return (
    <div>
      <p>content</p>
      <C />
      <p>footer</p>
    </div>
  );
}
function C() {
  if (!cResolved) throw cPromise;
  return (
    <Fragment>
      <span>c</span>
      <D />
    </Fragment>
  );
}
function D() {
  if (!dResolved) throw dPromise;
  return <em>d</em>;
}

const rendered = await renderToStringAsync(
  <Suspense fallback={null}>
    <A />
  </Suspense>,
);
// Without fix: "<!--$s--><div>,,,,<p>content</p>,[object Promise],<p>footer</p>,</div><!--/$s-->"
// With fix:    "<!--$s--><div><p>content</p><!--$s--><span>c</span><!--$s--><em>d</em><!--/$s--><!--/$s--><p>footer</p></div><!--/$s-->"

Fix

Extract a helper that handles all three return types — string, Array, and Promise — using the same pattern already present in the synchronous path.

…with markers

`renderToStringAsync` corrupts HTML output when a suspended component's
children produce an Array or Promise result from `_renderToString`. The
suspense marker wrapping code used string concatenation (`+`), which calls
`Array.toString()` on arrays (producing commas) and `[object Promise]` on
unresolved Promises.

This manifests with multi-level cascading suspensions: component A suspends,
and after resolving, its render tree contains components that also suspend
on independent Promises at different times. The `.then()` handler wrapping
A's output receives an Array (from inner elements containing pending Promises)
instead of a string.

Extract a `wrapWithSuspenseMarkers()` helper that handles all three return
types (string, Array, Promise) and use it at both affected call sites:
the try-path `.then()` handler and the catch-path `renderNestedChildren`.

Fixes corrupted SSR output like ",,,,,[object Promise]," in apps using
multi-level suspensions for data fetching.
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 28, 2026

🦋 Changeset detected

Latest commit: ce6ef71

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
preact-render-to-string Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copy link
Copy Markdown
Member

@JoviDeCroock JoviDeCroock left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great find

@JoviDeCroock JoviDeCroock merged commit 180565b into preactjs:main Mar 28, 2026
1 check passed
@github-actions github-actions bot mentioned this pull request Mar 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants