7

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.

orelby
  • 227
  • 3
  • 14
  • Did you ever find a solution to this? – mait.taim Oct 14 '19 at 12:12
  • @mait.taim No, I ended up using a single top-component and [creating components dynamically](https://angular.io/guide/dynamic-component-loader) without a built-in router. – orelby Oct 15 '19 at 13:32

2 Answers2

2

1.In main.ts:

export let appInjector: Injector;


platformBrowserDynamic().bootstrapModule(AppModule)
      .then(m => appInjector = m.injector)

2. In your router module:

import {appInjector} from '../../main';

const routes: Routes = [
  {
     matcher: (segments: UrlSegment[]): UrlMatchResult => {
       if (appInjector.get(AccessService).hasAccess('test')) {
         return {consumed: [segment]};
       }
   },
   component: TestComponent,
];
0

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:

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

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

  1. 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'
      },
      // ...
    ];
    
  2. 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

  1. 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' },
          },
          // ...
        ]
      },
    ];
    
  2. Dynamically load components using custom logic inside a component that has a standard route.

  3. 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);
    
orelby
  • 227
  • 3
  • 14