Repository navigation
SSR emits <select value="…"> as an attribute and never marks an option selected — the server markup shows the wrong option #3013
Description
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
ryansolid commentedon Aug 18, 2026
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
valueattribute for<select>, so the bound value both compilers emit into the markup was inert — the browser showed the first option until hydration assigned thevalueproperty. 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-sidequeueMicrotaskdeferral 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
valuemarker intoselectedon the matching option(s) — byvalueattribute, or spec text-content fallback for options without one — and strips the invalid attribute.multiplematches each value in the array. An option already carryingselected(defaultSelected) wins over the bound value, mirroring thedefaultValue+valuecontract on<input>, andform.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.fix(runtime): resolve <select value> into selected options at SSR flu…
test(web): pin SSR <select value> → selected resolution and hydration…
chore: bump dom-expressions to 0.50.0-next.43 and vite-plugin to 3.0.…
TylerRick commentedon Aug 27, 2026
Thanks for the fix — re-tested against
solid-js/@solidjs/web2.0.0-rc.3 (same standalonerepro shape as above). The non-empty case is fixed and stays fixed. A bound value of
''stillmisses the flush-time pass, though — and I believe it's inside this issue's stated contract, since
a
value=""option is a matching option.SSR output at rc.3 — a bare
valueattribute on the<select>, and no option marked:So the pre-hydration page (and any no-JS client) shows "English" while app state says
''— andhydration doesn't rescue it: after hydrating,
select.valuereads'en'with the signal still''. For a single<select>there is no "nothing selected" state in HTML, so the emptyplaceholder pattern only works if the flush-time pass treats
''as a real bound value and marksthe
value=""optionselected(React's SSR emits exactly that). The client-only render pathsettles 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.
fix(web): resolve empty-string select values in the SSR flush pass (#…
ryansolid commentedon Aug 27, 2026
@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 quotedvalue="…"form — so a bound''skipped the select entirely (and even where the select side matched, the barevalueon 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.
fix: restore next work reverted by the stage-2 replay (pre-merge sweep)
fix: restore next work reverted by the stage-2 replay (pre-merge sweep)
Stage 2: store patch channel + patch-mode list driver (dormant by def…