Skip to content

SSR emits <select value="…"> as an attribute and never marks an option selected — the server markup shows the wrong option #3013

Description

@TylerRick

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

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