Short Answer
It's not directly possible with a UrlMatcher
, but in Angular 14.1 the CanMatch
route guard was introduced for this purpose. It lets you control whether a route should be used at all even if its path
or matcher
match, allowing to skip the route and match other routes. It supports dependency injection and can be asynchronous (return a Promise
or an Observable
). It can also return a UrlTree
to cancel the navigation and redirect to another route.
Alternatively, for asynchronicity you can either:
Use a guard like CanActivateChild
to perform the asynchronous operations when needed. You can then redirect (cancel the navigation by returning a UrlTree
) and let the router match the routes again, this time with the updated data available synchronously in any UrlMatcher
as explained below.
Handle some of your routing outisde of the Angular router by dynamically loading components inside a component.
As for dependencies in UrlMatcher
, you can either keep the data itself in a global variable or capture the injector and keep that as a global variable, then use it in UrlMatcher
to resolve the service. Either way the data access will be synchronous.
See below for more details and code examples.
Background
DI-capable UrlMatcher
and asynchronicity in the matching stage of routes have been requested and discussed for years (even back in Angular 2.0), most notably in the following issues on Angular's GitHub: Load a component in a route depending on an asynchronous condition (#12088) and UrlMatcher as a service (#17145). Both of those and others were marked as resolved following the CanMatch
pull request.
The previously existing router guards (such as CanActivate
, CanLoad
and Resolve
) run only after the route is chosen/recognized (in its entirety, including child routes), thus not suitable for deciding where to navigate based on some data from a service, at least not directly (without redirecting). Furthermore, they don't solve the DI issue in UrlMatcher
so you'd need to resort to keeping the data in a global variable that gets updated asynchronously, or to capturing the injector.
CanMatch
guard (Angular 14.1+)
Although UrlMatcher
still can't return a promise or an observable, CanMatch
can be used for that purpose.
CanMatch
guards run after a route is matched using path
or matcher
but before it is considered recognized and before other guards run. If all such guards resolve to true
for a given route (from the root route to the innermost child route) it will be recognized, and other types of guards will be called. If a CanMatch
guard resolves to false
the route will be skipped, and the router will attempt to match the next route. It can also resolve to a UrlTree
for a navigation cancel and redirect.
At the time of writing, CanMatch
guards get called multiple times per navigation, like UrlMatcher
(UrlMatcher is called twice (#26081)). It's just another reason to consider reusing the results of the asynchronous operations there across duplicate subsequent requests and perhaps managing some cache.
An example with different routes for the same URL using CanMatch
:
@Injectable()
class CanMatchAdmin implements CanMatch {
constructor(private auth: AuthService) {}
canMatch(route: Route, segments: UrlSegment[]): Observable<boolean> {
// might wait for an API call
return this.auth.user$.pipe(
map(user => user.isAdmin)
);
}
}
const routes = [
{
path: '',
component: AdminDashboardComponent
canMatch: [CanMatchAdmin]
},
{
path: '',
component: UserDashboardComponent,
canMatch: [CanMatchLoggedIn]
},
{
path: '',
pathMatch: 'full',
redirectTo: 'login'
},
// ...
];
An example for the specific question's use case:
@Injectable({ providedIn: 'root' })
class CanMatchWpApi implements CanMatch {
constructor(private wp: WpApiService) {}
async canMatch(route: Route, segments: UrlSegment[]): Promise<boolean> {
const data = await this.wp.getByUrl(segments.join('/'));
return data.contentType === route.data.wpContentType;
}
}
// Wildcard routes (**) do not seem to allow subsequent routes
// so we'll use a UrlMatcher that consumes the entire path
const anyUrlMatcher: UrlMatcher = (segments) => ({ consumed: segments });
const routes: Routes = [
// ...
{
path: '',
children: [
{
path: ':categorySlug/:postSlug',
component: PostComponent,
canMatch: [CanMatchWpApi],
data: { wpContentType: 'post' },
},
{
path: ':parentPageSlug/:pageSlug',
component: PageComponent,
canMatch: [CanMatchWpApi],
data: { wpContentType: 'page' },
},
{
matcher: anyUrlMatcher,
component: SomeFunkyComponent,
canMatch: [CanMatchWpApi],
data: { wpContentType: 'some_funky_content_type' },
},
// ...
],
},
// ...
];
Alternatives
A combination of other guards (specifically CanActivate
or CanActivateChild
) and redirects. Keep in mind that in order for these guards to be run the route needs to be matched first. This may seem unintuitive at first, so consider that all of the competing routes will have that guard, and it will redirect when it detects the data used to match the current route is not right. The redirect will cause a rerun of the route matching, with the updated URL data now available synchronously in the route matchers. The example from the question can be implemented roughly like this:
let wpData = null;
let wpDataUrl = null;
@Injectable({ providedIn: 'root' })
class CanActivateChildWpApi implements CanActivateChild {
constructor(private router: Router, private wp: WpApiService) {}
async canActivateChild(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): Promise<boolean> {
const url = state.url;
// Pass if the data used to match the route was right
if (url === wpDataUrl) {
return true;
}
// Otherwise get the right data to match the routes
// (Utilize the asynchronicity here that UrlMatcher does not have)
const navId = this.router.getCurrentNavigation().id; // Angular 7.2+
let newWpData = await this.wp.getByUrl(url);
// Abort if this navigation is obsolete
if (navId !== this.router.getCurrentNavigation()?.id) {
return false;
}
// Update the data for the route re-matching
wpData = newWpData;
wpDataUrl = url;
// Preferred option for re-matching the routes:
return this.router.parseUrl(url); // Angular 7.1+
// Hacky option:
// First redirect to a different route
await this.router.navigateByUrl('/loading', { skipLocationChange: true });
// Now the router will re-match the routes for the original URL
await this.router.navigateByUrl(url);
return false;
}
}
const wpRouteMatcher: UrlMatcher = (
segments: UrlSegment[],
group: UrlSegmentGroup,
route: Route
) => {
return (wpData == null // match any route the first time
|| wpData.contentType === (route.data as any).wpContentType)
? { consumed: segments }
: null;
};
const routes: Routes = [
// {
// path: 'loading',
// component: LoadingComponent
// },
// ...
{
path: '',
canActivateChild: [CanActivateChildWpApi],
children: [
{
matcher: wpRouteMatcher,
component: PostComponent,
data: { wpContentType: 'post' },
},
{
matcher: wpRouteMatcher,
component: PageComponent,
data: { wpContentType: 'page' },
},
// ...
]
},
];
Dynamically load components using custom logic inside a component that has a standard route.
Expose the module injector as a variable by capturing it when bootstrapping the app, as shown in a previous answer. This will make it possible to directly resolve dependencies using Angular's DI, but still doesn't let you match routes asynchronously.
// app-injector.ts or some other file
import { Injector } from '@angular/core';
export let appInjector: Injector = null;
export function setAppInjector(newAppInjector: Injector) {
appInjector = newAppInjector;
}
// main.ts
import { setAppInjector } from './app-injector';
platformBrowserDynamic().bootstrapModule(AppModule).then(ref => {
// ...
setAppInjector(ref.injector);
});
import { appInjector } from './app-injector';
import { FooService } from './foo.service';
// Anywhere else, including inside a UrlMatcher
const fooService = appInjector.get<FooService>(FooService);