Skip to content

feat: ssr select value #16017

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 21 commits into from
May 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/new-turkeys-exercise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

fix: correctly mark <option> elements as selected during SSR
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/** @import { Expression } from 'estree' */
/** @import { Location } from 'locate-character' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext, ComponentServerTransformState } from '../types.js' */
Expand All @@ -6,8 +7,8 @@ import { is_void } from '../../../../../utils.js';
import { dev, locator } from '../../../../state.js';
import * as b from '#compiler/builders';
import { clean_nodes, determine_namespace_for_children } from '../../utils.js';
import { build_element_attributes } from './shared/element.js';
import { process_children, build_template } from './shared/utils.js';
import { build_element_attributes, build_spread_object } from './shared/element.js';
import { process_children, build_template, build_attribute_value } from './shared/utils.js';

/**
* @param {AST.RegularElement} node
Expand Down Expand Up @@ -71,21 +72,96 @@ export function RegularElement(node, context) {
);
}

if (body === null) {
process_children(trimmed, { ...context, state });
} else {
let id = body;
let select_with_value = false;

if (body.type !== 'Identifier') {
id = b.id(state.scope.generate('$$body'));
state.template.push(b.const(id, body));
if (node.name === 'select') {
const value = node.attributes.find(
(attribute) =>
(attribute.type === 'Attribute' || attribute.type === 'BindDirective') &&
attribute.name === 'value'
);
if (node.attributes.some((attribute) => attribute.type === 'SpreadAttribute')) {
select_with_value = true;
state.template.push(
b.stmt(
b.assignment(
'=',
b.id('$$payload.select_value'),
b.member(
build_spread_object(
node,
node.attributes.filter(
(attribute) =>
attribute.type === 'Attribute' ||
attribute.type === 'BindDirective' ||
attribute.type === 'SpreadAttribute'
),
context
),
'value',
false,
true
)
)
)
);
} else if (value) {
select_with_value = true;
const left = b.id('$$payload.select_value');
if (value.type === 'Attribute') {
state.template.push(
b.stmt(b.assignment('=', left, build_attribute_value(value.value, context)))
);
} else if (value.type === 'BindDirective') {
state.template.push(
b.stmt(
b.assignment(
'=',
left,
value.expression.type === 'SequenceExpression'
? b.call(value.expression.expressions[0])
: value.expression
)
)
);
}
}
}

if (
node.name === 'option' &&
!node.attributes.some(
(attribute) =>
attribute.type === 'SpreadAttribute' ||
((attribute.type === 'Attribute' || attribute.type === 'BindDirective') &&
attribute.name === 'value')
)
) {
const inner_state = { ...state, template: [], init: [] };
process_children(trimmed, { ...context, state: inner_state });

state.template.push(
b.stmt(
b.call(
'$.valueless_option',
b.id('$$payload'),
b.thunk(b.block([...inner_state.init, ...build_template(inner_state.template)]))
)
)
);
} else if (body !== null) {
// if this is a `<textarea>` value or a contenteditable binding, we only add
// the body if the attribute/binding is falsy
const inner_state = { ...state, template: [], init: [] };
process_children(trimmed, { ...context, state: inner_state });

let id = /** @type {Expression} */ (body);

if (body.type !== 'Identifier') {
id = b.id(state.scope.generate('$$body'));
state.template.push(b.const(id, body));
}

// Use the body expression as the body if it's truthy, otherwise use the inner template
state.template.push(
b.if(
Expand All @@ -94,6 +170,12 @@ export function RegularElement(node, context) {
b.block([...inner_state.init, ...build_template(inner_state.template)])
)
);
} else {
process_children(trimmed, { ...context, state });
}

if (select_with_value) {
state.template.push(b.stmt(b.assignment('=', b.id('$$payload.select_value'), b.void0)));
}

if (!node_is_void) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,29 @@ export function build_element_attributes(node, context) {

if (has_spread) {
build_element_spread_attributes(node, attributes, style_directives, class_directives, context);
if (node.name === 'option') {
context.state.template.push(
b.call(
'$.maybe_selected',
b.id('$$payload'),
b.member(
build_spread_object(
node,
node.attributes.filter(
(attribute) =>
attribute.type === 'Attribute' ||
attribute.type === 'BindDirective' ||
attribute.type === 'SpreadAttribute'
),
context
),
'value',
false,
true
)
)
);
}
} else {
const css_hash = node.metadata.scoped ? context.state.analysis.css.hash : null;

Expand Down Expand Up @@ -236,6 +259,16 @@ export function build_element_attributes(node, context) {
);
}

if (node.name === 'option' && name === 'value') {
context.state.template.push(
b.call(
'$.maybe_selected',
b.id('$$payload'),
literal_value != null ? b.literal(/** @type {any} */ (literal_value)) : b.void0
)
);
}

continue;
}

Expand All @@ -260,6 +293,10 @@ export function build_element_attributes(node, context) {
b.call('$.attr', b.literal(name), value, is_boolean_attribute(name) && b.true)
);
}

if (name === 'value' && node.name === 'option') {
context.state.template.push(b.call('$.maybe_selected', b.id('$$payload'), value));
}
}
}

Expand All @@ -274,7 +311,7 @@ export function build_element_attributes(node, context) {

/**
* @param {AST.RegularElement | AST.SvelteElement} element
* @param {AST.Attribute} attribute
* @param {AST.Attribute | AST.BindDirective} attribute
*/
function get_attribute_name(element, attribute) {
let name = attribute.name;
Expand All @@ -286,6 +323,36 @@ function get_attribute_name(element, attribute) {
return name;
}

/**
* @param {AST.RegularElement | AST.SvelteElement} element
* @param {Array<AST.Attribute | AST.SpreadAttribute | AST.BindDirective>} attributes
* @param {ComponentContext} context
*/
export function build_spread_object(element, attributes, context) {
return b.object(
attributes.map((attribute) => {
if (attribute.type === 'Attribute') {
const name = get_attribute_name(element, attribute);
const value = build_attribute_value(
attribute.value,
context,
WHITESPACE_INSENSITIVE_ATTRIBUTES.includes(name)
);
return b.prop('init', b.key(name), value);
} else if (attribute.type === 'BindDirective') {
const name = get_attribute_name(element, attribute);
const value =
attribute.expression.type === 'SequenceExpression'
? b.call(attribute.expression.expressions[0])
: /** @type {Expression} */ (context.visit(attribute.expression));
return b.prop('init', b.key(name), value);
}

return b.spread(/** @type {Expression} */ (context.visit(attribute)));
})
);
}

/**
*
* @param {AST.RegularElement | AST.SvelteElement} element
Expand Down Expand Up @@ -336,21 +403,7 @@ function build_element_spread_attributes(
flags |= ELEMENT_PRESERVE_ATTRIBUTE_CASE;
}

const object = b.object(
attributes.map((attribute) => {
if (attribute.type === 'Attribute') {
const name = get_attribute_name(element, attribute);
const value = build_attribute_value(
attribute.value,
context,
WHITESPACE_INSENSITIVE_ATTRIBUTES.includes(name)
);
return b.prop('init', b.key(name), value);
}

return b.spread(/** @type {Expression} */ (context.visit(attribute)));
})
);
const object = build_spread_object(element, attributes, context);

const css_hash =
element.metadata.scoped && context.state.analysis.css.hash
Expand Down
27 changes: 27 additions & 0 deletions packages/svelte/src/internal/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -535,3 +535,30 @@ export function derived(fn) {
return updated_value;
};
}

/**
*
* @param {Payload} payload
* @param {*} value
*/
export function maybe_selected(payload, value) {
return value === payload.select_value ? ' selected' : '';
}

/**
* @param {Payload} payload
* @param {() => void} children
* @returns {void}
*/
export function valueless_option(payload, children) {
var i = payload.out.length;

children();

var body = payload.out.slice(i);

if (body.replace(/<!---->/g, '') === payload.select_value) {
// replace '>' with ' selected>' (closing tag will be added later)
payload.out = payload.out.slice(0, i - 1) + ' selected>' + body;
}
}
1 change: 1 addition & 0 deletions packages/svelte/src/internal/server/payload.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export class Payload {
css = new Set();
out = '';
uid = () => '';
select_value = undefined;

head = new HeadPayload();

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { test } from '../../test';

export default test({
html: `
ssrHtml: `
<select>
<option value="[object Object]">wheeee</option>
<option selected value="[object Object]">wheeee</option>
</select>
`
});
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ export default test({
return { tasks, selected: tasks[0] };
},

html: `
ssrHtml: `
<select>
<option value='[object Object]'>put your left leg in</option>
<option selected value='[object Object]'>put your left leg in</option>
<option value='[object Object]'>your left leg out</option>
<option value='[object Object]'>in, out, in, out</option>
<option value='[object Object]'>shake it all about</option>
Expand All @@ -36,7 +36,28 @@ export default test({
<p>shake it all about</p>
`,

async test({ assert, component, target, window }) {
async test({ assert, component, target, window, variant }) {
assert.htmlEqual(
target.innerHTML,
`
<select>
<option ${variant === 'hydrate' ? 'selected ' : ''}value='[object Object]'>put your left leg in</option>
<option value='[object Object]'>your left leg out</option>
<option value='[object Object]'>in, out, in, out</option>
<option value='[object Object]'>shake it all about</option>
</select>

<label>
<input type='checkbox'> put your left leg in
</label>

<h2>Pending tasks</h2>
<p>put your left leg in</p>
<p>your left leg out</p>
<p>in, out, in, out</p>
<p>shake it all about</p>
`
);
const input = target.querySelector('input');
const select = target.querySelector('select');
const options = target.querySelectorAll('option');
Expand All @@ -57,7 +78,7 @@ export default test({
target.innerHTML,
`
<select>
<option value='[object Object]'>put your left leg in</option>
<option ${variant === 'hydrate' ? 'selected ' : ''}value='[object Object]'>put your left leg in</option>
<option value='[object Object]'>your left leg out</option>
<option value='[object Object]'>in, out, in, out</option>
<option value='[object Object]'>shake it all about</option>
Expand Down Expand Up @@ -94,7 +115,7 @@ export default test({
target.innerHTML,
`
<select>
<option value='[object Object]'>put your left leg in</option>
<option ${variant === 'hydrate' ? 'selected ' : ''}value='[object Object]'>put your left leg in</option>
<option value='[object Object]'>your left leg out</option>
<option value='[object Object]'>in, out, in, out</option>
<option value='[object Object]'>shake it all about</option>
Expand Down
Loading