In this piece, we’ll underscore the significance of a fix introduced in Angular Router version 11. To illustrate, we’ll walk through a situation where, without this fix, an otherwise straightforward approach fails—and then explore why the update resolves that failure.
A foundational grasp of Angular Router is all that’s required from the reader. We’ll touch on some advanced notions like UrlTree and UrlSegmentGroup, but each will be succinctly explained prior to use.
The origin of this content is this Stack Overflow question, which served as its inspiration.
Instead of relying on the example from the referenced Stack Overflow post, we’ll craft a simpler one—it will better spotlight the issue at hand. First, though, we need to clarify how Angular Router handles route transitions. This brings us to the UrlTree concept.
UrlTree
A URL string gets translated into a corresponding UrlTree, which Angular Router then uses to check if a matching route configuration exists. It does so by going through the Routes configuration array in tandem with the UrlTree. Here’s the structure of the UrlTree:
export class UrlTree {
/* ... */
constructor(
/** The root segment group of the URL tree */
public root: UrlSegmentGroup,
/** The query params of the URL */
public queryParams: Params,
/** The fragment of the URL */
public fragment: string|null) {}
}
By now, the URL-like shape of this structure should be fairly obvious — it carries fields such as queryParams and fragment. What stands out as absent are the actual segments of the URL. Those are handled by UrlSegmentGroup, which is defined in this manner:
export class UrlSegmentGroup {
/* ... */
parent: UrlSegmentGroup|null = null;
constructor(
/** The URL segments of this group. See `UrlSegment` for more information */
public segments: UrlSegment[],
/** The list of children of this group */
public children: {[key: string]: UrlSegmentGroup}) {
forEach(children, (v: any, k: any) => v.parent = this);
}
}
This clarifies why the term UrlTree is used—the URL is effectively a tree of segments. The real question then arises: what purpose does a tree serve for representing URL segments? The answer lies in Angular Router's support for named outlets, where each entry in the children object corresponds to one such outlet. If no outlet is explicitly named, the primary outlet is the default.
Each segment is modeled with a UrlSegment, which holds the segment's name along with its segment parameters.
Consider the URL 'foo/123/(a//named:b)'(with named being an outlet named named). The resulting UrlTree looks like this:
{
segments: [], // The root UrlSegmentGroup never has any segments
children: {
primary: {
segments: [{ path: 'foo', parameters: {} }, { path: '123', parameters: {} }],
children: {
primary: { segments: [{ path: 'a', parameters: {} }], children: {} },
named: { segments: [{ path: 'b', parameters: {} }], children: {} },
},
},
},
}
When traversing the Routes configuration array, the structure described above is what comes into play. The following configuration would be a match for the provided URL:
{
// app-routing.module.ts
{
path: 'foo/:id',
loadChildren: () => import('./foo/foo.module').then(m => m.FooModule)
},
// foo.module.ts
{
path: 'a',
component: AComponent,
},
{
path: 'b',
component: BComponent,
outlet: 'named',
},
}
The example above is also available for experimentation on StackBlitz.
With a solid understanding of UrlTree in place, we can now turn our attention to the issue at hand.
If you want to dive deeper into UrlTree, check out Angular Router: Getting to know UrlTree, ActivatedRouteSnapshot and ActivatedRoute.
The problem
Imagine you are handed a configuration similar to the following:
const routes: Routes = [
{
path: '',
component: FooContainer1,
children: [
{
path: '',
component: FooContainer2,
children: [
{
path: ':id',
component: FooComponent1,
outlet: 'test'
},
{
path: '',
pathMatch: 'full',
component: DummyComponent1
}
]
}
]
}
];
What URL would cause FooComponent1 to be rendered?
If you guessed
<button [routerLink]="['/', { outlets: { test: [123] } }]"><!-- ... --></button>
from there, the verdict changes depending on your Angular version. In either scenario, the resulting UrlTree for the example looks like this:
{
fragment: undefined
queryParams: {}
root: {
children:
test: {
children: {}
segments: [{ path: '123' }]
}
segments: []
}
}
Before Angular 11, the approach described above was not viable, forcing developers to seek alternative solutions. The release of Angular 11 resolved this issue. In this section, we explore each scenario comprehensively.
The process of matching Routes with UrlSegmentGroups
Examining the matching process between the Routes configuration and a UrlSegmentGroup is worthwhile at this point.
The path property of a Route does not mandatorily have the same segment count, where segments are separated by /, as the UrlSegmentGroup.segments. For a Route to be successfully matched, the segment count in its path property must not exceed the total number found in UrlSegmentGroup.segments. When this prerequisite holds, a subset of the UrlSegmentGroup.segments are deemed consumed.
This identical logic also governs the case of { path: '', }:
if (route.path === '') {
if (route.pathMatch === 'full' && (segmentGroup.hasChildren() || segments.length > 0)) {
throw new NoMatch();
}
return {consumedSegments: [], lastChild: 0, parameters: {}};
}
This concept had to be introduced for a reason — depending on the consumed segments, three distinct scenarios arise:
- When every
UrlSegmentGroup.segmentsentry is consumed andUrlSegmentGroup.childrencontains elements:
Even the very first example from the start of this discussion illustrates this situation:
// The `UrlTree`
{
segments: [], // The root UrlSegmentGroup never has any segments
children: {
primary: {
segments: [{ path: 'foo', parameters: {} }, { path: '123', parameters: {} }],
children: {
primary: { segments: [{ path: 'a', parameters: {} }], children: {} },
named: { segments: [{ path: 'b', parameters: {} }], children: {} },
},
},
},
}
// The configuration
{
// app-routing.module.ts
{
path: 'foo/:id',
loadChildren: () => import('./foo/foo.module').then(m => m.FooModule)
},
// foo.module.ts
{
path: 'a',
component: AComponent,
},
{
path: 'b',
component: BComponent,
outlet: 'named',
},
}
As a reminder, every entry in UrlSegmentGroup.children corresponds to a named outlet together with its segments.
- Every segment in
UrlSegmentGroup.segmentshas been used up and no children exist underUrlSegmentGroup.children:
const routes: Routes = [
{
path: 'foo/bar'
}
];
and the address bar shows foo/bar.
Below is the UrlTree structure corresponding to foo/bar:
{
fragment: null,
queryParams: {},
root: {
children: {
primary: {
// It is empty
children: {},
// Both will be *consumed*
segments: [{ path: 'foo', parameters: {} }, { path: 'bar', parameters: {} }]
}
},
segments: [],
}
}
- Not all of the
UrlSegmentGroup.segmentshave been consumed:
The behavior diverges between Angular 11 and earlier versions at this exact point.
Here, only a subset of UrlSegmentGroup.segments gets used. Should the current Route object include a children or loadChildren property, the traversal proceeds through the array referenced by either property.
In versions prior to 11, a flaw arises: when navigating the newly formed inner Routes array, the current outlet name is completely disregarded. Keep in mind that the outlet identifier lives as a property inside the UrlSegmentGroup.children object.
Let’s revisit the original scenario:
const routes: Routes = [
{
path: '',
component: FooContainer1,
children: [
{
path: '',
component: FooContainer2,
children: [
{
path: ':id',
component: FooComponent1,
outlet: 'test'
},
{
path: '',
pathMatch: 'full',
component: DummyComponent1
}
]
}
]
}
];
and
<button [routerLink]="['/', { outlets: { test: [123] } }]"><!-- ... --></button>
Since the path is set to '', the UrlSegmentGroup.segments remain unconsumed (the explanation is here). In earlier releases, the handling of this always gravitates toward the primary outlet name, even when the active outlet goes by a different name. Consider the UrlTree generated from the aforementioned RouterLink, which takes the following shape:
{
fragment: undefined
queryParams: {}
root: {
children:
// No `primary` outlet here, only `test`.
test: {
children: {}
segments: [{ path: '123' }]
}
segments: []
}
}
As a result, no route will be matched and the navigation process will come to a halt.
The current implementation that blocks this strategy is available for review.
You can also check out a StackBlitz demo that reproduces this exact scenario, where the failure is evident.
Angular 11's resolution
In this release, the issue we encountered in the third scenario is resolved. The solution leverages the currently active outlet name whenever that particular case comes into play.
An Angular 11 StackBlitz example demonstrates the original problem, now functioning correctly.
For the source code that implemented this correction, see this reference:
/* ... */
// `childConfig` in this case refers to the content of `children` property.
const matchedOnOutlet = getOutlet(route) === outlet;
const expanded$ = this.expandSegment(
childModule, segmentGroup, childConfig, slicedSegments,
matchedOnOutlet ? PRIMARY_OUTLET : outlet, true);
Let's briefly visualize the process:
Since the outlet name is not always primary and every path before FooComponent1 is '', the first children array is traversed (marked as (1)), followed by the second children array (marked as (2)), where the actual match is eventually located.
Conclusion
Though the change was minor, its effect was substantial. Prior to this, I had encountered several bugs stemming from the same issue, so I’m genuinely pleased that a resolution was finally introduced.
Thank you for your time!
The question on Stack Overflow was posted by user Dina Flies, credited accordingly.
The illustrations were generated using Excalidraw.


