No one tells Angular’s canMatch url segments is empty!

Recently, I hit an unexpected snag while working on an Angular project. The plan was simple: use a parent routing file to handle main routes and lazy-load child routes only when needed, making use of Angular’s canMatch. This design was supposed to keep our app modular and efficient, but things didn’t go as planned.

The Problem: Missing Route Segments in canMatch

In our setup, we had several independent features, each in its own module within the project. These features were designed to be lazy-loaded, keeping the app lightweight and performant.

Here’s a simplified version of our parent routing configuration:

const parentRoutes = [
    {
        path: ':id/details',
        loadChildren: () => import('./features/details/details.module').then(m => m.DetailsModule)
    },
    {
        path: ':id/settings',
        loadChildren: () => import('./features/settings/settings.module').then(m => m.SettingsModule)
    },
    {
        path: ':id',
        loadChildren: () => import('./features/overview/overview.module').then(m => m.OverviewModule)
    }
];

Each route used a dynamic :id parameter to load the corresponding module only when necessary. The child modules contained routes like this:

const childRoutes = [
    {
        path: '',
        component: OverviewComponent,
        canMatch: [myCustomGuard]
    }
    {
        path: '',
        component: AnotherComponent,
    }
];

The Angular’s canMatch guard was meant to verify access before loading the route and in case of not having a match for the overview, load the next available route.

Then, inside the guard, we needed to retrieve the url :id parameter and do a few checks with it. However, when I tested it, I noticed that the canMatch function wasn’t receiving the :id segment from the parent route. Instead, it got an empty segments array, which rendered the guard useless.

Moving the :id parameter into the child routes did fix the issue, but at the cost of eagerly loading multiple modules to check for matches—defeating the purpose of lazy loading.

The Root Cause: Angular’s Default UrlMatcher

After digging into Angular’s routing mechanics, I found the culprit: the default UrlMatcher. When a parent route matches a segment, the default UrlMatcher consumes that segment, leaving nothing for the child routes.

This behavior wasn’t clearly documented, which is why it caught me off guard. But understanding it led me to a more elegant solution.

The Solution: A Custom UrlMatcher

To keep the original design intact and avoid eager loading, I wrote a custom UrlMatcher that prevents the parent route from consuming the :id segment. Here’s the custom matcher:

const customMatcher: UrlMatcher = (
    segments: UrlSegment[]
) => {
    if (segments.length === 1 && segments[0].path.match(/^[\w-]+$/gm)) {
        return {
            consumed: [], // Prevent segment consumption
            posParams: {
                id: new UrlSegment(segments[0].path, {}),
            },
        };
    }
    return null;
};

By applying this matcher to the parent routes:

const parentRoutes = [
    {
        matcher: customMatcher,
        loadChildren: () => import('./features/overview/overview.module').then(m => m.OverviewModule)
    }
];

I ensured that the Angular’s canMatch guard received the correct segment data without forcing eager loading. The app stayed modular and efficient, with each feature remaining independent and housed in separate libraries.

The Takeaway: Understanding Framework Nuances

This experience was a reminder that even mature frameworks like Angular have their quirks. A deep dive into the routing system uncovered the solution and allowed us to maintain our clean, modular design without sacrificing performance.

So, if you’re wrestling with similar routing issues, consider what’s happening under the hood with the UrlMatcher. Sometimes, a custom solution is the key to achieving your goals and keeping your app performing at its best.