feat(router): allow CanLoad guard to return UrlTree (#36610)

A CanLoad guard returning UrlTree cancels current navigation and redirects.
This matches the behavior available to `CanActivate` guards added in #26521.

Note that this does not affect preloading. A `CanLoad` guard blocks any
preloading. That is, any route with a `CanLoad` guard is not preloaded
and the guards are not executed as part of preloading.

fixes #28306

PR Close #36610
This commit is contained in:
Andrew Scott 2020-04-13 10:50:44 -07:00 committed by Andrew Kushnir
parent cae2a893f2
commit 00e6cb1d62
4 changed files with 150 additions and 66 deletions

View File

@ -64,7 +64,7 @@ export declare interface CanDeactivate<T> {
} }
export declare interface CanLoad { export declare interface CanLoad {
canLoad(route: Route, segments: UrlSegment[]): Observable<boolean> | Promise<boolean> | boolean; canLoad(route: Route, segments: UrlSegment[]): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree;
} }
export declare class ChildActivationEnd { export declare class ChildActivationEnd {

View File

@ -8,7 +8,7 @@
import {Injector, NgModuleRef} from '@angular/core'; import {Injector, NgModuleRef} from '@angular/core';
import {EmptyError, from, Observable, Observer, of} from 'rxjs'; import {EmptyError, from, Observable, Observer, of} from 'rxjs';
import {catchError, concatAll, every, first, map, mergeMap} from 'rxjs/operators'; import {catchError, concatAll, every, first, map, mergeMap, tap} from 'rxjs/operators';
import {LoadedRouterConfig, Route, Routes} from './config'; import {LoadedRouterConfig, Route, Routes} from './config';
import {CanLoadFn} from './interfaces'; import {CanLoadFn} from './interfaces';
@ -16,7 +16,7 @@ import {RouterConfigLoader} from './router_config_loader';
import {defaultUrlMatcher, navigationCancelingError, Params, PRIMARY_OUTLET} from './shared'; import {defaultUrlMatcher, navigationCancelingError, Params, PRIMARY_OUTLET} from './shared';
import {UrlSegment, UrlSegmentGroup, UrlSerializer, UrlTree} from './url_tree'; import {UrlSegment, UrlSegmentGroup, UrlSerializer, UrlTree} from './url_tree';
import {forEach, waitForMap, wrapIntoObservable} from './utils/collection'; import {forEach, waitForMap, wrapIntoObservable} from './utils/collection';
import {isCanLoad, isFunction} from './utils/type_guards'; import {isCanLoad, isFunction, isUrlTree} from './utils/type_guards';
class NoMatch { class NoMatch {
public segmentGroup: UrlSegmentGroup|null; public segmentGroup: UrlSegmentGroup|null;
@ -300,9 +300,9 @@ class ApplyRedirects {
return of(route._loadedConfig); return of(route._loadedConfig);
} }
return runCanLoadGuard(ngModule.injector, route, segments) return this.runCanLoadGuards(ngModule.injector, route, segments)
.pipe(mergeMap((shouldLoad: boolean) => { .pipe(mergeMap((shouldLoadResult: boolean) => {
if (shouldLoad) { if (shouldLoadResult) {
return this.configLoader.load(ngModule.injector, route) return this.configLoader.load(ngModule.injector, route)
.pipe(map((cfg: LoadedRouterConfig) => { .pipe(map((cfg: LoadedRouterConfig) => {
route._loadedConfig = cfg; route._loadedConfig = cfg;
@ -316,6 +316,38 @@ class ApplyRedirects {
return of(new LoadedRouterConfig([], ngModule)); return of(new LoadedRouterConfig([], ngModule));
} }
private runCanLoadGuards(moduleInjector: Injector, route: Route, segments: UrlSegment[]):
Observable<boolean> {
const canLoad = route.canLoad;
if (!canLoad || canLoad.length === 0) return of(true);
const obs = from(canLoad).pipe(map((injectionToken: any) => {
const guard = moduleInjector.get(injectionToken);
let guardVal;
if (isCanLoad(guard)) {
guardVal = guard.canLoad(route, segments);
} else if (isFunction<CanLoadFn>(guard)) {
guardVal = guard(route, segments);
} else {
throw new Error('Invalid CanLoad guard');
}
return wrapIntoObservable(guardVal);
}));
return obs.pipe(
concatAll(),
tap((result: UrlTree|boolean) => {
if (!isUrlTree(result)) return;
const error: Error&{url?: UrlTree} =
navigationCancelingError(`Redirecting to "${this.urlSerializer.serialize(result)}"`);
error.url = result;
throw error;
}),
every(result => result === true),
);
}
private lineralizeSegments(route: Route, urlTree: UrlTree): Observable<UrlSegment[]> { private lineralizeSegments(route: Route, urlTree: UrlTree): Observable<UrlSegment[]> {
let res: UrlSegment[] = []; let res: UrlSegment[] = [];
let c = urlTree.root; let c = urlTree.root;
@ -406,27 +438,6 @@ class ApplyRedirects {
} }
} }
function runCanLoadGuard(
moduleInjector: Injector, route: Route, segments: UrlSegment[]): Observable<boolean> {
const canLoad = route.canLoad;
if (!canLoad || canLoad.length === 0) return of(true);
const obs = from(canLoad).pipe(map((injectionToken: any) => {
const guard = moduleInjector.get(injectionToken);
let guardVal;
if (isCanLoad(guard)) {
guardVal = guard.canLoad(route, segments);
} else if (isFunction<CanLoadFn>(guard)) {
guardVal = guard(route, segments);
} else {
throw new Error('Invalid CanLoad guard');
}
return wrapIntoObservable(guardVal);
}));
return obs.pipe(concatAll(), every(result => result === true));
}
function match(segmentGroup: UrlSegmentGroup, route: Route, segments: UrlSegment[]): { function match(segmentGroup: UrlSegmentGroup, route: Route, segments: UrlSegment[]): {
matched: boolean, matched: boolean,
consumedSegments: UrlSegment[], consumedSegments: UrlSegment[],

View File

@ -339,6 +339,10 @@ export interface Resolve<T> {
* @description * @description
* *
* Interface that a class can implement to be a guard deciding if children can be loaded. * Interface that a class can implement to be a guard deciding if children can be loaded.
* If all guards return `true`, navigation will continue. If any guard returns `false`,
* navigation will be cancelled. If any guard returns a `UrlTree`, current navigation will
* be cancelled and a new navigation will be kicked off to the `UrlTree` returned from the
* guard.
* *
* ``` * ```
* class UserToken {} * class UserToken {}
@ -400,8 +404,9 @@ export interface Resolve<T> {
* @publicApi * @publicApi
*/ */
export interface CanLoad { export interface CanLoad {
canLoad(route: Route, segments: UrlSegment[]): Observable<boolean>|Promise<boolean>|boolean; canLoad(route: Route, segments: UrlSegment[]):
Observable<boolean|UrlTree>|Promise<boolean|UrlTree>|boolean|UrlTree;
} }
export type CanLoadFn = (route: Route, segments: UrlSegment[]) => export type CanLoadFn = (route: Route, segments: UrlSegment[]) =>
Observable<boolean>|Promise<boolean>|boolean; Observable<boolean|UrlTree>|Promise<boolean|UrlTree>|boolean|UrlTree;

View File

@ -3403,6 +3403,13 @@ describe('Integration', () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [
{provide: 'alwaysFalse', useValue: (a: any) => false}, {provide: 'alwaysFalse', useValue: (a: any) => false},
{
provide: 'returnUrlTree',
useFactory: (router: Router) => () => {
return router.createUrlTree(['blank']);
},
deps: [Router],
},
{ {
provide: 'returnFalseAndNavigate', provide: 'returnFalseAndNavigate',
useFactory: (router: any) => (a: any) => { useFactory: (router: any) => (a: any) => {
@ -3522,6 +3529,37 @@ describe('Integration', () => {
]); ]);
}))); })));
it('should support returning UrlTree from within the guard',
fakeAsync(inject([Router, Location], (router: Router, location: Location) => {
const fixture = createRoot(router, RootCmp);
router.resetConfig([
{path: 'lazyFalse', canLoad: ['returnUrlTree'], loadChildren: 'lazyFalse'},
{path: 'blank', component: BlankCmp}
]);
const recordedEvents: any[] = [];
router.events.forEach(e => recordedEvents.push(e));
router.navigateByUrl('/lazyFalse/loaded');
advance(fixture);
expect(location.path()).toEqual('/blank');
expectEvents(recordedEvents, [
[NavigationStart, '/lazyFalse/loaded'],
// No GuardCheck events as `canLoad` is a special guard that's not actually part of
// the guard lifecycle.
[NavigationCancel, '/lazyFalse/loaded'],
[NavigationStart, '/blank'], [RoutesRecognized, '/blank'],
[GuardsCheckStart, '/blank'], [ChildActivationStart], [ActivationStart],
[GuardsCheckEnd, '/blank'], [ResolveStart, '/blank'], [ResolveEnd, '/blank'],
[ActivationEnd], [ChildActivationEnd], [NavigationEnd, '/blank']
]);
})));
// Regression where navigateByUrl with false CanLoad no longer resolved `false` value on // Regression where navigateByUrl with false CanLoad no longer resolved `false` value on
// navigateByUrl promise: https://github.com/angular/angular/issues/26284 // navigateByUrl promise: https://github.com/angular/angular/issues/26284
it('should resolve navigateByUrl promise after CanLoad executes', it('should resolve navigateByUrl promise after CanLoad executes',
@ -4498,43 +4536,50 @@ describe('Integration', () => {
}))); })));
describe('preloading', () => { describe('preloading', () => {
beforeEach(() => { let log: string[] = [];
TestBed.configureTestingModule(
{providers: [{provide: PreloadingStrategy, useExisting: PreloadAllModules}]});
const preloader = TestBed.inject(RouterPreloader);
preloader.setUpPreloading();
});
it('should work',
fakeAsync(inject(
[Router, Location, NgModuleFactoryLoader],
(router: Router, location: Location, loader: SpyNgModuleFactoryLoader) => {
@Component({selector: 'lazy', template: 'should not show'}) @Component({selector: 'lazy', template: 'should not show'})
class LazyLoadedComponent { class LazyLoadedComponent {
} }
@NgModule({ @NgModule({
declarations: [LazyLoadedComponent], declarations: [LazyLoadedComponent],
imports: [RouterModule.forChild( imports: [RouterModule.forChild([{path: 'LoadedModule2', component: LazyLoadedComponent}])]
[{path: 'LoadedModule2', component: LazyLoadedComponent}])]
}) })
class LoadedModule2 { class LoadedModule2 {
} }
@NgModule({ @NgModule(
imports: {imports: [RouterModule.forChild([{path: 'LoadedModule1', loadChildren: 'expected2'}])]})
[RouterModule.forChild([{path: 'LoadedModule1', loadChildren: 'expected2'}])]
})
class LoadedModule1 { class LoadedModule1 {
} }
loader.stubbedModules = {expected: LoadedModule1, expected2: LoadedModule2}; beforeEach(() => {
log.length = 0;
TestBed.configureTestingModule({
providers: [
{provide: PreloadingStrategy, useExisting: PreloadAllModules}, {
provide: 'loggingReturnsTrue',
useValue: () => {
log.push('loggingReturnsTrue');
return true;
}
}
]
});
const preloader = TestBed.inject(RouterPreloader);
preloader.setUpPreloading();
});
it('should work', fakeAsync(() => {
(TestBed.inject(NgModuleFactoryLoader) as SpyNgModuleFactoryLoader).stubbedModules = {
expected: LoadedModule1,
expected2: LoadedModule2
};
const router = TestBed.inject(Router);
const fixture = createRoot(router, RootCmp); const fixture = createRoot(router, RootCmp);
router.resetConfig([ router.resetConfig(
{path: 'blank', component: BlankCmp}, {path: 'lazy', loadChildren: 'expected'} [{path: 'blank', component: BlankCmp}, {path: 'lazy', loadChildren: 'expected'}]);
]);
router.navigateByUrl('/blank'); router.navigateByUrl('/blank');
advance(fixture); advance(fixture);
@ -4548,7 +4593,30 @@ describe('Integration', () => {
const secondConfig = firstConfig.routes[0]._loadedConfig!; const secondConfig = firstConfig.routes[0]._loadedConfig!;
expect(secondConfig).toBeDefined(); expect(secondConfig).toBeDefined();
expect(secondConfig.routes[0].path).toEqual('LoadedModule2'); expect(secondConfig.routes[0].path).toEqual('LoadedModule2');
}))); }));
it('should not preload when canLoad is present and does not execute guard', fakeAsync(() => {
(TestBed.inject(NgModuleFactoryLoader) as SpyNgModuleFactoryLoader).stubbedModules = {
expected: LoadedModule1,
expected2: LoadedModule2
};
const router = TestBed.inject(Router);
const fixture = createRoot(router, RootCmp);
router.resetConfig([
{path: 'blank', component: BlankCmp},
{path: 'lazy', loadChildren: 'expected', canLoad: ['loggingReturnsTrue']}
]);
router.navigateByUrl('/blank');
advance(fixture);
const config = router.config as any;
const firstConfig = config[1]._loadedConfig!;
expect(firstConfig).toBeUndefined();
expect(log.length).toBe(0);
}));
}); });
describe('custom url handling strategies', () => { describe('custom url handling strategies', () => {