Skip to content

Commit eb53bda

Browse files
arturovtalxhub
authored andcommitted
fix(core): enable stashing only when withEventReplay() is invoked (#61352)
Patch version of #61077. PR Close #61352
1 parent dfed15f commit eb53bda

File tree

6 files changed

+112
-46
lines changed

6 files changed

+112
-46
lines changed

packages/core/src/event_delegation_utils.ts

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
*/
88

99
// tslint:disable:no-duplicate-imports
10-
import {EventContract} from '../primitives/event-dispatch';
10+
import type {EventContract} from '../primitives/event-dispatch';
1111
import {Attribute} from '../primitives/event-dispatch';
12+
import {APP_ID} from './application/application_tokens';
1213
import {InjectionToken} from './di';
13-
import {RElement} from './render3/interfaces/renderer_dom';
14+
import type {RElement, RNode} from './render3/interfaces/renderer_dom';
15+
import {INJECTOR, type LView} from './render3/interfaces/view';
1416

1517
export const DEFER_BLOCK_SSR_ID_ATTRIBUTE = 'ngb';
1618

@@ -109,3 +111,86 @@ export function invokeListeners(event: Event, currentTarget: Element | null) {
109111
handler(event);
110112
}
111113
}
114+
115+
/** Shorthand for an event listener callback function to reduce duplication. */
116+
type EventCallback = (event?: any) => any;
117+
118+
/**
119+
* Represents a signature of a function that disables event replay feature
120+
* for server-side rendered applications. This function is overridden with
121+
* an actual implementation when the event replay feature is enabled via
122+
* `withEventReplay()` call.
123+
*/
124+
type StashEventListener = (el: RNode, eventName: string, listenerFn: EventCallback) => void;
125+
126+
const stashEventListeners = new Map<string, StashEventListener>();
127+
128+
/**
129+
* Registers a stashing function for a specific application ID.
130+
*
131+
* @param appId The unique identifier for the application instance.
132+
* @param fn The stashing function to associate with this app ID.
133+
* @returns A cleanup function that removes the stashing function when called.
134+
*/
135+
export function setStashFn(appId: string, fn: StashEventListener) {
136+
stashEventListeners.set(appId, fn);
137+
return () => stashEventListeners.delete(appId);
138+
}
139+
140+
/**
141+
* Indicates whether the stashing code was added, prevents adding it multiple times.
142+
*/
143+
let isStashEventListenerImplEnabled = false;
144+
145+
let _stashEventListenerImpl = (
146+
lView: LView,
147+
target: RElement | EventTarget,
148+
eventName: string,
149+
listenerFn: EventCallback,
150+
) => {};
151+
152+
/**
153+
* Optionally stashes an event listener for later replay during hydration.
154+
*
155+
* This function delegates to an internal `_stashEventListenerImpl`, which may
156+
* be a no-op unless the event replay feature is enabled. When active, this
157+
* allows capturing event listener metadata before hydration completes, so that
158+
* user interactions during SSR can be replayed.
159+
*
160+
* @param lView The logical view (LView) where the listener is being registered.
161+
* @param target The DOM element or event target the listener is attached to.
162+
* @param eventName The name of the event being listened for (e.g., 'click').
163+
* @param listenerFn The event handler that was registered.
164+
*/
165+
export function stashEventListenerImpl(
166+
lView: LView,
167+
target: RElement | EventTarget,
168+
eventName: string,
169+
listenerFn: EventCallback,
170+
): void {
171+
_stashEventListenerImpl(lView, target, eventName, listenerFn);
172+
}
173+
174+
/**
175+
* Enables the event listener stashing logic in a tree-shakable way.
176+
*
177+
* This function lazily sets the implementation of `_stashEventListenerImpl`
178+
* so that it becomes active only when `withEventReplay` is invoked. This ensures
179+
* that the stashing logic is excluded from production builds unless needed.
180+
*/
181+
export function enableStashEventListenerImpl(): void {
182+
if (!isStashEventListenerImplEnabled) {
183+
_stashEventListenerImpl = (
184+
lView: LView,
185+
target: RElement | EventTarget,
186+
eventName: string,
187+
listenerFn: EventCallback,
188+
) => {
189+
const appId = lView[INJECTOR].get(APP_ID);
190+
const stashEventListener = stashEventListeners.get(appId);
191+
stashEventListener?.(target as RElement, eventName, listenerFn);
192+
};
193+
194+
isStashEventListenerImplEnabled = true;
195+
}
196+
}

packages/core/src/hydration/event_replay.ts

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import {APP_BOOTSTRAP_LISTENER, ApplicationRef} from '../application/application
2222
import {ENVIRONMENT_INITIALIZER, Injector} from '../di';
2323
import {inject} from '../di/injector_compatibility';
2424
import {Provider} from '../di/interface/provider';
25-
import {clearStashFn, setStashFn} from '../render3/instructions/listener';
2625
import {RElement, RNode} from '../render3/interfaces/renderer_dom';
2726
import {CLEANUP, LView, TView} from '../render3/interfaces/view';
2827
import {unwrapRNode} from '../render3/util/view_utils';
@@ -40,6 +39,8 @@ import {
4039
JSACTION_EVENT_CONTRACT,
4140
invokeListeners,
4241
removeListeners,
42+
setStashFn,
43+
enableStashEventListenerImpl,
4344
} from '../event_delegation_utils';
4445
import {APP_ID} from '../application/application_tokens';
4546
import {performanceMarkFeature} from '../util/performance';
@@ -106,15 +107,23 @@ export function withEventReplay(): Provider[] {
106107
if (!appsWithEventReplay.has(appRef)) {
107108
const jsActionMap = inject(JSACTION_BLOCK_ELEMENT_MAP);
108109
if (shouldEnableEventReplay(injector)) {
110+
enableStashEventListenerImpl();
109111
const appId = injector.get(APP_ID);
110-
setStashFn(appId, (rEl: RNode, eventName: string, listenerFn: VoidFunction) => {
111-
// If a user binds to a ng-container and uses a directive that binds using a host listener,
112-
// this element could be a comment node. So we need to ensure we have an actual element
113-
// node before stashing anything.
114-
if ((rEl as Node).nodeType !== Node.ELEMENT_NODE) return;
115-
sharedStashFunction(rEl as RElement, eventName, listenerFn);
116-
sharedMapFunction(rEl as RElement, jsActionMap);
117-
});
112+
const clearStashFn = setStashFn(
113+
appId,
114+
(rEl: RNode, eventName: string, listenerFn: VoidFunction) => {
115+
// If a user binds to a ng-container and uses a directive that binds using a host listener,
116+
// this element could be a comment node. So we need to ensure we have an actual element
117+
// node before stashing anything.
118+
if ((rEl as Node).nodeType !== Node.ELEMENT_NODE) return;
119+
sharedStashFunction(rEl as RElement, eventName, listenerFn);
120+
sharedMapFunction(rEl as RElement, jsActionMap);
121+
},
122+
);
123+
// Clean up the reference to the function set by the environment initializer,
124+
// as the function closure may capture injected elements and prevent them
125+
// from being properly garbage collected.
126+
appRef.onDestroy(clearStashFn);
118127
}
119128
}
120129
},
@@ -145,10 +154,6 @@ export function withEventReplay(): Provider[] {
145154
// no elements are still captured in the global list and are not prevented
146155
// from being garbage collected.
147156
clearAppScopedEarlyEventContract(appId);
148-
// Clean up the reference to the function set by the environment initializer,
149-
// as the function closure may capture injected elements and prevent them
150-
// from being properly garbage collected.
151-
clearStashFn(appId);
152157
}
153158
});
154159

packages/core/src/render3/instructions/listener.ts

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {APP_ID} from '../../application/application_tokens';
9+
import {stashEventListenerImpl} from '../../event_delegation_utils';
1010
import {TNode, TNodeType} from '../interfaces/node';
1111
import {GlobalTargetResolver, Renderer} from '../interfaces/renderer';
12-
import {RElement, RNode} from '../interfaces/renderer_dom';
12+
import {RElement} from '../interfaces/renderer_dom';
1313
import {isDirectiveHost} from '../interfaces/type_checks';
14-
import {CLEANUP, CONTEXT, INJECTOR, LView, RENDERER, TView} from '../interfaces/view';
14+
import {CLEANUP, LView, RENDERER, TView} from '../interfaces/view';
1515
import {assertTNodeType} from '../node_assert';
1616
import {getCurrentDirectiveDef, getCurrentTNode, getLView, getTView} from '../state';
1717
import {
@@ -25,24 +25,6 @@ import {listenToOutput} from '../view/directive_outputs';
2525
import {wrapListener} from '../view/listeners';
2626
import {loadComponentRenderer} from './shared';
2727

28-
/**
29-
* Represents a signature of a function that disables event replay feature
30-
* for server-side rendered applications. This function is overridden with
31-
* an actual implementation when the event replay feature is enabled via
32-
* `withEventReplay()` call.
33-
*/
34-
type StashEventListener = (el: RNode, eventName: string, listenerFn: (e?: any) => any) => void;
35-
36-
const stashEventListeners = new Map<string, StashEventListener>();
37-
38-
export function setStashFn(appId: string, fn: StashEventListener) {
39-
stashEventListeners.set(appId, fn);
40-
}
41-
42-
export function clearStashFn(appId: string) {
43-
stashEventListeners.delete(appId);
44-
}
45-
4628
/**
4729
* Adds an event listener to the current node.
4830
*
@@ -161,7 +143,6 @@ export function listenerInternal(
161143
const isTNodeDirectiveHost = isDirectiveHost(tNode);
162144
const firstCreatePass = tView.firstCreatePass;
163145
const tCleanup = firstCreatePass ? getOrCreateTViewCleanup(tView) : null;
164-
const context = lView[CONTEXT];
165146

166147
// When the ɵɵlistener instruction was generated and is executed we know that there is either a
167148
// native listener or a directive output on this element. As such we we know that we will have to
@@ -218,9 +199,7 @@ export function listenerInternal(
218199
processOutputs = false;
219200
} else {
220201
listenerFn = wrapListener(tNode, lView, listenerFn);
221-
const appId = lView[INJECTOR].get(APP_ID);
222-
const stashEventListener = stashEventListeners.get(appId);
223-
stashEventListener?.(target as RElement, eventName, listenerFn);
202+
stashEventListenerImpl(lView, target, eventName, listenerFn);
224203
const cleanupFn = renderer.listen(target as RElement, eventName, listenerFn);
225204
ngDevMode && ngDevMode.rendererAddEventListener++;
226205

packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -611,7 +611,6 @@
611611
"signal",
612612
"signalAsReadonlyFn",
613613
"signalSetFn",
614-
"stashEventListeners",
615614
"storeLViewOnDestroy",
616615
"stringify",
617616
"stringifyCSSSelector",
@@ -661,4 +660,4 @@
661660
"ɵɵproperty",
662661
"ɵɵtemplate",
663662
"ɵɵtext"
664-
]
663+
]

packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -606,7 +606,6 @@
606606
"signal",
607607
"signalAsReadonlyFn",
608608
"signalSetFn",
609-
"stashEventListeners",
610609
"storeLViewOnDestroy",
611610
"stringify",
612611
"stringifyCSSSelector",
@@ -661,4 +660,4 @@
661660
"ɵɵtext",
662661
"ɵɵtwoWayListener",
663662
"ɵɵtwoWayProperty"
664-
]
663+
]

packages/core/test/bundling/router/bundle.golden_symbols.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -690,7 +690,6 @@
690690
"split",
691691
"squashSegmentGroup",
692692
"standardizeConfig",
693-
"stashEventListeners",
694693
"storeLViewOnDestroy",
695694
"stringify",
696695
"stringify12",
@@ -749,4 +748,4 @@
749748
"ɵɵsanitizeUrl",
750749
"ɵɵtext",
751750
"ɵɵtextInterpolate1"
752-
]
751+
]

0 commit comments

Comments
 (0)