From ae28db0f2e2465835c4e43d7b716162e3a941c17 Mon Sep 17 00:00:00 2001 From: Pawel Kozlowski Date: Tue, 18 Mar 2025 13:59:03 +0100 Subject: [PATCH] refactor(core): share code between ComponentRef and targeted instructions (#60440) This refactor reorganizes code such that the dynamic bindings logic and targeted listener instruction can share most of the code. PR Close #60440 --- packages/core/src/render3/dynamic_bindings.ts | 21 +- .../core/src/render3/instructions/listener.ts | 201 +----------------- .../core/src/render3/instructions/shared.ts | 16 +- .../src/render3/view/directive_outputs.ts | 165 ++++++++++++++ packages/core/src/render3/view/listeners.ts | 96 +++++++++ .../bundling/defer/bundle.golden_symbols.json | 2 + 6 files changed, 270 insertions(+), 231 deletions(-) create mode 100644 packages/core/src/render3/view/directive_outputs.ts create mode 100644 packages/core/src/render3/view/listeners.ts diff --git a/packages/core/src/render3/dynamic_bindings.ts b/packages/core/src/render3/dynamic_bindings.ts index 1f2638488ca..342d44619e5 100644 --- a/packages/core/src/render3/dynamic_bindings.ts +++ b/packages/core/src/render3/dynamic_bindings.ts @@ -11,12 +11,11 @@ import {RuntimeError, RuntimeErrorCode} from '../errors'; import {Type, Writable} from '../interface/type'; import {assertNotDefined} from '../util/assert'; import {bindingUpdated} from './bindings'; -import {listenToDirectiveOutput, wrapListener} from './instructions/listener'; import {setDirectiveInput, storePropertyBindingMetadata} from './instructions/shared'; import {DirectiveDef} from './interfaces/definition'; -import {CONTEXT} from './interfaces/view'; import {getCurrentTNode, getLView, getSelectedTNode, getTView, nextBindingIndex} from './state'; import {stringifyForError} from './util/stringify_utils'; +import {createOutputListener} from './view/directive_outputs'; /** Symbol used to store and retrieve metadata about a binding. */ export const BINDING = /* @__PURE__ */ Symbol('BINDING'); @@ -152,25 +151,9 @@ export function outputBinding(eventName: string, listener: (event: T) => unkn } const lView = getLView<{} | null>(); - const tView = getTView(); const tNode = getCurrentTNode()!; - const context = lView[CONTEXT]; - const wrappedListener = wrapListener(tNode, lView, context, listener); - const hasBound = listenToDirectiveOutput( - tNode, - tView, - lView, - target, - eventName, - wrappedListener, - ); - if (!hasBound && ngDevMode) { - throw new RuntimeError( - RuntimeErrorCode.INVALID_BINDING_TARGET, - `${stringifyForError(target.type)} does not have an output with a public name of "${eventName}".`, - ); - } + createOutputListener(tNode, lView, listener, target, eventName); }, }; diff --git a/packages/core/src/render3/instructions/listener.ts b/packages/core/src/render3/instructions/listener.ts index b72819eb630..6e3903a24a7 100644 --- a/packages/core/src/render3/instructions/listener.ts +++ b/packages/core/src/render3/instructions/listener.ts @@ -6,30 +6,23 @@ * found in the LICENSE file at https://angular.dev/license */ -import {setActiveConsumer} from '@angular/core/primitives/signals'; - -import {NotificationSource} from '../../change_detection/scheduling/zoneless_scheduling'; -import {assertIndexInRange} from '../../util/assert'; import {TNode, TNodeType} from '../interfaces/node'; import {GlobalTargetResolver, Renderer} from '../interfaces/renderer'; import {RElement, RNode} from '../interfaces/renderer_dom'; -import {isComponentHost, isDirectiveHost} from '../interfaces/type_checks'; +import {isDirectiveHost} from '../interfaces/type_checks'; import {CLEANUP, CONTEXT, LView, RENDERER, TView} from '../interfaces/view'; import {assertTNodeType} from '../node_assert'; -import {profiler} from '../profiler'; -import {ProfilerEvent} from '../profiler_types'; import {getCurrentDirectiveDef, getCurrentTNode, getLView, getTView} from '../state'; import { - getComponentLViewByIndex, getNativeByTNode, getOrCreateLViewCleanup, getOrCreateTViewCleanup, unwrapRNode, } from '../util/view_utils'; -import {markViewDirty} from './mark_view_dirty'; -import {handleError, loadComponentRenderer} from './shared'; -import {DirectiveDef} from '../interfaces/definition'; +import {listenToOutput} from '../view/directive_outputs'; +import {wrapListener} from '../view/listeners'; +import {loadComponentRenderer} from './shared'; /** * Contains a reference to a function that disables event replay feature @@ -266,189 +259,3 @@ export function listenerInternal( } } } - -function listenToOutput( - tNode: TNode, - tView: TView, - lView: LView, - index: number, - lookupName: string, - eventName: string, - listenerFn: (e?: any) => any, - lCleanup: any[], - tCleanup: any[] | null, -) { - ngDevMode && assertIndexInRange(lView, index); - const instance = lView[index]; - const def = tView.data[index] as DirectiveDef; - const propertyName = def.outputs[lookupName]; - const output = instance[propertyName]; - - if (ngDevMode && !isOutputSubscribable(output)) { - throw new Error(`@Output ${propertyName} not initialized in '${instance.constructor.name}'.`); - } - - const subscription = (output as SubscribableOutput).subscribe(listenerFn); - const idx = lCleanup.length; - lCleanup.push(listenerFn, subscription); - tCleanup && tCleanup.push(eventName, tNode.index, idx, -(idx + 1)); -} - -function executeListenerWithErrorHandling( - lView: LView, - context: {} | null, - listenerFn: (e?: any) => any, - e: any, -): boolean { - const prevConsumer = setActiveConsumer(null); - try { - profiler(ProfilerEvent.OutputStart, context, listenerFn); - // Only explicitly returning false from a listener should preventDefault - return listenerFn(e) !== false; - } catch (error) { - // TODO(atscott): This should report to the application error handler, not the ErrorHandler on LView injector - handleError(lView, error); - return false; - } finally { - profiler(ProfilerEvent.OutputEnd, context, listenerFn); - setActiveConsumer(prevConsumer); - } -} - -/** - * Wraps an event listener with a function that marks ancestors dirty and prevents default behavior, - * if applicable. - * - * @param tNode The TNode associated with this listener - * @param lView The LView that contains this listener - * @param listenerFn The listener function to call - * @param wrapWithPreventDefault Whether or not to prevent default behavior - * (the procedural renderer does this already, so in those cases, we should skip) - */ -export function wrapListener( - tNode: TNode, - lView: LView<{} | null>, - context: {} | null, - listenerFn: (e?: any) => any, -): EventListener { - // Note: we are performing most of the work in the listener function itself - // to optimize listener registration. - return function wrapListenerIn_markDirtyAndPreventDefault(e: any) { - // Ivy uses `Function` as a special token that allows us to unwrap the function - // so that it can be invoked programmatically by `DebugNode.triggerEventHandler`. - if (e === Function) { - return listenerFn; - } - - // In order to be backwards compatible with View Engine, events on component host nodes - // must also mark the component view itself dirty (i.e. the view that it owns). - const startView = isComponentHost(tNode) ? getComponentLViewByIndex(tNode.index, lView) : lView; - markViewDirty(startView, NotificationSource.Listener); - - let result = executeListenerWithErrorHandling(lView, context, listenerFn, e); - // A just-invoked listener function might have coalesced listeners so we need to check for - // their presence and invoke as needed. - let nextListenerFn = (wrapListenerIn_markDirtyAndPreventDefault).__ngNextListenerFn__; - while (nextListenerFn) { - // We should prevent default if any of the listeners explicitly return false - result = executeListenerWithErrorHandling(lView, context, nextListenerFn, e) && result; - nextListenerFn = (nextListenerFn).__ngNextListenerFn__; - } - - return result; - }; -} - -/** Describes a subscribable output field value. */ -interface SubscribableOutput { - subscribe(listener: (v: T) => void): {unsubscribe: () => void}; -} - -/** - * Whether the given value represents a subscribable output. - * - * For example, an `EventEmitter, a `Subject`, an `Observable` or an - * `OutputEmitter`. - */ -function isOutputSubscribable(value: unknown): value is SubscribableOutput { - return ( - value != null && typeof (value as Partial>).subscribe === 'function' - ); -} - -/** Listens to an output on a specific directive. */ -export function listenToDirectiveOutput( - tNode: TNode, - tView: TView, - lView: LView, - target: DirectiveDef, - eventName: string, - listenerFn: (e?: any) => any, -): boolean { - const tCleanup = tView.firstCreatePass ? getOrCreateTViewCleanup(tView) : null; - const lCleanup = getOrCreateLViewCleanup(lView); - let hostIndex: number | null = null; - let hostDirectivesStart: number | null = null; - let hostDirectivesEnd: number | null = null; - let hasOutput = false; - - if (ngDevMode && !tNode.directiveToIndex?.has(target.type)) { - throw new Error(`Node does not have a directive with type ${target.type.name}`); - } - - const data = tNode.directiveToIndex!.get(target.type)!; - - if (typeof data === 'number') { - hostIndex = data; - } else { - [hostIndex, hostDirectivesStart, hostDirectivesEnd] = data; - } - - if ( - hostDirectivesStart !== null && - hostDirectivesEnd !== null && - tNode.hostDirectiveOutputs?.hasOwnProperty(eventName) - ) { - const hostDirectiveOutputs = tNode.hostDirectiveOutputs[eventName]; - - for (let i = 0; i < hostDirectiveOutputs.length; i += 2) { - const index = hostDirectiveOutputs[i] as number; - - if (index >= hostDirectivesStart && index <= hostDirectivesEnd) { - ngDevMode && assertIndexInRange(lView, index); - hasOutput = true; - listenToOutput( - tNode, - tView, - lView, - index, - hostDirectiveOutputs[i + 1] as string, - eventName, - listenerFn, - lCleanup, - tCleanup, - ); - } else if (index > hostDirectivesEnd) { - break; - } - } - } - - if (hostIndex !== null && target.outputs.hasOwnProperty(eventName)) { - ngDevMode && assertIndexInRange(lView, hostIndex); - hasOutput = true; - listenToOutput( - tNode, - tView, - lView, - hostIndex, - eventName, - eventName, - listenerFn, - lCleanup, - tCleanup, - ); - } - - return hasOutput; -} diff --git a/packages/core/src/render3/instructions/shared.ts b/packages/core/src/render3/instructions/shared.ts index 65fcb9a7439..a9c5bd15468 100644 --- a/packages/core/src/render3/instructions/shared.ts +++ b/packages/core/src/render3/instructions/shared.ts @@ -7,7 +7,7 @@ */ import {Injector} from '../../di/injector'; -import {ErrorHandler, INTERNAL_APPLICATION_ERROR_HANDLER} from '../../error_handler'; +import {INTERNAL_APPLICATION_ERROR_HANDLER} from '../../error_handler'; import {hasSkipHydrationAttrOnRElement} from '../../hydration/skip_hydration'; import {PRESERVE_HOST_CONTENT, PRESERVE_HOST_CONTENT_DEFAULT} from '../../hydration/tokens'; import {processTextNodeMarkersBeforeHydration} from '../../hydration/utils'; @@ -29,7 +29,6 @@ import { InitialInputData, InitialInputs, LocalRefExtractor, - NodeInputBindings, TContainerNode, TDirectiveHostNode, TElementContainerNode, @@ -58,7 +57,6 @@ import {isNodeMatchingSelectorList} from '../node_selector_matcher'; import {profiler} from '../profiler'; import {ProfilerEvent} from '../profiler_types'; import { - getBindingsEnabled, getCurrentDirectiveIndex, getSelectedIndex, isInCheckNoChangesMode, @@ -613,18 +611,6 @@ export function handleUncaughtError(lView: LView, error: any): void { errorHandler?.(error); } -/** - * Handles an error thrown in an LView. - * @deprecated Use handleUncaughtError to report to application error handler - */ -export function handleError(lView: LView, error: any): void { - const injector = lView[INJECTOR]; - if (!injector) { - return; - } - injector.get(ErrorHandler, null)?.handleError(error); -} - /** * Set all directive inputs with the specific public name on the node. * diff --git a/packages/core/src/render3/view/directive_outputs.ts b/packages/core/src/render3/view/directive_outputs.ts new file mode 100644 index 00000000000..60d6d37ebc3 --- /dev/null +++ b/packages/core/src/render3/view/directive_outputs.ts @@ -0,0 +1,165 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {RuntimeError, RuntimeErrorCode} from '../../errors'; +import {assertIndexInRange} from '../../util/assert'; +import {DirectiveDef} from '../interfaces/definition'; +import {TNode} from '../interfaces/node'; +import {CONTEXT, LView, TVIEW, TView} from '../interfaces/view'; +import {stringifyForError} from '../util/stringify_utils'; +import {getOrCreateLViewCleanup, getOrCreateTViewCleanup} from '../util/view_utils'; +import {wrapListener} from './listeners'; + +/** Describes a subscribable output field value. */ +interface SubscribableOutput { + subscribe(listener: (v: T) => void): {unsubscribe: () => void}; +} + +export function createOutputListener( + tNode: TNode, + lView: LView<{} | null>, + listenerFn: (e?: any) => any, + targetDef: DirectiveDef, + eventName: string, +) { + // TODO(pk): decouple checks from the actual binding + const wrappedListener = wrapListener(tNode, lView, lView[CONTEXT], listenerFn); + + // TODO(pk): simplify signature of listenToDirectiveOutput + const hasBound = listenToDirectiveOutput( + tNode, + lView[TVIEW], + lView, + targetDef, + eventName, + wrappedListener, + ); + + if (!hasBound && ngDevMode) { + throw new RuntimeError( + RuntimeErrorCode.INVALID_BINDING_TARGET, + `${stringifyForError(targetDef.type)} does not have an output with a public name of "${eventName}".`, + ); + } +} + +/** Listens to an output on a specific directive. */ +function listenToDirectiveOutput( + tNode: TNode, + tView: TView, + lView: LView, + target: DirectiveDef, + eventName: string, + listenerFn: (e?: any) => any, +): boolean { + const tCleanup = tView.firstCreatePass ? getOrCreateTViewCleanup(tView) : null; + const lCleanup = getOrCreateLViewCleanup(lView); + let hostIndex: number | null = null; + let hostDirectivesStart: number | null = null; + let hostDirectivesEnd: number | null = null; + let hasOutput = false; + + if (ngDevMode && !tNode.directiveToIndex?.has(target.type)) { + throw new Error(`Node does not have a directive with type ${target.type.name}`); + } + + const data = tNode.directiveToIndex!.get(target.type)!; + + if (typeof data === 'number') { + hostIndex = data; + } else { + [hostIndex, hostDirectivesStart, hostDirectivesEnd] = data; + } + + if ( + hostDirectivesStart !== null && + hostDirectivesEnd !== null && + tNode.hostDirectiveOutputs?.hasOwnProperty(eventName) + ) { + const hostDirectiveOutputs = tNode.hostDirectiveOutputs[eventName]; + + for (let i = 0; i < hostDirectiveOutputs.length; i += 2) { + const index = hostDirectiveOutputs[i] as number; + + if (index >= hostDirectivesStart && index <= hostDirectivesEnd) { + ngDevMode && assertIndexInRange(lView, index); + hasOutput = true; + listenToOutput( + tNode, + tView, + lView, + index, + hostDirectiveOutputs[i + 1] as string, + eventName, + listenerFn, + lCleanup, + tCleanup, + ); + } else if (index > hostDirectivesEnd) { + break; + } + } + } + + if (target.outputs.hasOwnProperty(eventName)) { + ngDevMode && assertIndexInRange(lView, hostIndex); + hasOutput = true; + listenToOutput( + tNode, + tView, + lView, + hostIndex, + eventName, + eventName, + listenerFn, + lCleanup, + tCleanup, + ); + } + + return hasOutput; +} + +export function listenToOutput( + tNode: TNode, + tView: TView, + lView: LView, + index: number, + lookupName: string, + eventName: string, + listenerFn: (e?: any) => any, + lCleanup: any[], + tCleanup: any[] | null, +) { + ngDevMode && assertIndexInRange(lView, index); + const instance = lView[index]; + const def = tView.data[index] as DirectiveDef; + const propertyName = def.outputs[lookupName]; + const output = instance[propertyName]; + + if (ngDevMode && !isOutputSubscribable(output)) { + throw new Error(`@Output ${propertyName} not initialized in '${instance.constructor.name}'.`); + } + + const subscription = (output as SubscribableOutput).subscribe(listenerFn); + const idx = lCleanup.length; + lCleanup.push(listenerFn, subscription); + tCleanup && tCleanup.push(eventName, tNode.index, idx, -(idx + 1)); +} + +/** + * Whether the given value represents a subscribable output. + * + * For example, an `EventEmitter, a `Subject`, an `Observable` or an + * `OutputEmitter`. + */ +function isOutputSubscribable(value: unknown): value is SubscribableOutput { + return ( + value != null && typeof (value as Partial>).subscribe === 'function' + ); +} diff --git a/packages/core/src/render3/view/listeners.ts b/packages/core/src/render3/view/listeners.ts new file mode 100644 index 00000000000..2d714e67b11 --- /dev/null +++ b/packages/core/src/render3/view/listeners.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +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 {INJECTOR, LView} from '../interfaces/view'; +import {getComponentLViewByIndex} 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'; + +/** + * Wraps an event listener with a function that marks ancestors dirty and prevents default behavior, + * if applicable. + * + * @param tNode The TNode associated with this listener + * @param lView The LView that contains this listener + * @param listenerFn The listener function to call + * @param wrapWithPreventDefault Whether or not to prevent default behavior + * (the procedural renderer does this already, so in those cases, we should skip) + */ +export function wrapListener( + tNode: TNode, + lView: LView<{} | null>, + context: {} | null, + listenerFn: (e?: any) => any, +): EventListener { + // Note: we are performing most of the work in the listener function itself + // to optimize listener registration. + return function wrapListenerIn_markDirtyAndPreventDefault(e: any) { + // Ivy uses `Function` as a special token that allows us to unwrap the function + // so that it can be invoked programmatically by `DebugNode.triggerEventHandler`. + if (e === Function) { + return listenerFn; + } + + // In order to be backwards compatible with View Engine, events on component host nodes + // must also mark the component view itself dirty (i.e. the view that it owns). + const startView = isComponentHost(tNode) ? getComponentLViewByIndex(tNode.index, lView) : lView; + markViewDirty(startView, NotificationSource.Listener); + + let result = executeListenerWithErrorHandling(lView, context, listenerFn, e); + // A just-invoked listener function might have coalesced listeners so we need to check for + // their presence and invoke as needed. + let nextListenerFn = (wrapListenerIn_markDirtyAndPreventDefault).__ngNextListenerFn__; + while (nextListenerFn) { + // We should prevent default if any of the listeners explicitly return false + result = executeListenerWithErrorHandling(lView, context, nextListenerFn, e) && result; + nextListenerFn = (nextListenerFn).__ngNextListenerFn__; + } + + return result; + }; +} + +function executeListenerWithErrorHandling( + lView: LView, + context: {} | null, + listenerFn: (e?: any) => any, + e: any, +): boolean { + const prevConsumer = setActiveConsumer(null); + try { + profiler(ProfilerEvent.OutputStart, context, listenerFn); + // Only explicitly returning false from a listener should preventDefault + return listenerFn(e) !== false; + } catch (error) { + // TODO(atscott): This should report to the application error handler, not the ErrorHandler on LView injector + handleError(lView, error); + return false; + } finally { + profiler(ProfilerEvent.OutputEnd, context, listenerFn); + setActiveConsumer(prevConsumer); + } +} + +/** + * Handles an error thrown in an LView. + * @deprecated Use handleUncaughtError to report to application error handler + */ +function handleError(lView: LView, error: any): void { + const injector = lView[INJECTOR]; + if (!injector) { + return; + } + injector.get(ErrorHandler, null)?.handleError(error); +} diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json index ac7de57415f..88fabe10c08 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -466,6 +466,7 @@ "init_di_attr", "init_di_setup", "init_directive", + "init_directive_outputs", "init_directives", "init_directives2", "init_discovery", @@ -581,6 +582,7 @@ "init_linker", "init_list_reconciliation", "init_listener", + "init_listeners", "init_local_compilation", "init_locale_data_api", "init_locale_en",