feat: ssr select value (#16017)

* feat: ssr select value

* fix: adjust test output

* minor aesthetic OCD tweak - the normalizer takes care of this

* fix: handle implicit values

* chore: implicit value test

* fix: deal with spreads on select AND option

* fix: spreading

* chore: add test for cross component

* fix: test

* chore: add failing test

* fix: runtime valueless option selection

* tweak

* fix: snapshots

* fix (options are erroneously being marked as valueless

* simplify a bit (pass callback direct to valueless_option)

* tweak

* tweak ("execute the child" is the sort of thing that gets you visited by the FBI)

* lint

* changeset

---------

Co-authored-by: Rich Harris <rich.harris@vercel.com>
pull/16023/head
Paolo Ricciuti 4 months ago committed by GitHub
parent b2fa84702d
commit 40e0e33883
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: correctly mark <option> elements as selected during SSR

@ -1,3 +1,4 @@
/** @import { Expression } from 'estree' */
/** @import { Location } from 'locate-character' */ /** @import { Location } from 'locate-character' */
/** @import { AST } from '#compiler' */ /** @import { AST } from '#compiler' */
/** @import { ComponentContext, ComponentServerTransformState } from '../types.js' */ /** @import { ComponentContext, ComponentServerTransformState } from '../types.js' */
@ -6,8 +7,8 @@ import { is_void } from '../../../../../utils.js';
import { dev, locator } from '../../../../state.js'; import { dev, locator } from '../../../../state.js';
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { clean_nodes, determine_namespace_for_children } from '../../utils.js'; import { clean_nodes, determine_namespace_for_children } from '../../utils.js';
import { build_element_attributes } from './shared/element.js'; import { build_element_attributes, build_spread_object } from './shared/element.js';
import { process_children, build_template } from './shared/utils.js'; import { process_children, build_template, build_attribute_value } from './shared/utils.js';
/** /**
* @param {AST.RegularElement} node * @param {AST.RegularElement} node
@ -71,21 +72,96 @@ export function RegularElement(node, context) {
); );
} }
if (body === null) { let select_with_value = false;
process_children(trimmed, { ...context, state });
} else {
let id = body;
if (body.type !== 'Identifier') { if (node.name === 'select') {
id = b.id(state.scope.generate('$$body')); const value = node.attributes.find(
state.template.push(b.const(id, body)); (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 // if this is a `<textarea>` value or a contenteditable binding, we only add
// the body if the attribute/binding is falsy // the body if the attribute/binding is falsy
const inner_state = { ...state, template: [], init: [] }; const inner_state = { ...state, template: [], init: [] };
process_children(trimmed, { ...context, state: inner_state }); 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 // Use the body expression as the body if it's truthy, otherwise use the inner template
state.template.push( state.template.push(
b.if( b.if(
@ -94,6 +170,12 @@ export function RegularElement(node, context) {
b.block([...inner_state.init, ...build_template(inner_state.template)]) 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) { if (!node_is_void) {

@ -202,6 +202,29 @@ export function build_element_attributes(node, context) {
if (has_spread) { if (has_spread) {
build_element_spread_attributes(node, attributes, style_directives, class_directives, context); 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 { } else {
const css_hash = node.metadata.scoped ? context.state.analysis.css.hash : null; const css_hash = node.metadata.scoped ? context.state.analysis.css.hash : null;
@ -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; continue;
} }
@ -260,6 +293,10 @@ export function build_element_attributes(node, context) {
b.call('$.attr', b.literal(name), value, is_boolean_attribute(name) && b.true) 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));
}
} }
} }
@ -274,7 +311,7 @@ export function build_element_attributes(node, context) {
/** /**
* @param {AST.RegularElement | AST.SvelteElement} element * @param {AST.RegularElement | AST.SvelteElement} element
* @param {AST.Attribute} attribute * @param {AST.Attribute | AST.BindDirective} attribute
*/ */
function get_attribute_name(element, attribute) { function get_attribute_name(element, attribute) {
let name = attribute.name; let name = attribute.name;
@ -286,6 +323,36 @@ function get_attribute_name(element, attribute) {
return name; 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 * @param {AST.RegularElement | AST.SvelteElement} element
@ -336,21 +403,7 @@ function build_element_spread_attributes(
flags |= ELEMENT_PRESERVE_ATTRIBUTE_CASE; flags |= ELEMENT_PRESERVE_ATTRIBUTE_CASE;
} }
const object = b.object( const object = build_spread_object(element, attributes, context);
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 css_hash = const css_hash =
element.metadata.scoped && context.state.analysis.css.hash element.metadata.scoped && context.state.analysis.css.hash

@ -535,3 +535,30 @@ export function derived(fn) {
return updated_value; 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;
}
}

@ -18,6 +18,7 @@ export class Payload {
css = new Set(); css = new Set();
out = ''; out = '';
uid = () => ''; uid = () => '';
select_value = undefined;
head = new HeadPayload(); head = new HeadPayload();

@ -1,9 +1,9 @@
import { test } from '../../test'; import { test } from '../../test';
export default test({ export default test({
html: ` ssrHtml: `
<select> <select>
<option value="[object Object]">wheeee</option> <option selected value="[object Object]">wheeee</option>
</select> </select>
` `
}); });

@ -17,9 +17,9 @@ export default test({
return { tasks, selected: tasks[0] }; return { tasks, selected: tasks[0] };
}, },
html: ` ssrHtml: `
<select> <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]'>your left leg out</option>
<option value='[object Object]'>in, out, in, out</option> <option value='[object Object]'>in, out, in, out</option>
<option value='[object Object]'>shake it all about</option> <option value='[object Object]'>shake it all about</option>
@ -36,7 +36,28 @@ export default test({
<p>shake it all about</p> <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 input = target.querySelector('input');
const select = target.querySelector('select'); const select = target.querySelector('select');
const options = target.querySelectorAll('option'); const options = target.querySelectorAll('option');
@ -57,7 +78,7 @@ export default test({
target.innerHTML, target.innerHTML,
` `
<select> <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]'>your left leg out</option>
<option value='[object Object]'>in, out, in, out</option> <option value='[object Object]'>in, out, in, out</option>
<option value='[object Object]'>shake it all about</option> <option value='[object Object]'>shake it all about</option>
@ -94,7 +115,7 @@ export default test({
target.innerHTML, target.innerHTML,
` `
<select> <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]'>your left leg out</option>
<option value='[object Object]'>in, out, in, out</option> <option value='[object Object]'>in, out, in, out</option>
<option value='[object Object]'>shake it all about</option> <option value='[object Object]'>shake it all about</option>

@ -6,17 +6,29 @@ export default test({
return { values: [1, 2, 3], foo: 2 }; return { values: [1, 2, 3], foo: 2 };
}, },
html: ` ssrHtml: `
<select> <select>
<option value="1">1</option> <option value="1">1</option>
<option value="2">2</option> <option selected value="2">2</option>
<option value="3">3</option> <option value="3">3</option>
</select> </select>
<p>foo: 2</p> <p>foo: 2</p>
`, `,
test({ assert, component, target, window }) { test({ assert, component, target, window, variant }) {
assert.htmlEqual(
target.innerHTML,
`
<select>
<option value="1">1</option>
<option ${variant === 'hydrate' ? 'selected ' : ''}value="2">2</option>
<option value="3">3</option>
</select>
<p>foo: 2</p>
`
);
const select = target.querySelector('select'); const select = target.querySelector('select');
ok(select); ok(select);
const options = [...target.querySelectorAll('option')]; const options = [...target.querySelectorAll('option')];
@ -36,7 +48,7 @@ export default test({
` `
<select> <select>
<option value="1">1</option> <option value="1">1</option>
<option value="2">2</option> <option ${variant === 'hydrate' ? 'selected ' : ''}value="2">2</option>
<option value="3">3</option> <option value="3">3</option>
</select> </select>

@ -1,15 +1,15 @@
import { test } from '../../test'; import { test } from '../../test';
export default test({ export default test({
html: ` ssrHtml: `
<select> <select>
<option value='hullo'>Hullo</option> <option selected value='hullo'>Hullo</option>
<option value='world'>World</option> <option value='world'>World</option>
</select> </select>
<select> <select>
<option value='hullo'>Hullo</option> <option value='hullo'>Hullo</option>
<option value='world'>World</option> <option selected value='world'>World</option>
</select> </select>
`, `,
@ -19,7 +19,21 @@ export default test({
}; };
}, },
test({ assert, component, target, window }) { test({ assert, component, target, window, variant }) {
assert.htmlEqual(
target.innerHTML,
`
<select>
<option ${variant === 'hydrate' ? 'selected ' : ''}value='hullo'>Hullo</option>
<option value='world'>World</option>
</select>
<select>
<option value='hullo'>Hullo</option>
<option ${variant === 'hydrate' ? 'selected ' : ''}value='world'>World</option>
</select>
`
);
const selects = [...target.querySelectorAll('select')]; const selects = [...target.querySelectorAll('select')];
const change = new window.Event('change'); const change = new window.Event('change');

@ -8,7 +8,7 @@ export default test({
<select> <select>
<option>a</option> <option>a</option>
<option selected="">b</option> <option selected>b</option>
<option>c</option> <option>c</option>
</select> </select>

@ -1,12 +1,12 @@
import { ok, test } from '../../test'; import { ok, test } from '../../test';
export default test({ export default test({
html: ` ssrHtml: `
<p>selected: b</p> <p>selected: b</p>
<select> <select>
<option>a</option> <option>a</option>
<option>b</option> <option selected>b</option>
<option>c</option> <option>c</option>
</select> </select>
@ -17,7 +17,21 @@ export default test({
return { selected: 'b' }; return { selected: 'b' };
}, },
test({ assert, target }) { test({ assert, target, variant }) {
assert.htmlEqual(
target.innerHTML,
`
<p>selected: b</p>
<select>
<option>a</option>
<option${variant === 'hydrate' ? ' selected' : ''}>b</option>
<option>c</option>
</select>
<p>selected: b</p>
`
);
const select = target.querySelector('select'); const select = target.querySelector('select');
ok(select); ok(select);
const options = [...target.querySelectorAll('option')]; const options = [...target.querySelectorAll('option')];

@ -2,11 +2,11 @@ import { flushSync } from 'svelte';
import { ok, test } from '../../test'; import { ok, test } from '../../test';
export default test({ export default test({
html: ` ssrHtml: `
<p>selected: one</p> <p>selected: one</p>
<select> <select>
<option>one</option> <option selected>one</option>
<option>two</option> <option>two</option>
<option>three</option> <option>three</option>
</select> </select>
@ -18,7 +18,21 @@ export default test({
return { selected: 'one' }; return { selected: 'one' };
}, },
test({ assert, component, target, window }) { test({ assert, component, target, window, variant }) {
assert.htmlEqual(
target.innerHTML,
`
<p>selected: one</p>
<select>
<option${variant === 'hydrate' ? ' selected' : ''}>one</option$>
<option>two</option>
<option>three</option>
</select>
<p>selected: one</p>
`
);
const select = target.querySelector('select'); const select = target.querySelector('select');
ok(select); ok(select);
@ -40,7 +54,7 @@ export default test({
<p>selected: two</p> <p>selected: two</p>
<select> <select>
<option>one</option> <option${variant === 'hydrate' ? ' selected' : ''}>one</option$>
<option>two</option> <option>two</option>
<option>three</option> <option>three</option>
</select> </select>

@ -2,27 +2,28 @@ import { flushSync } from 'svelte';
import { ok, test } from '../../test'; import { ok, test } from '../../test';
export default test({ export default test({
html: ` test({ assert, target, variant }) {
<select> assert.htmlEqual(
<option value="a">A</option> target.innerHTML,
<option value="b">B</option> `
</select> <select>
selected: a <option${variant === 'hydrate' ? ' selected' : ''} value="a">A</option$>
`, <option value="b">B</option>
</select>
test({ assert, target }) { selected: a
`
);
const select = target.querySelector('select'); const select = target.querySelector('select');
ok(select); ok(select);
const event = new window.Event('change'); const event = new window.Event('change');
select.value = 'b'; select.value = 'b';
select.dispatchEvent(event); select.dispatchEvent(event);
flushSync(); flushSync();
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
` `
<select> <select>
<option value="a">A</option> <option${variant === 'hydrate' ? ' selected' : ''} value="a">A</option$>
<option value="b">B</option> <option value="b">B</option>
</select> </select>
selected: b selected: b

@ -3,8 +3,14 @@
let checkbox = $state(true); let checkbox = $state(true);
let radio_group = $state('a'); let radio_group = $state('a');
let checkbox_group = $state(['a']); let checkbox_group = $state(['a']);
let select = $state('b'); // this will be ssrd
let select = $state('a');
let textarea = $state('textarea'); let textarea = $state('textarea');
$effect(()=>{
// changing the value of `select` on mount
select = 'b';
})
</script> </script>
<p>{JSON.stringify({ text, checkbox, radio_group, checkbox_group, select, textarea })}</p> <p>{JSON.stringify({ text, checkbox, radio_group, checkbox_group, select, textarea })}</p>

@ -0,0 +1,5 @@
<script>
let props = $props();
</script>
<option {...props}>{@render props.children?.()}</option>

@ -0,0 +1 @@
<select><option value="">--Please choose an option--</option><option value="dog" selected>Dog</option><option value="cat">Cat</option></select>

@ -0,0 +1,8 @@
<script lang="ts">
import Option from './Option.svelte';
</script>
<select value="dog">
<Option value="">--Please choose an option--</Option>
<Option value="dog">Dog</Option>
<Option value="cat">Cat</Option>
</select>

@ -0,0 +1 @@
<select><option>--Please choose an option--</option><option selected>dog</option><option>cat</option></select>

@ -0,0 +1,13 @@
<select value="dog">
<option>
{@render option("--Please choose an option--")}
</option>
<option>
{@render option("dog")}
</option>
<option>
{@render option("cat")}
</option>
</select>
{#snippet option(val)}{val}{/snippet}

@ -0,0 +1 @@
<select><option>--Please choose an option--</option><option selected>dog</option><option>cat</option></select>

@ -0,0 +1,5 @@
<select value="dog">
<option>--Please choose an option--</option>
<option>dog</option>
<option>cat</option>
</select>

@ -0,0 +1 @@
<select><option value="">--Please choose an option--</option><option value="dog" selected>Dog</option><option value="cat">Cat</option></select>

@ -0,0 +1,5 @@
<select value="dog">
<option value="">--Please choose an option--</option>
<option value="dog">Dog</option>
<option value="cat">Cat</option>
</select>

@ -3,5 +3,5 @@ import * as $ from 'svelte/internal/server';
export default function Skip_static_subtree($$payload, $$props) { export default function Skip_static_subtree($$payload, $$props) {
let { title, content } = $$props; let { title, content } = $$props;
$$payload.out += `<header><nav><a href="/">Home</a> <a href="/away">Away</a></nav></header> <main><h1>${$.escape(title)}</h1> <div class="static"><p>we don't need to traverse these nodes</p></div> <p>or</p> <p>these</p> <p>ones</p> ${$.html(content)} <p>these</p> <p>trailing</p> <p>nodes</p> <p>can</p> <p>be</p> <p>completely</p> <p>ignored</p></main> <cant-skip><custom-elements with="attributes"></custom-elements></cant-skip> <div><input autofocus/></div> <div><source muted/></div> <select><option value="a">a</option></select> <img src="..." alt="" loading="lazy"/> <div><img src="..." alt="" loading="lazy"/></div>`; $$payload.out += `<header><nav><a href="/">Home</a> <a href="/away">Away</a></nav></header> <main><h1>${$.escape(title)}</h1> <div class="static"><p>we don't need to traverse these nodes</p></div> <p>or</p> <p>these</p> <p>ones</p> ${$.html(content)} <p>these</p> <p>trailing</p> <p>nodes</p> <p>can</p> <p>be</p> <p>completely</p> <p>ignored</p></main> <cant-skip><custom-elements with="attributes"></custom-elements></cant-skip> <div><input autofocus/></div> <div><source muted/></div> <select><option value="a"${$.maybe_selected($$payload, 'a')}>a</option></select> <img src="..." alt="" loading="lazy"/> <div><img src="..." alt="" loading="lazy"/></div>`;
} }
Loading…
Cancel
Save