Skip to content

perf(react): serialize nested Stencil SSR children shallowly - #851

Merged
davidpett merged 2 commits into
stenciljs:mainfrom
Burzmalian:perf/react-ssr-shallow-children
Oct 21, 2026
Merged

davidpett merged 2 commits into
stenciljs:mainfrom
Burzmalian:perf/react-ssr-shallow-children

Conversation

@Burzmalian

@Burzmalian Burzmalian commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Pull request checklist

  • Tests for the changes have been added (for bug fixes / features)
  • Docs have been reviewed and added / updated if needed (for bug fixes / features)
  • Build (npm run build) was run locally for affected output targets
  • Tests (npm test) were run locally and passed
  • Prettier (npm run prettier) was run locally and passed

Pull request type

  • Bugfix
  • Feature
  • Code style update (formatting, renaming)
  • Refactoring (no functional changes, no api changes)
  • Build related changes
  • Documentation content changes
  • Other (please describe): performance

What is the current behavior?

createComponentForServerSideRendering serializes each component's light DOM with renderToString. resolveComponentTypes resolves children by calling them, so a nested Stencil SSR component runs its full render (hydrate module and html-react-parser) inside its parent's light DOM pass, and React renders the same children again in the final output. Server time doubles with each nesting level.

Measured with this repo's example-project/component-library (Node 24, React 19.1, warm medians):

tree before after
30 × MyComponent 16 ms 9 ms
MyList > 30 × MyComponent 18 ms 11 ms
MyList > MyList > 30 × MyComponent 24 ms 12 ms
MyList > MyList > MyList > 30 × MyComponent 44 ms 14 ms
MyList > 10 × (MyListItem > MyComponent + MyButton) 22 ms 13 ms

These components are small. In a design system with heavier components, the same shape (30 buttons under three layout wrappers) went from 1,674 ms to 318 ms.

Issue URL: #849

What is the new behavior?

  • createComponent marks the async component it returns with a symbol carrying its options.
  • When resolveType meets a marked component, it returns a plain function component that renders the tag with the same attributes and passes children through. The child's own render happens once, when React renders the real children.
  • The prop-to-attribute loop moved into a shared splitProps helper used by both paths.
  • HTML output is unchanged (checked byte for byte after normalising Stencil instance ids).

Does this introduce a breaking change?

  • Yes
  • No

Other information

A parent still sees its nested children as tags with attributes, slot names and text, which is what the light DOM pass is for. The remaining per-level cost is the parent's hydrate call rendering the bare nested tags; excludeComponents could remove that later but changes what the parent sees, so it is left out here.

@Burzmalian
Burzmalian requested a review from a team as a code owner September 28, 2026 20:58
Nested Stencil SSR components were fully rendered inside every
ancestor's light DOM pass and again as real children, so server time
doubled with each nesting level. Mark the SSR wrapper and, when
resolving children, emit a bare tag with the same attributes instead of
running the child. Each component now renders once and the output is
unchanged.

Fixes stenciljs#849
@Burzmalian
Burzmalian force-pushed the perf/react-ssr-shallow-children branch from f5213b9 to 64b39a7 Compare September 28, 2026 21:02

@davidpett davidpett left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

good improvement!

@wessmeister

Copy link
Copy Markdown

Thanks for this, the shallow-child path also addresses the nested-content failure in #852. I tested 64b39a7 and found two remaining scoped SSR cases: the separator in <Child />{' '}text is lost, and object props on nested children do not reach Stencil.

Draft #853 includes regression tests and a proposed correction for both cases. I think the relevant changes could fit into this work. Would you prefer them here or as a follow-up?

@Burzmalian

Copy link
Copy Markdown
Contributor Author

Thanks for testing this and for the detailed repro, @wessmeister. I ran your two cases against main, this branch and #853 with a real hydrate build (your fixture, React 19.2.7, serializeShadowRoot: 'scoped'):

case main this PR (64b39a7) #853
<Child /> {'date'} child and text both missing defaultdate, space lost default date
<Child data={{ label }} /> child missing renders default renders object value

So both gaps are real, and you're right that this PR doesn't close them. It moves scoped nesting from "nothing rendered" to "rendered, minus the separator and object props".

I'd like to keep this one small. It targets the perf problem in #849 and leaves declarative-shadow-DOM output unchanged, which should keep it easy to review. Your changes go further (compact serialization, the props channel), and I think they deserve their own review as a follow-up on top of this one. Happy to help with the rebase or with review when it gets there.

I left a few notes on #853 from testing it.

@wessmeister wessmeister left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for checking those cases, @Burzmalian. This looks good to me as a focused performance fix. I'll keep the correctness follow-up in #853 and rebase once this lands.

@OS-jacobbell OS-jacobbell left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for opening the PR, looks good to me!

@davidpett
davidpett merged commit 723a289 into stenciljs:main Oct 21, 2026
12 checks passed
wessmeister added a commit to wessmeister/stencil-ds-output-targets that referenced this pull request Oct 21, 2026
With `serializeShadowRoot: 'scoped'`, a parent's server HTML is its
Stencil render, which also renders its nested Stencil children. Three
things were lost there:

- Whitespace next to nested children. Stencil's `prettyHtml` output was
  cut by lines and its formatting stripped again, together with real
  text, so `<Child /> text` rendered as `<child></child>text`.
- Complex props of nested children. The shallow children from stenciljs#851
  dropped them, so they rendered with their default values.
- The light DOM of a parent whose nested child had a string `style`,
  which React rejects.

Render without `prettyHtml` and cut the shadow root and light DOM out of
the output by parser offsets, so whitespace stays as React rendered it.
Keep the complex props of the host and its nested children per render,
and assign them in `beforeHydrate` to the element with their
`data-stencil-react-props` marker. Convert a string `style` of a nested
child to the object React expects.

Without `prettyHtml`, Stencil no longer sorts attributes and classes,
so the order in the server HTML and in the snapshots changes.

Fixes stenciljs#852
wessmeister added a commit to wessmeister/stencil-ds-output-targets that referenced this pull request Oct 21, 2026
With `serializeShadowRoot: 'scoped'`, a parent's server HTML is its
Stencil render, which also renders its nested Stencil children. Three
things were lost there:

- Whitespace in the light DOM. Stencil formatted its output with
  `prettyHtml`, and the adapter cut it by lines and stripped the
  formatting again, together with real text: `<Child /> text` rendered
  as `<child></child>text`, and `<pre>` content lost its line breaks.
- Complex props of nested children. The shallow children from stenciljs#851
  dropped them, so they rendered with their default values.
- The light DOM of a parent whose nested child had a string `style`,
  which React rejects.

Render without `prettyHtml` and cut the shadow root and light DOM out of
Stencil's output by parser offsets, so the adapter no longer strips
whitespace. Keep the complex props of the host and its nested children
per render, and assign them in `beforeHydrate` to the element with their
`data-stencil-react-props` marker, which is then removed. Markers that
`beforeHydrate` can't reach, such as those in `<template>` or
`<noscript>` content, are removed from the output. Convert a string
`style` of a nested child to the object React expects.

Without `prettyHtml`, Stencil no longer sorts attributes and classes,
and declarative shadow roots no longer carry formatting whitespace in
their text nodes.

Fixes stenciljs#852
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.

4 participants