Skip to content

Commit ee093e4

Browse files
authored
fix: preserve <select> state while focused (#16958)
1 parent 9b5fb3f commit ee093e4

File tree

6 files changed

+160
-19
lines changed

6 files changed

+160
-19
lines changed

.changeset/quiet-weeks-camp.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': patch
3+
---
4+
5+
fix: preserve `<select>` state while focused

packages/svelte/src/internal/client/dom/elements/bindings/select.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { listen_to_event_and_reset_event } from './shared.js';
33
import { is } from '../../../proxy.js';
44
import { is_array } from '../../../../shared/utils.js';
55
import * as w from '../../../warnings.js';
6+
import { Batch, current_batch, previous_batch } from '../../../reactivity/batch.js';
67

78
/**
89
* Selects the correct option(s) (depending on whether this is a multiple select)
@@ -83,6 +84,7 @@ export function init_select(select) {
8384
* @returns {void}
8485
*/
8586
export function bind_select_value(select, get, set = get) {
87+
var batches = new WeakSet();
8688
var mounting = true;
8789

8890
listen_to_event_and_reset_event(select, 'change', (is_reset) => {
@@ -102,11 +104,30 @@ export function bind_select_value(select, get, set = get) {
102104
}
103105

104106
set(value);
107+
108+
if (current_batch !== null) {
109+
batches.add(current_batch);
110+
}
105111
});
106112

107113
// 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
108114
effect(() => {
109115
var value = get();
116+
117+
if (select === document.activeElement) {
118+
// we need both, because in non-async mode, render effects run before previous_batch is set
119+
var batch = /** @type {Batch} */ (previous_batch ?? current_batch);
120+
121+
// Don't update the <select> if it is focused. We can get here if, for example,
122+
// an update is deferred because of async work depending on the select:
123+
//
124+
// <select bind:value={selected}>...</select>
125+
// <p>{await find(selected)}</p>
126+
if (batches.has(batch)) {
127+
return;
128+
}
129+
}
130+
110131
select_option(select, value, mounting);
111132

112133
// Mounting and value undefined -> take selection from dom

packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-2/_config.js

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import { tick } from 'svelte';
22
import { test } from '../../test';
33

44
export default test({
5-
async test({ assert, target, instance }) {
6-
instance.shift();
5+
async test({ assert, target }) {
6+
const [shift] = target.querySelectorAll('button');
7+
shift.click();
78
await tick();
89

910
const [input] = target.querySelectorAll('input');
@@ -13,25 +14,25 @@ export default test({
1314
input.dispatchEvent(new InputEvent('input', { bubbles: true }));
1415
await tick();
1516

16-
assert.htmlEqual(target.innerHTML, `<input type="number" /> <p>0</p>`);
17+
assert.htmlEqual(target.innerHTML, `<button>shift</button><input type="number" /> <p>0</p>`);
1718
assert.equal(input.value, '1');
1819

1920
input.focus();
2021
input.value = '2';
2122
input.dispatchEvent(new InputEvent('input', { bubbles: true }));
2223
await tick();
2324

24-
assert.htmlEqual(target.innerHTML, `<input type="number" /> <p>0</p>`);
25+
assert.htmlEqual(target.innerHTML, `<button>shift</button><input type="number" /> <p>0</p>`);
2526
assert.equal(input.value, '2');
2627

27-
instance.shift();
28+
shift.click();
2829
await tick();
29-
assert.htmlEqual(target.innerHTML, `<input type="number" /> <p>1</p>`);
30+
assert.htmlEqual(target.innerHTML, `<button>shift</button><input type="number" /> <p>1</p>`);
3031
assert.equal(input.value, '2');
3132

32-
instance.shift();
33+
shift.click();
3334
await tick();
34-
assert.htmlEqual(target.innerHTML, `<input type="number" /> <p>2</p>`);
35+
assert.htmlEqual(target.innerHTML, `<button>shift</button><input type="number" /> <p>2</p>`);
3536
assert.equal(input.value, '2');
3637
}
3738
});

packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-2/main.svelte

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,23 @@
11
<script lang="ts">
22
let count = $state(0);
33
4-
let deferreds = [];
4+
let resolvers = [];
5+
let input;
56
6-
export function shift() {
7-
const d = deferreds.shift();
8-
d.d.resolve(d.v);
9-
}
10-
11-
function push(v) {
12-
const d = Promise.withResolvers();
13-
deferreds.push({ d, v });
14-
return d.promise;
7+
function push(value) {
8+
const { promise, resolve } = Promise.withResolvers();
9+
resolvers.push(() => resolve(value));
10+
return promise;
1511
}
1612
</script>
1713

14+
<button onclick={() => {
15+
input.focus();
16+
resolvers.shift()?.();
17+
}}>shift</button>
18+
1819
<svelte:boundary>
19-
<input type="number" bind:value={count} />
20+
<input bind:this={input} type="number" bind:value={count} />
2021
<p>{await push(count)}</p>
2122

2223
{#snippet pending()}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { tick } from 'svelte';
2+
import { test } from '../../test';
3+
4+
export default test({
5+
async test({ assert, target }) {
6+
const [shift] = target.querySelectorAll('button');
7+
shift.click();
8+
await tick();
9+
10+
const [select] = target.querySelectorAll('select');
11+
12+
select.focus();
13+
select.value = 'three';
14+
select.dispatchEvent(new InputEvent('change', { bubbles: true }));
15+
await tick();
16+
17+
assert.htmlEqual(
18+
target.innerHTML,
19+
`
20+
<button>shift</button>
21+
<select>
22+
<option>one</option>
23+
<option>two</option>
24+
<option>three</option>
25+
</select>
26+
<p>two</p>
27+
`
28+
);
29+
assert.equal(select.value, 'three');
30+
31+
select.focus();
32+
select.value = 'one';
33+
select.dispatchEvent(new InputEvent('change', { bubbles: true }));
34+
await tick();
35+
36+
assert.htmlEqual(
37+
target.innerHTML,
38+
`
39+
<button>shift</button>
40+
<select>
41+
<option>one</option>
42+
<option>two</option>
43+
<option>three</option>
44+
</select>
45+
<p>two</p>
46+
`
47+
);
48+
assert.equal(select.value, 'one');
49+
50+
shift.click();
51+
await tick();
52+
assert.htmlEqual(
53+
target.innerHTML,
54+
`
55+
<button>shift</button>
56+
<select>
57+
<option>one</option>
58+
<option>two</option>
59+
<option>three</option>
60+
</select>
61+
<p>three</p>
62+
`
63+
);
64+
assert.equal(select.value, 'one');
65+
66+
shift.click();
67+
await tick();
68+
assert.htmlEqual(
69+
target.innerHTML,
70+
`
71+
<button>shift</button>
72+
<select>
73+
<option>one</option>
74+
<option>two</option>
75+
<option>three</option>
76+
</select>
77+
<p>one</p>
78+
`
79+
);
80+
assert.equal(select.value, 'one');
81+
}
82+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<script lang="ts">
2+
let selected = $state('two');
3+
4+
let resolvers = [];
5+
let select;
6+
7+
function push(value) {
8+
const { promise, resolve } = Promise.withResolvers();
9+
resolvers.push(() => resolve(value));
10+
return promise;
11+
}
12+
</script>
13+
14+
<button onclick={() => {
15+
select.focus();
16+
resolvers.shift()?.();
17+
}}>shift</button>
18+
19+
<svelte:boundary>
20+
<select bind:this={select} bind:value={selected}>
21+
<option>one</option>
22+
<option>two</option>
23+
<option>three</option>
24+
</select>
25+
26+
<p>{await push(selected)}</p>
27+
28+
{#snippet pending()}
29+
<p>loading...</p>
30+
{/snippet}
31+
</svelte:boundary>

0 commit comments

Comments
 (0)