Skip to content

Commit efe85fc

Browse files
trueadmdummdidummbrunnerh
authored
fix: more robust select element logic (#10848)
* follow up to 10846 * lint * simplify * don't update value * rework logic, rely more on mutation observer, fix bug related to select multiple * fix lazy select options bug --------- Co-authored-by: Simon Holthausen <[email protected]> Co-authored-by: Simon H <[email protected]> Co-authored-by: brunnerh <[email protected]>
1 parent f5f9465 commit efe85fc

File tree

10 files changed

+228
-142
lines changed

10 files changed

+228
-142
lines changed

.changeset/smart-turkeys-tell.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"svelte": patch
33
---
44

5-
fix: ensure select value is updated upon select element removal
5+
fix: ensure select value is updated upon select option removal

packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js

Lines changed: 103 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -158,26 +158,6 @@ function serialize_class_directives(class_directives, element_id, context, is_at
158158
}
159159
}
160160

161-
/**
162-
*
163-
* @param {string | null} spread_id
164-
* @param {import('#compiler').RegularElement} node
165-
* @param {import('../types.js').ComponentContext} context
166-
* @param {import('estree').Identifier} node_id
167-
*/
168-
function add_select_to_spread_update(spread_id, node, context, node_id) {
169-
if (spread_id !== null && node.name === 'select') {
170-
context.state.update.push({
171-
grouped: b.if(
172-
b.binary('in', b.literal('value'), b.id(spread_id)),
173-
b.block([
174-
b.stmt(b.call('$.select_option', node_id, b.member(b.id(spread_id), b.id('value'))))
175-
])
176-
)
177-
});
178-
}
179-
}
180-
181161
/**
182162
* @param {import('#compiler').Binding[]} references
183163
* @param {import('../types.js').ComponentContext} context
@@ -223,6 +203,8 @@ function collect_transitive_dependencies(binding, seen = new Set()) {
223203
* @param {import('../types.js').ComponentContext} context
224204
*/
225205
function setup_select_synchronization(value_binding, context) {
206+
if (context.state.analysis.runes) return;
207+
226208
let bound = value_binding.expression;
227209
while (bound.type === 'MemberExpression') {
228210
bound = /** @type {import('estree').Identifier | import('estree').MemberExpression} */ (
@@ -243,59 +225,49 @@ function setup_select_synchronization(value_binding, context) {
243225
}
244226
}
245227

246-
if (!context.state.analysis.runes) {
247-
const invalidator = b.call(
248-
'$.invalidate_inner_signals',
249-
b.thunk(
250-
b.block(
251-
names.map((name) => {
252-
const serialized = serialize_get_binding(b.id(name), context.state);
253-
return b.stmt(serialized);
254-
})
255-
)
228+
const invalidator = b.call(
229+
'$.invalidate_inner_signals',
230+
b.thunk(
231+
b.block(
232+
names.map((name) => {
233+
const serialized = serialize_get_binding(b.id(name), context.state);
234+
return b.stmt(serialized);
235+
})
256236
)
257-
);
237+
)
238+
);
258239

259-
context.state.init.push(
260-
b.stmt(
261-
b.call(
262-
'$.invalidate_effect',
263-
b.thunk(
264-
b.block([
265-
b.stmt(
266-
/** @type {import('estree').Expression} */ (context.visit(value_binding.expression))
267-
),
268-
b.stmt(invalidator)
269-
])
270-
)
240+
context.state.init.push(
241+
b.stmt(
242+
b.call(
243+
'$.invalidate_effect',
244+
b.thunk(
245+
b.block([
246+
b.stmt(
247+
/** @type {import('estree').Expression} */ (context.visit(value_binding.expression))
248+
),
249+
b.stmt(invalidator)
250+
])
271251
)
272252
)
273-
);
274-
}
253+
)
254+
);
275255
}
276256

277257
/**
278-
* Serializes element attribute assignments that contain spreads to either only
279-
* the init or the the init and update arrays, depending on whether or not the value is dynamic.
280-
* Resulting code for static looks something like this:
281-
* ```js
282-
* $.spread_attributes(element, null, [...]);
283-
* ```
284-
* Resulting code for dynamic looks something like this:
285-
* ```js
286-
* let value;
287-
* $.render_effect(() => {
288-
* value = $.spread_attributes(element, value, [...])
289-
* });
290-
* ```
291-
* Returns the id of the spread_attribute variable if spread isn't isolated, `null` otherwise.
292258
* @param {Array<import('#compiler').Attribute | import('#compiler').SpreadAttribute>} attributes
293259
* @param {import('../types.js').ComponentContext} context
294260
* @param {import('#compiler').RegularElement} element
295261
* @param {import('estree').Identifier} element_id
296-
* @returns {string | null}
262+
* @param {boolean} needs_select_handling
297263
*/
298-
function serialize_element_spread_attributes(attributes, context, element, element_id) {
264+
function serialize_element_spread_attributes(
265+
attributes,
266+
context,
267+
element,
268+
element_id,
269+
needs_select_handling
270+
) {
299271
let needs_isolation = false;
300272

301273
/** @type {import('estree').Expression[]} */
@@ -317,8 +289,9 @@ function serialize_element_spread_attributes(attributes, context, element, eleme
317289

318290
const lowercase_attributes =
319291
element.metadata.svg || is_custom_element_node(element) ? b.false : b.true;
292+
const id = context.state.scope.generate('spread_attributes');
320293

321-
const isolated = b.stmt(
294+
const standalone = b.stmt(
322295
b.call(
323296
'$.spread_attributes_effect',
324297
element_id,
@@ -327,32 +300,57 @@ function serialize_element_spread_attributes(attributes, context, element, eleme
327300
b.literal(context.state.analysis.css.hash)
328301
)
329302
);
303+
const inside_effect = b.stmt(
304+
b.assignment(
305+
'=',
306+
b.id(id),
307+
b.call(
308+
'$.spread_attributes',
309+
element_id,
310+
b.id(id),
311+
b.array(values),
312+
lowercase_attributes,
313+
b.literal(context.state.analysis.css.hash)
314+
)
315+
)
316+
);
317+
318+
if (!needs_isolation || needs_select_handling) {
319+
context.state.init.push(b.let(id));
320+
}
330321

331322
// objects could contain reactive getters -> play it safe and always assume spread attributes are reactive
332323
if (needs_isolation) {
333-
context.state.update_effects.push(isolated);
334-
return null;
324+
if (needs_select_handling) {
325+
context.state.update_effects.push(
326+
b.stmt(b.call('$.render_effect', b.arrow([], b.block([inside_effect]))))
327+
);
328+
} else {
329+
context.state.update_effects.push(standalone);
330+
}
335331
} else {
336-
const id = context.state.scope.generate('spread_attributes');
337-
context.state.init.push(b.let(id));
338332
context.state.update.push({
339-
singular: isolated,
340-
grouped: b.stmt(
341-
b.assignment(
342-
'=',
343-
b.id(id),
344-
b.call(
345-
'$.spread_attributes',
346-
element_id,
347-
b.id(id),
348-
b.array(values),
349-
lowercase_attributes,
350-
b.literal(context.state.analysis.css.hash)
351-
)
352-
)
333+
singular: needs_select_handling ? undefined : standalone,
334+
grouped: inside_effect
335+
});
336+
}
337+
338+
if (needs_select_handling) {
339+
context.state.init.push(
340+
b.stmt(b.call('$.init_select', element_id, b.thunk(b.member(b.id(id), b.id('value')))))
341+
);
342+
context.state.update.push({
343+
grouped: b.if(
344+
b.binary('in', b.literal('value'), b.id(id)),
345+
b.block([
346+
// This ensures a one-way street to the DOM in case it's <select {value}>
347+
// and not <select bind:value>. We need it in addition to $.init_select
348+
// because the select value is not reflected as an attribute, so the
349+
// mutation observer wouldn't notice.
350+
b.stmt(b.call('$.select_option', element_id, b.member(b.id(id), b.id('value'))))
351+
])
353352
)
354353
});
355-
return id;
356354
}
357355
}
358356

@@ -644,27 +642,27 @@ function serialize_element_special_value_attribute(element, node_id, attribute,
644642
)
645643
);
646644
const is_reactive = attribute.metadata.dynamic;
647-
const needs_selected_call =
648-
element === 'option' && (is_reactive || collect_parent_each_blocks(context).length > 0);
649-
const needs_option_call = element === 'select' && is_reactive;
645+
const is_select_with_value =
646+
// attribute.metadata.dynamic would give false negatives because even if the value does not change,
647+
// the inner options could still change, so we need to always treat it as reactive
648+
element === 'select' && attribute.value !== true && !is_text_attribute(attribute);
650649
const assignment = b.stmt(
651-
needs_selected_call
650+
is_select_with_value
652651
? b.sequence([
653652
inner_assignment,
654-
// This ensures things stay in sync with the select binding
655-
// in case of updates to the option value or new values appearing
656-
b.call('$.selected', node_id)
653+
// This ensures a one-way street to the DOM in case it's <select {value}>
654+
// and not <select bind:value>. We need it in addition to $.init_select
655+
// because the select value is not reflected as an attribute, so the
656+
// mutation observer wouldn't notice.
657+
b.call('$.select_option', node_id, value)
657658
])
658-
: needs_option_call
659-
? b.sequence([
660-
inner_assignment,
661-
// This ensures a one-way street to the DOM in case it's <select {value}>
662-
// and not <select bind:value>
663-
b.call('$.select_option', node_id, value)
664-
])
665-
: inner_assignment
659+
: inner_assignment
666660
);
667661

662+
if (is_select_with_value) {
663+
state.init.push(b.stmt(b.call('$.init_select', node_id, b.thunk(value))));
664+
}
665+
668666
if (is_reactive) {
669667
const id = state.scope.generate(`${node_id.name}_value`);
670668
serialize_update_assignment(
@@ -2082,11 +2080,15 @@ export const template_visitors = {
20822080
// Then do attributes
20832081
let is_attributes_reactive = false;
20842082
if (node.metadata.has_spread) {
2085-
const spread_id = serialize_element_spread_attributes(attributes, context, node, node_id);
2086-
if (child_metadata.namespace !== 'foreign') {
2087-
add_select_to_spread_update(spread_id, node, context, node_id);
2088-
}
2089-
is_attributes_reactive = spread_id !== null;
2083+
serialize_element_spread_attributes(
2084+
attributes,
2085+
context,
2086+
node,
2087+
node_id,
2088+
// If value binding exists, that one takes care of calling $.init_select
2089+
value_binding === null && node.name === 'select' && child_metadata.namespace !== 'foreign'
2090+
);
2091+
is_attributes_reactive = true;
20902092
} else {
20912093
for (const attribute of /** @type {import('#compiler').Attribute[]} */ (attributes)) {
20922094
if (is_event_attribute(attribute)) {

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

Lines changed: 37 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { effect } from '../../../reactivity/effects.js';
2+
import { untrack } from '../../../runtime.js';
23

34
/**
45
* Selects the correct option(s) (depending on whether this is a multiple select)
@@ -26,26 +27,43 @@ export function select_option(select, value, mounting) {
2627
}
2728

2829
/**
29-
* Finds the containing `<select>` element and potentially updates its `selected` state.
30-
* @param {HTMLOptionElement} option
31-
* @returns {void}
30+
* Selects the correct option(s) if `value` is given,
31+
* and then sets up a mutation observer to sync the
32+
* current selection to the dom when it changes. Such
33+
* changes could for example occur when options are
34+
* inside an `#each` block.
35+
* @template V
36+
* @param {HTMLSelectElement} select
37+
* @param {() => V} [get_value]
3238
*/
33-
export function selected(option) {
34-
// Inside an effect because the element might not be connected
35-
// to the parent <select> yet when this is called
39+
export function init_select(select, get_value) {
3640
effect(() => {
37-
var select = option.parentNode;
38-
39-
while (select != null) {
40-
if (select.nodeName === 'SELECT') break;
41-
select = select.parentNode;
41+
if (get_value) {
42+
select_option(select, untrack(get_value));
4243
}
4344

44-
// @ts-ignore
45-
if (select != null && option.__value === select.__value) {
46-
// never set to false, since this causes browser to select default option
47-
option.selected = true;
48-
}
45+
var observer = new MutationObserver(() => {
46+
// @ts-ignore
47+
var value = select.__value;
48+
select_option(select, value);
49+
// Deliberately don't update the potential binding value,
50+
// the model should be preserved unless explicitly changed
51+
});
52+
53+
observer.observe(select, {
54+
// Listen to option element changes
55+
childList: true,
56+
subtree: true, // because of <optgroup>
57+
// Listen to option element value attribute changes
58+
// (doesn't get notified of select value changes,
59+
// because that property is not reflected as an attribute)
60+
attributes: true,
61+
attributeFilter: ['value']
62+
});
63+
64+
return () => {
65+
observer.disconnect();
66+
};
4967
});
5068
}
5169

@@ -78,6 +96,7 @@ export function bind_select_value(select, get_value, update) {
7896
var value = get_value();
7997
select_option(select, value, mounting);
8098

99+
// Mounting and value undefined -> take selection from dom
81100
if (mounting && value === undefined) {
82101
/** @type {HTMLOptionElement | null} */
83102
var selected_option = select.querySelector(':checked');
@@ -92,27 +111,8 @@ export function bind_select_value(select, get_value, update) {
92111
mounting = false;
93112
});
94113

95-
// If one of the options gets removed from the DOM, the value might have changed
96-
effect(() => {
97-
var observer = new MutationObserver(() => {
98-
// @ts-ignore
99-
var value = select.__value;
100-
select_option(select, value, mounting);
101-
/** @type {HTMLOptionElement | null} */
102-
var selected_option = select.querySelector(':checked');
103-
if (selected_option === null || get_option_value(selected_option) !== value) {
104-
update('');
105-
}
106-
});
107-
108-
observer.observe(select, {
109-
childList: true
110-
});
111-
112-
return () => {
113-
observer.disconnect();
114-
};
115-
});
114+
// don't pass get_value, we already initialize it in the effect above
115+
init_select(select);
116116
}
117117

118118
/**

0 commit comments

Comments
 (0)