Skip to content

Commit de058bb

Browse files
atscottthePunderWoman
authored andcommittedJun 13, 2022
feat(router): Add CanMatch guard to control whether a Route should match (#46021)
Currently we have two main types of guards: `CanLoad`: decides if we can load a module (used with lazy loading) `CanActivate` and friends. It decides if we can activate/deactivate a route. So we always decide where we want to navigate first ("recognize") and create a new router state snapshot. And only then we run guards to check if the navigation should be allowed. This doesn't handle one very important use case where we want to decide where to navigate based on some data (e.g., who the user is). I suggest to add a new guard that allows us to do that. ``` [ {path: 'home', component: AdminHomePage, canUse: [IsAdmin]}, {path: 'home', component: SimpleHomePage} ] ``` Here, navigating to '/home' will render `AdminHomePage` if the user is an admin and will render 'SimpleHomePage' otherwise. Note that the url will remain '/home'. With the introduction of standalone components and new features in the Router such as `loadComponent`, there's a case for deprecating `CanLoad` and replacing it with the `CanMatch` guard. There are a few reasons for this: * One of the intentions of having separate providers on a Route is that lazy loading should not be an architectural feature of an application. It's an optimization you do for code size. That is, there should not be an architectural feature in the router to specifically control whether to lazy load something or not based on conditions such as authentication. This is a slight nuanced difference between the proposed canUse guard: this guard would control whether you can use the route at all and as a side-effect, whether we download the code. `CanLoad` only specified whether the code should be downloaded so canUse is more powerful and more appropriate. * The naming of `CanLoad` will be potentially misunderstood for the `loadComponent` feature. Because it applies to `loadChildren`, it feels reasonable to think that it will also apply to `loadComponent`. This isn’t the case: since we don't need to load the component until right before activation, we defer the loading until all guards/resolvers have run. When considering the removal of `CanLoad` and replacing it with `CanMatch`, this does inform another decision that needed to be made: whether it makes sense for `CanMatch` guards to return a UrlTree or if they should be restricted to just boolean. The original thought was that no, these new guards should not allow returning UrlTree because that significantly expands the intent of the feature from simply “can I use the route” to “can I use this route, and if not, should I redirect?” I now believe it should allowed to return `UrlTree` for several reasons: * For feature parity with `CanLoad` * Because whether we allow it as a return value or not, developers will still be able to trigger a redirect from the guards using the `Router.navigate` function. * Inevitably, there will be developers who disagree with the philosophical decision to disallow `UrlTree` and we don’t necessarily have a compelling reason to refuse this as a feature. Relates to #16211 - `CanMatch` instead of `CanActivate` would prevent blank screen. Additional work is required to close this issue. This can be accomplished by making the initial navigation result trackable (including the redirects). Resolves #14515 Replaces #16416 Resolves #34231 Resolves #17145 Resolves #12088 PR Close #46021
1 parent 96f5c97 commit de058bb

File tree

16 files changed

+645
-329
lines changed

16 files changed

+645
-329
lines changed
 
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,40 @@
11
// #docplaster
22
// #docregion
3-
import { NgModule } from '@angular/core';
4-
import { RouterModule, Routes } from '@angular/router';
5-
6-
import { AdminComponent } from './admin/admin.component';
7-
import { AdminDashboardComponent } from './admin-dashboard/admin-dashboard.component';
8-
import { ManageCrisesComponent } from './manage-crises/manage-crises.component';
9-
import { ManageHeroesComponent } from './manage-heroes/manage-heroes.component';
3+
import {NgModule} from '@angular/core';
4+
import {RouterModule, Routes} from '@angular/router';
105

116
// #docregion admin-route
12-
import { AuthGuard } from '../auth/auth.guard';
7+
import {AuthGuard} from '../auth/auth.guard';
8+
9+
import {AdminDashboardComponent} from './admin-dashboard/admin-dashboard.component';
10+
import {AdminComponent} from './admin/admin.component';
11+
import {ManageCrisesComponent} from './manage-crises/manage-crises.component';
12+
import {ManageHeroesComponent} from './manage-heroes/manage-heroes.component';
13+
14+
const adminRoutes: Routes = [{
15+
path: 'admin',
16+
component: AdminComponent,
17+
canActivate: [AuthGuard],
1318

14-
const adminRoutes: Routes = [
15-
{
16-
path: 'admin',
17-
component: AdminComponent,
18-
canActivate: [AuthGuard],
19+
// #enddocregion admin-route
20+
// #docregion can-match
21+
canMatch: [AuthGuard],
22+
// #enddocregion can-match
23+
// #docregion admin-route
24+
children: [{
25+
path: '',
1926
children: [
20-
{
21-
path: '',
22-
children: [
23-
{ path: 'crises', component: ManageCrisesComponent },
24-
{ path: 'heroes', component: ManageHeroesComponent },
25-
{ path: '', component: AdminDashboardComponent }
26-
],
27-
// #enddocregion admin-route
28-
canActivateChild: [AuthGuard]
29-
// #docregion admin-route
30-
}
31-
]
32-
}
33-
];
27+
{path: 'crises', component: ManageCrisesComponent},
28+
{path: 'heroes', component: ManageHeroesComponent},
29+
{path: '', component: AdminDashboardComponent}
30+
],
31+
// #enddocregion admin-route
32+
canActivateChild: [AuthGuard]
33+
// #docregion admin-route
34+
}]
35+
}];
3436

35-
@NgModule({
36-
imports: [
37-
RouterModule.forChild(adminRoutes)
38-
],
39-
exports: [
40-
RouterModule
41-
]
42-
})
43-
export class AdminRoutingModule {}
37+
@NgModule({imports: [RouterModule.forChild(adminRoutes)], exports: [RouterModule]})
38+
export class AdminRoutingModule {
39+
}
4440
// #enddocregion

‎aio/content/examples/router/src/app/auth/auth.guard.2.ts

+19-5
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
// #docregion
2-
import { Injectable } from '@angular/core';
3-
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, Router, UrlTree } from '@angular/router';
2+
import {Injectable} from '@angular/core';
3+
import {
4+
ActivatedRouteSnapshot, CanActivate, CanMatch,
5+
Route, Router, RouterStateSnapshot, UrlTree
6+
} from '@angular/router';
47

5-
import { AuthService } from './auth.service';
8+
import {AuthService} from './auth.service';
69

710
@Injectable({
811
providedIn: 'root',
912
})
10-
export class AuthGuard implements CanActivate {
13+
export class AuthGuard implements CanActivate, CanMatch {
1114
constructor(private authService: AuthService, private router: Router) {}
1215

1316
canActivate(
@@ -18,8 +21,19 @@ export class AuthGuard implements CanActivate {
1821
return this.checkLogin(url);
1922
}
2023

24+
// #enddocregion
25+
// #docregion can-match
26+
canMatch(route: Route) {
27+
const url = `/${route.path}`;
28+
return this.checkLogin(url) === true;
29+
}
30+
// #enddocregion can-match
31+
// #docregion
32+
2133
checkLogin(url: string): true|UrlTree {
22-
if (this.authService.isLoggedIn) { return true; }
34+
if (this.authService.isLoggedIn) {
35+
return true;
36+
}
2337

2438
// Store the attempted URL for redirecting
2539
this.authService.redirectUrl = url;

‎aio/content/guide/router-tutorial-toh.md

+20-4
Original file line numberDiff line numberDiff line change
@@ -1646,9 +1646,8 @@ A guard's return value controls the router's behavior:
16461646

16471647
<div class="alert is-helpful">
16481648

1649-
**NOTE**: <br />
1650-
The guard can also tell the router to navigate elsewhere, effectively canceling the current navigation.
1651-
When doing so inside a guard, the guard should return `false`.
1649+
**Note:** The guard can also tell the router to navigate elsewhere, effectively canceling the current navigation.
1650+
When doing so inside a guard, the guard should return `UrlTree`;
16521651

16531652
</div>
16541653

@@ -1675,12 +1674,16 @@ The router supports multiple guard interfaces:
16751674
| [`CanDeactivate`](api/router/CanDeactivate) | To mediate navigation *away* from the current route |
16761675
| [`Resolve`](api/router/Resolve) | To perform route data retrieval *before* route activation |
16771676
| [`CanLoad`](api/router/CanLoad) | To mediate navigation *to* a feature module loaded *asynchronously* |
1677+
| [`CanMatch`](api/router/CanMatch) | To control whether a `Route` should be used at all, even if the `path` matches the URL segment. |
16781678

16791679
You can have multiple guards at every level of a routing hierarchy.
16801680
The router checks the `CanDeactivate` guards first, from the deepest child route to the top.
16811681
Then it checks the `CanActivate` and `CanActivateChild` guards from the top down to the deepest child route.
16821682
If the feature module is loaded asynchronously, the `CanLoad` guard is checked before the module is loaded.
1683-
If *any* guard returns false, pending guards that have not completed are canceled, and the entire navigation is canceled.
1683+
1684+
With the exception of `CanMatch`, if *any* guard returns false, pending guards that have not completed are canceled, and the entire navigation is canceled. If a `CanMatch` guard returns `false`, the `Router` continues
1685+
processing the rest of the `Routes` to see if a different `Route` config matches the URL. You can think of this
1686+
as though the `Router` is pretending the `Route` with the `CanMatch` guard did not exist.
16841687

16851688
There are several examples over the next few sections.
16861689

@@ -1941,6 +1944,19 @@ In `app.module.ts`, import and add the `AuthModule` to the `AppModule` imports.
19411944
<code-pane header="src/app/auth/auth.module.ts" path="router/src/app/auth/auth.module.ts"></code-pane>
19421945
</code-tabs>
19431946

1947+
<a id="can-match-guard"></a>
1948+
1949+
### `CanMatch`: Controlling `Route` matching based on application conditions
1950+
1951+
As an alternative to using a `CanActivate` guard which redirects the user to a new page if they do not have access, you can instead
1952+
use a `CanMatch` guard to control whether the `Router` even attempts to activate a `Route`. This allows you to have
1953+
multiple `Route` configurations which share the same `path` but are match based on different conditions. In addition, this approach
1954+
can allow the `Router` to match the wildcard `Route` instead.
1955+
1956+
<code-example path="router/src/app/auth/auth.guard.2.ts" header="src/app/auth/auth.guard.ts (excerpt)" region="can-match"></code-example>
1957+
1958+
<code-example path="router/src/app/admin/admin-routing.module.2.ts" header="src/app/admin/admin-routing.module.ts (guarded admin route)" region="can-match"></code-example>
1959+
19441960
<a id="can-activate-child-guard"></a>
19451961

19461962
### `CanActivateChild`: guarding child routes

‎goldens/public-api/router/index.md

+7
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,12 @@ export interface CanLoad {
135135
canLoad(route: Route, segments: UrlSegment[]): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree;
136136
}
137137

138+
// @public
139+
export interface CanMatch {
140+
// (undocumented)
141+
canMatch(route: Route, segments: UrlSegment[]): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree;
142+
}
143+
138144
// @public
139145
export class ChildActivationEnd {
140146
constructor(
@@ -511,6 +517,7 @@ export interface Route {
511517
canActivateChild?: any[];
512518
canDeactivate?: any[];
513519
canLoad?: any[];
520+
canMatch?: Array<Type<CanMatch> | InjectionToken<CanMatchFn>>;
514521
children?: Routes;
515522
component?: Type<any>;
516523
data?: Data;

‎packages/router/src/apply_redirects.ts

+43-40
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,16 @@
88

99
import {EnvironmentInjector} from '@angular/core';
1010
import {EmptyError, from, Observable, of, throwError} from 'rxjs';
11-
import {catchError, concatMap, first, last, map, mergeMap, scan, tap} from 'rxjs/operators';
11+
import {catchError, concatMap, first, last, map, mergeMap, scan, switchMap, tap} from 'rxjs/operators';
1212

13-
import {CanLoadFn, LoadedRouterConfig, Route, Routes} from './models';
13+
import {LoadedRouterConfig, Route, Routes} from './models';
1414
import {runCanLoadGuards} from './operators/check_guards';
15-
import {prioritizedGuardValue} from './operators/prioritized_guard_value';
1615
import {RouterConfigLoader} from './router_config_loader';
1716
import {navigationCancelingError, Params, PRIMARY_OUTLET} from './shared';
1817
import {createRoot, squashSegmentGroup, UrlSegment, UrlSegmentGroup, UrlSerializer, UrlTree} from './url_tree';
1918
import {forEach, wrapIntoObservable} from './utils/collection';
2019
import {getOrCreateRouteInjectorIfNeeded, getOutlet, sortByMatchingOutlets} from './utils/config';
21-
import {isImmediateMatch, match, noLeftoversInUrl, split} from './utils/config_matching';
22-
import {isCanLoad, isFunction, isUrlTree} from './utils/type_guards';
20+
import {isImmediateMatch, match, matchWithChecks, noLeftoversInUrl, split} from './utils/config_matching';
2321

2422
class NoMatch {
2523
public segmentGroup: UrlSegmentGroup|null;
@@ -286,41 +284,46 @@ class ApplyRedirects {
286284
return of(new UrlSegmentGroup(segments, {}));
287285
}
288286

289-
const {matched, consumedSegments, remainingSegments} = match(rawSegmentGroup, route, segments);
290-
if (!matched) return noMatch(rawSegmentGroup);
291-
292-
// Only create the Route's `EnvironmentInjector` if it matches the attempted navigation
293-
injector = getOrCreateRouteInjectorIfNeeded(route, injector);
294-
const childConfig$ = this.getChildConfig(injector, route, segments);
295-
296-
return childConfig$.pipe(mergeMap((routerConfig: LoadedRouterConfig) => {
297-
const childInjector = routerConfig.injector ?? injector;
298-
const childConfig = routerConfig.routes;
299-
300-
const {segmentGroup: splitSegmentGroup, slicedSegments} =
301-
split(rawSegmentGroup, consumedSegments, remainingSegments, childConfig);
302-
// See comment on the other call to `split` about why this is necessary.
303-
const segmentGroup =
304-
new UrlSegmentGroup(splitSegmentGroup.segments, splitSegmentGroup.children);
305-
306-
if (slicedSegments.length === 0 && segmentGroup.hasChildren()) {
307-
const expanded$ = this.expandChildren(childInjector, childConfig, segmentGroup);
308-
return expanded$.pipe(
309-
map((children: any) => new UrlSegmentGroup(consumedSegments, children)));
310-
}
311-
312-
if (childConfig.length === 0 && slicedSegments.length === 0) {
313-
return of(new UrlSegmentGroup(consumedSegments, {}));
314-
}
315-
316-
const matchedOnOutlet = getOutlet(route) === outlet;
317-
const expanded$ = this.expandSegment(
318-
childInjector, segmentGroup, childConfig, slicedSegments,
319-
matchedOnOutlet ? PRIMARY_OUTLET : outlet, true);
320-
return expanded$.pipe(
321-
map((cs: UrlSegmentGroup) =>
322-
new UrlSegmentGroup(consumedSegments.concat(cs.segments), cs.children)));
323-
}));
287+
return matchWithChecks(rawSegmentGroup, route, segments, injector, this.urlSerializer)
288+
.pipe(
289+
switchMap(({matched, consumedSegments, remainingSegments}) => {
290+
if (!matched) return noMatch(rawSegmentGroup);
291+
292+
// Only create the Route's `EnvironmentInjector` if it matches the attempted
293+
// navigation
294+
injector = getOrCreateRouteInjectorIfNeeded(route, injector);
295+
const childConfig$ = this.getChildConfig(injector, route, segments);
296+
297+
return childConfig$.pipe(mergeMap((routerConfig: LoadedRouterConfig) => {
298+
const childInjector = routerConfig.injector ?? injector;
299+
const childConfig = routerConfig.routes;
300+
301+
const {segmentGroup: splitSegmentGroup, slicedSegments} =
302+
split(rawSegmentGroup, consumedSegments, remainingSegments, childConfig);
303+
// See comment on the other call to `split` about why this is necessary.
304+
const segmentGroup =
305+
new UrlSegmentGroup(splitSegmentGroup.segments, splitSegmentGroup.children);
306+
307+
if (slicedSegments.length === 0 && segmentGroup.hasChildren()) {
308+
const expanded$ = this.expandChildren(childInjector, childConfig, segmentGroup);
309+
return expanded$.pipe(
310+
map((children: any) => new UrlSegmentGroup(consumedSegments, children)));
311+
}
312+
313+
if (childConfig.length === 0 && slicedSegments.length === 0) {
314+
return of(new UrlSegmentGroup(consumedSegments, {}));
315+
}
316+
317+
const matchedOnOutlet = getOutlet(route) === outlet;
318+
const expanded$ = this.expandSegment(
319+
childInjector, segmentGroup, childConfig, slicedSegments,
320+
matchedOnOutlet ? PRIMARY_OUTLET : outlet, true);
321+
return expanded$.pipe(
322+
map((cs: UrlSegmentGroup) => new UrlSegmentGroup(
323+
consumedSegments.concat(cs.segments), cs.children)));
324+
}));
325+
}),
326+
);
324327
}
325328

326329
private getChildConfig(injector: EnvironmentInjector, route: Route, segments: UrlSegment[]):

‎packages/router/src/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export {RouterLink, RouterLinkWithHref} from './directives/router_link';
1212
export {RouterLinkActive} from './directives/router_link_active';
1313
export {RouterOutlet, RouterOutletContract} from './directives/router_outlet';
1414
export {ActivationEnd, ActivationStart, ChildActivationEnd, ChildActivationStart, Event, EventType, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RouterEvent, RoutesRecognized, Scroll} from './events';
15-
export {CanActivate, CanActivateChild, CanDeactivate, CanLoad, Data, LoadChildren, LoadChildrenCallback, QueryParamsHandling, Resolve, ResolveData, Route, Routes, RunGuardsAndResolvers, UrlMatcher, UrlMatchResult} from './models';
15+
export {CanActivate, CanActivateChild, CanDeactivate, CanLoad, CanMatch, Data, LoadChildren, LoadChildrenCallback, QueryParamsHandling, Resolve, ResolveData, Route, Routes, RunGuardsAndResolvers, UrlMatcher, UrlMatchResult} from './models';
1616
export {DefaultTitleStrategy, TitleStrategy} from './page_title_strategy';
1717
export {BaseRouteReuseStrategy, DetachedRouteHandle, RouteReuseStrategy} from './route_reuse_strategy';
1818
export {Navigation, NavigationBehaviorOptions, NavigationExtras, Router, UrlCreationOptions} from './router';

0 commit comments

Comments
 (0)
Please sign in to comment.