Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 87 additions & 2 deletions 89 packages/core/src/event_delegation_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
*/

// tslint:disable:no-duplicate-imports
import {EventContract} from '../primitives/event-dispatch';
import type {EventContract} from '../primitives/event-dispatch';
import {Attribute} from '../primitives/event-dispatch';
import {APP_ID} from './application/application_tokens';
import {InjectionToken} from './di';
import {RElement} from './render3/interfaces/renderer_dom';
import type {RElement, RNode} from './render3/interfaces/renderer_dom';
import {INJECTOR, type LView} from './render3/interfaces/view';

export const DEFER_BLOCK_SSR_ID_ATTRIBUTE = 'ngb';

Expand Down Expand Up @@ -109,3 +111,86 @@ export function invokeListeners(event: Event, currentTarget: Element | null) {
handler(event);
}
}

/** Shorthand for an event listener callback function to reduce duplication. */
type EventCallback = (event?: any) => any;

/**
* Represents a signature of 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.
*/
type StashEventListener = (el: RNode, eventName: string, listenerFn: EventCallback) => void;

const stashEventListeners = new Map<string, StashEventListener>();

/**
* Registers a stashing function for a specific application ID.
*
* @param appId The unique identifier for the application instance.
* @param fn The stashing function to associate with this app ID.
* @returns A cleanup function that removes the stashing function when called.
*/
export function setStashFn(appId: string, fn: StashEventListener) {
stashEventListeners.set(appId, fn);
return () => stashEventListeners.delete(appId);
}

/**
* Indicates whether the stashing code was added, prevents adding it multiple times.
*/
let isStashEventListenerImplEnabled = false;

let _stashEventListenerImpl = (
lView: LView,
target: RElement | EventTarget,
eventName: string,
listenerFn: EventCallback,
) => {};

/**
* Optionally stashes an event listener for later replay during hydration.
*
* This function delegates to an internal `_stashEventListenerImpl`, which may
* be a no-op unless the event replay feature is enabled. When active, this
* allows capturing event listener metadata before hydration completes, so that
* user interactions during SSR can be replayed.
*
* @param lView The logical view (LView) where the listener is being registered.
* @param target The DOM element or event target the listener is attached to.
* @param eventName The name of the event being listened for (e.g., 'click').
* @param listenerFn The event handler that was registered.
*/
export function stashEventListenerImpl(
lView: LView,
target: RElement | EventTarget,
eventName: string,
listenerFn: EventCallback,
): void {
_stashEventListenerImpl(lView, target, eventName, listenerFn);
}

/**
* Enables the event listener stashing logic in a tree-shakable way.
*
* This function lazily sets the implementation of `_stashEventListenerImpl`
* so that it becomes active only when `withEventReplay` is invoked. This ensures
* that the stashing logic is excluded from production builds unless needed.
*/
export function enableStashEventListenerImpl(): void {
if (!isStashEventListenerImplEnabled) {
_stashEventListenerImpl = (
lView: LView,
target: RElement | EventTarget,
eventName: string,
listenerFn: EventCallback,
) => {
const appId = lView[INJECTOR].get(APP_ID);
const stashEventListener = stashEventListeners.get(appId);
stashEventListener?.(target as RElement, eventName, listenerFn);
};

isStashEventListenerImplEnabled = true;
}
}
31 changes: 18 additions & 13 deletions 31 packages/core/src/hydration/event_replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {clearStashFn, 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';
Expand All @@ -40,6 +39,8 @@ import {
JSACTION_EVENT_CONTRACT,
invokeListeners,
removeListeners,
setStashFn,
enableStashEventListenerImpl,
} from '../event_delegation_utils';
import {APP_ID} from '../application/application_tokens';
import {performanceMarkFeature} from '../util/performance';
Expand Down Expand Up @@ -106,15 +107,23 @@ export function withEventReplay(): Provider[] {
if (!appsWithEventReplay.has(appRef)) {
const jsActionMap = inject(JSACTION_BLOCK_ELEMENT_MAP);
if (shouldEnableEventReplay(injector)) {
enableStashEventListenerImpl();
const appId = injector.get(APP_ID);
setStashFn(appId, (rEl: RNode, eventName: string, listenerFn: VoidFunction) => {
// If a user binds to a ng-container and uses a directive that binds using a host listener,
// this element could be a comment node. So we need to ensure we have an actual element
// node before stashing anything.
if ((rEl as Node).nodeType !== Node.ELEMENT_NODE) return;
sharedStashFunction(rEl as RElement, eventName, listenerFn);
sharedMapFunction(rEl as RElement, jsActionMap);
});
const clearStashFn = setStashFn(
appId,
(rEl: RNode, eventName: string, listenerFn: VoidFunction) => {
// If a user binds to a ng-container and uses a directive that binds using a host listener,
// this element could be a comment node. So we need to ensure we have an actual element
// node before stashing anything.
if ((rEl as Node).nodeType !== Node.ELEMENT_NODE) return;
sharedStashFunction(rEl as RElement, eventName, listenerFn);
sharedMapFunction(rEl as RElement, jsActionMap);
},
);
// Clean up the reference to the function set by the environment initializer,
// as the function closure may capture injected elements and prevent them
// from being properly garbage collected.
appRef.onDestroy(clearStashFn);
}
}
},
Expand Down Expand Up @@ -145,10 +154,6 @@ export function withEventReplay(): Provider[] {
// no elements are still captured in the global list and are not prevented
// from being garbage collected.
clearAppScopedEarlyEventContract(appId);
// Clean up the reference to the function set by the environment initializer,
// as the function closure may capture injected elements and prevent them
// from being properly garbage collected.
clearStashFn(appId);
}
});

Expand Down
29 changes: 4 additions & 25 deletions 29 packages/core/src/render3/instructions/listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {APP_ID} from '../../application/application_tokens';
import {stashEventListenerImpl} from '../../event_delegation_utils';
import {TNode, TNodeType} from '../interfaces/node';
import {GlobalTargetResolver, Renderer} from '../interfaces/renderer';
import {RElement, RNode} from '../interfaces/renderer_dom';
import {RElement} from '../interfaces/renderer_dom';
import {isDirectiveHost} from '../interfaces/type_checks';
import {CLEANUP, CONTEXT, INJECTOR, LView, RENDERER, TView} from '../interfaces/view';
import {CLEANUP, LView, RENDERER, TView} from '../interfaces/view';
import {assertTNodeType} from '../node_assert';
import {getCurrentDirectiveDef, getCurrentTNode, getLView, getTView} from '../state';
import {
Expand All @@ -25,24 +25,6 @@ import {listenToOutput} from '../view/directive_outputs';
import {wrapListener} from '../view/listeners';
import {loadComponentRenderer} from './shared';

/**
* Represents a signature of 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.
*/
type StashEventListener = (el: RNode, eventName: string, listenerFn: (e?: any) => any) => void;

const stashEventListeners = new Map<string, StashEventListener>();

export function setStashFn(appId: string, fn: StashEventListener) {
stashEventListeners.set(appId, fn);
}

export function clearStashFn(appId: string) {
stashEventListeners.delete(appId);
}

/**
* Adds an event listener to the current node.
*
Expand Down Expand Up @@ -161,7 +143,6 @@ export function listenerInternal(
const isTNodeDirectiveHost = isDirectiveHost(tNode);
const firstCreatePass = tView.firstCreatePass;
const tCleanup = firstCreatePass ? getOrCreateTViewCleanup(tView) : null;
const context = lView[CONTEXT];

// 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
Expand Down Expand Up @@ -218,9 +199,7 @@ export function listenerInternal(
processOutputs = false;
} else {
listenerFn = wrapListener(tNode, lView, listenerFn);
const appId = lView[INJECTOR].get(APP_ID);
const stashEventListener = stashEventListeners.get(appId);
stashEventListener?.(target as RElement, eventName, listenerFn);
stashEventListenerImpl(lView, target, eventName, listenerFn);
const cleanupFn = renderer.listen(target as RElement, eventName, listenerFn);
ngDevMode && ngDevMode.rendererAddEventListener++;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -611,7 +611,6 @@
"signal",
"signalAsReadonlyFn",
"signalSetFn",
"stashEventListeners",
"storeLViewOnDestroy",
"stringify",
"stringifyCSSSelector",
Expand Down Expand Up @@ -661,4 +660,4 @@
"ɵɵproperty",
"ɵɵtemplate",
"ɵɵtext"
]
]
Original file line number Diff line number Diff line change
Expand Up @@ -606,7 +606,6 @@
"signal",
"signalAsReadonlyFn",
"signalSetFn",
"stashEventListeners",
"storeLViewOnDestroy",
"stringify",
"stringifyCSSSelector",
Expand Down Expand Up @@ -661,4 +660,4 @@
"ɵɵtext",
"ɵɵtwoWayListener",
"ɵɵtwoWayProperty"
]
]
Original file line number Diff line number Diff line change
Expand Up @@ -690,7 +690,6 @@
"split",
"squashSegmentGroup",
"standardizeConfig",
"stashEventListeners",
"storeLViewOnDestroy",
"stringify",
"stringify12",
Expand Down Expand Up @@ -749,4 +748,4 @@
"ɵɵsanitizeUrl",
"ɵɵtext",
"ɵɵtextInterpolate1"
]
]
Morty Proxy This is a proxified and sanitized view of the page, visit original site.