Issue
How can I inject dependencies for a route's UrlMatcher
and perform asynchronous route matching?
I need to make a call to a back end API in order to find out the right route for each URL (by parsing rewrite rules and running queries in WordPress).
That's why I need a singleton service for the UrlMatcher
to fetch the data once and then use it to determine the route (and then inject it to the component with the fetched data).
I created a UrlMatcher factory:
{
component: PostComponent,
matcher: wpApiUrlMatcherFactory('post')
}
But I don't know how to inject the same service to all of the matchers and how to make it work since the service is asynchronous and UrlMatcher
can't return a Promise
or an Observable
.
Solution
Short Answer
While still not directly possible using only UrlMatcher
, in Angular 14.1+ (currently in pre-release so it's subject to change), CanMatch
guards let you control whether a route should be used at all even if its path
or matcher
match, allowing to skip it 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 redirect to another route.
Alternatively (and in previous versions), you can either:
Use a guard like
CanActivate
to perform the asynchronous operations when needed. You can then redirect (cancel the navigation by returning aUrlTree
) and let the router match the routes again, this time with the new data available synchronously inUrlMatcher
, perhaps exposed as a global variable since it doesn't support DI.Handle some of your routing outisde of the Angular router by dynamically loading components inside a component.
If all you needed is Angular's DI you can technically expose the injector (
UrlMatcher
still won't let you return aPromise
/Observable
though).
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), most notably 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), which 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 problem in UrlMatcher
so you'd need to resort to keeping the data in a global variable that gets populated asynchronously, or to exposing the injector.
CanMatch
guard (Angular 14.1+)
Altough 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 redirect.
At the time of writing, CanMatch
guards get called multiple times per navigation, like UrlMatcher
(UrlMatcher is called twice (#26081)). This should not really matter if you already think you should handle intensive asynchronous operations in this context by reusing their results 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 make an API call return this.auth.login().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) {} canMatch( route: Route, segments: UrlSegment[] ): Promise<boolean> { return this.wp .getByUrl(segments.join('/')) .then((data) => 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: ':category-slug/:post-slug', component: PostComponent, canMatch: [CanMatchWpApi], data: { wpContentType: 'post' }, }, { path: ':parent-page-slug/:page-slug', 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
andCanActivateChild
) and redirects. The example from the question can be implemented roughly like this:let wpData = null;
@Injectable({ providedIn: 'root' }) class CanActivateChildWpApi implements CanActivateChild { constructor(private router: Router, private wp: WpApiService) {} async canActivate( route: ActivatedRouteSnapshot, state: RouterStateSnapshot ): Promise<boolean> { const url = state.url; const navId = this.router.getCurrentNavigation().id; // Angular 7.2+ let newWpData = this.wp.getCachedByUrl(url); if (url === newWpData?.url) { wpData = newWpData; return true; } newWpData = await this.wp.getByUrl(url); // Skip if this navigation is obsolete if (navId === this.router.getCurrentNavigation()?.id) { return false; } wpData = newWpData; // Preferred option: 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 || 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 root injector as a variable by capturing it when bootstraping the app, as shown in a previous answer. This will allow to directly resolve dependencies using Angular's DI, but still doesn't let you match routes asynchronously.
// app-injector.ts 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);
Answered By - Or'el
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.