fix(router): routes should not get stale providers (#56798)

This fixes a bug with RouterOutlet and its context where it would reuse
providers from a previously activated route.

fixes #56774

PR Close #56798
This commit is contained in:
Andrew Scott 2024-07-01 15:30:12 -07:00 committed by Jessica Janiuk
parent 445dd96d68
commit b7d3ecc873
8 changed files with 78 additions and 32 deletions

View file

@ -183,7 +183,7 @@ export class ChildActivationStart {
// @public
export class ChildrenOutletContexts {
constructor(parentInjector: EnvironmentInjector);
constructor(rootInjector: EnvironmentInjector);
// (undocumented)
getContext(childName: string): OutletContext | null;
// (undocumented)
@ -530,13 +530,14 @@ export type OnSameUrlNavigation = 'reload' | 'ignore';
// @public
export class OutletContext {
constructor(injector: EnvironmentInjector);
constructor(rootInjector: EnvironmentInjector);
// (undocumented)
attachRef: ComponentRef<any> | null;
// (undocumented)
children: ChildrenOutletContexts;
// (undocumented)
injector: EnvironmentInjector;
get injector(): EnvironmentInjector;
set injector(_: EnvironmentInjector);
// (undocumented)
outlet: RouterOutletContract | null;
// (undocumented)

View file

@ -9,6 +9,9 @@
import {Component} from '@angular/core';
import {RouterOutlet} from '../directives/router_outlet';
import {PRIMARY_OUTLET} from '../shared';
import {Route} from '../models';
export {ɵEmptyOutletComponent as EmptyOutletComponent};
/**
* This component is used internally within the router to be a placeholder when an empty
@ -26,4 +29,20 @@ import {RouterOutlet} from '../directives/router_outlet';
})
export class ɵEmptyOutletComponent {}
export {ɵEmptyOutletComponent as EmptyOutletComponent};
/**
* Makes a copy of the config and adds any default required properties.
*/
export function standardizeConfig(r: Route): Route {
const children = r.children && r.children.map(standardizeConfig);
const c = children ? {...r, children} : {...r};
if (
!c.component &&
!c.loadComponent &&
(children || c.loadChildren) &&
c.outlet &&
c.outlet !== PRIMARY_OUTLET
) {
c.component = ɵEmptyOutletComponent;
}
return c;
}

View file

@ -221,10 +221,8 @@ export class ActivateRoutes {
advanceActivatedRoute(stored.route.value);
this.activateChildRoutes(futureNode, null, context.children);
} else {
const injector = getClosestRouteInjector(future.snapshot);
context.attachRef = null;
context.route = future;
context.injector = injector ?? context.injector;
if (context.outlet) {
// Activate the outlet when it has already been instantiated
// Otherwise it will get activated from its `ngOnInit` when instantiated

View file

@ -54,8 +54,9 @@ import {
UrlSerializer,
UrlTree,
} from './url_tree';
import {standardizeConfig, validateConfig} from './utils/config';
import {validateConfig} from './utils/config';
import {afterNextNavigation} from './utils/navigations';
import {standardizeConfig} from './components/empty_outlet';
function defaultErrorHandler(error: any): never {
throw error;

View file

@ -21,7 +21,8 @@ import {finalize, map, mergeMap, refCount, tap} from 'rxjs/operators';
import {DefaultExport, LoadedRouterConfig, Route, Routes} from './models';
import {wrapIntoObservable} from './utils/collection';
import {assertStandalone, standardizeConfig, validateConfig} from './utils/config';
import {assertStandalone, validateConfig} from './utils/config';
import {standardizeConfig} from './components/empty_outlet';
/**
* The DI token for a router configuration.

View file

@ -10,6 +10,7 @@ import {ComponentRef, EnvironmentInjector, Injectable} from '@angular/core';
import {RouterOutletContract} from './directives/router_outlet';
import {ActivatedRoute} from './router_state';
import {getClosestRouteInjector} from './utils/config';
/**
* Store contextual information about a `RouterOutlet`
@ -19,9 +20,15 @@ import {ActivatedRoute} from './router_state';
export class OutletContext {
outlet: RouterOutletContract | null = null;
route: ActivatedRoute | null = null;
children = new ChildrenOutletContexts(this.injector);
children = new ChildrenOutletContexts(this.rootInjector);
attachRef: ComponentRef<any> | null = null;
constructor(public injector: EnvironmentInjector) {}
get injector(): EnvironmentInjector {
return getClosestRouteInjector(this.route?.snapshot) ?? this.rootInjector;
}
// TODO(atscott): Only here to avoid a "breaking" change in a patch/minor. Remove in v19.
set injector(_: EnvironmentInjector) {}
constructor(private readonly rootInjector: EnvironmentInjector) {}
}
/**
@ -35,7 +42,7 @@ export class ChildrenOutletContexts {
private contexts = new Map<string, OutletContext>();
/** @nodoc */
constructor(private parentInjector: EnvironmentInjector) {}
constructor(private rootInjector: EnvironmentInjector) {}
/** Called when a `RouterOutlet` directive is instantiated */
onChildOutletCreated(childName: string, outlet: RouterOutletContract): void {
@ -75,7 +82,7 @@ export class ChildrenOutletContexts {
let context = this.getContext(childName);
if (!context) {
context = new OutletContext(this.parentInjector);
context = new OutletContext(this.rootInjector);
this.contexts.set(childName, context);
}

View file

@ -15,7 +15,6 @@ import {
ɵRuntimeError as RuntimeError,
} from '@angular/core';
import {EmptyOutletComponent} from '../components/empty_outlet';
import {RuntimeErrorCode} from '../errors';
import {Route, Routes} from '../models';
import {ActivatedRouteSnapshot} from '../router_state';
@ -222,24 +221,6 @@ function getFullPath(parentPath: string, currentRoute: Route): string {
}
}
/**
* Makes a copy of the config and adds any default required properties.
*/
export function standardizeConfig(r: Route): Route {
const children = r.children && r.children.map(standardizeConfig);
const c = children ? {...r, children} : {...r};
if (
!c.component &&
!c.loadComponent &&
(children || c.loadChildren) &&
c.outlet &&
c.outlet !== PRIMARY_OUTLET
) {
c.component = EmptyOutletComponent;
}
return c;
}
/** Returns the `route.outlet` or PRIMARY_OUTLET if none exists. */
export function getOutlet(route: Route): string {
return route.outlet || PRIMARY_OUTLET;
@ -268,7 +249,7 @@ export function sortByMatchingOutlets(routes: Routes, outletName: string): Route
* also used for getting the correct injector to use for creating components.
*/
export function getClosestRouteInjector(
snapshot: ActivatedRouteSnapshot,
snapshot: ActivatedRouteSnapshot | undefined,
): EnvironmentInjector | null {
if (!snapshot) return null;

View file

@ -427,6 +427,44 @@ describe('injectors', () => {
fixture.detectChanges();
expect(childTokenValue).toEqual(null);
});
it('should not get sibling providers', async () => {
let childTokenValue: any = null;
const TOKEN = new InjectionToken<any>('');
@Component({
template: '',
standalone: true,
})
class Child {
constructor() {
childTokenValue = inject(TOKEN, {optional: true});
}
}
@Component({
template: '<router-outlet/>',
imports: [RouterOutlet],
standalone: true,
})
class App {}
TestBed.configureTestingModule({
providers: [
provideRouter([
{path: 'a', providers: [{provide: TOKEN, useValue: 'a value'}], component: Child},
{path: 'b', component: Child},
]),
],
});
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
await TestBed.inject(Router).navigateByUrl('/a');
fixture.detectChanges();
expect(childTokenValue).toEqual('a value');
await TestBed.inject(Router).navigateByUrl('/b');
fixture.detectChanges();
expect(childTokenValue).toEqual(null);
});
});
function advance(fixture: ComponentFixture<unknown>, millis?: number): void {