fix: render defaultValue/defaultChecked in SSR (#18446)

defaultValue/defaultChecked are DOM properties, not HTML attributes, so they
were being dropped from the SSR output for <input> and <textarea>. This caused
non-JS users to see no default and hydrated pages to briefly show the empty
state before hydration ran.

The compiler now translates:
- <input defaultValue=x>  -> <input value="x">
- <input defaultChecked>  -> <input checked>
- <textarea defaultValue=x> -> <textarea>x</textarea>

Non-input elements still drop the property (no HTML equivalent). The spread
path already handled this at runtime via `$.attributes()`; this keeps the
non-spread form symmetric.

Adds an SSR regression test covering both static and bound values across
text/checkbox/radio inputs and textareas.
pull/18452/head
ther12k 3 weeks ago
parent c4daa490bb
commit de011c19c0

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: render `defaultValue`/`defaultChecked` as SSR-visible `value`/`checked` on `<input>`, and `defaultValue` as text content on `<textarea>`

@ -75,8 +75,35 @@ export function build_element_attributes(node, context, transform) {
) {
events_to_capture.add(attribute.name);
}
// the defaultValue/defaultChecked properties don't exist as attributes
} else if (attribute.name !== 'defaultValue' && attribute.name !== 'defaultChecked') {
// `defaultValue` / `defaultChecked` are DOM properties, not HTML
// attributes. For `<input>` and `<textarea>`, render them as their
// SSR-visible equivalents (`value` / `checked`) so non-JS users see
// the default value and there's no FOUC on hydrated pages. For
// `<select>`, `defaultValue` controls which `<option>` is initially
// selected and is handled by the select-special path below, so we
// drop it here. For any other element, drop it: there is no HTML
// attribute equivalent.
//
// The spread path already does this mapping at runtime via
// `$.attributes()` in `internal/server/index.js`; this branch keeps
// the non-spread form symmetric.
// https://github.com/sveltejs/svelte/issues/18446
} else if (attribute.name === 'defaultValue' || attribute.name === 'defaultChecked') {
if (node.name === 'textarea' && attribute.name === 'defaultValue') {
// Same handling as `value` on `<textarea>`: render as child
// content. `content` may already be set if both `value` and
// `defaultValue` were specified; later wins (matching the runtime
// behaviour where the property is read once).
content = b.call('$.escape', build_attribute_value(attribute.value, context, transform));
} else if (node.name === 'input') {
const renamed =
attribute.name === 'defaultValue'
? { ...attribute, name: 'value' }
: { ...attribute, name: 'checked' };
attributes.push(renamed);
}
// else: drop silently (select handled elsewhere; other elements have no equivalent)
} else {
if (attribute.name === 'class') {
if (attribute.metadata.needs_clsx) {
attributes.push({

@ -0,0 +1,3 @@
import { test } from '../../test';
export default test({});

@ -0,0 +1,7 @@
<input value="foo"/>
<input value="bar"/>
<input checked/>
<input type="checkbox" checked/>
<input type="radio" checked/>
<textarea>baz</textarea>
<textarea>hello</textarea>

@ -0,0 +1,11 @@
<script>
let value = 'hello';
</script>
<input defaultValue="foo" />
<input defaultValue={"bar"} />
<input defaultChecked />
<input type="checkbox" defaultChecked />
<input type="radio" defaultChecked />
<textarea defaultValue="baz"></textarea>
<textarea defaultValue={value}></textarea>
Loading…
Cancel
Save