Skip to content

SSR spread ignores omit()'s hidden keys when props are a merge proxy — omitted props leak into the HTML and the leaked handler is re-bound on hydration #3014

Description

@TylerRick

Describe the bug

When a component's props are a merge proxy — which they are as soon as a caller mixes static JSX props with a dynamic spread — spreading the result of omit(props, …) onto an element leaks the omitted keys during SSR. The client-side spread respects omit()'s key-hiding proxy; the SSR spread serializer does not.

Worse than the stray attributes: an omitted event handler leaks too, and hydration re-binds it as a native listener, so it is invoked with the raw Event instead of whatever the component's own handler would pass.

function Field(
	props: { name: string; label: string; value: string; onChange: (v: string) => void } & Record<
		string,
		unknown
	>,
) {
	const rest = omit(props, 'name', 'label', 'value', 'onChange');
	return (
		<label>
			{props.label}
			<input
				name={props.name}
				value={props.value}
				onInput={(e) => props.onChange(e.currentTarget.value)}
				{...rest}
			/>
		</label>
	);
}

export default function App() {
	const [value, setValue] = createSignal('a@b.c');
	// getters, like a form library's fieldProps() — this dynamic spread is what makes the
	// callee's props a merge proxy
	const fieldProps = () => ({
		get value() {
			return value();
		},
		onChange: (v: string) => setValue(v),
	});
	return (
		<>
			<Field label="Email address" name="email" placeholder="you@example.com" {...fieldProps()} />
			<pre id="state">signal: {value()}</pre>
		</>
	);
}

Server output — label was omitted from rest, and is on the input anyway:

<label _hk="1">
	<!--$-->Email address<!--/-->
	<input name="email" value="a@b.c" label="Email address" placeholder="you@example.com" />
</label>

Then type one character into the field. The leaked onChange fires with the Event object, setValue(event) puts it in the signal, and the next render dies:

[REACTIVITY_HALTED] An uncaught error halted the reactive system. No further updates will be processed.
TypeError: Failed to execute 'insertBefore' on 'Node': parameter 1 is not of type 'Node'.
    at reconcileArrays (@solidjs_web.js:250:16)
    at insertExpression (@solidjs_web.js:1579:9)

In the app where we hit this, it showed up as form fields whose submitted value was the string [object Event].

Two things make it go away, which is what points at the merge proxy + SSR spread combination:

  • change the call site's {...fieldProps()} to a static spread — the props are no longer a merge proxy, and the plain-object path respects omit();
  • snapshot inside the component, const rest = { ...omit(props, …) } — this is the workaround we ship, at the cost of the pass-through attributes no longer being reactive.

Expected behavior

The SSR spread serializer enumerates props through the same trap the client path uses, so keys hidden by omit() stay hidden on both sides.

Reproduction

Standalone project: 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. No other packages. src/App.tsx is the snippet above; the rest is boilerplate:

package.json
{
	"name": "solid2-omit-ssr-spread-leak-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: 5592, 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 () => {
		const { render } = await vite.ssrLoadModule('/src/entry-server.tsx');
		res.setHeader('content-type', 'text/html');
		res.end(await render());
	});
});
server.listen(5592, () => console.log('repro on http://localhost:5592'));
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" />${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';

hydrate(() => <App />, document.getElementById('root'));

Platform

  • solid-js / @solidjs/web: 2.0.0-rc.0
  • vite 8.2.1, vite-plugin-solid 3.0.0-next.27
  • Chromium 141, Linux

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