Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-weeks-camp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

fix: preserve `<select>` state while focused
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { listen_to_event_and_reset_event } from './shared.js';
import { is } from '../../../proxy.js';
import { is_array } from '../../../../shared/utils.js';
import * as w from '../../../warnings.js';
import { Batch, current_batch, previous_batch } from '../../../reactivity/batch.js';

/**
* Selects the correct option(s) (depending on whether this is a multiple select)
Expand Down Expand Up @@ -83,6 +84,7 @@ export function init_select(select) {
* @returns {void}
*/
export function bind_select_value(select, get, set = get) {
var batches = new WeakSet();
var mounting = true;

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

set(value);

if (current_batch !== null) {
batches.add(current_batch);
}
});

// 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
effect(() => {
var value = get();

if (select === document.activeElement) {
// we need both, because in non-async mode, render effects run before previous_batch is set
var batch = /** @type {Batch} */ (previous_batch ?? current_batch);

// Don't update the <select> if it is focused. We can get here if, for example,
// an update is deferred because of async work depending on the select:
//
// <select bind:value={selected}>...</select>
// <p>{await find(selected)}</p>
if (batches.has(batch)) {
return;
}
}

select_option(select, value, mounting);

// Mounting and value undefined -> take selection from dom
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { tick } from 'svelte';
import { test } from '../../test';

export default test({
async test({ assert, target, instance }) {
instance.shift();
async test({ assert, target }) {
const [shift] = target.querySelectorAll('button');
shift.click();
await tick();

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

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

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

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

instance.shift();
shift.click();
await tick();
assert.htmlEqual(target.innerHTML, `<input type="number" /> <p>1</p>`);
assert.htmlEqual(target.innerHTML, `<button>shift</button><input type="number" /> <p>1</p>`);
assert.equal(input.value, '2');

instance.shift();
shift.click();
await tick();
assert.htmlEqual(target.innerHTML, `<input type="number" /> <p>2</p>`);
assert.htmlEqual(target.innerHTML, `<button>shift</button><input type="number" /> <p>2</p>`);
assert.equal(input.value, '2');
}
});
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
<script lang="ts">
let count = $state(0);

let deferreds = [];
let resolvers = [];
let input;

export function shift() {
const d = deferreds.shift();
d.d.resolve(d.v);
}

function push(v) {
const d = Promise.withResolvers();
deferreds.push({ d, v });
return d.promise;
function push(value) {
const { promise, resolve } = Promise.withResolvers();
resolvers.push(() => resolve(value));
return promise;
}
</script>

<button onclick={() => {
input.focus();
resolvers.shift()?.();
}}>shift</button>

<svelte:boundary>
<input type="number" bind:value={count} />
<input bind:this={input} type="number" bind:value={count} />
<p>{await push(count)}</p>

{#snippet pending()}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { tick } from 'svelte';
import { test } from '../../test';

export default test({
async test({ assert, target }) {
const [shift] = target.querySelectorAll('button');
shift.click();
await tick();

const [select] = target.querySelectorAll('select');

select.focus();
select.value = 'three';
select.dispatchEvent(new InputEvent('change', { bubbles: true }));
await tick();

assert.htmlEqual(
target.innerHTML,
`
<button>shift</button>
<select>
<option>one</option>
<option>two</option>
<option>three</option>
</select>
<p>two</p>
`
);
assert.equal(select.value, 'three');

select.focus();
select.value = 'one';
select.dispatchEvent(new InputEvent('change', { bubbles: true }));
await tick();

assert.htmlEqual(
target.innerHTML,
`
<button>shift</button>
<select>
<option>one</option>
<option>two</option>
<option>three</option>
</select>
<p>two</p>
`
);
assert.equal(select.value, 'one');

shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>shift</button>
<select>
<option>one</option>
<option>two</option>
<option>three</option>
</select>
<p>three</p>
`
);
assert.equal(select.value, 'one');

shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>shift</button>
<select>
<option>one</option>
<option>two</option>
<option>three</option>
</select>
<p>one</p>
`
);
assert.equal(select.value, 'one');
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<script lang="ts">
let selected = $state('two');
let resolvers = [];
let select;
function push(value) {
const { promise, resolve } = Promise.withResolvers();
resolvers.push(() => resolve(value));
return promise;
}
</script>

<button onclick={() => {
select.focus();
resolvers.shift()?.();
}}>shift</button>

<svelte:boundary>
<select bind:this={select} bind:value={selected}>
<option>one</option>
<option>two</option>
<option>three</option>
</select>

<p>{await push(selected)}</p>

{#snippet pending()}
<p>loading...</p>
{/snippet}
</svelte:boundary>
Loading