Describe the bug
@solidjs/web@2.0.0-rc.0 serializes a controlled select's value binding as a value ATTRIBUTE on the <select> element. Browsers ignore that attribute — a select's initial selection comes from selected on an <option> — so the server-rendered page always displays the first enabled option, whatever the bound value is. Hydration then fixes the property in simple cases, so what the user sees is a flash of the wrong option; but the markup itself is wrong for everything that doesn't run the client bundle (no-JS clients, crawlers, anything that scrapes the HTML). React's SSR emits selected on the matching option for exactly this reason.
export default function App() {
const [value, setValue] = createSignal('fr');
return (
<select id="lang" value={value()} onChange={(e) => setValue(e.currentTarget.value)}>
<option value="en">English</option>
<option value="fr">French</option>
<option value="de">German</option>
</select>
);
}
Server output — the bound value is on the <select>, and no option is selected:
<select _hk="0" id="lang" value="fr">
<option value="en">English</option>
<option value="fr">French</option>
<option value="de">German</option>
</select>
Measured on that markup with the hydration script removed (i.e. what is on screen before hydration, and what a no-JS client gets):
select.getAttribute('value'); // "fr"
select.value; // "en"
select.selectedIndex; // 0 → "English", while app state says "fr"
Expected behavior
SSR resolves the bound value into selected on the matching <option> (honoring multiple), instead of emitting a value attribute that HTML does not define for <select>.
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.
package.json
{
"name": "solid2-select-value-ssr-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: 5591, 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(5591, () => console.log('repro on http://localhost:5591'));
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'));
To see the pre-hydration state on its own: save the served HTML, delete the <script type="module"> line, and open the file.
Related
With a spread on the <select> (a component that forwards rest-props, say), the wrong selection also SURVIVES hydration: the value is applied before <For>-rendered options exist, so the select stays on the browser default while the signal says fr. That is #1754, which still reproduces on 2.0.0-rc.0 — I've added the details there rather than duplicating them here.
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
@solidjs/web@2.0.0-rc.0serializes a controlled select'svaluebinding as avalueATTRIBUTE on the<select>element. Browsers ignore that attribute — a select's initial selection comes fromselectedon an<option>— so the server-rendered page always displays the first enabled option, whatever the bound value is. Hydration then fixes the property in simple cases, so what the user sees is a flash of the wrong option; but the markup itself is wrong for everything that doesn't run the client bundle (no-JS clients, crawlers, anything that scrapes the HTML). React's SSR emitsselectedon the matching option for exactly this reason.Server output — the bound value is on the
<select>, and no option isselected:Measured on that markup with the hydration script removed (i.e. what is on screen before hydration, and what a no-JS client gets):
Expected behavior
SSR resolves the bound
valueintoselectedon the matching<option>(honoringmultiple), instead of emitting avalueattribute that HTML does not define for<select>.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.package.json
{ "name": "solid2-select-value-ssr-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
To see the pre-hydration state on its own: save the served HTML, delete the
<script type="module">line, and open the file.Related
With a spread on the
<select>(a component that forwards rest-props, say), the wrong selection also SURVIVES hydration: the value is applied before<For>-rendered options exist, so the select stays on the browser default while the signal saysfr. That is #1754, which still reproduces on 2.0.0-rc.0 — I've added the details there rather than duplicating them here.Platform