Skip to content

Commit f6e9776

Browse files
arturovtAndrewKushnir
authored andcommitted
fix(core): cleanup _ejsa when app is destroyed (angular#59492)
In this commit, we delete `_ejsa` when the app is destroyed, ensuring that no elements are still captured in the global list and are not prevented from being garbage collected. PR Close angular#59492
1 parent c51c66e commit f6e9776

File tree

2 files changed

+48
-2
lines changed

2 files changed

+48
-2
lines changed

packages/core/src/hydration/event_replay.ts

+13-1
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,10 @@ export function withEventReplay(): Provider[] {
118118
{
119119
provide: APP_BOOTSTRAP_LISTENER,
120120
useFactory: () => {
121+
const appId = inject(APP_ID);
121122
const injector = inject(Injector);
122123
const appRef = inject(ApplicationRef);
124+
123125
return () => {
124126
// We have to check for the appRef here due to the possibility of multiple apps
125127
// being present on the same page. We only want to enable event replay for the
@@ -129,7 +131,17 @@ export function withEventReplay(): Provider[] {
129131
}
130132

131133
appsWithEventReplay.add(appRef);
132-
appRef.onDestroy(() => appsWithEventReplay.delete(appRef));
134+
135+
appRef.onDestroy(() => {
136+
appsWithEventReplay.delete(appRef);
137+
// Ensure that we're always safe calling this in the browser.
138+
if (typeof ngServerMode !== 'undefined' && !ngServerMode) {
139+
// `_ejsa` should be deleted when the app is destroyed, ensuring that
140+
// no elements are still captured in the global list and are not prevented
141+
// from being garbage collected.
142+
clearAppScopedEarlyEventContract(appId);
143+
}
144+
});
133145

134146
// Kick off event replay logic once hydration for the initial part
135147
// of the application is completed. This timing is similar to the unclaimed

packages/platform-server/test/event_replay_spec.ts

+35-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {Component, destroyPlatform, ErrorHandler, PLATFORM_ID, Type} from '@angular/core';
9+
import {APP_ID, Component, destroyPlatform, ErrorHandler, PLATFORM_ID, Type} from '@angular/core';
1010
import {
1111
withEventReplay,
1212
bootstrapApplication,
@@ -142,6 +142,40 @@ describe('event replay', () => {
142142
expect(onClickSpy).toHaveBeenCalled();
143143
});
144144

145+
it('should cleanup `window._ejsas[appId]` once app is destroyed', async () => {
146+
@Component({
147+
selector: 'app',
148+
standalone: true,
149+
template: `
150+
<button id="btn" (click)="onClick()"></button>
151+
`,
152+
})
153+
class AppComponent {
154+
onClick() {}
155+
}
156+
157+
const html = await ssr(AppComponent);
158+
const ssrContents = getAppContents(html);
159+
const doc = getDocument();
160+
161+
prepareEnvironment(doc, ssrContents);
162+
resetTViewsFor(AppComponent);
163+
164+
const btn = doc.getElementById('btn')!;
165+
btn.click();
166+
167+
const appRef = await hydrate(doc, AppComponent, {
168+
hydrationFeatures: () => [withEventReplay()],
169+
});
170+
appRef.tick();
171+
const appId = appRef.injector.get(APP_ID);
172+
173+
appRef.destroy();
174+
// This ensure that `_ejsas` for the current application is cleaned up
175+
// once the application is destroyed.
176+
expect(window._ejsas![appId]).toBeUndefined();
177+
});
178+
145179
it('should route to the appropriate component with content projection', async () => {
146180
const outerOnClickSpy = jasmine.createSpy();
147181
const innerOnClickSpy = jasmine.createSpy();

0 commit comments

Comments
 (0)