diff --git a/goldens/public-api/router/router.d.ts b/goldens/public-api/router/router.d.ts index a09f01f799..9f64ce7520 100644 --- a/goldens/public-api/router/router.d.ts +++ b/goldens/public-api/router/router.d.ts @@ -64,7 +64,7 @@ export declare interface CanDeactivate { } export declare interface CanLoad { - canLoad(route: Route, segments: UrlSegment[]): Observable | Promise | boolean; + canLoad(route: Route, segments: UrlSegment[]): Observable | Promise | boolean | UrlTree; } export declare class ChildActivationEnd { diff --git a/packages/router/src/apply_redirects.ts b/packages/router/src/apply_redirects.ts index 16d2e2d9fb..ad39710e9e 100644 --- a/packages/router/src/apply_redirects.ts +++ b/packages/router/src/apply_redirects.ts @@ -8,7 +8,7 @@ import {Injector, NgModuleRef} from '@angular/core'; 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 {CanLoadFn} from './interfaces'; @@ -16,7 +16,7 @@ import {RouterConfigLoader} from './router_config_loader'; import {defaultUrlMatcher, navigationCancelingError, Params, PRIMARY_OUTLET} from './shared'; import {UrlSegment, UrlSegmentGroup, UrlSerializer, UrlTree} from './url_tree'; import {forEach, waitForMap, wrapIntoObservable} from './utils/collection'; -import {isCanLoad, isFunction} from './utils/type_guards'; +import {isCanLoad, isFunction, isUrlTree} from './utils/type_guards'; class NoMatch { public segmentGroup: UrlSegmentGroup|null; @@ -300,9 +300,9 @@ class ApplyRedirects { return of(route._loadedConfig); } - return runCanLoadGuard(ngModule.injector, route, segments) - .pipe(mergeMap((shouldLoad: boolean) => { - if (shouldLoad) { + return this.runCanLoadGuards(ngModule.injector, route, segments) + .pipe(mergeMap((shouldLoadResult: boolean) => { + if (shouldLoadResult) { return this.configLoader.load(ngModule.injector, route) .pipe(map((cfg: LoadedRouterConfig) => { route._loadedConfig = cfg; @@ -316,6 +316,38 @@ class ApplyRedirects { return of(new LoadedRouterConfig([], ngModule)); } + private runCanLoadGuards(moduleInjector: Injector, route: Route, segments: UrlSegment[]): + Observable { + 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(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 { let res: UrlSegment[] = []; let c = urlTree.root; @@ -406,27 +438,6 @@ class ApplyRedirects { } } -function runCanLoadGuard( - moduleInjector: Injector, route: Route, segments: UrlSegment[]): Observable { - 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(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[]): { matched: boolean, consumedSegments: UrlSegment[], diff --git a/packages/router/src/interfaces.ts b/packages/router/src/interfaces.ts index 6f7ce68998..6d6e23b36d 100644 --- a/packages/router/src/interfaces.ts +++ b/packages/router/src/interfaces.ts @@ -339,6 +339,10 @@ export interface Resolve { * @description * * 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 {} @@ -400,8 +404,9 @@ export interface Resolve { * @publicApi */ export interface CanLoad { - canLoad(route: Route, segments: UrlSegment[]): Observable|Promise|boolean; + canLoad(route: Route, segments: UrlSegment[]): + Observable|Promise|boolean|UrlTree; } export type CanLoadFn = (route: Route, segments: UrlSegment[]) => - Observable|Promise|boolean; + Observable|Promise|boolean|UrlTree; diff --git a/packages/router/test/integration.spec.ts b/packages/router/test/integration.spec.ts index b6882f5c6d..4405614281 100644 --- a/packages/router/test/integration.spec.ts +++ b/packages/router/test/integration.spec.ts @@ -3403,6 +3403,13 @@ describe('Integration', () => { TestBed.configureTestingModule({ providers: [ {provide: 'alwaysFalse', useValue: (a: any) => false}, + { + provide: 'returnUrlTree', + useFactory: (router: Router) => () => { + return router.createUrlTree(['blank']); + }, + deps: [Router], + }, { provide: 'returnFalseAndNavigate', 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 // navigateByUrl promise: https://github.com/angular/angular/issues/26284 it('should resolve navigateByUrl promise after CanLoad executes', @@ -4498,57 +4536,87 @@ describe('Integration', () => { }))); describe('preloading', () => { + let log: string[] = []; + @Component({selector: 'lazy', template: 'should not show'}) + class LazyLoadedComponent { + } + + @NgModule({ + declarations: [LazyLoadedComponent], + imports: [RouterModule.forChild([{path: 'LoadedModule2', component: LazyLoadedComponent}])] + }) + class LoadedModule2 { + } + + @NgModule( + {imports: [RouterModule.forChild([{path: 'LoadedModule1', loadChildren: 'expected2'}])]}) + class LoadedModule1 { + } + beforeEach(() => { - TestBed.configureTestingModule( - {providers: [{provide: PreloadingStrategy, useExisting: PreloadAllModules}]}); + 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(inject( - [Router, Location, NgModuleFactoryLoader], - (router: Router, location: Location, loader: SpyNgModuleFactoryLoader) => { - @Component({selector: 'lazy', template: 'should not show'}) - class LazyLoadedComponent { - } + it('should work', fakeAsync(() => { + (TestBed.inject(NgModuleFactoryLoader) as SpyNgModuleFactoryLoader).stubbedModules = { + expected: LoadedModule1, + expected2: LoadedModule2 + }; + const router = TestBed.inject(Router); + const fixture = createRoot(router, RootCmp); - @NgModule({ - declarations: [LazyLoadedComponent], - imports: [RouterModule.forChild( - [{path: 'LoadedModule2', component: LazyLoadedComponent}])] - }) - class LoadedModule2 { - } + router.resetConfig( + [{path: 'blank', component: BlankCmp}, {path: 'lazy', loadChildren: 'expected'}]); - @NgModule({ - imports: - [RouterModule.forChild([{path: 'LoadedModule1', loadChildren: 'expected2'}])] - }) - class LoadedModule1 { - } + router.navigateByUrl('/blank'); + advance(fixture); - loader.stubbedModules = {expected: LoadedModule1, expected2: LoadedModule2}; + const config = router.config as any; + const firstConfig = config[1]._loadedConfig!; - const fixture = createRoot(router, RootCmp); + expect(firstConfig).toBeDefined(); + expect(firstConfig.routes[0].path).toEqual('LoadedModule1'); - router.resetConfig([ - {path: 'blank', component: BlankCmp}, {path: 'lazy', loadChildren: 'expected'} - ]); + const secondConfig = firstConfig.routes[0]._loadedConfig!; + expect(secondConfig).toBeDefined(); + expect(secondConfig.routes[0].path).toEqual('LoadedModule2'); + })); - router.navigateByUrl('/blank'); - advance(fixture); + 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); - const config = router.config as any; - const firstConfig = config[1]._loadedConfig!; + router.resetConfig([ + {path: 'blank', component: BlankCmp}, + {path: 'lazy', loadChildren: 'expected', canLoad: ['loggingReturnsTrue']} + ]); - expect(firstConfig).toBeDefined(); - expect(firstConfig.routes[0].path).toEqual('LoadedModule1'); + router.navigateByUrl('/blank'); + advance(fixture); - const secondConfig = firstConfig.routes[0]._loadedConfig!; - expect(secondConfig).toBeDefined(); - expect(secondConfig.routes[0].path).toEqual('LoadedModule2'); - }))); + const config = router.config as any; + const firstConfig = config[1]._loadedConfig!; + + expect(firstConfig).toBeUndefined(); + expect(log.length).toBe(0); + })); }); describe('custom url handling strategies', () => {