mirror of https://github.com/sveltejs/svelte
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
parent
ee3807ecbe
commit
3dbd95075c
@ -0,0 +1,5 @@
|
||||
---
|
||||
'svelte': patch
|
||||
---
|
||||
|
||||
fix: update `select.__value` on `change`
|
||||
@ -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…
Reference in new issue