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:
parent
cae2a893f2
commit
00e6cb1d62
|
@ -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 {
|
||||||
|
|
|
@ -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[],
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,57 +4536,87 @@ describe('Integration', () => {
|
||||||
})));
|
})));
|
||||||
|
|
||||||
describe('preloading', () => {
|
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(() => {
|
beforeEach(() => {
|
||||||
TestBed.configureTestingModule(
|
log.length = 0;
|
||||||
{providers: [{provide: PreloadingStrategy, useExisting: PreloadAllModules}]});
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
{provide: PreloadingStrategy, useExisting: PreloadAllModules}, {
|
||||||
|
provide: 'loggingReturnsTrue',
|
||||||
|
useValue: () => {
|
||||||
|
log.push('loggingReturnsTrue');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
const preloader = TestBed.inject(RouterPreloader);
|
const preloader = TestBed.inject(RouterPreloader);
|
||||||
preloader.setUpPreloading();
|
preloader.setUpPreloading();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should work',
|
it('should work', fakeAsync(() => {
|
||||||
fakeAsync(inject(
|
(TestBed.inject(NgModuleFactoryLoader) as SpyNgModuleFactoryLoader).stubbedModules = {
|
||||||
[Router, Location, NgModuleFactoryLoader],
|
expected: LoadedModule1,
|
||||||
(router: Router, location: Location, loader: SpyNgModuleFactoryLoader) => {
|
expected2: LoadedModule2
|
||||||
@Component({selector: 'lazy', template: 'should not show'})
|
};
|
||||||
class LazyLoadedComponent {
|
const router = TestBed.inject(Router);
|
||||||
}
|
const fixture = createRoot(router, RootCmp);
|
||||||
|
|
||||||
@NgModule({
|
router.resetConfig(
|
||||||
declarations: [LazyLoadedComponent],
|
[{path: 'blank', component: BlankCmp}, {path: 'lazy', loadChildren: 'expected'}]);
|
||||||
imports: [RouterModule.forChild(
|
|
||||||
[{path: 'LoadedModule2', component: LazyLoadedComponent}])]
|
|
||||||
})
|
|
||||||
class LoadedModule2 {
|
|
||||||
}
|
|
||||||
|
|
||||||
@NgModule({
|
router.navigateByUrl('/blank');
|
||||||
imports:
|
advance(fixture);
|
||||||
[RouterModule.forChild([{path: 'LoadedModule1', loadChildren: 'expected2'}])]
|
|
||||||
})
|
|
||||||
class LoadedModule1 {
|
|
||||||
}
|
|
||||||
|
|
||||||
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([
|
const secondConfig = firstConfig.routes[0]._loadedConfig!;
|
||||||
{path: 'blank', component: BlankCmp}, {path: 'lazy', loadChildren: 'expected'}
|
expect(secondConfig).toBeDefined();
|
||||||
]);
|
expect(secondConfig.routes[0].path).toEqual('LoadedModule2');
|
||||||
|
}));
|
||||||
|
|
||||||
router.navigateByUrl('/blank');
|
it('should not preload when canLoad is present and does not execute guard', fakeAsync(() => {
|
||||||
advance(fixture);
|
(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;
|
router.resetConfig([
|
||||||
const firstConfig = config[1]._loadedConfig!;
|
{path: 'blank', component: BlankCmp},
|
||||||
|
{path: 'lazy', loadChildren: 'expected', canLoad: ['loggingReturnsTrue']}
|
||||||
|
]);
|
||||||
|
|
||||||
expect(firstConfig).toBeDefined();
|
router.navigateByUrl('/blank');
|
||||||
expect(firstConfig.routes[0].path).toEqual('LoadedModule1');
|
advance(fixture);
|
||||||
|
|
||||||
const secondConfig = firstConfig.routes[0]._loadedConfig!;
|
const config = router.config as any;
|
||||||
expect(secondConfig).toBeDefined();
|
const firstConfig = config[1]._loadedConfig!;
|
||||||
expect(secondConfig.routes[0].path).toEqual('LoadedModule2');
|
|
||||||
})));
|
expect(firstConfig).toBeUndefined();
|
||||||
|
expect(log.length).toBe(0);
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('custom url handling strategies', () => {
|
describe('custom url handling strategies', () => {
|
||||||
|
|
Loading…
Reference in New Issue