Repository navigation
bug: @stencil/ssr Next.js strategy wraps components in display:contents div, breaking named slot assignment #858
Description
Prerequisites
- I have read the Contributing Guidelines.
- I agree to follow the Code of Conduct.
- I have searched for existing issues that already report this problem, without success.
Framework Output Target
SSR (@stencil/ssr)
Output Target Version
@stencil/ssr 0.4.0 (with @stencil/react-output-target 1.6.4)
Stencil Version
4.45.2
Reproduction URL
https://github.com/ianliangglobalpay/stencil-ssr-next
Based on the Next.js starter linked from the issue template (the CodeSandbox link no longer opens GitHub repositories). Next.js 15.5.9, React 19.2.3, Node 22.
Describe the Bug
With @stencil/ssr/next (strategy nextjs), every Stencil component rendered from React is wrapped in <div style="display: contents">. The consumer's slot prop is spread onto the inner custom element, not onto the wrapper. Slot assignment only considers direct children of the host, so a Stencil component placed in another Stencil component's named slot is assigned to the default slot via its wrapper, and the named slot stays empty.
- Plain HTML elements are not wrapped, so they are assigned correctly.
- The wrapper is emitted by both the server branch and the
typeof window !== 'undefined'branch, so the layout is wrong at first paint and is never corrected by hydration. - It repeats at every nesting level.
Steps to Reproduce
pnpm install && pnpm start- Open http://localhost:3000/slots (Compiler,
@stencil/ssr/next) and http://localhost:3000/slots-runtime (control: same markup, imported from the/nextRuntime entry). - Both pages render:
<ExampleCard> <ExampleInput slot="start" placeholder="component in start slot"> <span slot="label">nested label slot</span> </ExampleInput> <span slot="start">markup in start slot</span> <p>default slot content</p> </ExampleCard>
example-cardhas<slot name="start">and a default<slot>. - In the console:
const sr = document.querySelector('example-card').shadowRoot; const show = (s) => s.assignedNodes().filter((n) => n.nodeType === 1).map((n) => n.tagName.toLowerCase()); ({ start: show(sr.querySelector('slot[name="start"]')), default: show(sr.querySelector('slot:not([name])')) });
| Page | start slot |
default slot |
|---|---|---|
/slots (Compiler) |
span |
div (wrapper around example-input), p |
/slots-runtime (Runtime) |
example-input, span |
p |
The same result is in the first-paint HTML (curl http://localhost:3000/slots): <example-input slot="start"> is inside <div style="display:contents">.
Expected Behavior
<example-input slot="start"> is assigned to the start slot, as it is with the Runtime path on Next.js and with strategy: 'react' on Vite.
Additional Context
- The wrapper comes from the
nextjsbranch of the transform in@stencil/ssr(dist/transform-*.js), which returns<div style={{ display: 'contents' }}>around the component in both the client and server branches and puts{...props}on the inner element. - Forcing
strategy: 'react'on Next removes the wrapper and fixes first paint, but the client then throwsHydration failedand the custom elements never upgrade, so it is not a workaround. - Possible fix: move
slotfrom the props onto the wrapperdiv(and omit it from the inner element). - Likely the concrete form of the documented Compiler caveat about injected elements breaking slot structure, except the injected element is a
div.
Activity
smorrisods commentedon Oct 21, 2026
We're hitting this in the Ontario Design System (
ontario-button, Next.js 16.2.12, React 19.2.4,@stencil/ssr0.4.0, Compiler/stencilSSR setup).Our case is a leading icon in a named slot:
When this goes to render in Next.js the icon does not get injected into the slot because the immediate child of within the
<OntarioButton>becomes an unlabelled<div>and browsers don't project that into the named slot.We're looking into patching our copy of
@stencil/ssrto try and mitigate this issue.muntalee commentedon Oct 21, 2026
I reproduced it with the repro from the description and in this repo's
next-appexample. A few things I found while digging in:packages/ssr/src/utils.ts:serializeScopedComponent, and the two inserializeShadowComponentthat are used for thenextjsstrategy.scopedis wrapped on Vite and Remix too. There the slot is only wrong in the server rendered HTML, and assigned correctly after hydration. On Next (compiler) the wrapper is also rendered by the client branch, so it stays wrong after hydration.The fix I'd propose is to set
sloton the wrapper in all three templates, and to keep it on the inner element as well. Moving it would break code that looks the slotted element up by its attribute, for examplemy-buttonin the example library doesquerySelector('my-icon[slot="icon-only"]').I opened a PR with it: #863
It has the fix, a new
named-slot-shadowscenario in the shared React test scenarios (it fails innext-appbefore the fix and passes after, and it passes in the other React example projects), and unit tests for the transform. I also checked the repro from the description with the same change applied:startgoes fromspantodiv, span(the div wrapsexample-input), and the default slot goes fromdiv, ptop. We also checked our own Next 16 / React 19 app, and a leading icon in a named slot now renders.Does this approach work for you, or would you rather have the attribute moved to the wrapper? Happy to change it either way.