Skip to content

Commit 7edba25

Browse files
fix: run event attributes after binding event listeners (#11230)
* fix: run event attributes after binding event listeners By running the event listener logic inside an effect on the first run we guarantee that they're attached after binding listeners. Fixes #11138. * give arrow functions stable id, better code gen * optimise normal function expressions too (rare but valid!) --------- Co-authored-by: Rich Harris <[email protected]>
1 parent 68a12f0 commit 7edba25

File tree

5 files changed

+97
-21
lines changed

5 files changed

+97
-21
lines changed

.changeset/seven-garlics-serve.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: make sure event attributes run after bindings

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

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -263,17 +263,29 @@ function serialize_element_spread_attributes(
263263
) {
264264
let needs_isolation = false;
265265

266-
/** @type {import('estree').Expression[]} */
266+
/** @type {import('estree').ObjectExpression['properties']} */
267267
const values = [];
268268

269269
for (const attribute of attributes) {
270270
if (attribute.type === 'Attribute') {
271271
const name = get_attribute_name(element, attribute, context);
272272
// TODO: handle contains_call_expression
273273
const [, value] = serialize_attribute_value(attribute.value, context);
274-
values.push(b.object([b.init(name, value)]));
274+
275+
if (
276+
is_event_attribute(attribute) &&
277+
(attribute.value[0].expression.type === 'ArrowFunctionExpression' ||
278+
attribute.value[0].expression.type === 'FunctionExpression')
279+
) {
280+
// Give the event handler a stable ID so it isn't removed and readded on every update
281+
const id = context.state.scope.generate('event_handler');
282+
context.state.init.push(b.var(id, value));
283+
values.push(b.init(attribute.name, b.id(id)));
284+
} else {
285+
values.push(b.init(name, value));
286+
}
275287
} else {
276-
values.push(/** @type {import('estree').Expression} */ (context.visit(attribute)));
288+
values.push(b.spread(/** @type {import('estree').Expression} */ (context.visit(attribute))));
277289
}
278290

279291
needs_isolation ||=
@@ -292,7 +304,7 @@ function serialize_element_spread_attributes(
292304
'$.set_attributes',
293305
element_id,
294306
b.id(id),
295-
b.array(values),
307+
b.object(values),
296308
lowercase_attributes,
297309
b.literal(context.state.analysis.css.hash)
298310
)
@@ -350,15 +362,27 @@ function serialize_dynamic_element_attributes(attributes, context, element_id) {
350362
let needs_isolation = false;
351363
let is_reactive = false;
352364

353-
/** @type {import('estree').Expression[]} */
365+
/** @type {import('estree').ObjectExpression['properties']} */
354366
const values = [];
355367

356368
for (const attribute of attributes) {
357369
if (attribute.type === 'Attribute') {
358370
const [, value] = serialize_attribute_value(attribute.value, context);
359-
values.push(b.object([b.init(attribute.name, value)]));
371+
372+
if (
373+
is_event_attribute(attribute) &&
374+
(attribute.value[0].expression.type === 'ArrowFunctionExpression' ||
375+
attribute.value[0].expression.type === 'FunctionExpression')
376+
) {
377+
// Give the event handler a stable ID so it isn't removed and readded on every update
378+
const id = context.state.scope.generate('event_handler');
379+
context.state.init.push(b.var(id, value));
380+
values.push(b.init(attribute.name, b.id(id)));
381+
} else {
382+
values.push(b.init(attribute.name, value));
383+
}
360384
} else {
361-
values.push(/** @type {import('estree').Expression} */ (context.visit(attribute)));
385+
values.push(b.spread(/** @type {import('estree').Expression} */ (context.visit(attribute))));
362386
}
363387

364388
is_reactive ||=
@@ -381,7 +405,7 @@ function serialize_dynamic_element_attributes(attributes, context, element_id) {
381405
'$.set_dynamic_element_attributes',
382406
element_id,
383407
b.id(id),
384-
b.array(values),
408+
b.object(values),
385409
b.literal(context.state.analysis.css.hash)
386410
)
387411
)
@@ -402,7 +426,7 @@ function serialize_dynamic_element_attributes(attributes, context, element_id) {
402426
'$.set_dynamic_element_attributes',
403427
element_id,
404428
b.literal(null),
405-
b.array(values),
429+
b.object(values),
406430
b.literal(context.state.analysis.css.hash)
407431
)
408432
)

packages/svelte/src/internal/client/dom/elements/attributes.js

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { get_descriptors, map_get, map_set, object_assign } from '../../utils.js
44
import { AttributeAliases, DelegatedEvents, namespace_svg } from '../../../../constants.js';
55
import { delegate } from './events.js';
66
import { autofocus } from './misc.js';
7+
import { effect } from '../../reactivity/effects.js';
8+
import { run } from '../../../shared/utils.js';
79

810
/**
911
* The value/checked attribute in the template actually corresponds to the defaultValue property, so we need
@@ -81,14 +83,13 @@ export function set_custom_element_data(node, prop, value) {
8183
/**
8284
* Spreads attributes onto a DOM element, taking into account the currently set attributes
8385
* @param {Element & ElementCSSInlineStyle} element
84-
* @param {Record<string, unknown> | undefined} prev
85-
* @param {Record<string, unknown>[]} attrs
86+
* @param {Record<string, any> | undefined} prev
87+
* @param {Record<string, any>} next New attributes - this function mutates this object
8688
* @param {boolean} lowercase_attributes
8789
* @param {string} css_hash
88-
* @returns {Record<string, unknown>}
90+
* @returns {Record<string, any>}
8991
*/
90-
export function set_attributes(element, prev, attrs, lowercase_attributes, css_hash) {
91-
var next = object_assign({}, ...attrs);
92+
export function set_attributes(element, prev, next, lowercase_attributes, css_hash) {
9293
var has_hash = css_hash.length !== 0;
9394

9495
for (var key in prev) {
@@ -106,6 +107,8 @@ export function set_attributes(element, prev, attrs, lowercase_attributes, css_h
106107

107108
// @ts-expect-error
108109
var attributes = /** @type {Record<string, unknown>} **/ (element.__attributes ??= {});
110+
/** @type {Array<() => void>} */
111+
var events = [];
109112

110113
for (key in next) {
111114
var value = next[key];
@@ -135,7 +138,11 @@ export function set_attributes(element, prev, attrs, lowercase_attributes, css_h
135138

136139
if (value != null) {
137140
if (!delegated) {
138-
element.addEventListener(event_name, value, opts);
141+
if (!prev) {
142+
events.push(() => element.addEventListener(event_name, value, opts));
143+
} else {
144+
element.addEventListener(event_name, value, opts);
145+
}
139146
} else {
140147
// @ts-ignore
141148
element[`__${event_name}`] = value;
@@ -177,19 +184,23 @@ export function set_attributes(element, prev, attrs, lowercase_attributes, css_h
177184
}
178185
}
179186

187+
// On the first run, ensure that events are added after bindings so
188+
// that their listeners fire after the binding listeners
189+
if (!prev) {
190+
effect(() => events.forEach(run));
191+
}
192+
180193
return next;
181194
}
182195

183196
/**
184197
* @param {Element} node
185-
* @param {Record<string, unknown> | undefined} prev
186-
* @param {Record<string, unknown>[]} attrs
198+
* @param {Record<string, any> | undefined} prev
199+
* @param {Record<string, any>} next The new attributes - this function mutates this object
187200
* @param {string} css_hash
188201
*/
189-
export function set_dynamic_element_attributes(node, prev, attrs, css_hash) {
202+
export function set_dynamic_element_attributes(node, prev, next, css_hash) {
190203
if (node.tagName.includes('-')) {
191-
var next = object_assign({}, ...attrs);
192-
193204
for (var key in prev) {
194205
if (!(key in next)) {
195206
next[key] = null;
@@ -206,7 +217,7 @@ export function set_dynamic_element_attributes(node, prev, attrs, css_hash) {
206217
return set_attributes(
207218
/** @type {Element & ElementCSSInlineStyle} */ (node),
208219
prev,
209-
attrs,
220+
next,
210221
node.namespaceURI !== namespace_svg,
211222
css_hash
212223
);
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { test } from '../../test';
2+
3+
export default test({
4+
async test({ assert, target }) {
5+
const [i1, i2] = target.querySelectorAll('input');
6+
7+
i1?.click();
8+
await Promise.resolve();
9+
assert.htmlEqual(
10+
target.innerHTML,
11+
'true true <input type="checkbox"> false false <input type="checkbox">'
12+
);
13+
14+
i2?.click();
15+
await Promise.resolve();
16+
assert.htmlEqual(
17+
target.innerHTML,
18+
'true true <input type="checkbox"> true true <input type="checkbox">'
19+
);
20+
}
21+
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<script>
2+
let checked_simple = $state(false);
3+
let checked_simple_copy = $state(false);
4+
5+
let checked_rest = $state(false);
6+
let checked_rest_copy = $state(false);
7+
let rest = $state(() => ({}));
8+
</script>
9+
10+
{checked_simple} {checked_simple_copy}
11+
<input type="checkbox" onchange={() => {checked_simple_copy = checked_simple}} bind:checked={checked_simple} />
12+
13+
{checked_rest} {checked_rest_copy}
14+
<!-- {...rest()} in order to force an isolated render effect -->
15+
<input type="checkbox" onchange={() => {checked_rest_copy = checked_rest}} {...rest()} bind:checked={checked_rest} />

0 commit comments

Comments
 (0)