fix: prevent `selectedcontent` mutation from changing the selected option (#18495)

Basically, in a situation like this

```svelte
<script lang="ts">
	let value = $state('A');
</script>

<input oninput={() => {}} />
<select bind:value>
	<button><selectedcontent></selectedcontent></button>
	<option>A</option>
	<option>B</option>
	<option>C</option>
</select>

<style>
	select,::picker(select){
		appearance: base-select;
	}
</style>
```
what happens is that the `oninput` is delegated and so it registers the
global listener (which means it listen on every `oninput` not just the
one from the input). When the select change, the `input` event is
dispatched first, the listener runs, doesn't find an `__input` handler
and returns. Now before the `change` event is emitted, the
`MutationObserver` in `init_select` is triggered by the browser updating
`selectedcontent` and invokes `select_option` with `select.__value`.
However, since the `change` event has yet to fire, `select.__value`
still has the old value, so we "reselect" that. When the change event
runs, the selected option is effectively the old one and the whole thing
breaks.

I had to add the test in `runtime-browser` because JSDom doesn't support
`selectedcontent`
pull/18685/head
Paolo Ricciuti 3 days ago committed by GitHub
parent b2a24b0426
commit 06c9b7929a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: prevent `selectedcontent` mutation from changing the selected option

@ -55,11 +55,14 @@ export function select_option(select, value, mounting = false) {
* @param {HTMLSelectElement} select
*/
export function init_select(select) {
var observer = new MutationObserver(() => {
if ('__value' in select) {
// @ts-ignore
select_option(select, select.__value);
}
var observer = new MutationObserver((entries) => {
// Mutations related to `<selectedcontent>` can never affect the option list.
// Reacting to them could revert a user-initiated selection change, because the
// records are delivered as soon as any listener returns (e.g. a delegated `input`
// handler), which can happen before the `change` handler has updated `__value`
if (entries.every(is_selectedcontent_mutation) || !('__value' in select)) return;
// @ts-ignore
select_option(select, select.__value);
// Deliberately don't update the potential binding value,
// the model should be preserved unless explicitly changed
});
@ -164,3 +167,22 @@ function get_option_value(option) {
return option.value;
}
}
/**
* Returns `true` if the mutation stems from the browser mirroring the selected
* option's content into `<selectedcontent>`, or from us replacing the
* `<selectedcontent>` element with a clone of itself
* @param {MutationRecord} entry
*/
function is_selectedcontent_mutation(entry) {
if (/** @type {Element} */ (entry.target).closest('selectedcontent') !== null) {
return true;
}
if (entry.type === 'childList') {
var nodes = [...entry.addedNodes, ...entry.removedNodes];
return nodes.length > 0 && nodes.every((node) => node.nodeName === 'SELECTEDCONTENT');
}
return false;
}

@ -0,0 +1,22 @@
import { flushSync } from 'svelte';
import { ok, test } from '../../assert';
export default test({
async test({ target, assert }) {
const select = target.querySelector('select');
ok(select);
select.value = 'B';
select.dispatchEvent(new Event('input', { bubbles: true }));
// because another element has a delegated `oninput` handler, a global `input`
// listener runs and, once it returns, a microtask checkpoint delivers the
// mutation records *before* the `change` event — we emulate that checkpoint here
await Promise.resolve();
select.dispatchEvent(new Event('change', { bubbles: true }));
flushSync();
assert.equal(select.value, 'B');
}
});

@ -0,0 +1,17 @@
<script lang="ts">
let value = $state('A');
</script>
<input oninput={() => {}} />
<select bind:value>
<button><selectedcontent></selectedcontent></button>
<option>A</option>
<option>B</option>
<option>C</option>
</select>
<style>
select,::picker(select){
appearance: base-select;
}
</style>
Loading…
Cancel
Save