fix: keep select.__value current when effect is deferred (#17745)

## Summary

Fixes #17148

When a `<select>` is focused inside an async boundary, the
`bind_select_value` effect gets deferred by the batch system, leaving
`select.__value` stale. If options then change dynamically (e.g. via
`{#each}`), the `MutationObserver` in `init_select` uses the stale
`__value`, snapping the select to the wrong option.

- Update `__value` in the change handler so it's always current, even
when the effect is deferred
- Update `__value` in the effect's early-return path (defensive fix for
when the effect runs but skips the DOM update)

## Test plan

- Added `select-dynamic-options-while-focused` test that renders a
`<select>` with dynamic `{#each}` options inside an async boundary,
selects a non-initial option while focused, adds another option, and
verifies the select retains the user's choice
- Verified existing `async-binding-update-while-focused-3` test still
passes
- All 7151 tests pass (`pnpm test`)

---------

Co-authored-by: Tee Ming <chewteeming01@gmail.com>
Co-authored-by: Rich Harris <rich.harris@vercel.com>
Co-authored-by: Rich Harris <hello@rich-harris.dev>
pull/17926/head
Apoorv Darshan 1 month ago committed by GitHub
parent ee3807ecbe
commit 3dbd95075c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: update `select.__value` on `change`

@ -106,6 +106,9 @@ export function bind_select_value(select, get, set = get) {
set(value);
// @ts-ignore
select.__value = value;
if (current_batch !== null) {
batches.add(current_batch);
}

@ -0,0 +1,54 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
const [add, shift, reset] = target.querySelectorAll('button');
// resolve initial pending state
shift.click();
await tick();
const [p] = target.querySelectorAll('p');
const select = /** @type {HTMLSelectElement} */ (target.querySelector('select'));
assert.equal(select.value, 'a');
// add option 'c', making items ['a', 'b', 'c']
add.click();
await tick();
// select 'b' while focused
select.focus();
select.value = 'b';
select.dispatchEvent(new InputEvent('change', { bubbles: true }));
await tick();
assert.equal(select.value, 'b');
assert.equal(p.textContent, 'a');
// add option 'd', making items ['a', 'b', 'c', 'd']
// this triggers MutationObserver which uses select.__value
add.click();
await tick();
// select should still show 'b', not snap to a stale value
assert.equal(select.value, 'b');
assert.equal(p.textContent, 'a');
shift.click();
await tick();
assert.equal(select.value, 'b');
assert.equal(p.textContent, 'b');
reset.click();
await tick();
assert.equal(select.value, 'b');
assert.equal(p.textContent, 'b');
shift.click();
await tick();
assert.equal(select.value, 'a');
assert.equal(p.textContent, 'a');
}
});

@ -0,0 +1,31 @@
<script lang="ts">
let selected = $state('a');
let items = $state(['a', 'b']);
let resolvers = [];
let select;
function push(value) {
const { promise, resolve } = Promise.withResolvers();
resolvers.push(() => resolve(value));
return promise;
}
</script>
<button onclick={() => items.push(String.fromCharCode(97 + items.length))}>add</button>
<button onclick={() => resolvers.shift()?.()}>shift</button>
<button onclick={() => selected = 'a'}>reset</button>
<svelte:boundary>
<select bind:this={select} bind:value={selected}>
{#each items as item}
<option value={item}>{item}</option>
{/each}
</select>
<p>{await push(selected)}</p>
{#snippet pending()}
<p>loading...</p>
{/snippet}
</svelte:boundary>

@ -0,0 +1,49 @@
import { flushSync, tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target, variant }) {
const [button] = target.querySelectorAll('button');
const [select] = target.querySelectorAll('select');
flushSync(() => {
select.focus();
select.value = '2';
select.dispatchEvent(new InputEvent('change', { bubbles: true }));
});
assert.equal(select.selectedOptions[0].textContent, '2');
assert.htmlEqual(
target.innerHTML,
`
<button>add option</button>
<p>selected: 2</p>
<select>
<option${variant === 'hydrate' ? ' selected=""' : ''}>1</option>
<option>2</option>
<option>3</option>
</select>
`
);
flushSync(() => button.click());
await tick();
assert.equal(select.selectedOptions[0].textContent, '2');
assert.htmlEqual(
target.innerHTML,
`
<button>add option</button>
<p>selected: 2</p>
<select>
<option${variant === 'hydrate' ? ' selected=""' : ''}>1</option>
<option>2</option>
<option>3</option>
<option>4</option>
</select>
`
);
}
});

@ -0,0 +1,16 @@
<script>
let options = $state([1, 2, 3]);
let selected = $state(1);
</script>
<button onclick={() => options.push(options.length + 1)}>
add option
</button>
<p>selected: {selected}</p>
<select bind:value={selected}>
{#each options as o}
<option>{o}</option>
{/each}
</select>
Loading…
Cancel
Save