Skip to content

Commit

Permalink
feat(router): Add CanMatch guard to control whether a Route should ma…
Browse files Browse the repository at this point in the history
…tch (#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
  • Loading branch information
atscott authored and jessicajaniuk committed Jun 13, 2022
1 parent 96f5c97 commit de058bb
Show file tree
Hide file tree
Showing 16 changed files with 645 additions and 329 deletions.
68 changes: 32 additions & 36 deletions aio/content/examples/router/src/app/admin/admin-routing.module.2.ts
@@ -1,44 +1,40 @@
// #docplaster
// #docregion
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';

import { AdminComponent } from './admin/admin.component';
import { AdminDashboardComponent } from './admin-dashboard/admin-dashboard.component';
import { ManageCrisesComponent } from './manage-crises/manage-crises.component';
import { ManageHeroesComponent } from './manage-heroes/manage-heroes.component';
import {NgModule} from '@angular/core';
import {RouterModule, Routes} from '@angular/router';

// #docregion admin-route
import { AuthGuard } from '../auth/auth.guard';
import {AuthGuard} from '../auth/auth.guard';

import {AdminDashboardComponent} from './admin-dashboard/admin-dashboard.component';
import {AdminComponent} from './admin/admin.component';
import {ManageCrisesComponent} from './manage-crises/manage-crises.component';
import {ManageHeroesComponent} from './manage-heroes/manage-heroes.component';

const adminRoutes: Routes = [{
path: 'admin',
component: AdminComponent,
canActivate: [AuthGuard],

const adminRoutes: Routes = [
{
path: 'admin',
component: AdminComponent,
canActivate: [AuthGuard],
// #enddocregion admin-route
// #docregion can-match
canMatch: [AuthGuard],
// #enddocregion can-match
// #docregion admin-route
children: [{
path: '',
children: [
{
path: '',
children: [
{ path: 'crises', component: ManageCrisesComponent },
{ path: 'heroes', component: ManageHeroesComponent },
{ path: '', component: AdminDashboardComponent }
],
// #enddocregion admin-route
canActivateChild: [AuthGuard]
// #docregion admin-route
}
]
}
];
{path: 'crises', component: ManageCrisesComponent},
{path: 'heroes', component: ManageHeroesComponent},
{path: '', component: AdminDashboardComponent}
],
// #enddocregion admin-route
canActivateChild: [AuthGuard]
// #docregion admin-route
}]
}];

@NgModule({
imports: [
RouterModule.forChild(adminRoutes)
],
exports: [
RouterModule
]
})
export class AdminRoutingModule {}
@NgModule({imports: [RouterModule.forChild(adminRoutes)], exports: [RouterModule]})
export class AdminRoutingModule {
}
// #enddocregion
24 changes: 19 additions & 5 deletions aio/content/examples/router/src/app/auth/auth.guard.2.ts
@@ -1,13 +1,16 @@
// #docregion
import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, Router, UrlTree } from '@angular/router';
import {Injectable} from '@angular/core';
import {
ActivatedRouteSnapshot, CanActivate, CanMatch,
Route, Router, RouterStateSnapshot, UrlTree
} from '@angular/router';

import { AuthService } from './auth.service';
import {AuthService} from './auth.service';

@Injectable({
providedIn: 'root',
})
export class AuthGuard implements CanActivate {
export class AuthGuard implements CanActivate, CanMatch {
constructor(private authService: AuthService, private router: Router) {}

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

// #enddocregion
// #docregion can-match
canMatch(route: Route) {
const url = `/${route.path}`;
return this.checkLogin(url) === true;
}
// #enddocregion can-match
// #docregion

checkLogin(url: string): true|UrlTree {
if (this.authService.isLoggedIn) { return true; }
if (this.authService.isLoggedIn) {
return true;
}

// Store the attempted URL for redirecting
this.authService.redirectUrl = url;
Expand Down
24 changes: 20 additions & 4 deletions aio/content/guide/router-tutorial-toh.md
Expand Up @@ -1646,9 +1646,8 @@ A guard's return value controls the router's behavior:

<div class="alert is-helpful">

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

</div>

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

You can have multiple guards at every level of a routing hierarchy.
The router checks the `CanDeactivate` guards first, from the deepest child route to the top.
Then it checks the `CanActivate` and `CanActivateChild` guards from the top down to the deepest child route.
If the feature module is loaded asynchronously, the `CanLoad` guard is checked before the module is loaded.
If *any* guard returns false, pending guards that have not completed are canceled, and the entire navigation is canceled.

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
processing the rest of the `Routes` to see if a different `Route` config matches the URL. You can think of this
as though the `Router` is pretending the `Route` with the `CanMatch` guard did not exist.

There are several examples over the next few sections.

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

<a id="can-match-guard"></a>

### `CanMatch`: Controlling `Route` matching based on application conditions

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
use a `CanMatch` guard to control whether the `Router` even attempts to activate a `Route`. This allows you to have
multiple `Route` configurations which share the same `path` but are match based on different conditions. In addition, this approach
can allow the `Router` to match the wildcard `Route` instead.

<code-example path="router/src/app/auth/auth.guard.2.ts" header="src/app/auth/auth.guard.ts (excerpt)" region="can-match"></code-example>

<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>

<a id="can-activate-child-guard"></a>

### `CanActivateChild`: guarding child routes
Expand Down
7 changes: 7 additions & 0 deletions goldens/public-api/router/index.md
Expand Up @@ -135,6 +135,12 @@ export interface CanLoad {
canLoad(route: Route, segments: UrlSegment[]): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree;
}

// @public
export interface CanMatch {
// (undocumented)
canMatch(route: Route, segments: UrlSegment[]): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree;
}

// @public
export class ChildActivationEnd {
constructor(
Expand Down Expand Up @@ -511,6 +517,7 @@ export interface Route {
canActivateChild?: any[];
canDeactivate?: any[];
canLoad?: any[];
canMatch?: Array<Type<CanMatch> | InjectionToken<CanMatchFn>>;
children?: Routes;
component?: Type<any>;
data?: Data;
Expand Down
83 changes: 43 additions & 40 deletions packages/router/src/apply_redirects.ts
Expand Up @@ -8,18 +8,16 @@

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

import {CanLoadFn, LoadedRouterConfig, Route, Routes} from './models';
import {LoadedRouterConfig, Route, Routes} from './models';
import {runCanLoadGuards} from './operators/check_guards';
import {prioritizedGuardValue} from './operators/prioritized_guard_value';
import {RouterConfigLoader} from './router_config_loader';
import {navigationCancelingError, Params, PRIMARY_OUTLET} from './shared';
import {createRoot, squashSegmentGroup, UrlSegment, UrlSegmentGroup, UrlSerializer, UrlTree} from './url_tree';
import {forEach, wrapIntoObservable} from './utils/collection';
import {getOrCreateRouteInjectorIfNeeded, getOutlet, sortByMatchingOutlets} from './utils/config';
import {isImmediateMatch, match, noLeftoversInUrl, split} from './utils/config_matching';
import {isCanLoad, isFunction, isUrlTree} from './utils/type_guards';
import {isImmediateMatch, match, matchWithChecks, noLeftoversInUrl, split} from './utils/config_matching';

class NoMatch {
public segmentGroup: UrlSegmentGroup|null;
Expand Down Expand Up @@ -286,41 +284,46 @@ class ApplyRedirects {
return of(new UrlSegmentGroup(segments, {}));
}

const {matched, consumedSegments, remainingSegments} = match(rawSegmentGroup, route, segments);
if (!matched) return noMatch(rawSegmentGroup);

// Only create the Route's `EnvironmentInjector` if it matches the attempted navigation
injector = getOrCreateRouteInjectorIfNeeded(route, injector);
const childConfig$ = this.getChildConfig(injector, route, segments);

return childConfig$.pipe(mergeMap((routerConfig: LoadedRouterConfig) => {
const childInjector = routerConfig.injector ?? injector;
const childConfig = routerConfig.routes;

const {segmentGroup: splitSegmentGroup, slicedSegments} =
split(rawSegmentGroup, consumedSegments, remainingSegments, childConfig);
// See comment on the other call to `split` about why this is necessary.
const segmentGroup =
new UrlSegmentGroup(splitSegmentGroup.segments, splitSegmentGroup.children);

if (slicedSegments.length === 0 && segmentGroup.hasChildren()) {
const expanded$ = this.expandChildren(childInjector, childConfig, segmentGroup);
return expanded$.pipe(
map((children: any) => new UrlSegmentGroup(consumedSegments, children)));
}

if (childConfig.length === 0 && slicedSegments.length === 0) {
return of(new UrlSegmentGroup(consumedSegments, {}));
}

const matchedOnOutlet = getOutlet(route) === outlet;
const expanded$ = this.expandSegment(
childInjector, segmentGroup, childConfig, slicedSegments,
matchedOnOutlet ? PRIMARY_OUTLET : outlet, true);
return expanded$.pipe(
map((cs: UrlSegmentGroup) =>
new UrlSegmentGroup(consumedSegments.concat(cs.segments), cs.children)));
}));
return matchWithChecks(rawSegmentGroup, route, segments, injector, this.urlSerializer)
.pipe(
switchMap(({matched, consumedSegments, remainingSegments}) => {
if (!matched) return noMatch(rawSegmentGroup);

// Only create the Route's `EnvironmentInjector` if it matches the attempted
// navigation
injector = getOrCreateRouteInjectorIfNeeded(route, injector);
const childConfig$ = this.getChildConfig(injector, route, segments);

return childConfig$.pipe(mergeMap((routerConfig: LoadedRouterConfig) => {
const childInjector = routerConfig.injector ?? injector;
const childConfig = routerConfig.routes;

const {segmentGroup: splitSegmentGroup, slicedSegments} =
split(rawSegmentGroup, consumedSegments, remainingSegments, childConfig);
// See comment on the other call to `split` about why this is necessary.
const segmentGroup =
new UrlSegmentGroup(splitSegmentGroup.segments, splitSegmentGroup.children);

if (slicedSegments.length === 0 && segmentGroup.hasChildren()) {
const expanded$ = this.expandChildren(childInjector, childConfig, segmentGroup);
return expanded$.pipe(
map((children: any) => new UrlSegmentGroup(consumedSegments, children)));
}

if (childConfig.length === 0 && slicedSegments.length === 0) {
return of(new UrlSegmentGroup(consumedSegments, {}));
}

const matchedOnOutlet = getOutlet(route) === outlet;
const expanded$ = this.expandSegment(
childInjector, segmentGroup, childConfig, slicedSegments,
matchedOnOutlet ? PRIMARY_OUTLET : outlet, true);
return expanded$.pipe(
map((cs: UrlSegmentGroup) => new UrlSegmentGroup(
consumedSegments.concat(cs.segments), cs.children)));
}));
}),
);
}

private getChildConfig(injector: EnvironmentInjector, route: Route, segments: UrlSegment[]):
Expand Down
2 changes: 1 addition & 1 deletion packages/router/src/index.ts
Expand Up @@ -12,7 +12,7 @@ export {RouterLink, RouterLinkWithHref} from './directives/router_link';
export {RouterLinkActive} from './directives/router_link_active';
export {RouterOutlet, RouterOutletContract} from './directives/router_outlet';
export {ActivationEnd, ActivationStart, ChildActivationEnd, ChildActivationStart, Event, EventType, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RouterEvent, RoutesRecognized, Scroll} from './events';
export {CanActivate, CanActivateChild, CanDeactivate, CanLoad, Data, LoadChildren, LoadChildrenCallback, QueryParamsHandling, Resolve, ResolveData, Route, Routes, RunGuardsAndResolvers, UrlMatcher, UrlMatchResult} from './models';
export {CanActivate, CanActivateChild, CanDeactivate, CanLoad, CanMatch, Data, LoadChildren, LoadChildrenCallback, QueryParamsHandling, Resolve, ResolveData, Route, Routes, RunGuardsAndResolvers, UrlMatcher, UrlMatchResult} from './models';
export {DefaultTitleStrategy, TitleStrategy} from './page_title_strategy';
export {BaseRouteReuseStrategy, DetachedRouteHandle, RouteReuseStrategy} from './route_reuse_strategy';
export {Navigation, NavigationBehaviorOptions, NavigationExtras, Router, UrlCreationOptions} from './router';
Expand Down

0 comments on commit de058bb

Please sign in to comment.