Skip to content

Commit

Permalink
fix(core): cleanup _ejsa when app is destroyed (#59492)
Browse files Browse the repository at this point in the history
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 #59492
  • Loading branch information
arturovt authored and AndrewKushnir committed Jan 16, 2025
1 parent 4507109 commit 4eb5418
Show file tree
Hide file tree
Showing 2 changed files with 48 additions and 2 deletions.
14 changes: 13 additions & 1 deletion packages/core/src/hydration/event_replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,10 @@ export function withEventReplay(): Provider[] {
{
provide: APP_BOOTSTRAP_LISTENER,
useFactory: () => {
const appId = inject(APP_ID);
const injector = inject(Injector);
const appRef = inject(ApplicationRef);

return () => {
// We have to check for the appRef here due to the possibility of multiple apps
// being present on the same page. We only want to enable event replay for the
Expand All @@ -129,7 +131,17 @@ export function withEventReplay(): Provider[] {
}

appsWithEventReplay.add(appRef);
appRef.onDestroy(() => appsWithEventReplay.delete(appRef));

appRef.onDestroy(() => {
appsWithEventReplay.delete(appRef);
// Ensure that we're always safe calling this in the browser.
if (typeof ngServerMode !== 'undefined' && !ngServerMode) {
// `_ejsa` should be deleted when the app is destroyed, ensuring that
// no elements are still captured in the global list and are not prevented
// from being garbage collected.
clearAppScopedEarlyEventContract(appId);
}
});

// Kick off event replay logic once hydration for the initial part
// of the application is completed. This timing is similar to the unclaimed
Expand Down
36 changes: 35 additions & 1 deletion packages/platform-server/test/event_replay_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {Component, destroyPlatform, ErrorHandler, PLATFORM_ID, Type} from '@angular/core';
import {APP_ID, Component, destroyPlatform, ErrorHandler, PLATFORM_ID, Type} from '@angular/core';
import {
withEventReplay,
bootstrapApplication,
Expand Down Expand Up @@ -142,6 +142,40 @@ describe('event replay', () => {
expect(onClickSpy).toHaveBeenCalled();
});

it('should cleanup `window._ejsas[appId]` once app is destroyed', async () => {
@Component({
selector: 'app',
standalone: true,
template: `
<button id="btn" (click)="onClick()"></button>
`,
})
class AppComponent {
onClick() {}
}

const html = await ssr(AppComponent);
const ssrContents = getAppContents(html);
const doc = getDocument();

prepareEnvironment(doc, ssrContents);
resetTViewsFor(AppComponent);

const btn = doc.getElementById('btn')!;
btn.click();

const appRef = await hydrate(doc, AppComponent, {
hydrationFeatures: () => [withEventReplay()],
});
appRef.tick();
const appId = appRef.injector.get(APP_ID);

appRef.destroy();
// This ensure that `_ejsas` for the current application is cleaned up
// once the application is destroyed.
expect(window._ejsas![appId]).toBeUndefined();
});

it('should route to the appropriate component with content projection', async () => {
const outerOnClickSpy = jasmine.createSpy();
const innerOnClickSpy = jasmine.createSpy();
Expand Down

0 comments on commit 4eb5418

Please sign in to comment.