A memo created with the (internal) { transparent: true } option inside a component while sharedConfig.hydrating is true causes every element AFTER the creating component to fail its claim:
Hydration tag mismatch for key "2": expected <pre> but found [object HTMLElement]
[REACTIVITY_HALTED] ... TypeError: Cannot read properties of null (reading 'nextSibling')
Hydration completed with 1 unclaimed server-rendered node(s): <pre _hk="3" ...>
The unclaimed nodes stay visible but no client scope owns them — bindings in them are permanently dead. In an app the failure is silent-but-fatal: a footer rendered after such a component keeps its SSR appearance and never reacts to state.
Minimal reproduction
vite 8.2.1 + vite-plugin-solid 3.0.0-next.27, solid-js / @solidjs/web 2.0.0-rc.0, renderToStream on the server, hydrate on the client. src/App.tsx is the whole story:
import { createMemo, createSignal } from 'solid-js';
function Inner(props: { disabled: boolean }) {
const pending = createMemo(() => false, { transparent: true });
return (
<nav>
<button id="continue" disabled={props.disabled || pending()}>
Continue
</button>
</nav>
);
}
export default function App() {
const [checked, setChecked] = createSignal(false);
return (
<>
<label>
<input
type="checkbox"
checked={checked()}
onChange={(e) => setChecked(e.currentTarget.checked)}
/>{' '}
done
</label>
<Inner disabled={!checked()} />
<pre id="after">sibling after Inner: {String(checked())}</pre>
</>
);
}
Observed:
- With the transparent memo: the
<pre> fails to claim, hydration reports it unclaimed, the REACTIVITY_HALTED TypeError fires, and the checkbox no longer enables the button.
- Remove
{ transparent: true } (or the memo entirely): hydration is clean and everything works.
- The memo's value never changes and is even never read in the broken variant — creating it is enough.
The rest of the repro project (4 small files) — pnpm install && node server.mjs → http://localhost:5599
package.json:
{
"name": "solid2-hydration-repro",
"private": true,
"type": "module",
"scripts": { "dev": "node server.mjs" },
"dependencies": {
"@solidjs/web": "2.0.0-rc.0",
"solid-js": "2.0.0-rc.0"
},
"devDependencies": {
"vite": "8.2.1",
"vite-plugin-solid": "3.0.0-next.27"
}
}
vite.config.mjs:
import { defineConfig } from 'vite';
import solid from 'vite-plugin-solid';
export default defineConfig({
plugins: [solid({ ssr: true })],
server: { port: 5599, strictPort: true },
});
server.mjs:
import http from 'node:http';
import { createServer as createViteServer } from 'vite';
const vite = await createViteServer({
configFile: './vite.config.mjs',
server: { middlewareMode: true },
appType: 'custom',
});
const server = http.createServer((req, res) => {
vite.middlewares(req, res, async () => {
try {
const { render } = await vite.ssrLoadModule('/src/entry-server.tsx');
const html = await render();
res.setHeader('content-type', 'text/html');
res.end(html);
} catch (e) {
vite.ssrFixStacktrace(e);
console.error(e);
res.statusCode = 500;
res.end(String(e && e.stack));
}
});
});
server.listen(5599, () => console.log('repro on http://localhost:5599'));
src/entry-server.tsx:
import { generateHydrationScript, renderToStream } from '@solidjs/web';
import App from './App';
export async function render(): Promise<string> {
const stream = renderToStream(() => <App />);
const chunks: string[] = [];
const decoder = new TextDecoder();
await stream.pipeTo(
new WritableStream({
write(chunk) {
chunks.push(typeof chunk === 'string' ? chunk : decoder.decode(chunk));
},
}),
);
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>solid2 hydration repro</title>
${generateHydrationScript()}
</head>
<body>
<div id="root">${chunks.join('')}</div>
<script type="module" src="/src/entry-client.tsx"></script>
</body>
</html>`;
}
src/entry-client.tsx:
import { hydrate } from '@solidjs/web';
import App from './App';
const root = document.getElementById('root');
if (!root) throw new Error('no root');
hydrate(() => <App />, root);
Why this matters even though the option is internal
@tanstack/solid-router@2.0.0-rc.0's useRouterState creates its select memo exactly this way (useRouterState.js: Solid.createMemo((prev) => {...}, { transparent: true })), so on the current RC line every useRouterState call poisons the hydration of all later siblings. We hit this in a real app as a page whose footer buttons were permanently dead after hydration. (filed there too: TanStack/router#8096)
Where it seems to live
From reading solid-js/dist/solid.js 2.0.0-rc.0: hydratedCreateMemo short-circuits for transparent memos — if (!sharedConfig.hydrating || options?.transparent) return createMemo$1(compute, options) — while non-transparent memos go through hydrateSignalLike. The server type docs say transparent owners must leave "hydration-id chains untouched"; the observed key shift (every sibling after the creation point expects id N but the DOM carries N+1) suggests the client-side transparent path still consumes a child id (or the SSR side skips one), so the claim cursor walks off by one at the first sibling boundary after the memo.
A memo created with the (internal)
{ transparent: true }option inside a component whilesharedConfig.hydratingis true causes every element AFTER the creating component to fail its claim:The unclaimed nodes stay visible but no client scope owns them — bindings in them are permanently dead. In an app the failure is silent-but-fatal: a footer rendered after such a component keeps its SSR appearance and never reacts to state.
Minimal reproduction
vite 8.2.1 + vite-plugin-solid 3.0.0-next.27, solid-js / @solidjs/web 2.0.0-rc.0,
renderToStreamon the server,hydrateon the client.src/App.tsxis the whole story:Observed:
<pre>fails to claim, hydration reports it unclaimed, the REACTIVITY_HALTED TypeError fires, and the checkbox no longer enables the button.{ transparent: true }(or the memo entirely): hydration is clean and everything works.The rest of the repro project (4 small files) —
pnpm install && node server.mjs→ http://localhost:5599package.json:{ "name": "solid2-hydration-repro", "private": true, "type": "module", "scripts": { "dev": "node server.mjs" }, "dependencies": { "@solidjs/web": "2.0.0-rc.0", "solid-js": "2.0.0-rc.0" }, "devDependencies": { "vite": "8.2.1", "vite-plugin-solid": "3.0.0-next.27" } }vite.config.mjs:server.mjs:src/entry-server.tsx:src/entry-client.tsx:Why this matters even though the option is internal
@tanstack/solid-router@2.0.0-rc.0'suseRouterStatecreates its select memo exactly this way (useRouterState.js:Solid.createMemo((prev) => {...}, { transparent: true })), so on the current RC line everyuseRouterStatecall poisons the hydration of all later siblings. We hit this in a real app as a page whose footer buttons were permanently dead after hydration. (filed there too: TanStack/router#8096)Where it seems to live
From reading
solid-js/dist/solid.js2.0.0-rc.0:hydratedCreateMemoshort-circuits for transparent memos —if (!sharedConfig.hydrating || options?.transparent) return createMemo$1(compute, options)— while non-transparent memos go throughhydrateSignalLike. The server type docs say transparent owners must leave "hydration-id chains untouched"; the observed key shift (every sibling after the creation point expects id N but the DOM carries N+1) suggests the client-side transparent path still consumes a child id (or the SSR side skips one), so the claim cursor walks off by one at the first sibling boundary after the memo.