Skip to content

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

Closed

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

Activity

  1. ryansolid commented on Aug 18, 2026

    @ryansolid
    Member

    Fixed in dom-expressions 21fe8e7b (runtime), with solid-side tests in bc7b146 — lands with the next @dom-expressions/runtime snapshot.

    What was happening: HTML defines no value attribute for <select>, so the bound value both compilers emit into the markup was inert — the browser showed the first option until hydration assigned the value property. This wasn't a 2.0 regression or a Rust-compiler gap: 1.x compiled the exact same shape (verified against babel-preset-solid 1.x output), and the client-side queueMicrotask deferral from the 1.x line (#1754) only ever fixed the post-JS experience.

    The fix is a flush-time pass in the server runtime over shell HTML, fragment templates, and renderToString output: it resolves the value marker into selected on the matching option(s) — by value attribute, or spec text-content fallback for options without one — and strips the invalid attribute. multiple matches each value in the array. An option already carrying selected (defaultSelected) wins over the bound value, mirroring the defaultValue + value contract on <input>, and form.reset() now returns to the SSR-rendered selection. A select whose options stream in later than the shell (a Loading boundary inside it) is left untouched and settles at hydration, as before. No compiler changes — both Babel and Rust output are handled uniformly, and it's all server-side bytes, so client bundle size is unaffected.

  2. TylerRick commented on Aug 27, 2026

    @TylerRick
    Author

    Thanks for the fix — re-tested against solid-js / @solidjs/web 2.0.0-rc.3 (same standalone
    repro shape as above). The non-empty case is fixed and stays fixed. A bound value of '' still
    misses the flush-time pass, though — and I believe it's inside this issue's stated contract, since
    a value="" option is a matching option.

    const [empty, setEmpty] = createSignal('');
    <select id="empty" value={empty()} onChange={…}>
    	<option value="" disabled style={{ display: 'none' }}>Choose a language</option>
    	<option value="en">English</option>
    	<option value="fr">French</option>
    	<option value="de">German</option>
    </select>

    SSR output at rc.3 — a bare value attribute on the <select>, and no option marked:

    <select _hk=8 id="empty" value name="empty"><option _hk=b0 value disabled style="display:none">Choose a language</option><option _hk=c00 value="en">English</option>…</select>

    So the pre-hydration page (and any no-JS client) shows "English" while app state says '' — and
    hydration doesn't rescue it: after hydrating, select.value reads 'en' with the signal still
    ''. For a single <select> there is no "nothing selected" state in HTML, so the empty
    placeholder pattern only works if the flush-time pass treats '' as a real bound value and marks
    the value="" option selected (React's SSR emits exactly that). The client-only render path
    settles correctly at rc.3, so this is SSR-specific and distinct from the #1754 spread-ordering
    half.

    Would you rather reopen this for the empty-value case, or have it as a separate issue? Happy to
    push the rc.3 repro if useful.

  3. ryansolid commented on Aug 27, 2026

    @ryansolid
    Member

    @TylerRick Good catch — you're right that it's inside this issue's contract, and no need for either: fixed in c9b4f2a, keeping this closed since it lands with the same batch.

    The gap was in serialization form, not the matching rule: empty attribute values serialize as bare attributes (<select value, <option value), and the flush-time pass only recognized the quoted value="…" form — so a bound '' skipped the select entirely (and even where the select side matched, the bare value on the placeholder option read as "no value attribute" and fell through to text-content matching). The pass now reads the bare form as the empty string on both sides, so your repro renders <option value disabled selected>Choose a language</option> with the marker stripped from the select — React SSR parity for the single-select placeholder pattern.

    Thanks for re-testing against rc.3 with a standalone repro both times — it made this a five-minute diagnosis.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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