fix(core): check whether application is destroyed before initializing event replay (#59789)

In this commit, we check whether the application is destroyed before initializing event replay. The application may be destroyed before it becomes stable, so when the `whenStable` resolves, the injector might already be in a destroyed state. As a result, calling `injector.get` would throw an error indicating that the injector has already been destroyed.

PR Close #59789
This commit is contained in:
arturovt 2025-01-28 08:50:36 +02:00 committed by Andrew Kushnir
parent 0f3fa5a2af
commit 79ae35577e
2 changed files with 81 additions and 3 deletions

View file

@ -98,8 +98,8 @@ export function withEventReplay(): Provider[] {
{
provide: ENVIRONMENT_INITIALIZER,
useValue: () => {
const injector = inject(Injector);
const appRef = injector.get(ApplicationRef);
const appRef = inject(ApplicationRef);
const {injector} = appRef;
// 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
// apps that actually want it.
@ -123,8 +123,8 @@ export function withEventReplay(): Provider[] {
provide: APP_BOOTSTRAP_LISTENER,
useFactory: () => {
const appId = inject(APP_ID);
const injector = inject(Injector);
const appRef = inject(ApplicationRef);
const {injector} = appRef;
return () => {
// We have to check for the appRef here due to the possibility of multiple apps
@ -155,6 +155,16 @@ export function withEventReplay(): Provider[] {
// of the application is completed. This timing is similar to the unclaimed
// dehydrated views cleanup timing.
appRef.whenStable().then(() => {
// Note: we have to check whether the application is destroyed before
// performing other operations with the `injector`.
// The application may be destroyed **before** it becomes stable, so when
// the `whenStable` resolves, the injector might already be in
// a destroyed state. Thus, calling `injector.get` would throw an error
// indicating that the injector has already been destroyed.
if (appRef.destroyed) {
return;
}
const eventContractDetails = injector.get(JSACTION_EVENT_CONTRACT);
initEventReplay(eventContractDetails, injector);
const jsActionMap = injector.get(JSACTION_BLOCK_ELEMENT_MAP);

View file

@ -13,8 +13,11 @@ import {
Directive,
ErrorHandler,
HostListener,
inject,
PendingTasks,
PLATFORM_ID,
} from '@angular/core';
import {isPlatformBrowser} from '@angular/common';
import {withEventReplay} from '@angular/platform-browser';
import {EventPhase} from '@angular/core/primitives/event-dispatch';
@ -363,6 +366,71 @@ describe('event replay', () => {
expect(getAppContents(html)).toContain('<script nonce="{{nonce}}">window.__jsaction_bootstrap');
});
it('should not throw an error when app is destroyed before becoming stable', async () => {
// Spy manually, because we may not be able to retrieve the `Console`
// after we destroy the application, but we still want to ensure that
// no error is thrown in the console.
const errorSpy = spyOn(console, 'error').and.callThrough();
const logs: string[] = [];
@Component({
selector: 'app',
standalone: true,
template: `
<button id="btn" (click)="onClick()"></button>
`,
})
class AppComponent {
constructor() {
const isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
if (isBrowser) {
const pendingTasks = inject(PendingTasks);
// Given that, in a real-world scenario, some APIs add a pending
// task and don't remove it until the app is destroyed.
// This could be an HTTP request that contributes to app stability
// and does not respond until the app is destroyed.
pendingTasks.add();
}
}
onClick(): void {}
}
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.isStable.subscribe((isStable) => {
logs.push(`isStable=${isStable}`);
});
// Destroy the application before it becomes stable, because we added
// a task and didn't remove it explicitly.
appRef.destroy();
// Wait for a microtask so that `whenStable` resolves.
await Promise.resolve();
expect(logs).toEqual([
'isStable=false',
'isStable=true',
'isStable=false',
// In the end, the application became stable while being destroyed.
'isStable=true',
]);
// Ensure no error has been logged in the console,
// such as "injector has already been destroyed."
expect(errorSpy).not.toHaveBeenCalledWith(/Injector has already been destroyed/);
});
describe('bubbling behavior', () => {
it('should propagate events', async () => {
const onClickSpy = jasmine.createSpy();