Skip to content

createMemo(fn, { transparent: true }) during hydration shifts sibling hydration ids → unclaimed DOM + REACTIVITY_HALTED #3012

Description

@TylerRick

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.

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