mirror of https://github.com/sveltejs/svelte
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
354 lines
11 KiB
354 lines
11 KiB
import { teardown } from '../../reactivity/effects.js';
|
|
import { define_property, is_array } from '../../../shared/utils.js';
|
|
import { hydrating } from '../hydration.js';
|
|
import { queue_micro_task } from '../task.js';
|
|
import { FILENAME } from '../../../../constants.js';
|
|
import * as w from '../../warnings.js';
|
|
import {
|
|
active_effect,
|
|
active_reaction,
|
|
set_active_effect,
|
|
set_active_reaction
|
|
} from '../../runtime.js';
|
|
import { without_reactive_context } from './bindings/shared.js';
|
|
|
|
/** @type {Set<string>} */
|
|
export const all_registered_events = new Set();
|
|
|
|
/** @type {Set<(events: Array<string>) => void>} */
|
|
export const root_event_handles = new Set();
|
|
|
|
/**
|
|
* SSR adds onload and onerror attributes to catch those events before the hydration.
|
|
* This function detects those cases, removes the attributes and replays the events.
|
|
* @param {HTMLElement} dom
|
|
*/
|
|
export function replay_events(dom) {
|
|
if (!hydrating) return;
|
|
|
|
dom.removeAttribute('onload');
|
|
dom.removeAttribute('onerror');
|
|
// @ts-expect-error
|
|
const event = dom.__e;
|
|
if (event !== undefined) {
|
|
// @ts-expect-error
|
|
dom.__e = undefined;
|
|
queueMicrotask(() => {
|
|
if (dom.isConnected) {
|
|
dom.dispatchEvent(event);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} event_name
|
|
* @param {EventTarget} dom
|
|
* @param {EventListener} [handler]
|
|
* @param {AddEventListenerOptions} [options]
|
|
*/
|
|
export function create_event(event_name, dom, handler, options = {}) {
|
|
/**
|
|
* @this {EventTarget}
|
|
*/
|
|
function target_handler(/** @type {Event} */ event) {
|
|
if (!options.capture) {
|
|
// Only call in the bubble phase, else delegated events would be called before the capturing events
|
|
handle_event_propagation.call(dom, event);
|
|
}
|
|
|
|
// @ts-expect-error Use this instead of cancelBubble, because cancelBubble is also true if
|
|
// we're the last element on which the event will be handled.
|
|
if (!event.__cancelled || event.__cancelled === dom) {
|
|
return without_reactive_context(() => {
|
|
return handler?.call(this, event);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Chrome has a bug where pointer events don't work when attached to a DOM element that has been cloned
|
|
// with cloneNode() and the DOM element is disconnected from the document. To ensure the event works, we
|
|
// defer the attachment till after it's been appended to the document. TODO: remove this once Chrome fixes
|
|
// this bug. The same applies to wheel events and touch events.
|
|
if (
|
|
event_name.startsWith('pointer') ||
|
|
event_name.startsWith('touch') ||
|
|
event_name === 'wheel'
|
|
) {
|
|
queue_micro_task(() => {
|
|
dom.addEventListener(event_name, target_handler, options);
|
|
});
|
|
} else {
|
|
dom.addEventListener(event_name, target_handler, options);
|
|
}
|
|
|
|
return target_handler;
|
|
}
|
|
|
|
/**
|
|
* Attaches an event handler to an element and returns a function that removes the handler. Using this
|
|
* rather than `addEventListener` will preserve the correct order relative to handlers added declaratively
|
|
* (with attributes like `onclick`), which use event delegation for performance reasons
|
|
*
|
|
* @param {EventTarget} element
|
|
* @param {string} type
|
|
* @param {EventListener} handler
|
|
* @param {AddEventListenerOptions} [options]
|
|
*/
|
|
export function on(element, type, handler, options = {}) {
|
|
var target_handler = create_event(type, element, handler, options);
|
|
|
|
return () => {
|
|
element.removeEventListener(type, target_handler, options);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {string} event_name
|
|
* @param {Element} dom
|
|
* @param {EventListener} [handler]
|
|
* @param {boolean} [capture]
|
|
* @param {boolean} [passive]
|
|
* @returns {void}
|
|
*/
|
|
export function event(event_name, dom, handler, capture, passive) {
|
|
var options = { capture, passive };
|
|
var target_handler = create_event(event_name, dom, handler, options);
|
|
|
|
if (
|
|
dom === document.body ||
|
|
// @ts-ignore
|
|
dom === window ||
|
|
// @ts-ignore
|
|
dom === document ||
|
|
// Firefox has quirky behavior, it can happen that we still get "canplay" events when the element is already removed
|
|
dom instanceof HTMLMediaElement
|
|
) {
|
|
teardown(() => {
|
|
dom.removeEventListener(event_name, target_handler, options);
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Array<string>} events
|
|
* @returns {void}
|
|
*/
|
|
export function delegate(events) {
|
|
for (var i = 0; i < events.length; i++) {
|
|
all_registered_events.add(events[i]);
|
|
}
|
|
|
|
for (var fn of root_event_handles) {
|
|
fn(events);
|
|
}
|
|
}
|
|
|
|
// used to store the reference to the currently propagated event
|
|
// to prevent garbage collection between microtasks in Firefox
|
|
// If the event object is GCed too early, the expando __root property
|
|
// set on the event object is lost, causing the event delegation
|
|
// to process the event twice
|
|
let last_propagated_event = null;
|
|
|
|
/**
|
|
* @this {EventTarget}
|
|
* @param {Event} event
|
|
* @returns {void}
|
|
*/
|
|
export function handle_event_propagation(event) {
|
|
var handler_element = this;
|
|
var owner_document = /** @type {Node} */ (handler_element).ownerDocument;
|
|
var event_name = event.type;
|
|
var path = event.composedPath?.() || [];
|
|
var current_target = /** @type {null | Element} */ (path[0] || event.target);
|
|
|
|
last_propagated_event = event;
|
|
|
|
// composedPath contains list of nodes the event has propagated through.
|
|
// We check __root to skip all nodes below it in case this is a
|
|
// parent of the __root node, which indicates that there's nested
|
|
// mounted apps. In this case we don't want to trigger events multiple times.
|
|
var path_idx = 0;
|
|
|
|
// the `last_propagated_event === event` check is redundant, but
|
|
// without it the variable will be DCE'd and things will
|
|
// fail mysteriously in Firefox
|
|
// @ts-expect-error is added below
|
|
var handled_at = last_propagated_event === event && event.__root;
|
|
|
|
if (handled_at) {
|
|
var at_idx = path.indexOf(handled_at);
|
|
if (
|
|
at_idx !== -1 &&
|
|
(handler_element === document || handler_element === /** @type {any} */ (window))
|
|
) {
|
|
// This is the fallback document listener or a window listener, but the event was already handled
|
|
// -> ignore, but set handle_at to document/window so that we're resetting the event
|
|
// chain in case someone manually dispatches the same event object again.
|
|
// @ts-expect-error
|
|
event.__root = handler_element;
|
|
// @ts-expect-error
|
|
event.__cancelled = null;
|
|
return;
|
|
}
|
|
|
|
// We're deliberately not skipping if the index is higher, because
|
|
// someone could create an event programmatically and emit it multiple times,
|
|
// in which case we want to handle the whole propagation chain properly each time.
|
|
// (this will only be a false negative if the event is dispatched multiple times and
|
|
// the fallback document listener isn't reached in between, but that's super rare)
|
|
var handler_idx = path.indexOf(handler_element);
|
|
if (handler_idx === -1) {
|
|
// handle_idx can theoretically be -1 (happened in some JSDOM testing scenarios with an event listener on the window object)
|
|
// so guard against that, too, and assume that everything was handled at this point.
|
|
return;
|
|
}
|
|
|
|
if (at_idx <= handler_idx) {
|
|
path_idx = at_idx;
|
|
}
|
|
}
|
|
|
|
current_target = /** @type {Element} */ (path[path_idx] || event.target);
|
|
// there can only be one delegated event per element, and we either already handled the current target,
|
|
// or this is the very first target in the chain which has a non-delegated listener, in which case it's safe
|
|
// to handle a possible delegated event on it later (through the root delegation listener for example).
|
|
if (current_target === handler_element) return;
|
|
|
|
// Proxy currentTarget to correct target
|
|
define_property(event, 'currentTarget', {
|
|
configurable: true,
|
|
get() {
|
|
return current_target || owner_document;
|
|
}
|
|
});
|
|
|
|
// This started because of Chromium issue https://chromestatus.com/feature/5128696823545856,
|
|
// where removal or moving of of the DOM can cause sync `blur` events to fire, which can cause logic
|
|
// to run inside the current `active_reaction`, which isn't what we want at all. However, on reflection,
|
|
// it's probably best that all event handled by Svelte have this behaviour, as we don't really want
|
|
// an event handler to run in the context of another reaction or effect.
|
|
var previous_reaction = active_reaction;
|
|
var previous_effect = active_effect;
|
|
set_active_reaction(null);
|
|
set_active_effect(null);
|
|
|
|
try {
|
|
var cancelled = event.cancelBubble;
|
|
/**
|
|
* @type {unknown}
|
|
*/
|
|
var throw_error;
|
|
/**
|
|
* @type {unknown[]}
|
|
*/
|
|
var other_errors = [];
|
|
|
|
while (current_target !== null) {
|
|
/** @type {null | Element} */
|
|
var parent_element =
|
|
current_target.assignedSlot ||
|
|
current_target.parentNode ||
|
|
/** @type {any} */ (current_target).host ||
|
|
null;
|
|
|
|
try {
|
|
// @ts-expect-error
|
|
var delegated = current_target['__' + event_name];
|
|
|
|
if (
|
|
delegated != null &&
|
|
(!(/** @type {any} */ (current_target).disabled) ||
|
|
// DOM could've been updated already by the time this is reached, so we check this as well
|
|
// -> the target could not have been disabled because it emits the event in the first place
|
|
event.target === current_target)
|
|
) {
|
|
if (is_array(delegated)) {
|
|
var [fn, ...data] = delegated;
|
|
fn.apply(current_target, [event, ...data]);
|
|
} else {
|
|
delegated.call(current_target, event);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (throw_error) {
|
|
other_errors.push(error);
|
|
} else {
|
|
throw_error = error;
|
|
}
|
|
}
|
|
if (event.cancelBubble || parent_element === handler_element || parent_element === null) {
|
|
if (!cancelled && event.cancelBubble) {
|
|
// @ts-expect-error
|
|
event.__cancelled = current_target;
|
|
}
|
|
break;
|
|
}
|
|
current_target = parent_element;
|
|
}
|
|
|
|
if (throw_error) {
|
|
for (let error of other_errors) {
|
|
// Throw the rest of the errors, one-by-one on a microtask
|
|
queueMicrotask(() => {
|
|
throw error;
|
|
});
|
|
}
|
|
throw throw_error;
|
|
}
|
|
} finally {
|
|
// @ts-expect-error is used above
|
|
event.__root = handler_element;
|
|
// @ts-ignore remove proxy on currentTarget
|
|
delete event.currentTarget;
|
|
set_active_reaction(previous_reaction);
|
|
set_active_effect(previous_effect);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* In dev, warn if an event handler is not a function, as it means the
|
|
* user probably called the handler or forgot to add a `() =>`
|
|
* @param {() => (event: Event, ...args: any) => void} thunk
|
|
* @param {EventTarget} element
|
|
* @param {[Event, ...any]} args
|
|
* @param {any} component
|
|
* @param {[number, number]} [loc]
|
|
* @param {boolean} [remove_parens]
|
|
*/
|
|
export function apply(
|
|
thunk,
|
|
element,
|
|
args,
|
|
component,
|
|
loc,
|
|
has_side_effects = false,
|
|
remove_parens = false
|
|
) {
|
|
let handler;
|
|
let error;
|
|
|
|
try {
|
|
handler = thunk();
|
|
} catch (e) {
|
|
error = e;
|
|
}
|
|
|
|
if (typeof handler !== 'function' && (has_side_effects || handler != null || error)) {
|
|
const filename = component?.[FILENAME];
|
|
const location = loc ? ` at ${filename}:${loc[0]}:${loc[1]}` : ` in ${filename}`;
|
|
const phase = args[0]?.eventPhase < Event.BUBBLING_PHASE ? 'capture' : '';
|
|
const event_name = args[0]?.type + phase;
|
|
const description = `\`${event_name}\` handler${location}`;
|
|
const suggestion = remove_parens ? 'remove the trailing `()`' : 'add a leading `() =>`';
|
|
|
|
w.event_handler_invalid(description, suggestion);
|
|
|
|
if (error) {
|
|
throw error;
|
|
}
|
|
}
|
|
handler?.apply(element, args);
|
|
}
|