Skip to content

Commit 624be2e

Browse files
arturovtmmalerba
authored andcommitted
fix(core): prevent stash listener conflicts (#59635)
The stash event listener is a global function that might be unsafely overridden if multiple microfrontend applications exist on the page. In this commit, we create a map of `APP_ID` to stash event listener functions. This map prevents conflicts because multiple applications might be bootstrapped simultaneously on the client (one rendered on the server and one rendering only on the client). I.e., the code that might be used is: ```ts // Given that `app-root` is rendered on the server bootstrapApplication(AppComponent, appConfig); bootstrapApplication(BlogRootComponent, appBlogConfig); ``` Two bootstrapped applications would conflict and override each other's code. PR Close #59635
1 parent fc4a56d commit 624be2e

File tree

6 files changed

+92
-10
lines changed

6 files changed

+92
-10
lines changed

packages/core/src/hydration/event_replay.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ import {APP_ID} from '../application/application_tokens';
4444
import {performanceMarkFeature} from '../util/performance';
4545
import {triggerHydrationFromBlockName} from '../defer/triggering';
4646
import {isIncrementalHydrationEnabled} from './utils';
47-
import {setStashFn} from '../render3/view/listeners';
47+
import {clearStashFn, setStashFn} from '../render3/view/listeners';
4848

4949
/** Apps in which we've enabled event replay.
5050
* This is to prevent initializing event replay more than once per app.
@@ -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/view/listeners.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {RElement, RNode} from '../interfaces/renderer_dom';
2626
import {GlobalTargetResolver, Renderer} from '../interfaces/renderer';
2727
import {assertNotSame} from '../../util/assert';
2828
import {handleUncaughtError} from '../instructions/shared';
29+
import {APP_ID} from '../../application/application_tokens';
2930

3031
/** Shorthand for an event listener callback function to reduce duplication. */
3132
export type EventCallback = (event?: any) => any;
@@ -34,15 +35,21 @@ export type EventCallback = (event?: any) => any;
3435
export type WrappedEventCallback = EventCallback & {__wrapped: boolean};
3536

3637
/**
37-
* Contains a reference to a function that disables event replay feature
38+
* Represents a signature of a function that disables event replay feature
3839
* for server-side rendered applications. This function is overridden with
3940
* an actual implementation when the event replay feature is enabled via
4041
* `withEventReplay()` call.
4142
*/
42-
let stashEventListener = (el: RNode, eventName: string, listenerFn: EventCallback) => {};
43+
type StashEventListener = (el: RNode, eventName: string, listenerFn: EventCallback) => void;
4344

44-
export function setStashFn(fn: typeof stashEventListener) {
45-
stashEventListener = fn;
45+
const stashEventListeners = new Map<string, StashEventListener>();
46+
47+
export function setStashFn(appId: string, fn: StashEventListener) {
48+
stashEventListeners.set(appId, fn);
49+
}
50+
51+
export function clearStashFn(appId: string) {
52+
stashEventListeners.delete(appId);
4653
}
4754

4855
/**
@@ -172,7 +179,9 @@ export function listenToDomEvent(
172179
} else {
173180
const native = getNativeByTNode(tNode, lView) as RElement;
174181
const target = eventTargetResolver ? eventTargetResolver(native) : native;
175-
stashEventListener(target as RElement, eventName, wrappedListener);
182+
const appId = lView[INJECTOR].get(APP_ID);
183+
const stashEventListener = stashEventListeners.get(appId);
184+
stashEventListener?.(target as RElement, eventName, wrappedListener);
176185

177186
const cleanupFn = renderer.listen(target as RElement, eventName, wrappedListener);
178187
const idxOrTargetGetter = eventTargetResolver

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
"storeListenerCleanup",
616617
"stringify",

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
"storeListenerCleanup",
611612
"stringify",

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,7 @@
692692
"split",
693693
"squashSegmentGroup",
694694
"standardizeConfig",
695+
"stashEventListeners",
695696
"storeLViewOnDestroy",
696697
"storeListenerCleanup",
697698
"stringify",

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)