Skip to content

Commit c51dfcf

Browse files
authored
feat: warn on invalid event handlers (#12818)
* feat: warn on invalid event handlers * handle assignments etc * handle component events too where possible * lint
1 parent c2fb1a6 commit c51dfcf

File tree

12 files changed

+200
-18
lines changed

12 files changed

+200
-18
lines changed

.changeset/brown-months-flow.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+
feat: warn on invalid event handlers

packages/svelte/messages/client-warnings/warnings.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
55
> `%binding%` (%location%) is binding to a non-reactive property
66
7+
## event_handler_invalid
8+
9+
> %handler% should be a function. Did you mean to %suggestion%?
10+
711
## hydration_attribute_changed
812

913
> The `%attribute%` attribute on `%html%` changed its value between server and client renders. The client value, `%value%`, will be ignored in favour of the server value

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

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
/** @import { Expression } from 'estree' */
2-
/** @import { Attribute, ExpressionMetadata, ExpressionTag, OnDirective, SvelteNode } from '#compiler' */
2+
/** @import { Attribute, ExpressionMetadata, ExpressionTag, SvelteNode } from '#compiler' */
33
/** @import { ComponentContext } from '../../types' */
4-
import { is_capture_event, is_passive_event } from '../../../../../../utils.js';
4+
import { is_capture_event } from '../../../../../../utils.js';
5+
import { dev, locator } from '../../../../../state.js';
56
import * as b from '../../../../../utils/builders.js';
67

78
/**
@@ -136,9 +137,47 @@ export function build_event_handler(node, metadata, context) {
136137
}
137138

138139
// wrap the handler in a function, so the expression is re-evaluated for each event
139-
return b.function(
140-
null,
141-
[b.rest(b.id('$$args'))],
142-
b.block([b.stmt(b.call(b.member(handler, b.id('apply'), false, true), b.this, b.id('$$args')))])
143-
);
140+
let call = b.call(b.member(handler, b.id('apply'), false, true), b.this, b.id('$$args'));
141+
142+
if (dev) {
143+
const loc = locator(/** @type {number} */ (node.start));
144+
145+
const remove_parens =
146+
node.type === 'CallExpression' &&
147+
node.arguments.length === 0 &&
148+
node.callee.type === 'Identifier';
149+
150+
call = b.call(
151+
'$.apply',
152+
b.thunk(handler),
153+
b.this,
154+
b.id('$$args'),
155+
b.id(context.state.analysis.name),
156+
loc && b.array([b.literal(loc.line), b.literal(loc.column)]),
157+
has_side_effects(node) && b.true,
158+
remove_parens && b.true
159+
);
160+
}
161+
162+
return b.function(null, [b.rest(b.id('$$args'))], b.block([b.stmt(call)]));
163+
}
164+
165+
/**
166+
* @param {Expression} node
167+
*/
168+
function has_side_effects(node) {
169+
if (
170+
node.type === 'CallExpression' ||
171+
node.type === 'NewExpression' ||
172+
node.type === 'AssignmentExpression' ||
173+
node.type === 'UpdateExpression'
174+
) {
175+
return true;
176+
}
177+
178+
if (node.type === 'SequenceExpression') {
179+
return node.expressions.some(has_side_effects);
180+
}
181+
182+
return false;
144183
}

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
/** @import { Location } from 'locate-character' */
12
import { teardown } from '../../reactivity/effects.js';
23
import { define_property, is_array } from '../../../shared/utils.js';
34
import { hydrating } from '../hydration.js';
45
import { queue_micro_task } from '../task.js';
6+
import { dev_current_component_function } from '../../runtime.js';
7+
import { FILENAME } from '../../../../constants.js';
8+
import * as w from '../../warnings.js';
59

610
/** @type {Set<string>} */
711
export const all_registered_events = new Set();
@@ -273,3 +277,53 @@ export function handle_event_propagation(event) {
273277
current_target = handler_element;
274278
}
275279
}
280+
281+
/**
282+
* In dev, warn if an event handler is not a function, as it means the
283+
* user probably called the handler or forgot to add a `() =>`
284+
* @param {() => (event: Event, ...args: any) => void} thunk
285+
* @param {EventTarget} element
286+
* @param {[Event, ...any]} args
287+
* @param {any} component
288+
* @param {[number, number]} [loc]
289+
* @param {boolean} [remove_parens]
290+
*/
291+
export function apply(
292+
thunk,
293+
element,
294+
args,
295+
component,
296+
loc,
297+
has_side_effects = false,
298+
remove_parens = false
299+
) {
300+
let handler;
301+
let error;
302+
303+
try {
304+
handler = thunk();
305+
} catch (e) {
306+
error = e;
307+
}
308+
309+
if (typeof handler === 'function') {
310+
handler.apply(element, args);
311+
} else if (has_side_effects || handler != null) {
312+
const filename = component?.[FILENAME];
313+
const location = filename
314+
? loc
315+
? ` at ${filename}:${loc[0]}:${loc[1]}`
316+
: ` in ${filename}`
317+
: '';
318+
319+
const event_name = args[0].type;
320+
const description = `\`${event_name}\` handler${location}`;
321+
const suggestion = remove_parens ? 'remove the trailing `()`' : 'add a leading `() =>`';
322+
323+
w.event_handler_invalid(description, suggestion);
324+
325+
if (error) {
326+
throw error;
327+
}
328+
}
329+
}

packages/svelte/src/internal/client/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export {
3636
set_checked
3737
} from './dom/elements/attributes.js';
3838
export { set_class, set_svg_class, set_mathml_class, toggle_class } from './dom/elements/class.js';
39-
export { event, delegate, replay_events } from './dom/elements/events.js';
39+
export { apply, event, delegate, replay_events } from './dom/elements/events.js';
4040
export { autofocus, remove_textarea_child } from './dom/elements/misc.js';
4141
export { set_style } from './dom/elements/style.js';
4242
export { animation, transition } from './dom/elements/transitions.js';

packages/svelte/src/internal/client/reactivity/deriveds.js

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -91,19 +91,24 @@ let stack = [];
9191
* @returns {void}
9292
*/
9393
export function update_derived(derived) {
94-
if (DEV) {
95-
if (stack.includes(derived)) {
96-
e.derived_references_self();
97-
}
94+
var value;
9895

99-
stack.push(derived);
100-
}
96+
if (DEV) {
97+
try {
98+
if (stack.includes(derived)) {
99+
e.derived_references_self();
100+
}
101101

102-
destroy_derived_children(derived);
103-
var value = update_reaction(derived);
102+
stack.push(derived);
104103

105-
if (DEV) {
106-
stack.pop();
104+
destroy_derived_children(derived);
105+
value = update_reaction(derived);
106+
} finally {
107+
stack.pop();
108+
}
109+
} else {
110+
destroy_derived_children(derived);
111+
value = update_reaction(derived);
107112
}
108113

109114
var status =

packages/svelte/src/internal/client/warnings.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,20 @@ export function binding_property_non_reactive(binding, location) {
1919
}
2020
}
2121

22+
/**
23+
* %handler% should be a function. Did you mean to %suggestion%?
24+
* @param {string} handler
25+
* @param {string} suggestion
26+
*/
27+
export function event_handler_invalid(handler, suggestion) {
28+
if (DEV) {
29+
console.warn(`%c[svelte] event_handler_invalid\n%c${handler} should be a function. Did you mean to ${suggestion}?`, bold, normal);
30+
} else {
31+
// TODO print a link to the documentation
32+
console.warn("event_handler_invalid");
33+
}
34+
}
35+
2236
/**
2337
* The `%attribute%` attribute on `%html%` changed its value between server and client renders. The client value, `%value%`, will be ignored in favour of the server value
2438
* @param {string} attribute
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<script>
2+
let { children, onclick } = $props();
3+
</script>
4+
5+
<button {onclick}>
6+
{@render children()}
7+
</button>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { test } from '../../test';
2+
3+
export default test({
4+
mode: ['client'],
5+
6+
compileOptions: {
7+
dev: true
8+
},
9+
10+
test({ assert, target, warnings }) {
11+
target.querySelector('button')?.click();
12+
13+
assert.deepEqual(warnings, [
14+
'`click` handler at Button.svelte:5:9 should be a function. Did you mean to add a leading `() =>`?'
15+
]);
16+
}
17+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<script>
2+
import Button from './Button.svelte';
3+
4+
let count = $state(0);
5+
</script>
6+
7+
<Button onclick={count++}>
8+
clicks: {count}
9+
</Button>

0 commit comments

Comments
 (0)