mirror of https://github.com/sveltejs/svelte
fix: better event handling (#12722)
* simplify * fix/simplify * fix/simplify * start getting a grip of this mess * tidy up * more * more * more * tidy up * make things a bit less weird * tweak * more * more * add once once * consolidate event handling code * some progress. man, this stuff is entangled * more * tidy up * simplify * simplify * more * fix * fix test names * fix a bug * tidy up * changeset * simplify * regenerate * tidy up * tidy up * tidy up * simplify * the module declaration case is already accounted for, above * simplify/document * typo * "hoistable" is a misnomer * hoist non_hoistable, rename * more typos * tweak * regeneratepull/12741/head
parent
e78cfd393e
commit
59ea0b9e13
@ -0,0 +1,5 @@
|
||||
---
|
||||
'svelte': patch
|
||||
---
|
||||
|
||||
fix: avoid recreating handlers for component events
|
@ -0,0 +1,5 @@
|
||||
---
|
||||
'svelte': patch
|
||||
---
|
||||
|
||||
fix: call correct event handler for properties of non-reactive objects
|
@ -1,11 +1,38 @@
|
||||
/** @import { OnDirective } from '#compiler' */
|
||||
/** @import { OnDirective, SvelteNode } from '#compiler' */
|
||||
/** @import { ComponentContext } from '../types' */
|
||||
import { build_event } from './shared/element.js';
|
||||
import * as b from '../../../../utils/builders.js';
|
||||
import { build_event, build_event_handler } from './shared/events.js';
|
||||
|
||||
const modifiers = [
|
||||
'stopPropagation',
|
||||
'stopImmediatePropagation',
|
||||
'preventDefault',
|
||||
'self',
|
||||
'trusted',
|
||||
'once'
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {OnDirective} node
|
||||
* @param {ComponentContext} context
|
||||
*/
|
||||
export function OnDirective(node, context) {
|
||||
build_event(node, node.metadata.expression, context);
|
||||
if (!node.expression) {
|
||||
context.state.analysis.needs_props = true;
|
||||
}
|
||||
|
||||
let handler = build_event_handler(node.expression, node.metadata.expression, context);
|
||||
|
||||
for (const modifier of modifiers) {
|
||||
if (node.modifiers.includes(modifier)) {
|
||||
handler = b.call('$.' + modifier, handler);
|
||||
}
|
||||
}
|
||||
|
||||
const capture = node.modifiers.includes('capture');
|
||||
const passive =
|
||||
node.modifiers.includes('passive') ||
|
||||
(node.modifiers.includes('nonpassive') ? false : undefined);
|
||||
|
||||
return build_event(node.name, context.state.node, handler, capture, passive);
|
||||
}
|
||||
|
@ -1,14 +1,11 @@
|
||||
/** @import { SvelteBody } from '#compiler' */
|
||||
/** @import { ComponentContext } from '../types' */
|
||||
import * as b from '../../../../utils/builders.js';
|
||||
import { visit_special_element } from './shared/special_element.js';
|
||||
|
||||
/**
|
||||
* @param {SvelteBody} node
|
||||
* @param {ComponentContext} context
|
||||
*/
|
||||
export function SvelteBody(node, context) {
|
||||
context.next({
|
||||
...context.state,
|
||||
node: b.id('$.document.body')
|
||||
});
|
||||
visit_special_element(node, '$.document.body', context);
|
||||
}
|
||||
|
@ -1,14 +1,11 @@
|
||||
/** @import { SvelteDocument } from '#compiler' */
|
||||
/** @import { ComponentContext } from '../types' */
|
||||
import * as b from '../../../../utils/builders.js';
|
||||
import { visit_special_element } from './shared/special_element.js';
|
||||
|
||||
/**
|
||||
* @param {SvelteDocument} node
|
||||
* @param {ComponentContext} context
|
||||
*/
|
||||
export function SvelteDocument(node, context) {
|
||||
context.next({
|
||||
...context.state,
|
||||
node: b.id('$.document')
|
||||
});
|
||||
visit_special_element(node, '$.document', context);
|
||||
}
|
||||
|
@ -1,14 +1,12 @@
|
||||
/** @import { Expression } from 'estree' */
|
||||
/** @import { SvelteWindow } from '#compiler' */
|
||||
/** @import { ComponentContext } from '../types' */
|
||||
import * as b from '../../../../utils/builders.js';
|
||||
import { visit_special_element } from './shared/special_element.js';
|
||||
|
||||
/**
|
||||
* @param {SvelteWindow} node
|
||||
* @param {ComponentContext} context
|
||||
*/
|
||||
export function SvelteWindow(node, context) {
|
||||
context.next({
|
||||
...context.state,
|
||||
node: b.id('$.window')
|
||||
});
|
||||
visit_special_element(node, '$.window', context);
|
||||
}
|
||||
|
@ -0,0 +1,144 @@
|
||||
/** @import { Expression } from 'estree' */
|
||||
/** @import { Attribute, ExpressionMetadata, ExpressionTag, OnDirective, SvelteNode } from '#compiler' */
|
||||
/** @import { ComponentContext } from '../../types' */
|
||||
import { is_capture_event, is_passive_event } from '../../../../../../utils.js';
|
||||
import * as b from '../../../../../utils/builders.js';
|
||||
|
||||
/**
|
||||
* @param {Attribute} node
|
||||
* @param {ComponentContext} context
|
||||
*/
|
||||
export function visit_event_attribute(node, context) {
|
||||
let capture = false;
|
||||
|
||||
let event_name = node.name.slice(2);
|
||||
if (is_capture_event(event_name)) {
|
||||
event_name = event_name.slice(0, -7);
|
||||
capture = true;
|
||||
}
|
||||
|
||||
// we still need to support the weird `onclick="{() => {...}}" form
|
||||
const tag = Array.isArray(node.value)
|
||||
? /** @type {ExpressionTag} */ (node.value[0])
|
||||
: /** @type {ExpressionTag} */ (node.value);
|
||||
|
||||
let handler = build_event_handler(tag.expression, tag.metadata.expression, context);
|
||||
|
||||
if (node.metadata.delegated) {
|
||||
let delegated_assignment;
|
||||
|
||||
if (!context.state.events.has(event_name)) {
|
||||
context.state.events.add(event_name);
|
||||
}
|
||||
|
||||
// Hoist function if we can, otherwise we leave the function as is
|
||||
if (node.metadata.delegated.hoisted) {
|
||||
if (node.metadata.delegated.function === tag.expression) {
|
||||
const func_name = context.state.scope.root.unique('on_' + event_name);
|
||||
context.state.hoisted.push(b.var(func_name, handler));
|
||||
handler = func_name;
|
||||
}
|
||||
|
||||
const hoisted_params = /** @type {Expression[]} */ (
|
||||
node.metadata.delegated.function.metadata.hoisted_params
|
||||
);
|
||||
|
||||
// When we hoist a function we assign an array with the function and all
|
||||
// hoisted closure params.
|
||||
const args = [handler, ...hoisted_params];
|
||||
delegated_assignment = b.array(args);
|
||||
} else {
|
||||
delegated_assignment = handler;
|
||||
}
|
||||
|
||||
context.state.init.push(
|
||||
b.stmt(
|
||||
b.assignment(
|
||||
'=',
|
||||
b.member(context.state.node, b.id('__' + event_name)),
|
||||
delegated_assignment
|
||||
)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
const statement = b.stmt(
|
||||
build_event(event_name, context.state.node, handler, capture, undefined)
|
||||
);
|
||||
|
||||
const type = /** @type {SvelteNode} */ (context.path.at(-1)).type;
|
||||
|
||||
if (type === 'SvelteDocument' || type === 'SvelteWindow' || type === 'SvelteBody') {
|
||||
// These nodes are above the component tree, and its events should run parent first
|
||||
context.state.init.push(statement);
|
||||
} else {
|
||||
context.state.after_update.push(statement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a `$.event(...)` call for non-delegated event handlers
|
||||
* @param {string} event_name
|
||||
* @param {Expression} node
|
||||
* @param {Expression} handler
|
||||
* @param {boolean} capture
|
||||
* @param {boolean | undefined} passive
|
||||
*/
|
||||
export function build_event(event_name, node, handler, capture, passive) {
|
||||
return b.call(
|
||||
'$.event',
|
||||
b.literal(event_name),
|
||||
node,
|
||||
handler,
|
||||
capture && b.true,
|
||||
passive === undefined ? undefined : b.literal(passive)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event handler
|
||||
* @param {Expression | null} node
|
||||
* @param {ExpressionMetadata} metadata
|
||||
* @param {ComponentContext} context
|
||||
* @returns {Expression}
|
||||
*/
|
||||
export function build_event_handler(node, metadata, context) {
|
||||
if (node === null) {
|
||||
// bubble event
|
||||
return b.function(
|
||||
null,
|
||||
[b.id('$$arg')],
|
||||
b.block([b.stmt(b.call('$.bubble_event.call', b.this, b.id('$$props'), b.id('$$arg')))])
|
||||
);
|
||||
}
|
||||
|
||||
let handler = /** @type {Expression} */ (context.visit(node));
|
||||
|
||||
// inline handler
|
||||
if (handler.type === 'ArrowFunctionExpression' || handler.type === 'FunctionExpression') {
|
||||
return handler;
|
||||
}
|
||||
|
||||
// function declared in the script
|
||||
if (
|
||||
handler.type === 'Identifier' &&
|
||||
context.state.scope.get(handler.name)?.declaration_kind !== 'import'
|
||||
) {
|
||||
return handler;
|
||||
}
|
||||
|
||||
if (metadata.has_call) {
|
||||
// memoize where necessary
|
||||
const id = b.id(context.state.scope.generate('event_handler'));
|
||||
|
||||
context.state.init.push(b.var(id, b.call('$.derived', b.thunk(handler))));
|
||||
handler = b.call('$.get', id);
|
||||
}
|
||||
|
||||
// wrap the handler in a function, so the expression is re-evaluated for each event
|
||||
return b.function(
|
||||
null,
|
||||
[b.rest(b.id('$$args'))],
|
||||
b.block([b.stmt(b.call(b.member(handler, b.id('apply'), false, true), b.this, b.id('$$args')))])
|
||||
);
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
/** @import { Expression } from 'estree' */
|
||||
/** @import { SvelteBody, SvelteDocument, SvelteWindow } from '#compiler' */
|
||||
/** @import { ComponentContext } from '../../types' */
|
||||
import { is_event_attribute } from '../../../../../utils/ast.js';
|
||||
import * as b from '../../../../../utils/builders.js';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {SvelteBody | SvelteDocument | SvelteWindow} node
|
||||
* @param {string} id
|
||||
* @param {ComponentContext} context
|
||||
*/
|
||||
export function visit_special_element(node, id, context) {
|
||||
const state = { ...context.state, node: b.id(id) };
|
||||
|
||||
for (const attribute of node.attributes) {
|
||||
if (attribute.type === 'OnDirective') {
|
||||
context.state.init.push(b.stmt(/** @type {Expression} */ (context.visit(attribute, state))));
|
||||
} else {
|
||||
context.visit(attribute, state);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
<button on:click>
|
||||
<slot />
|
||||
</button>
|
@ -0,0 +1,34 @@
|
||||
import { test } from '../../test';
|
||||
|
||||
export default test({
|
||||
test({ assert, logs, target }) {
|
||||
const [b1, b2, b3] = target.querySelectorAll('button');
|
||||
|
||||
b2?.click();
|
||||
b2?.click();
|
||||
b3?.click();
|
||||
b3?.click();
|
||||
|
||||
b1?.click();
|
||||
|
||||
b2?.click();
|
||||
b2?.click();
|
||||
b3?.click();
|
||||
b3?.click();
|
||||
|
||||
assert.deepEqual(logs, [
|
||||
'creating handler (1)',
|
||||
1,
|
||||
2,
|
||||
'creating handler (1)',
|
||||
3,
|
||||
4,
|
||||
'creating handler (2)',
|
||||
6,
|
||||
8,
|
||||
'creating handler (2)',
|
||||
10,
|
||||
12
|
||||
]);
|
||||
}
|
||||
});
|
@ -0,0 +1,27 @@
|
||||
<script>
|
||||
import Button from './Button.svelte';
|
||||
|
||||
let count = $state(0);
|
||||
let d = $state(1);
|
||||
|
||||
function create_handler() {
|
||||
const change = d;
|
||||
|
||||
console.log(`creating handler (${change})`);
|
||||
|
||||
return function increment() {
|
||||
count += change;
|
||||
console.log(count);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<button on:click={() => (d += 1)}>increase d ({d})</button>
|
||||
|
||||
<button on:click={create_handler()}>
|
||||
clicks: {count}
|
||||
</button>
|
||||
|
||||
<Button on:click={create_handler()}>
|
||||
clicks: {count}
|
||||
</Button>
|
@ -0,0 +1,19 @@
|
||||
import { flushSync } from 'svelte';
|
||||
import { test } from '../../test';
|
||||
|
||||
export default test({
|
||||
test({ assert, target }) {
|
||||
const [btn1, btn2, btn3] = target.querySelectorAll('button');
|
||||
|
||||
flushSync(() => btn3.click());
|
||||
assert.htmlEqual(/** @type {string} */ (btn3.textContent), 'clicks: 1');
|
||||
|
||||
flushSync(() => btn2.click());
|
||||
flushSync(() => btn3.click());
|
||||
assert.htmlEqual(/** @type {string} */ (btn3.textContent), 'clicks: 0');
|
||||
|
||||
flushSync(() => btn1.click());
|
||||
flushSync(() => btn3.click());
|
||||
assert.htmlEqual(/** @type {string} */ (btn3.textContent), 'clicks: 1');
|
||||
}
|
||||
});
|
@ -0,0 +1,19 @@
|
||||
<script>
|
||||
let count = $state(0);
|
||||
|
||||
const handlers = {
|
||||
current: increment
|
||||
};
|
||||
|
||||
function increment() {
|
||||
count += 1;
|
||||
}
|
||||
|
||||
function decrement() {
|
||||
count -= 1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<button onclick={() => (handlers.current = increment)}>increment</button>
|
||||
<button onclick={() => (handlers.current = decrement)}>decrement</button>
|
||||
<button onclick={handlers.current}>clicks: {count}</button>
|
Loading…
Reference in new issue