19
Angular Router: empty paths, named outlets and a fix that came with Angular 11
In this article, we are going to highlight the importance of a fix that came with the 11th version of Angular Router. We will do so by examining a scenario where the lack of that fix prevents an intuitive solution from working, as well as understanding why the fix in question solves the problem.
The reader doesn't need to have more than a basic knowledge of Angular Router. Although we will make use of some advanced concepts like UrlTree
, UrlSegmentGroup
, they will be briefly described before being applied.
This article has been inspired by this Stack Overflow question.
Throughout the article we will not be using the example from the aforementioned Stack Overflow question. Instead, it will be a simpler example, so that it can better illustrate the problem we are trying to solve. Before going any further, we must be aware of how Angular Router resolves routes transitions. For that, we'll have to introduce the notion of UrlTree
.
Given a URL string, it will be converted into an equivalent UrlTree
, which Angular Router will further use to determine whether a configuration exists or not for that route. It can achieve that by traversing the given Routes
configuration array and the UrlTree
simultaneously. This is how the UrlTree
structure looks like:
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) {}
}
as you can probably see, it already resembles a URL, since it has properties like queryParams
and fragment
. It looks like only the segments of a URL are missing. For that, there is UrlSegmentGroup
, which looks as follows:
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);
}
}
and with this we can understand why it's called a UrlTree
, because a URL can apparently be seen as a tree of segments. Now it comes the genuine question: why would you need a tree-like structure to represent the segments of a URL? The answer is because Angular Router also supports named outlets and in fact, every property from the children
object form above represents a named outlet. It should also be mentioned that when no outlet is specified, the primary outlet is used by default.
A UrlSegment
is used to represent a URL segment and for each segment it keeps track of the name and the segment parameters.
Let's see an example: given the URL 'foo/123/(a//named:b)'
(where named
refers to a named outlet called named
), its equivalent UrlTree
will be:
{
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: {} },
},
},
},
}
A structure like the one from above is then used when traversing the Routes
configuration array. A configuration that would match the given URL would be this one:
{
// 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',
},
}
You can try out the above example here.
Now that we grasped the fundamentals of UrlTree
, it's time to see the problem we are trying to solve.
If you'd like to read more about UrlTree
, I'd recommend having a look at Angular Router: Getting to know UrlTree, ActivatedRouteSnapshot and ActivatedRoute.
Suppose you are given a configuration that looks like this:
const routes: Routes = [
{
path: '',
component: FooContainer1,
children: [
{
path: '',
component: FooContainer2,
children: [
{
path: ':id',
component: FooComponent1,
outlet: 'test'
},
{
path: '',
pathMatch: 'full',
component: DummyComponent1
}
]
}
]
}
];
Can you think of a URL that would activate the FooComponent1
component?
If your answer is
<button [routerLink]="['/', { outlets: { test: [123] } }]"><!-- ... --></button>
then, whether you are correct or not depends on which version of Angular you're using. In both cases, the UrlTree
of the above is:
{
fragment: undefined
queryParams: {}
root: {
children:
test: {
children: {}
segments: [{ path: '123' }]
}
segments: []
}
}
In Angular versions earlier than 11, the above solution won't work and we will have to find another approach. In Angular 11 this is fixed. Let's see each case in detail.
It is now worth talking about the matching process between Routes
configuration and a UrlSegmentGroup
.
The number of segments(delimited by /
) in the Route
's path
property does not have to be equal to the number of UrlSegmentGroup.segments
. In order for a Route
to be matched, the numbers of segments in the path
property must be less than or equal to the length of UrlSegmentGroup.segments
. If the previous condition is met, the some of the UrlSegmentGroup.segments
segments are said to be consumed.
The same logic applies in case of { path: '', }
:
if (route.path === '') {
if (route.pathMatch === 'full' && (segmentGroup.hasChildren() || segments.length > 0)) {
throw new NoMatch();
}
return {consumedSegments: [], lastChild: 0, parameters: {}};
}
It was necessary to briefly introduce this notion because, based on the consumed segments, there will be 3 cases:
- All the
UrlSegmentGroup.segments
are consumed andUrlSegmentGroup.children
is not empty:
An example of this is even the one we have seen at the beginning of this article:
// 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',
},
}
Recall that UrlSegmentGroup.children
's values are named outlets and their segments.
- All the
UrlSegmentGroup.segments
are consumed andUrlSegmentGroup.children
is empty:
const routes: Routes = [
{
path: 'foo/bar'
}
];
and the URL is foo/bar
.
Here's how the UrlTree
for foo/bar
looks like:
{
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.segments
have been consumed:
This is the point where Angular 11 and Angular <11 are different.
In this case, only a few parts of UrlSegmentGroup.segments
are consumed. In this case, if the current Route
object has either a children
property or loadChildren
, it will traverse the array found in one of these properties.
The problem here with version earlier than 11 is that when traversing the new inner Routes
configuration array, it will not take into account the current outlet name. Recall that an outlet's name is a property in the UrlSegmentGroup.children
object.
Coming back to our initial example:
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>
Because the path
is ''
, the UrlSegmentGroup.segments
won't be consumed(here's why). The way this is handled in earlier versions is to always use the primary outlet name, although the current outlet name might be different. Since the UrlTree
generated by the above RouterLink
looks like this:
{
fragment: undefined
queryParams: {}
root: {
children:
// No `primary` outlet here, only `test`.
test: {
children: {}
segments: [{ path: '123' }]
}
segments: []
}
}
there won't be any match and the navigation will fail.
And here is a StackBlitz app with our example and there you can see the navigation fails.
With this version, the exact problem that we had before(at the third case) is fixed. The way this is done is by using the current outlet name when in the third scenario occurs.
And here's the relevant source code that fixed the problem:
/* ... */
// `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:
Because the outlet name won't always be primary
and since all the paths until FooComponent1
are ''
, the first children
array will be traversed(denoted by(1)
), then the second children
array(denoted by (2)
) and there it will finally find the match.
Although the fix was a small one, it had a big impact. I had stumbled across a few bugs in the past which were caused by this, so personally I'm glad they eventually found a solution.
Thanks for reading!
Credit goes to the Stack Overflow user Dina Flies, who posted the question.
The diagrams were made with Excalidraw.
19