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