refactor(core): separate out DOM listener logic (#60549)

Moves the logic for listening to a DOM event out into a separate function for easier reuse.

PR Close #60549
This commit is contained in:
Kristiyan Kostadinov
2025-03-25 12:16:25 +01:00
committed by Alex Rickabaugh
parent defefae4b5
commit acf84db778
3 changed files with 139 additions and 116 deletions
+1 -1
View File
@@ -22,7 +22,6 @@ import {APP_BOOTSTRAP_LISTENER, ApplicationRef} from '../application/application
import {ENVIRONMENT_INITIALIZER, Injector} from '../di';
import {inject} from '../di/injector_compatibility';
import {Provider} from '../di/interface/provider';
import {setStashFn} from '../render3/instructions/listener';
import {RElement, RNode} from '../render3/interfaces/renderer_dom';
import {CLEANUP, LView, TView} from '../render3/interfaces/view';
import {unwrapRNode} from '../render3/util/view_utils';
@@ -45,6 +44,7 @@ import {APP_ID} from '../application/application_tokens';
import {performanceMarkFeature} from '../util/performance';
import {triggerHydrationFromBlockName} from '../defer/triggering';
import {isIncrementalHydrationEnabled} from './utils';
import {setStashFn} from '../render3/view/listeners';
/** Apps in which we've enabled event replay.
* This is to prevent initializing event replay more than once per app.
@@ -8,34 +8,15 @@
import {TNode, TNodeType} from '../interfaces/node';
import {GlobalTargetResolver, Renderer} from '../interfaces/renderer';
import {RElement, RNode} from '../interfaces/renderer_dom';
import {isDirectiveHost} from '../interfaces/type_checks';
import {CLEANUP, CONTEXT, LView, RENDERER, TView} from '../interfaces/view';
import {LView, RENDERER, TView} from '../interfaces/view';
import {assertTNodeType} from '../node_assert';
import {getCurrentDirectiveDef, getCurrentTNode, getLView, getTView} from '../state';
import {
getNativeByTNode,
getOrCreateLViewCleanup,
getOrCreateTViewCleanup,
unwrapRNode,
} from '../util/view_utils';
import {getOrCreateLViewCleanup, getOrCreateTViewCleanup} from '../util/view_utils';
import {listenToOutput} from '../view/directive_outputs';
import {wrapListener} from '../view/listeners';
import {listenToDomEvent, wrapListener} from '../view/listeners';
import {loadComponentRenderer} from './shared';
/**
* Contains a reference to a function that disables event replay feature
* for server-side rendered applications. This function is overridden with
* an actual implementation when the event replay feature is enabled via
* `withEventReplay()` call.
*/
let stashEventListener = (el: RNode, eventName: string, listenerFn: (e?: any) => any) => {};
export function setStashFn(fn: typeof stashEventListener) {
stashEventListener = fn;
}
/**
* Adds an event listener to the current node.
*
@@ -103,42 +84,6 @@ export function ɵɵsyntheticHostListener(
return ɵɵsyntheticHostListener;
}
/**
* A utility function that checks if a given element has already an event handler registered for an
* event with a specified name. The TView.cleanup data structure is used to find out which events
* are registered for a given element.
*/
function findExistingListener(
tView: TView,
lView: LView,
eventName: string,
tNodeIdx: number,
): ((e?: any) => any) | null {
const tCleanup = tView.cleanup;
if (tCleanup != null) {
for (let i = 0; i < tCleanup.length - 1; i += 2) {
const cleanupEventName = tCleanup[i];
if (cleanupEventName === eventName && tCleanup[i + 1] === tNodeIdx) {
// We have found a matching event name on the same node but it might not have been
// registered yet, so we must explicitly verify entries in the LView cleanup data
// structures.
const lCleanup = lView[CLEANUP]!;
const listenerIdxInLCleanup = tCleanup[i + 2];
return lCleanup.length > listenerIdxInLCleanup ? lCleanup[listenerIdxInLCleanup] : null;
}
// TView.cleanup can have a mix of 4-elements entries (for event handler cleanups) or
// 2-element entries (for directive and queries destroy hooks). As such we can encounter
// blocks of 4 or 2 items in the tView.cleanup and this is why we iterate over 2 elements
// first and jump another 2 elements if we detect listeners cleanup (4 elements). Also check
// documentation of TView.cleanup for more details of this data structure layout.
if (typeof cleanupEventName === 'string') {
i += 2;
}
}
}
return null;
}
export function listenerInternal(
tView: TView,
lView: LView<{} | null>,
@@ -148,10 +93,7 @@ export function listenerInternal(
listenerFn: (e?: any) => any,
eventTargetResolver?: GlobalTargetResolver,
): void {
const isTNodeDirectiveHost = isDirectiveHost(tNode);
const firstCreatePass = tView.firstCreatePass;
const tCleanup = firstCreatePass ? getOrCreateTViewCleanup(tView) : null;
const context = lView[CONTEXT];
const tCleanup = tView.firstCreatePass ? getOrCreateTViewCleanup(tView) : null;
// When the ɵɵlistener instruction was generated and is executed we know that there is either a
// native listener or a directive output on this element. As such we we know that we will have to
@@ -161,63 +103,30 @@ export function listenerInternal(
ngDevMode && assertTNodeType(tNode, TNodeType.AnyRNode | TNodeType.AnyContainer);
let processOutputs = true;
let wrappedListener: ((e?: any) => any) | null = null;
// Adding a native event listener is applicable when:
// - The corresponding TNode represents a DOM element.
// - The event target has a resolver (usually resulting in a global object,
// such as `window` or `document`).
if (tNode.type & TNodeType.AnyRNode || eventTargetResolver) {
const native = getNativeByTNode(tNode, lView) as RElement;
const target = eventTargetResolver ? eventTargetResolver(native) : native;
const lCleanupIndex = lCleanup.length;
const idxOrTargetGetter = eventTargetResolver
? (_lView: LView) => eventTargetResolver(unwrapRNode(_lView[tNode.index]))
: tNode.index;
wrappedListener ??= wrapListener(tNode, lView, listenerFn);
const hasCoalescedDomEvent = listenToDomEvent(
tNode,
lView,
eventTargetResolver,
lCleanup,
tView,
eventName,
wrappedListener,
listenerFn,
renderer,
tCleanup,
);
// In order to match current behavior, native DOM event listeners must be added for all
// events (including outputs).
// There might be cases where multiple directives on the same element try to register an event
// handler function for the same event. In this situation we want to avoid registration of
// several native listeners as each registration would be intercepted by NgZone and
// trigger change detection. This would mean that a single user action would result in several
// change detections being invoked. To avoid this situation we want to have only one call to
// native handler registration (for the same element and same type of event).
//
// In order to have just one native event handler in presence of multiple handler functions,
// we just register a first handler function as a native event listener and then chain
// (coalesce) other handler functions on top of the first native handler function.
let existingListener = null;
// Please note that the coalescing described here doesn't happen for events specifying an
// alternative target (ex. (document:click)) - this is to keep backward compatibility with the
// view engine.
// Also, we don't have to search for existing listeners is there are no directives
// matching on a given node as we can't register multiple event handlers for the same event in
// a template (this would mean having duplicate attributes).
if (!eventTargetResolver && isTNodeDirectiveHost) {
existingListener = findExistingListener(tView, lView, eventName, tNode.index);
}
if (existingListener !== null) {
// Attach a new listener to coalesced listeners list, maintaining the order in which
// listeners are registered. For performance reasons, we keep a reference to the last
// listener in that list (in `__ngLastListenerFn__` field), so we can avoid going through
// the entire set each time we need to add a new listener.
const lastListenerFn = (<any>existingListener).__ngLastListenerFn__ || existingListener;
lastListenerFn.__ngNextListenerFn__ = listenerFn;
(<any>existingListener).__ngLastListenerFn__ = listenerFn;
if (hasCoalescedDomEvent) {
processOutputs = false;
} else {
listenerFn = wrapListener(tNode, lView, listenerFn);
stashEventListener(target as RElement, eventName, listenerFn);
const cleanupFn = renderer.listen(target as RElement, eventName, listenerFn);
lCleanup.push(listenerFn, cleanupFn);
tCleanup && tCleanup.push(eventName, idxOrTargetGetter, lCleanupIndex, lCleanupIndex + 1);
}
} else {
// Even if there is no native listener to add, we still need to wrap the listener so that OnPush
// ancestors are marked dirty when an event occurs.
listenerFn = wrapListener(tNode, lView, listenerFn);
}
if (processOutputs) {
@@ -228,13 +137,15 @@ export function listenerInternal(
for (let i = 0; i < hostDirectiveOutputConfig.length; i += 2) {
const index = hostDirectiveOutputConfig[i] as number;
const lookupName = hostDirectiveOutputConfig[i + 1] as string;
listenToOutput(tNode, lView, index, lookupName, eventName, listenerFn);
wrappedListener ??= wrapListener(tNode, lView, listenerFn);
listenToOutput(tNode, lView, index, lookupName, eventName, wrappedListener);
}
}
if (outputConfig && outputConfig.length) {
for (const index of outputConfig) {
listenToOutput(tNode, lView, index, eventName, eventName, listenerFn);
wrappedListener ??= wrapListener(tNode, lView, listenerFn);
listenToOutput(tNode, lView, index, eventName, eventName, wrappedListener);
}
}
}
+115 -3
View File
@@ -10,13 +10,27 @@ import {setActiveConsumer} from '@angular/core/primitives/signals';
import {NotificationSource} from '../../change_detection/scheduling/zoneless_scheduling';
import {TNode} from '../interfaces/node';
import {isComponentHost} from '../interfaces/type_checks';
import {CONTEXT, INJECTOR, LView} from '../interfaces/view';
import {getComponentLViewByIndex} from '../util/view_utils';
import {isComponentHost, isDirectiveHost} from '../interfaces/type_checks';
import {CLEANUP, CONTEXT, INJECTOR, LView, TView} from '../interfaces/view';
import {getComponentLViewByIndex, getNativeByTNode, unwrapRNode} from '../util/view_utils';
import {profiler} from '../profiler';
import {ProfilerEvent} from '../profiler_types';
import {ErrorHandler} from '../../error_handler';
import {markViewDirty} from '../instructions/mark_view_dirty';
import {RElement, RNode} from '../interfaces/renderer_dom';
import {GlobalTargetResolver, Renderer} from '../interfaces/renderer';
/**
* Contains a reference to a function that disables event replay feature
* for server-side rendered applications. This function is overridden with
* an actual implementation when the event replay feature is enabled via
* `withEventReplay()` call.
*/
let stashEventListener = (el: RNode, eventName: string, listenerFn: (e?: any) => any) => {};
export function setStashFn(fn: typeof stashEventListener) {
stashEventListener = fn;
}
/**
* Wraps an event listener with a function that marks ancestors dirty and prevents default behavior,
@@ -88,3 +102,101 @@ function handleError(lView: LView, error: any): void {
}
injector.get(ErrorHandler, null)?.handleError(error);
}
export function listenToDomEvent(
tNode: TNode,
lView: LView<{} | null>,
eventTargetResolver: GlobalTargetResolver | undefined,
lCleanup: any[],
tView: TView,
eventName: string,
wrappedListener: (e?: any) => any,
originalListener: (e?: any) => any,
renderer: Renderer,
tCleanup: any[] | null,
) {
const isTNodeDirectiveHost = isDirectiveHost(tNode);
const native = getNativeByTNode(tNode, lView) as RElement;
const target = eventTargetResolver ? eventTargetResolver(native) : native;
const lCleanupIndex = lCleanup.length;
const idxOrTargetGetter = eventTargetResolver
? (_lView: LView) => eventTargetResolver(unwrapRNode(_lView[tNode.index]))
: tNode.index;
let hasCoalesced = false;
// In order to match current behavior, native DOM event listeners must be added for all
// events (including outputs).
// There might be cases where multiple directives on the same element try to register an event
// handler function for the same event. In this situation we want to avoid registration of
// several native listeners as each registration would be intercepted by NgZone and
// trigger change detection. This would mean that a single user action would result in several
// change detections being invoked. To avoid this situation we want to have only one call to
// native handler registration (for the same element and same type of event).
//
// In order to have just one native event handler in presence of multiple handler functions,
// we just register a first handler function as a native event listener and then chain
// (coalesce) other handler functions on top of the first native handler function.
let existingListener: any = null;
// Please note that the coalescing described here doesn't happen for events specifying an
// alternative target (ex. (document:click)) - this is to keep backward compatibility with the
// view engine.
// Also, we don't have to search for existing listeners if there are no directives
// matching on a given node as we can't register multiple event handlers for the same event in
// a template (this would mean having duplicate attributes).
if (!eventTargetResolver && isTNodeDirectiveHost) {
existingListener = findExistingListener(tView, lView, eventName, tNode.index);
}
if (existingListener !== null) {
// Attach a new listener to coalesced listeners list, maintaining the order in which
// listeners are registered. For performance reasons, we keep a reference to the last
// listener in that list (in `__ngLastListenerFn__` field), so we can avoid going through
// the entire set each time we need to add a new listener.
const lastListenerFn = existingListener.__ngLastListenerFn__ || existingListener;
lastListenerFn.__ngNextListenerFn__ = originalListener;
existingListener.__ngLastListenerFn__ = originalListener;
hasCoalesced = true;
} else {
stashEventListener(target as RElement, eventName, wrappedListener);
const cleanupFn = renderer.listen(target as RElement, eventName, wrappedListener);
lCleanup.push(wrappedListener, cleanupFn);
tCleanup && tCleanup.push(eventName, idxOrTargetGetter, lCleanupIndex, lCleanupIndex + 1);
}
return hasCoalesced;
}
/**
* A utility function that checks if a given element has already an event handler registered for an
* event with a specified name. The TView.cleanup data structure is used to find out which events
* are registered for a given element.
*/
function findExistingListener(
tView: TView,
lView: LView,
eventName: string,
tNodeIndex: number,
): ((e?: any) => any) | null {
const tCleanup = tView.cleanup;
if (tCleanup != null) {
for (let i = 0; i < tCleanup.length - 1; i += 2) {
const cleanupEventName = tCleanup[i];
if (cleanupEventName === eventName && tCleanup[i + 1] === tNodeIndex) {
// We have found a matching event name on the same node but it might not have been
// registered yet, so we must explicitly verify entries in the LView cleanup data
// structures.
const lCleanup = lView[CLEANUP]!;
const listenerIdxInLCleanup = tCleanup[i + 2];
return lCleanup.length > listenerIdxInLCleanup ? lCleanup[listenerIdxInLCleanup] : null;
}
// TView.cleanup can have a mix of 4-elements entries (for event handler cleanups) or
// 2-element entries (for directive and queries destroy hooks). As such we can encounter
// blocks of 4 or 2 items in the tView.cleanup and this is why we iterate over 2 elements
// first and jump another 2 elements if we detect listeners cleanup (4 elements). Also check
// documentation of TView.cleanup for more details of this data structure layout.
if (typeof cleanupEventName === 'string') {
i += 2;
}
}
}
return null;
}