Skip to content

Commit e3da35e

Browse files
arturovtalxhub
authored andcommitted
fix(router): prevent error handling when injector is destroyed (#59457)
In this commit, we prevent error handling when the root injector is already destroyed. This may happen when the observable completes before emitting a value, which would trigger a `catchError` block that attempts to call `runInInjectionContext` on a destroyed injector. PR Close #59457
1 parent 1de822c commit e3da35e

File tree

2 files changed

+60
-1
lines changed

2 files changed

+60
-1
lines changed

packages/router/src/navigation_transition.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import {Location} from '@angular/common';
1010
import {
11+
DestroyRef,
1112
EnvironmentInjector,
1213
inject,
1314
Injectable,
@@ -354,6 +355,7 @@ export class NavigationTransitions {
354355
readonly transitionAbortSubject = new Subject<Error>();
355356
private readonly configLoader = inject(RouterConfigLoader);
356357
private readonly environmentInjector = inject(EnvironmentInjector);
358+
private readonly destroyRef = inject(DestroyRef);
357359
private readonly urlSerializer = inject(UrlSerializer);
358360
private readonly rootContexts = inject(ChildrenOutletContexts);
359361
private readonly location = inject(Location);
@@ -381,11 +383,16 @@ export class NavigationTransitions {
381383
/** @internal */
382384
rootComponentType: Type<any> | null = null;
383385

386+
private destroyed = false;
387+
384388
constructor() {
385389
const onLoadStart = (r: Route) => this.events.next(new RouteConfigLoadStart(r));
386390
const onLoadEnd = (r: Route) => this.events.next(new RouteConfigLoadEnd(r));
387391
this.configLoader.onLoadEndListener = onLoadEnd;
388392
this.configLoader.onLoadStartListener = onLoadStart;
393+
this.destroyRef.onDestroy(() => {
394+
this.destroyed = true;
395+
});
389396
}
390397

391398
complete() {
@@ -831,6 +838,14 @@ export class NavigationTransitions {
831838
}
832839
}),
833840
catchError((e) => {
841+
// If the application is already destroyed, the catch block should not
842+
// execute anything in practice because other resources have already
843+
// been released and destroyed.
844+
if (this.destroyed) {
845+
overallTransitionState.resolve(false);
846+
return EMPTY;
847+
}
848+
834849
errored = true;
835850
/* This error type is issued during Redirect, and is handled as a
836851
* cancellation rather than an error. */

packages/router/test/integration.spec.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
ViewChildren,
2626
ɵConsole as Console,
2727
ɵNoopNgZone as NoopNgZone,
28+
DestroyRef,
2829
} from '@angular/core';
2930
import {ComponentFixture, fakeAsync, inject, TestBed, tick} from '@angular/core/testing';
3031
import {By} from '@angular/platform-browser/src/dom/debug/by';
@@ -74,7 +75,7 @@ import {
7475
UrlTree,
7576
} from '@angular/router';
7677
import {RouterTestingHarness} from '@angular/router/testing';
77-
import {concat, EMPTY, firstValueFrom, Observable, Observer, of, Subscription} from 'rxjs';
78+
import {concat, EMPTY, firstValueFrom, Observable, Observer, of, Subject, Subscription} from 'rxjs';
7879
import {delay, filter, first, last, map, mapTo, takeWhile, tap} from 'rxjs/operators';
7980

8081
import {
@@ -3619,6 +3620,49 @@ for (const browserAPI of ['navigation', 'history'] as const) {
36193620
});
36203621
describe('guards', () => {
36213622
describe('CanActivate', () => {
3623+
describe('guard completes before emitting a value', () => {
3624+
@Injectable({providedIn: 'root'})
3625+
class CompletesBeforeEmitting {
3626+
private subject$ = new Subject<boolean>();
3627+
3628+
constructor(destroyRef: DestroyRef) {
3629+
destroyRef.onDestroy(() => this.subject$.complete());
3630+
}
3631+
3632+
// Note that this is a simple illustrative case of when an observable
3633+
// completes without emitting a value. In a real-world scenario, this
3634+
// might represent an HTTP request that never emits before the app is
3635+
// destroyed and then completes when the app is destroyed.
3636+
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
3637+
return this.subject$;
3638+
}
3639+
}
3640+
3641+
it('should not thrown an unhandled promise rejection', fakeAsync(
3642+
inject([Router], async (router: Router) => {
3643+
const fixture = createRoot(router, RootCmp);
3644+
3645+
const onUnhandledrejection = jasmine.createSpy();
3646+
window.addEventListener('unhandledrejection', onUnhandledrejection);
3647+
3648+
router.resetConfig([
3649+
{path: 'team/:id', component: TeamCmp, canActivate: [CompletesBeforeEmitting]},
3650+
]);
3651+
3652+
router.navigateByUrl('/team/22');
3653+
3654+
// This was previously throwing an error `NG0205: Injector has already been destroyed`.
3655+
fixture.destroy();
3656+
3657+
// Wait until the event task is dispatched.
3658+
await new Promise((resolve) => setTimeout(resolve, 10));
3659+
window.removeEventListener('unhandledrejection', onUnhandledrejection);
3660+
3661+
expect(onUnhandledrejection).not.toHaveBeenCalled();
3662+
}),
3663+
));
3664+
});
3665+
36223666
describe('should not activate a route when CanActivate returns false', () => {
36233667
beforeEach(() => {
36243668
TestBed.configureTestingModule({

0 commit comments

Comments
 (0)