From acf84db778b6675a6a9c1d5f726bcd174a6c2f3a Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Tue, 25 Mar 2025 12:16:25 +0100 Subject: [PATCH] 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 --- packages/core/src/hydration/event_replay.ts | 2 +- .../core/src/render3/instructions/listener.ts | 135 +++--------------- packages/core/src/render3/view/listeners.ts | 118 ++++++++++++++- 3 files changed, 139 insertions(+), 116 deletions(-) diff --git a/packages/core/src/hydration/event_replay.ts b/packages/core/src/hydration/event_replay.ts index 0ba669066ec..4d58a97d619 100644 --- a/packages/core/src/hydration/event_replay.ts +++ b/packages/core/src/hydration/event_replay.ts @@ -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. diff --git a/packages/core/src/render3/instructions/listener.ts b/packages/core/src/render3/instructions/listener.ts index 383cdf63bb3..7b561c341e4 100644 --- a/packages/core/src/render3/instructions/listener.ts +++ b/packages/core/src/render3/instructions/listener.ts @@ -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 = (existingListener).__ngLastListenerFn__ || existingListener; - lastListenerFn.__ngNextListenerFn__ = listenerFn; - (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); } } } diff --git a/packages/core/src/render3/view/listeners.ts b/packages/core/src/render3/view/listeners.ts index 4c08d7bd3a5..0314c87b4ae 100644 --- a/packages/core/src/render3/view/listeners.ts +++ b/packages/core/src/render3/view/listeners.ts @@ -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; +}