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
This commit is contained in:
Pawel Kozlowski
2025-03-18 13:59:03 +01:00
parent afc1d01f7e
commit ae28db0f2e
6 changed files with 270 additions and 231 deletions
+2 -19
View File
@@ -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<T>(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);
},
};
@@ -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<unknown>;
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<unknown>).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 = (<any>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 = (<any>nextListenerFn).__ngNextListenerFn__;
}
return result;
};
}
/** Describes a subscribable output field value. */
interface SubscribableOutput<T> {
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<unknown> {
return (
value != null && typeof (value as Partial<SubscribableOutput<unknown>>).subscribe === 'function'
);
}
/** Listens to an output on a specific directive. */
export function listenToDirectiveOutput(
tNode: TNode,
tView: TView,
lView: LView,
target: DirectiveDef<unknown>,
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;
}
@@ -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.
*
@@ -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<T> {
subscribe(listener: (v: T) => void): {unsubscribe: () => void};
}
export function createOutputListener<T = unknown>(
tNode: TNode,
lView: LView<{} | null>,
listenerFn: (e?: any) => any,
targetDef: DirectiveDef<unknown>,
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<unknown>,
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<unknown>;
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<unknown>).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<unknown> {
return (
value != null && typeof (value as Partial<SubscribableOutput<unknown>>).subscribe === 'function'
);
}
@@ -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 = (<any>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 = (<any>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);
}
@@ -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",