Skip to content

Commit 6dc4126

Browse files
arturovtAndrewKushnir
authored andcommitted
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
1 parent 3076254 commit 6dc4126

File tree

2 files changed

+81
-3
lines changed

2 files changed

+81
-3
lines changed

packages/core/src/hydration/event_replay.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,8 @@ export function withEventReplay(): Provider[] {
9898
{
9999
provide: ENVIRONMENT_INITIALIZER,
100100
useValue: () => {
101-
const injector = inject(Injector);
102-
const appRef = injector.get(ApplicationRef);
101+
const appRef = inject(ApplicationRef);
102+
const {injector} = appRef;
103103
// We have to check for the appRef here due to the possibility of multiple apps
104104
// being present on the same page. We only want to enable event replay for the
105105
// apps that actually want it.
@@ -123,8 +123,8 @@ export function withEventReplay(): Provider[] {
123123
provide: APP_BOOTSTRAP_LISTENER,
124124
useFactory: () => {
125125
const appId = inject(APP_ID);
126-
const injector = inject(Injector);
127126
const appRef = inject(ApplicationRef);
127+
const {injector} = appRef;
128128

129129
return () => {
130130
// We have to check for the appRef here due to the possibility of multiple apps
@@ -155,6 +155,16 @@ export function withEventReplay(): Provider[] {
155155
// of the application is completed. This timing is similar to the unclaimed
156156
// dehydrated views cleanup timing.
157157
appRef.whenStable().then(() => {
158+
// Note: we have to check whether the application is destroyed before
159+
// performing other operations with the `injector`.
160+
// The application may be destroyed **before** it becomes stable, so when
161+
// the `whenStable` resolves, the injector might already be in
162+
// a destroyed state. Thus, calling `injector.get` would throw an error
163+
// indicating that the injector has already been destroyed.
164+
if (appRef.destroyed) {
165+
return;
166+
}
167+
158168
const eventContractDetails = injector.get(JSACTION_EVENT_CONTRACT);
159169
initEventReplay(eventContractDetails, injector);
160170
const jsActionMap = injector.get(JSACTION_BLOCK_ELEMENT_MAP);

packages/platform-server/test/event_replay_spec.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@ import {
1313
Directive,
1414
ErrorHandler,
1515
HostListener,
16+
inject,
17+
PendingTasks,
1618
PLATFORM_ID,
1719
} from '@angular/core';
20+
import {isPlatformBrowser} from '@angular/common';
1821
import {withEventReplay} from '@angular/platform-browser';
1922

2023
import {EventPhase} from '@angular/core/primitives/event-dispatch';
@@ -363,6 +366,71 @@ describe('event replay', () => {
363366
expect(getAppContents(html)).toContain('<script nonce="{{nonce}}">window.__jsaction_bootstrap');
364367
});
365368

369+
it('should not throw an error when app is destroyed before becoming stable', async () => {
370+
// Spy manually, because we may not be able to retrieve the `Console`
371+
// after we destroy the application, but we still want to ensure that
372+
// no error is thrown in the console.
373+
const errorSpy = spyOn(console, 'error').and.callThrough();
374+
const logs: string[] = [];
375+
376+
@Component({
377+
selector: 'app',
378+
standalone: true,
379+
template: `
380+
<button id="btn" (click)="onClick()"></button>
381+
`,
382+
})
383+
class AppComponent {
384+
constructor() {
385+
const isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
386+
if (isBrowser) {
387+
const pendingTasks = inject(PendingTasks);
388+
// Given that, in a real-world scenario, some APIs add a pending
389+
// task and don't remove it until the app is destroyed.
390+
// This could be an HTTP request that contributes to app stability
391+
// and does not respond until the app is destroyed.
392+
pendingTasks.add();
393+
}
394+
}
395+
396+
onClick(): void {}
397+
}
398+
const html = await ssr(AppComponent);
399+
const ssrContents = getAppContents(html);
400+
const doc = getDocument();
401+
402+
prepareEnvironment(doc, ssrContents);
403+
resetTViewsFor(AppComponent);
404+
const btn = doc.getElementById('btn')!;
405+
btn.click();
406+
const appRef = await hydrate(doc, AppComponent, {
407+
hydrationFeatures: () => [withEventReplay()],
408+
});
409+
410+
appRef.isStable.subscribe((isStable) => {
411+
logs.push(`isStable=${isStable}`);
412+
});
413+
414+
// Destroy the application before it becomes stable, because we added
415+
// a task and didn't remove it explicitly.
416+
appRef.destroy();
417+
418+
// Wait for a microtask so that `whenStable` resolves.
419+
await Promise.resolve();
420+
421+
expect(logs).toEqual([
422+
'isStable=false',
423+
'isStable=true',
424+
'isStable=false',
425+
// In the end, the application became stable while being destroyed.
426+
'isStable=true',
427+
]);
428+
429+
// Ensure no error has been logged in the console,
430+
// such as "injector has already been destroyed."
431+
expect(errorSpy).not.toHaveBeenCalledWith(/Injector has already been destroyed/);
432+
});
433+
366434
describe('bubbling behavior', () => {
367435
it('should propagate events', async () => {
368436
const onClickSpy = jasmine.createSpy();

0 commit comments

Comments
 (0)