Skip to content

[2.0 rc.9] JSX passed through a non-children prop fails hydration when another id-allocating hole follows it #3567

Closed

Description

@brenelz

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

  1. Render App above with renderToString (or renderToStream).
  2. Put the markup into a container, evaluate the inline scripts, and call hydrate(() => <App />, container).
  3. Click the server-rendered #hdr button and flush().

As a three-file spec against next @ 37fd1e6 (rc.9):

  • packages/web/test/harness/slot.tsx: the App above.
  • 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

  1. brenelz commented on Sep 21, 2026

    @brenelz
    ContributorAuthor

    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:

    const [count] = createSignal(3);
    const Card = (p) => <div>{p.icon}<span>{count()}</span></div>;
    
    <Card icon={<button onClick={...}>...</button>} />

    The server emits the button as _hk=2, the client asks for 1, and the button is dead after hydration ("Hydration key miss for "1"" plus one unclaimed node). Same result with the native compiler and JSX_COMPILER=babel.

    Holding the component constant and changing only what follows {p.icon}:

    After the slot Result
    <span>{count()}</span> (signal read) fails
    <span>{p.title}</span> (prop read) passes
    <span>{`n=${count()}`}</span> (template literal) passes
    <span title={String(count())}>x</span> (attribute only) passes
    <Btn /> (component element, no expression hole) passes
    <span>{count()}</span> placed before {p.icon} instead passes

    Other 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} after omit(p, "children"), a context value exposing JSX through a getter, {p.header} followed by {c()} from children(() => 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.

  2. ryansolid commented on Sep 23, 2026

    @ryansolid
    Member

    Fixed on next by #3599 (merged as 45407d1) — thank you @brenelz for both the reproduction and the fix.

    Root cause. Both compilers decide per child hole whether to wrap its thunk in _$scope(...). A scoped hole reserves its hydration-id slot at registration on both sides: the server's virtual ssrScope takes the slot while the _$ssr(...) arguments are evaluated, the client's non-transparent insert effect takes it at effect creation. An unscoped hole reserves nothing — and the two runtimes evaluate unscoped thunks at different points: the server resolves them later, inside the ssr() walk (after every scoped sibling in the template has reserved), the client runs them in statement order. That is invisible only when an unscoped hole allocates zero ids, which is the invariant the predicate exists to guarantee. The predicate treated only .children reads as able to build JSX, so {props.header} compiled unscoped; the <button> it builds allocated an id on both sides, but after the children scope's reservation on the server and before it on the client. Same id count, permuted — <button _hk=2>/<p _hk=10> on the server, 1/30 asked for by the client — so the server nodes stayed unclaimed and the handler was bound to a detached copy.

    Fix (#3599). The predicate is inverted to "scope unless the value is provably a primitive" (literals, template literals, unary/binary/update expressions), identically in the Babel plugin and the native compiler. Property reads of any name, optional calls (props.render?.()), the left operand of ||/??, sequence and assignment expressions, and array literals with hydratable entries are all scoped now, so the header slot, an object of slots, an omit()/splitProps local, a context getter, spread-element children and destructured props all hydrate with aligned keys. Cost: a member-read text hole such as {state.name} reserves one id slot on both sides, the same as {count()} always has. The two compilers are pinned against each other (51 shapes), and a 22-scenario server-render → hydrate regression spec covers the shapes above end to end, including that a click on the server-rendered button reaches the handler.

    Known remaining gap. A bare identifier bound to a function that returns JSX (const renderHead = () => props.header; … {renderHead}) never classifies as dynamic, so neither generate scopes it, and the same late-vs-statement ordering misaligns it when a scoped hole follows. (2.0's JSX.Element type excludes functions, so type-checked code reaches this only through a cast; JS can hit it.) A value-holding identifier (const h = props.header; {h}) is eager on both sides and safe. The shape is pinned as test.fails in the new spec; the follow-up is a dev-only assertion on both runtimes that an unscoped hole did not move the id counter, so any remaining predicate gap surfaces as a pinpointed error rather than downstream key misses.

    — Claude via Cursor

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