fix: keep the current selection of a `<select>` when its `defaultValue` is applied (#18719)

Also ensure init_select isn't called multiple times

Follow-up to #18591.
pull/18722/head
Nic Polumeyv 1 month ago committed by GitHub
parent 4d5139552d
commit 864de81bb6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -520,20 +520,28 @@ export function RegularElement(node, context) {
// deferred from the attribute loop above, so that the options it selects from
// have been created and had their values assigned
if (!has_spread && name === 'select') {
for (const attribute of /** @type {AST.Attribute[]} */ (attributes)) {
if (get_attribute_name(node, attribute) === 'defaultValue') {
const { value, has_state } = build_attribute_value(attribute.value, context, (v, m) =>
context.state.memoizer.add(v, m)
);
const default_value = /** @type {AST.Attribute[]} */ (attributes).find(
(attribute) => get_attribute_name(node, attribute) === 'defaultValue'
);
const update = b.stmt(b.call('$.set_default_select_value', node_id, value));
if (default_value) {
const { value, has_state } = build_attribute_value(default_value.value, context, (v, m) =>
context.state.memoizer.add(v, m)
);
(has_state ? context.state.update : context.state.init).push(update);
if (!bindings.has('value')) {
context.state.init.push(b.stmt(b.call('$.init_select', node_id)));
}
break;
}
(has_state ? context.state.update : context.state.init).push(
b.stmt(b.call('$.set_default_select_value', node_id, value))
);
}
const value_attribute = lookup.get('value');
const dynamic_value =
value_attribute !== undefined &&
value_attribute.value !== true &&
!is_text_attribute(value_attribute);
if (default_value || dynamic_value || bindings.has('value')) {
context.state.init.push(b.stmt(b.call('$.init_select', node_id)));
}
}
@ -772,10 +780,6 @@ function build_element_special_value_attribute(
} else {
state.init.push(build_update(value));
}
if (is_select_with_value) {
state.init.push(b.stmt(b.call('$.init_select', node_id)));
}
}
/**

@ -525,7 +525,7 @@ export function attribute_effect(
var select = /** @type {HTMLSelectElement} */ (element);
if ('defaultValue' in next) {
set_default_select_value(select, next.defaultValue, false);
set_default_select_value(select, next.defaultValue);
}
if ('value' in next) {
@ -558,7 +558,7 @@ export function attribute_effect(
var attrs = /** @type {Record<string | symbol, any>} */ (prev);
if ('defaultValue' in attrs) {
set_default_select_value(select, attrs.defaultValue, true);
set_default_select_value(select, attrs.defaultValue);
}
select_option(select, attrs.value, true);

@ -20,33 +20,54 @@ export function set_selected(option, selected) {
}
/**
* Sets the options a form reset should restore without changing the current selection.
* The initial call is allowed to establish the current selection when no value exists.
* Sets the options a form reset should restore. The first call selects
* them if nothing has set a value, later calls leave the current selection alone.
* @param {HTMLSelectElement} select
* @param {any} value
* @param {boolean} [mounting]
*/
export function set_default_select_value(select, value, mounting = !('__defaultValue' in select)) {
// The DOM cannot recover unmatched, object or multiple defaults from selected options.
// Keep the requested value so option mutations can reapply it; property presence also
// distinguishes the initial application from later updates when the value is undefined.
export function set_default_select_value(select, value) {
var mounting = !('__defaultValue' in select);
// @ts-expect-error
if (!mounting && select.__defaultValue === value) return;
// @ts-expect-error
select.__defaultValue = value;
var values = select.multiple ? (value == null ? [] : value) : null;
apply_default_select_value(select, !mounting || '__value' in select);
}
if (select.multiple && !is_array(values)) return;
var selected = !mounting || '__value' in select ? new Set(select.selectedOptions) : null;
/**
* Marks the options matching `__defaultValue` as selected. Without `preserve`
* a newly matching option gets selected, as an inserted `<option selected>` would.
* @param {HTMLSelectElement} select
* @param {boolean} preserve
*/
function apply_default_select_value(select, preserve) {
// @ts-expect-error
var value = select.__defaultValue;
var multiple = select.multiple;
var values = multiple ? value ?? [] : null;
if (multiple && !is_array(values)) return;
var index = select.selectedIndex;
var selected = preserve && multiple ? new Set(select.selectedOptions) : null;
for (var option of select.options) {
var option_value = get_option_value(option);
var is_selected = select.multiple
? /** @type {any[]} */ (values).includes(option_value)
: is(option_value, value);
set_selected(option, is_selected);
set_selected(
option,
multiple ? /** @type {any[]} */ (values).includes(option_value) : is(option_value, value)
);
}
if (!preserve) return;
if (selected !== null) {
for (option of select.options) option.selected = selected.has(option);
for (option of select.options) {
var was_selected = selected.has(option);
if (option.selected !== was_selected) option.selected = was_selected;
}
} else if (select.selectedIndex !== index) {
select.selectedIndex = index;
}
}
@ -91,11 +112,10 @@ export function select_option(select, value, mounting = false) {
}
/**
* Selects the correct option(s) if `value` is given,
* and then sets up a mutation observer to sync the
* current selection to the dom when it changes. Such
* changes could for example occur when options are
* inside an `#each` block.
* Sets up a mutation observer to sync the current selection
* and default to the dom when the options change, for example
* when they are inside an `#each` block. Called once per `<select>`,
* by the compiled output or by `attribute_effect` for spreads.
* @param {HTMLSelectElement} select
*/
export function init_select(select) {
@ -107,7 +127,7 @@ export function init_select(select) {
if (entries.every(is_selectedcontent_mutation)) return;
if ('__defaultValue' in select) {
set_default_select_value(select, select.__defaultValue, false);
apply_default_select_value(select, false);
}
if ('__value' in select) {
@ -204,8 +224,6 @@ export function bind_select_value(select, get, set = get) {
select.__value = value;
mounting = false;
});
init_select(select);
}
/** @param {HTMLOptionElement} option */

@ -62,7 +62,7 @@ export default test({
button('add').click();
flushSync();
await Promise.resolve();
check(selects[3], [true, false]);
check(selects[3], [false, true]);
assert.equal(selects[3].options[1].defaultSelected, true);
reset.click();

@ -0,0 +1,52 @@
import { flushSync } from 'svelte';
import { ok, test } from '../../test';
export default test({
async test({ assert, target }) {
const [nothing, unmatched, spread, late, touched] = target.querySelectorAll('select');
const [change_default, change_spread, load, add] = target.querySelectorAll('button');
const p = target.querySelector('p');
ok(p);
assert.equal(nothing.selectedIndex, -1);
assert.equal(unmatched.selectedIndex, -1);
assert.equal(spread.selectedIndex, -1);
assert.equal(late.selectedIndex, -1);
assert.equal(touched.value, 'b');
change_default.click();
change_spread.click();
flushSync();
assert.equal(nothing.selectedIndex, -1);
assert.equal(unmatched.selectedIndex, -1);
assert.equal(spread.selectedIndex, -1);
assert.equal(spread.className, 'two');
assert.htmlEqual(p.innerHTML, 'zzz null');
assert.deepEqual(
[...nothing.options].map((option) => option.defaultSelected),
[true, false]
);
// a default change never moves the current selection
assert.equal(touched.value, 'b');
// a default whose option arrives later selects it
load.click();
flushSync();
await Promise.resolve();
assert.equal(late.value, 'b');
// a user selection survives option mutations and default changes
touched.options[2].selected = true;
touched.dispatchEvent(new Event('change', { bubbles: true }));
add.click();
flushSync();
await Promise.resolve();
assert.equal(touched.value, 'c');
assert.deepEqual(
[...touched.options].map((option) => option.defaultSelected),
[true, false, false, false]
);
}
});

@ -0,0 +1,37 @@
<script>
let unmatched = $state('zzz');
let nothing = $state(null);
let defaultValue = $state('b');
let props = $state({ defaultValue: 'b', class: 'one' });
let late = $state([]);
let options = $state(['a', 'b', 'c']);
</script>
<select value={null} {defaultValue}>
<option value="a">A</option>
<option value="b">B</option>
</select>
<select bind:value={unmatched} {defaultValue}>
<option value="a">A</option>
<option value="b">B</option>
</select>
<select {...props} bind:value={nothing}>
<option value="a">A</option>
<option value="b">B</option>
</select>
<select defaultValue="b">
{#each late as option}<option value={option}>{option}</option>{/each}
</select>
<select {defaultValue}>
{#each options as option}<option value={option}>{option}</option>{/each}
</select>
<button onclick={() => (defaultValue = 'a')}>change default</button>
<button onclick={() => (props.class = 'two')}>change class</button>
<button onclick={() => (late = ['a', 'b', 'c'])}>load options</button>
<button onclick={() => options.push('d')}>add option</button>
<p>{unmatched} {String(nothing)}</p>

@ -0,0 +1,30 @@
import { test } from '../../test';
let observers = 0;
const MutationObserver = globalThis.MutationObserver;
export default test({
before_test() {
observers = 0;
globalThis.MutationObserver = class extends MutationObserver {
/** @param {MutationCallback} callback */
constructor(callback) {
super(callback);
observers++;
}
};
},
after_test() {
globalThis.MutationObserver = MutationObserver;
},
test({ assert, target }) {
const selects = target.querySelectorAll('select');
for (const select of selects) {
assert.equal(select.selectedIndex, 0);
assert.equal(select.options[1].defaultSelected, true);
}
assert.equal(observers, selects.length);
}
});

@ -0,0 +1,21 @@
<script>
let value = $state('a');
let bound = $state('a');
let spread = $state('a');
let props = { defaultValue: 'b' };
</script>
<select {value} defaultValue="b">
<option value="a">A</option>
<option value="b">B</option>
</select>
<select bind:value={bound} defaultValue="b">
<option value="a">A</option>
<option value="b">B</option>
</select>
<select {...props} bind:value={spread}>
<option value="a">A</option>
<option value="b">B</option>
</select>
Loading…
Cancel
Save