Skip to content

Commit ce5d466

Browse files
committed
Fix select binding when matching enabled option has null value
Resolves #9545
1 parent e7e8b82 commit ce5d466

File tree

5 files changed

+73
-57
lines changed

5 files changed

+73
-57
lines changed

packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -567,7 +567,11 @@ function serialize_element_special_value_attribute(element, node_id, attribute,
567567
const inner_assignment = b.assignment(
568568
'=',
569569
b.member(node_id, b.id('value')),
570-
b.assignment('=', b.member(node_id, b.id('__value')), value)
570+
b.conditional(
571+
b.binary('==', b.literal(null), b.assignment('=', b.member(node_id, b.id('__value')), value)),
572+
b.literal(''), // render null/undefined values as empty string to support placeholder options
573+
value
574+
)
571575
);
572576
const is_reactive = attribute.metadata.dynamic;
573577
const needs_selected_call =
@@ -582,9 +586,12 @@ function serialize_element_special_value_attribute(element, node_id, attribute,
582586
b.call('$.selected', node_id)
583587
])
584588
: needs_option_call
585-
? // This ensures a one-way street to the DOM in case it's <select {value}>
586-
// and not <select bind:value>
587-
b.call('$.select_option', node_id, inner_assignment)
589+
? b.sequence([
590+
inner_assignment,
591+
// This ensures a one-way street to the DOM in case it's <select {value}>
592+
// and not <select bind:value>
593+
b.call('$.select_option', node_id, value)
594+
])
588595
: inner_assignment
589596
);
590597

packages/svelte/src/internal/client/render.js

Lines changed: 22 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -444,20 +444,22 @@ export function class_toggle(dom, class_name, value) {
444444
* @template V
445445
* @param {HTMLSelectElement} select
446446
* @param {V} value
447+
* @param {boolean} [mounting]
447448
*/
448-
export function select_option(select, value) {
449+
export function select_option(select, value, mounting) {
449450
if (select.multiple) {
450451
return select_options(select, value);
451452
}
452-
for (let i = 0; i < select.options.length; i += 1) {
453-
const option = select.options[i];
453+
for (const option of select.options) {
454454
const option_value = get_option_value(option);
455455
if (option_value === value) {
456456
option.selected = true;
457457
return;
458458
}
459459
}
460-
select.value = '';
460+
if (!mounting || value !== undefined) {
461+
select.value = '';
462+
}
461463
}
462464

463465
/**
@@ -466,8 +468,7 @@ export function select_option(select, value) {
466468
* @param {V} value
467469
*/
468470
function select_options(select, value) {
469-
for (let i = 0; i < select.options.length; i += 1) {
470-
const option = select.options[i];
471+
for (const option of select.options) {
471472
// @ts-ignore
472473
option.selected = ~value.indexOf(get_option_value(option));
473474
}
@@ -897,20 +898,10 @@ export function selected(dom) {
897898
}
898899
select = select.parentNode;
899900
}
900-
if (select != null) {
901-
// @ts-ignore
902-
const select_value = select.__value;
903-
// @ts-ignore
904-
const option_value = dom.__value;
905-
const selected = select_value === option_value;
906-
dom.selected = selected;
907-
dom.value = option_value;
908-
// Handle the edge case of new options being added to a select when its state is "nothing selected"
909-
// and keeping the selection state in sync (the DOM auto-selects the first option on insert)
910-
// @ts-ignore
911-
if (select.__value === null) {
912-
/** @type {HTMLSelectElement} */ (select).value = '';
913-
}
901+
// @ts-ignore
902+
if (select != null && dom.__value === select.__value) {
903+
// never set to false, since this causes browser to select default option
904+
dom.selected = true;
914905
}
915906
});
916907
}
@@ -949,7 +940,7 @@ export function bind_value(dom, get_value, update) {
949940
* @returns {void}
950941
*/
951942
export function bind_select_value(dom, get_value, update) {
952-
let mounted = false;
943+
let mounting = true;
953944
dom.addEventListener('change', () => {
954945
/** @type {unknown} */
955946
let value;
@@ -964,40 +955,19 @@ export function bind_select_value(dom, get_value, update) {
964955
});
965956
// Needs to be an effect, not a render_effect, so that in case of each loops the logic runs after the each block has updated
966957
effect(() => {
967-
const value = get_value();
968-
if (value == null && !mounted) {
958+
let value = get_value();
959+
select_option(dom, value, mounting);
960+
if (mounting && value === undefined) {
969961
/** @type {HTMLOptionElement | null} */
970-
let selected_option = value === undefined ? dom.querySelector(':checked') : null;
971-
if (selected_option === null) {
972-
dom.value = '';
973-
// @ts-ignore
974-
dom.__value = null;
962+
let selected_option = dom.querySelector(':checked');
963+
if (selected_option !== null) {
964+
value = get_option_value(selected_option);
965+
update(value);
975966
}
976-
const options = dom.querySelectorAll('option');
977-
for (const option of options) {
978-
if (get_option_value(option) === value || option.hasAttribute('selected')) {
979-
if (option.disabled) {
980-
option.value = '';
981-
}
982-
option.selected = true;
983-
selected_option = option;
984-
break;
985-
}
986-
}
987-
if (selected_option != null) {
988-
const non_null_value = get_option_value(selected_option);
989-
update(non_null_value);
990-
if (selected_option.hasAttribute('selected')) {
991-
selected_option.removeAttribute('selected');
992-
selected_option.selected = true;
993-
}
994-
}
995-
} else {
996-
select_option(dom, value);
997-
// @ts-ignore
998-
dom.__value = value;
999967
}
1000-
mounted = true;
968+
// @ts-ignore
969+
dom.__value = value;
970+
mounting = false;
1001971
});
1002972
}
1003973

packages/svelte/tests/runtime-legacy/samples/binding-select-initial-value-undefined-2/_config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export default test({
88
99
<select>
1010
<option>a</option>
11-
<option>b</option>
11+
<option selected="">b</option>
1212
<option>c</option>
1313
</select>
1414
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { ok, test } from '../../test';
2+
3+
const items = [{ id: 'a' }, { id: 'b' }];
4+
5+
export default test({
6+
get props() {
7+
return {
8+
/** @type {{ id: string } | null} */
9+
foo: null,
10+
items
11+
};
12+
},
13+
14+
test({ assert, component, target }) {
15+
const select = target.querySelector('select');
16+
ok(select);
17+
18+
const options = target.querySelectorAll('option');
19+
20+
assert.equal(options[0].selected, true);
21+
assert.equal(options[1].selected, false);
22+
assert.equal(options[0].value, '');
23+
24+
component.foo = items[0];
25+
assert.equal(options[0].selected, false);
26+
assert.equal(options[1].selected, true);
27+
}
28+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<script>
2+
export let foo;
3+
export let items;
4+
</script>
5+
6+
<select bind:value={foo}>
7+
<option value={null}></option>
8+
{#each items as item}
9+
<option value={item}>{item.id}</option>
10+
{/each}
11+
</select>

0 commit comments

Comments
 (0)