Skip to content

bug: @stencil/ssr Next.js strategy wraps components in display:contents div, breaking named slot assignment #858

Open

Description

@ianliangglobalpay

Prerequisites

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

  1. pnpm install && pnpm start
  2. Open http://localhost:3000/slots (Compiler, @stencil/ssr/next) and http://localhost:3000/slots-runtime (control: same markup, imported from the /next Runtime entry).
  3. 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-card has <slot name="start"> and a default <slot>.
  4. 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 nextjs branch 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 throws Hydration failed and the custom elements never upgrade, so it is not a workaround.
  • Possible fix: move slot from the props onto the wrapper div (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

  1. smorrisods commented on Oct 21, 2026

    @smorrisods

    We're hitting this in the Ontario Design System (ontario-button, Next.js 16.2.12, React 19.2.4, @stencil/ssr 0.4.0, Compiler/stencilSSR setup).

    Our case is a leading icon in a named slot:

    <OntarioButton label="Add item">
      <OntarioIconAdd slot="leading-icon" />
    </OntarioButton>

    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/ssr to try and mitigate this issue.

  2. muntalee commented on Oct 21, 2026

    @muntalee

    I reproduced it with the repro from the description and in this repo's next-app example. A few things I found while digging in:

    • The wrapper comes from three templates in packages/ssr/src/utils.ts: serializeScopedComponent, and the two in serializeShadowComponent that are used for the nextjs strategy.
    • The scoped template is used by both strategies, so a component that is serialized as scoped is 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 slot on 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 example my-button in the example library does querySelector('my-icon[slot="icon-only"]').

    I opened a PR with it: #863

    It has the fix, a new named-slot-shadow scenario in the shared React test scenarios (it fails in next-app before 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: start goes from span to div, span (the div wraps example-input), and the default slot goes from div, p to p. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions