Skip to content

Commit a6b10f6

Browse files
atscottAndrewKushnir
authored andcommitted
fix(router): 'createUrlTreeFromSnapshot' with empty paths and named outlets (#48734)
The details of this commit are pretty thoroughly described in the tests and code comments. In short, it is sometimes ambiguous where to apply commands in a `SegmentGroup` tree that is created from an `ActivatedRoute` when dealing with empty paths. This adjusts the strategy to tolerate more ambiguity while still allowing developers to be explicit. This is a fix-forward for b/265215141 PR Close #48734
1 parent 080b875 commit a6b10f6

File tree

2 files changed

+147
-22
lines changed

2 files changed

+147
-22
lines changed

packages/router/src/create_url_tree.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,32 @@ function updateSegmentGroupChildren(
388388
} else {
389389
const outlets = getOutlets(commands);
390390
const children: {[key: string]: UrlSegmentGroup} = {};
391+
// If the set of commands does not apply anything to the primary outlet and the child segment is
392+
// an empty path primary segment on its own, we want to skip applying the commands at this
393+
// level. Imagine the following config:
394+
//
395+
// {path: '', children: [{path: '**', outlet: 'popup'}]}.
396+
//
397+
// Navigation to /(popup:a) will activate the child outlet correctly Given a follow-up
398+
// navigation with commands
399+
// ['/', {outlets: {'popup': 'b'}}], we _would not_ want to apply the outlet commands to the
400+
// root segment because that would result in
401+
// //(popup:a)(popup:b) since the outlet command got applied one level above where it appears in
402+
// the `ActivatedRoute` rather than updating the existing one.
403+
//
404+
// Because empty paths do not appear in the URL segments and the fact that the segments used in
405+
// the output `UrlTree` are squashed to eliminate these empty paths where possible
406+
// https://github.com/angular/angular/blob/13f10de40e25c6900ca55bd83b36bd533dacfa9e/packages/router/src/url_tree.ts#L755
407+
// it can be hard to determine what is the right thing to do when applying commands to a
408+
// `UrlSegmentGroup` that is created from an "unsquashed"/expanded `ActivatedRoute` tree.
409+
// This code effectively "squashes" empty path primary routes when they have no siblings on
410+
// the same level of the tree.
411+
if (!outlets[PRIMARY_OUTLET] && segmentGroup.children[PRIMARY_OUTLET] &&
412+
segmentGroup.numberOfChildren === 1 &&
413+
segmentGroup.children[PRIMARY_OUTLET].segments.length === 0) {
414+
return updateSegmentGroupChildren(
415+
segmentGroup.children[PRIMARY_OUTLET], startIndex, commands);
416+
}
391417

392418
forEach(outlets, (commands, outlet) => {
393419
if (typeof commands === 'string') {

packages/router/test/create_url_tree.spec.ts

Lines changed: 121 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -115,31 +115,30 @@ describe('createUrlTree', async () => {
115115
expect(serializer.serialize(t)).toEqual('/%2Fone/two%2Fthree');
116116
});
117117

118-
it('should preserve secondary segments', async () => {
119-
const p = serializer.parse('/a/11/b(right:c)');
120-
const t = await createRoot(p, ['/a', 11, 'd']);
121-
expect(serializer.serialize(t)).toEqual('/a/11/d(right:c)');
122-
});
123-
124-
it('should support updating secondary segments (absolute)', async () => {
125-
const p = serializer.parse('/a(right:b)');
126-
const t = await createRoot(p, ['/', {outlets: {right: ['c']}}]);
127-
expect(serializer.serialize(t)).toEqual('/a(right:c)');
128-
});
118+
describe('named outlets', async () => {
119+
it('should preserve secondary segments', async () => {
120+
const p = serializer.parse('/a/11/b(right:c)');
121+
const t = await createRoot(p, ['/a', 11, 'd']);
122+
expect(serializer.serialize(t)).toEqual('/a/11/d(right:c)');
123+
});
129124

130-
it('should support updating secondary segments', async () => {
131-
const p = serializer.parse('/a(right:b)');
132-
const t = await createRoot(p, [{outlets: {right: ['c', 11, 'd']}}]);
133-
expect(serializer.serialize(t)).toEqual('/a(right:c/11/d)');
134-
});
125+
it('should support updating secondary segments (absolute)', async () => {
126+
const p = serializer.parse('/a(right:b)');
127+
const t = await createRoot(p, ['/', {outlets: {right: ['c']}}]);
128+
expect(serializer.serialize(t)).toEqual('/a(right:c)');
129+
});
135130

136-
it('should support updating secondary segments (nested case)', async () => {
137-
const p = serializer.parse('/a/(b//right:c)');
138-
const t = await createRoot(p, ['a', {outlets: {right: ['d', 11, 'e']}}]);
139-
expect(serializer.serialize(t)).toEqual('/a/(b//right:d/11/e)');
140-
});
131+
it('should support updating secondary segments', async () => {
132+
const p = serializer.parse('/a(right:b)');
133+
const t = await createRoot(p, [{outlets: {right: ['c', 11, 'd']}}]);
134+
expect(serializer.serialize(t)).toEqual('/a(right:c/11/d)');
135+
});
141136

142-
describe('', async () => {
137+
it('should support updating secondary segments (nested case)', async () => {
138+
const p = serializer.parse('/a/(b//right:c)');
139+
const t = await createRoot(p, ['a', {outlets: {right: ['d', 11, 'e']}}]);
140+
expect(serializer.serialize(t)).toEqual('/a/(b//right:d/11/e)');
141+
});
143142
it('should support removing secondary outlet with prefix', async () => {
144143
const p = serializer.parse('/parent/(child//secondary:popup)');
145144
const t = await createRoot(p, ['parent', {outlets: {secondary: null}}]);
@@ -206,6 +205,106 @@ describe('createUrlTree', async () => {
206205
const t = await createRoot(p, ['parent', {outlets: {primary: 'child', secondary: null}}]);
207206
expect(serializer.serialize(t)).toEqual('/parent/child(rootSecondary:rootPopup)');
208207
});
208+
209+
describe('absolute navigations', () => {
210+
it('with and pathless root', async () => {
211+
router.resetConfig([
212+
{
213+
path: '',
214+
children: [
215+
{path: '**', outlet: 'left', component: class {}},
216+
],
217+
},
218+
]);
219+
await router.navigateByUrl('(left:search)');
220+
expect(router.url).toEqual('/(left:search)');
221+
expect(router.createUrlTree(['/', {outlets: {'left': ['projects', '123']}}]).toString())
222+
.toEqual('/(left:projects/123)');
223+
});
224+
it('empty path parent and sibling with a path', async () => {
225+
router.resetConfig([
226+
{
227+
path: '',
228+
children: [
229+
{path: 'x', component: class {}},
230+
{path: '**', outlet: 'left', component: class {}},
231+
],
232+
},
233+
]);
234+
await router.navigateByUrl('/x(left:search)');
235+
expect(router.url).toEqual('/x(left:search)');
236+
expect(router.createUrlTree(['/', {outlets: {'left': ['projects', '123']}}]).toString())
237+
.toEqual('/x(left:projects/123)');
238+
// TODO(atscott): router.createUrlTree uses the "legacy" strategy based on the current
239+
// UrlTree to generate new URLs. Once that changes, this can be `router.createUrlTree`
240+
// again.
241+
expect(createUrlTreeFromSnapshot(
242+
router.routerState.root.snapshot,
243+
[
244+
'/', {
245+
outlets: {
246+
'primary': [{
247+
outlets: {
248+
'left': ['projects', '123'],
249+
}
250+
}]
251+
}
252+
}
253+
])
254+
.toString())
255+
.toEqual('/x(left:projects/123)');
256+
});
257+
258+
it('empty path parent and sibling', async () => {
259+
router.resetConfig([
260+
{
261+
path: '',
262+
children: [
263+
{path: '', component: class {}},
264+
{path: '**', outlet: 'left', component: class {}},
265+
{path: '**', outlet: 'right', component: class {}},
266+
],
267+
},
268+
]);
269+
await router.navigateByUrl('/(left:search//right:define)');
270+
expect(router.url).toEqual('/(left:search//right:define)');
271+
expect(router.createUrlTree(['/', {outlets: {'left': ['projects', '123']}}]).toString())
272+
.toEqual('/(left:projects/123//right:define)');
273+
});
274+
it('two pathless parents', async () => {
275+
router.resetConfig([
276+
{
277+
path: '',
278+
children: [{
279+
path: '',
280+
children: [
281+
{path: '**', outlet: 'left', component: class {}},
282+
]
283+
}],
284+
},
285+
]);
286+
await router.navigateByUrl('(left:search)');
287+
expect(router.url).toEqual('/(left:search)');
288+
expect(router.createUrlTree(['/', {outlets: {'left': ['projects', '123']}}]).toString())
289+
.toEqual('/(left:projects/123)');
290+
});
291+
292+
it('maintains structure when primary outlet is not pathless', async () => {
293+
router.resetConfig([
294+
{
295+
path: 'a',
296+
children: [
297+
{path: '**', outlet: 'left', component: class {}},
298+
],
299+
},
300+
{path: '**', outlet: 'left', component: class {}},
301+
]);
302+
await router.navigateByUrl('/a/(left:search)');
303+
expect(router.url).toEqual('/a/(left:search)');
304+
expect(router.createUrlTree(['/', {outlets: {'left': ['projects', '123']}}]).toString())
305+
.toEqual('/a/(left:search)(left:projects/123)');
306+
});
307+
});
209308
});
210309

211310
it('can navigate to nested route where commands is string', async () => {

0 commit comments

Comments
 (0)