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
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 respectsomit()'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
Eventinstead of whatever the component's own handler would pass.Server output —
labelwas omitted fromrest, and is on the input anyway:Then type one character into the field. The leaked
onChangefires with theEventobject,setValue(event)puts it in the signal, and the next render dies: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:
{...fieldProps()}to a static spread — the props are no longer a merge proxy, and the plain-object path respectsomit();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/web2.0.0-rc.0,renderToStreamon the server +hydrateon the client. No other packages.src/App.tsxis 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
server.mjs
src/entry-server.tsx
src/entry-client.tsx
Platform