Skip to content

Commit 2e140a1

Browse files
arturovtmmalerba
authored andcommitted
fix(core): prevent stash listener conflicts [patch] (#61063)
Patch version of #59635. PR Close #61063
1 parent dbb8702 commit 2e140a1

File tree

6 files changed

+93
-11
lines changed

6 files changed

+93
-11
lines changed

packages/core/src/hydration/event_replay.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ 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 {setStashFn} from '../render3/instructions/listener';
25+
import {clearStashFn, setStashFn} from '../render3/instructions/listener';
2626
import {RElement, RNode} from '../render3/interfaces/renderer_dom';
2727
import {CLEANUP, LView, TView} from '../render3/interfaces/view';
2828
import {unwrapRNode} from '../render3/util/view_utils';
@@ -106,7 +106,8 @@ export function withEventReplay(): Provider[] {
106106
if (!appsWithEventReplay.has(appRef)) {
107107
const jsActionMap = inject(JSACTION_BLOCK_ELEMENT_MAP);
108108
if (shouldEnableEventReplay(injector)) {
109-
setStashFn((rEl: RNode, eventName: string, listenerFn: VoidFunction) => {
109+
const appId = injector.get(APP_ID);
110+
setStashFn(appId, (rEl: RNode, eventName: string, listenerFn: VoidFunction) => {
110111
// If a user binds to a ng-container and uses a directive that binds using a host listener,
111112
// this element could be a comment node. So we need to ensure we have an actual element
112113
// node before stashing anything.
@@ -122,7 +123,6 @@ export function withEventReplay(): Provider[] {
122123
{
123124
provide: APP_BOOTSTRAP_LISTENER,
124125
useFactory: () => {
125-
const appId = inject(APP_ID);
126126
const appRef = inject(ApplicationRef);
127127
const {injector} = appRef;
128128

@@ -140,14 +140,15 @@ export function withEventReplay(): Provider[] {
140140
appsWithEventReplay.delete(appRef);
141141
// Ensure that we're always safe calling this in the browser.
142142
if (typeof ngServerMode !== 'undefined' && !ngServerMode) {
143+
const appId = injector.get(APP_ID);
143144
// `_ejsa` should be deleted when the app is destroyed, ensuring that
144145
// no elements are still captured in the global list and are not prevented
145146
// from being garbage collected.
146147
clearAppScopedEarlyEventContract(appId);
147148
// Clean up the reference to the function set by the environment initializer,
148149
// as the function closure may capture injected elements and prevent them
149150
// from being properly garbage collected.
150-
setStashFn(() => {});
151+
clearStashFn(appId);
151152
}
152153
});
153154

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

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

9+
import {APP_ID} from '../../application/application_tokens';
910
import {TNode, TNodeType} from '../interfaces/node';
1011
import {GlobalTargetResolver, Renderer} from '../interfaces/renderer';
1112
import {RElement, RNode} from '../interfaces/renderer_dom';
1213
import {isDirectiveHost} from '../interfaces/type_checks';
13-
import {CLEANUP, CONTEXT, LView, RENDERER, TView} from '../interfaces/view';
14+
import {CLEANUP, CONTEXT, INJECTOR, LView, RENDERER, TView} from '../interfaces/view';
1415
import {assertTNodeType} from '../node_assert';
1516
import {getCurrentDirectiveDef, getCurrentTNode, getLView, getTView} from '../state';
1617
import {
@@ -25,15 +26,21 @@ import {wrapListener} from '../view/listeners';
2526
import {loadComponentRenderer} from './shared';
2627

2728
/**
28-
* Contains a reference to a function that disables event replay feature
29+
* Represents a signature of a function that disables event replay feature
2930
* for server-side rendered applications. This function is overridden with
3031
* an actual implementation when the event replay feature is enabled via
3132
* `withEventReplay()` call.
3233
*/
33-
let stashEventListener = (el: RNode, eventName: string, listenerFn: (e?: any) => any) => {};
34+
type StashEventListener = (el: RNode, eventName: string, listenerFn: (e?: any) => any) => void;
3435

35-
export function setStashFn(fn: typeof stashEventListener) {
36-
stashEventListener = fn;
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);
3744
}
3845

3946
/**
@@ -211,7 +218,9 @@ export function listenerInternal(
211218
processOutputs = false;
212219
} else {
213220
listenerFn = wrapListener(tNode, lView, listenerFn);
214-
stashEventListener(target as RElement, eventName, listenerFn);
221+
const appId = lView[INJECTOR].get(APP_ID);
222+
const stashEventListener = stashEventListeners.get(appId);
223+
stashEventListener?.(target as RElement, eventName, listenerFn);
215224
const cleanupFn = renderer.listen(target as RElement, eventName, listenerFn);
216225
ngDevMode && ngDevMode.rendererAddEventListener++;
217226

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,7 @@
611611
"signal",
612612
"signalAsReadonlyFn",
613613
"signalSetFn",
614+
"stashEventListeners",
614615
"storeLViewOnDestroy",
615616
"stringify",
616617
"stringifyCSSSelector",

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,7 @@
606606
"signal",
607607
"signalAsReadonlyFn",
608608
"signalSetFn",
609+
"stashEventListeners",
609610
"storeLViewOnDestroy",
610611
"stringify",
611612
"stringifyCSSSelector",

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,7 @@
690690
"split",
691691
"squashSegmentGroup",
692692
"standardizeConfig",
693+
"stashEventListeners",
693694
"storeLViewOnDestroy",
694695
"stringify",
695696
"stringify12",

packages/platform-server/test/event_replay_spec.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ import {
1818
PLATFORM_ID,
1919
} from '@angular/core';
2020
import {isPlatformBrowser} from '@angular/common';
21-
import {withEventReplay} from '@angular/platform-browser';
21+
import {
22+
bootstrapApplication,
23+
provideClientHydration,
24+
withEventReplay,
25+
} from '@angular/platform-browser';
2226

2327
import {EventPhase} from '@angular/core/primitives/event-dispatch';
2428

@@ -111,6 +115,71 @@ describe('event replay', () => {
111115
expect(onClickSpy).toHaveBeenCalled();
112116
});
113117

118+
it('stash event listeners should not conflict when multiple apps are bootstrapped', async () => {
119+
const onClickSpy = jasmine.createSpy();
120+
121+
@Component({
122+
selector: 'app',
123+
standalone: true,
124+
template: `
125+
<button id="btn-1" (click)="onClick()"></button>
126+
`,
127+
})
128+
class AppComponent_1 {
129+
onClick = onClickSpy;
130+
}
131+
132+
@Component({
133+
selector: 'app-2',
134+
standalone: true,
135+
template: `
136+
<button id="btn-2" (click)="onClick()"></button>
137+
`,
138+
})
139+
class AppComponent_2 {
140+
onClick() {}
141+
}
142+
143+
const hydrationFeatures = () => [withEventReplay()];
144+
const docHtml = `
145+
<html>
146+
<head></head>
147+
<body>
148+
${EVENT_DISPATCH_SCRIPT}
149+
<app></app>
150+
<app-2></app-2>
151+
</body>
152+
</html>
153+
`;
154+
const html = await ssr(AppComponent_1, {hydrationFeatures, doc: docHtml});
155+
const ssrContents = getAppContents(html);
156+
const doc = getDocument();
157+
158+
prepareEnvironment(doc, ssrContents);
159+
resetTViewsFor(AppComponent_1);
160+
161+
const btn = doc.getElementById('btn-1')!;
162+
btn.click();
163+
164+
// It's hard to server-side render multiple applications in this
165+
// particular unit test and hydrate them on the client, so instead,
166+
// let's render the application with `provideClientHydration` to enable
167+
// event replay features and ensure the stash event listener is set.
168+
await bootstrapApplication(AppComponent_2, {
169+
providers: [
170+
provideClientHydration(withEventReplay()),
171+
{provide: APP_ID, useValue: 'random_name'},
172+
],
173+
});
174+
175+
// Now let's hydrate the second application and ensure that the
176+
// button click event has been replayed.
177+
const appRef = await hydrate(doc, AppComponent_1, {hydrationFeatures});
178+
appRef.tick();
179+
180+
expect(onClickSpy).toHaveBeenCalled();
181+
});
182+
114183
it('should cleanup `window._ejsas[appId]` once app is destroyed', async () => {
115184
@Component({
116185
selector: 'app',

0 commit comments

Comments
 (0)