Repository navigation
Description
Describe the bug
A component that renders JSX received through a prop other than children, followed by another hole that allocates hydration ids, hydrates with mismatched keys. The server-rendered elements are left unclaimed in the document, and the client builds detached copies that receive the event handlers and the updates. The page looks right but the slotted content is dead.
This is the ordinary layout or slot pattern:
import { createSignal } from "solid-js";
const Layout = (props) => (
<div>
<header>{props.header}</header>
<main>{props.children}</main>
</div>
);
function App() {
const [n, setN] = createSignal(0);
return (
<Layout header={<button id="hdr" onClick={() => setN(n() + 1)}>clicks {n()}</button>}>
<p>body {n()}</p>
</Layout>
);
}The server emits:
<div _hk=0><header><button _hk=2 id="hdr">clicks <!--$-->0<!--/--></button></header><main><p _hk=10>body <!--$-->0<!--/--></p></main></div>The client asks for keys 1 (the button) and 30 (the <p>), so neither claim succeeds. Dev logs:
Hydration key miss for "1": no server-rendered element carries this key (template: <button ...). A detached element was created instead ...
Hydration key miss for "30": no server-rendered element carries this key (template: <p>body ...
Hydration completed with 2 unclaimed server-rendered node(s):
<button _hk="2" id="hdr">...
Your Example Website or App
No hosted example (the playground is currently blocked by #3565). The repro below runs inside the repo's own server and hydration vitest projects.
Steps to Reproduce the Bug or Issue
- Render
Appabove withrenderToString(orrenderToStream). - Put the markup into a container, evaluate the inline scripts, and call
hydrate(() => <App />, container). - Click the server-rendered
#hdrbutton andflush().
As a three-file spec against next @ 37fd1e6 (rc.9):
packages/web/test/harness/slot.tsx: theAppabove.packages/web/test/server/slot.spec.tsx:renderToString(() => <App />)written to an artifact, run with--config vite.config.server.mjs.packages/web/test/hydration/slot.spec.tsx: the hydrate and click steps, run with--config vite.config.hydrate.mjs.
The text stays clicks 0body 0 and the three warnings above are logged. It fails on every run, with both the native compiler and JSX_COMPILER=babel.
Which shapes fail, holding everything else constant:
| Component body | Result |
|---|---|
{props.header} alone |
passes |
{props.header}{props.footer} |
passes |
{props.children}{props.footer} |
passes |
{props.header}{props.children} |
fails |
{props.header}{cond() ? <b/> : null} |
fails |
{props.header}<Show when={...}> |
fails |
Expected behavior
The server button and paragraph are claimed, the click reaches the handler, and the text becomes clicks 1body 1, with no hydration warnings.
Platform
- solid-js / @solidjs/web 2.0.0-rc.9 (
next@ 37fd1e6) - Node 24.21, jsdom, macOS arm64
Additional context
The predicate that decides which holes get their own owner scope only treats .children member expressions as able to allocate hydration ids: canReturnHydratableChild in packages/babel-plugin/src/shared/utils.ts and expression_can_return_hydratable_child in packages/compiler/src/shared/utils.rs. {props.header} therefore compiles to a transparent insert with no scope.
On the server, the scoped sibling ({props.children}) reserves its id slot when it is registered, and the unscoped {props.header} thunk is evaluated later, so it takes the next id. On the client, inserts run in source order, so the unscoped hole takes the earlier id and every id after it shifts.
documentation/hole-owner-id-matrix.md already lists this under risks: "Arbitrary getters that lazily construct JSX are invisible to the predicate". Every JSX-valued prop is such a getter, so that risk covers a mainstream pattern rather than an edge case. Array literals in a branch ({cond() ? [<button/>, " text"] : x} followed by another hole) appear to miss the predicate the same way.
This is a different case from #3033, which was a conditional in a prop minting a memo id at read time.
Activity
brenelz commentedon Sep 21, 2026
The trigger is broader than the table above suggests. The second hole does not need to be
props.children, a ternary, or a<Show>. Any call expression counts, so an ordinary signal read later in the same template is enough:The server emits the button as
_hk=2, the client asks for1, and the button is dead after hydration ("Hydration key miss for "1"" plus one unclaimed node). Same result with the native compiler andJSX_COMPILER=babel.Holding the component constant and changing only what follows
{p.icon}:<span>{count()}</span>(signal read)<span>{p.title}</span>(prop read)<span>{`n=${count()}`}</span>(template literal)<span title={String(count())}>x</span>(attribute only)<Btn />(component element, no expression hole)<span>{count()}</span>placed before{p.icon}insteadOther ways of reaching slotted JSX that fail the same way when a call-expression hole (or
props.children) follows:{p.slots.header}(object of slots),{local.header}afteromit(p, "children"), a context value exposing JSX through a getter,{p.header}followed by{c()}fromchildren(() => p.children), and{p.header}<For>...</For>{p.footer}(the header and every row are dead).Shapes that work, which may help narrow the fix: a render prop (
{p.render("a")}), an accessor (const header = () => p.header; {header()}),createMemo(() => p.header)then{m()}, a component passed as a prop and rendered with<Dynamic>, and JSX created eagerly into a local const (const head = <Btn/>; {head}). All of these are call expressions or eager, so both sides allocate in the same order.fix(compiler): scope every hole that can build JSX, not only props.ch…
test(web): pin the solidjs#3567 slot-hole shapes end to end; document…
fix(compiler): scope every hole that can build JSX, not only props.ch…
ryansolid commentedon Sep 23, 2026
feat(dev): warn when an unscoped hole allocates hydration ids (#3567 …
feat(dev): warn when an unscoped hole allocates hydration ids (#3567 …
fix(flow): Errored builds a thunk fallback inside its own scope, like…