feat(router): Add ability to create UrlTree from any ActivatedRouteSnapshot (#45877)

This exposes a new function from the router public API that allows
developers to create a `UrlTree` from _any_ `ActivatedRouteSnapshot`.
The current Router APIs only support creating a `UrlTree` from an
`ActivatedRoute` which is both active _and_ materially appears in the
`UrlTree` (it cannot be an empty path named outlet). This is because the
implementation of the current way of creating a `UrlTree` attempts to
look up the `UrlSegment` of the `ActivatedRoute` in the currently active
`UrlTree` of the router. When this doesn't work, the `UrlTree` creation
fails.

Note that this API does not replace the current one. That would actually be a
breaking change but should be done at some point in the future (v15). That is,
`router.navigate` should call this new function. At that point, we can
remove `_lastPathIndex`, `_urlSegment`, and `_correctedPathIndex` from
the `ActivatedRoute`, along with all of the logic associated with
determining what those should be. In addition, this would unblock a fix
for #26081 because the `applyRedirects` and `recognize` operations
could be combined into one.  Overall, this would simplify logic in the router
and reduce code size. It also exposes core routing capabilities as a helper function
which were previously private API, which is a necessary step towards #42953.

As a stress test for this new function, it _was_ swapped in as the
default for `UrlTree` creation in #45859 and tested internally. The
results indicate that this function behaves correctly.

resolves #42191 (Tested directly)
resolves #38276 (The test with a guard covers this case)
resolves #22763 (Tested directly)

PR Close #45877
This commit is contained in:
Andrew Scott
2022-05-04 13:30:02 -07:00
committed by Alex Rickabaugh
parent 7efa09b401
commit 53ca936366
8 changed files with 309 additions and 51 deletions
+1 -6
View File
@@ -315,7 +315,7 @@
],
[
"packages/router/src/models.ts",
"packages/router/src/router_state.ts",
"packages/router/src/url_tree.ts",
"packages/router/src/shared.ts"
],
[
@@ -341,10 +341,5 @@
[
"packages/router/src/shared.ts",
"packages/router/src/url_tree.ts"
],
[
"packages/router/src/shared.ts",
"packages/router/src/url_tree.ts",
"packages/router/src/utils/collection.ts"
]
]
+3
View File
@@ -175,6 +175,9 @@ export class ChildrenOutletContexts {
// @public
export function convertToParamMap(params: Params): ParamMap;
// @public
export function createUrlTreeFromSnapshot(relativeTo: ActivatedRouteSnapshot, commands: any[], queryParams?: Params | null, fragment?: string | null): UrlTree;
// @public
export type Data = {
[key: string | symbol]: any;
@@ -962,6 +962,9 @@
{
"name": "createResultForNode"
},
{
"name": "createRoot"
},
{
"name": "createRouterScroller"
},
+2 -40
View File
@@ -14,7 +14,7 @@ import {CanLoadFn, LoadedRouterConfig, Route, Routes} from './models';
import {prioritizedGuardValue} from './operators/prioritized_guard_value';
import {RouterConfigLoader} from './router_config_loader';
import {navigationCancelingError, Params, PRIMARY_OUTLET} from './shared';
import {UrlSegment, UrlSegmentGroup, UrlSerializer, UrlTree} from './url_tree';
import {createRoot, squashSegmentGroup, UrlSegment, UrlSegmentGroup, UrlSerializer, UrlTree} from './url_tree';
import {forEach, wrapIntoObservable} from './utils/collection';
import {getOrCreateRouteInjectorIfNeeded, getOutlet, sortByMatchingOutlets} from './utils/config';
import {isImmediateMatch, match, noLeftoversInUrl, split} from './utils/config_matching';
@@ -124,9 +124,7 @@ class ApplyRedirects {
private createUrlTree(rootCandidate: UrlSegmentGroup, queryParams: Params, fragment: string|null):
UrlTree {
const root = rootCandidate.segments.length > 0 ?
new UrlSegmentGroup([], {[PRIMARY_OUTLET]: rootCandidate}) :
rootCandidate;
const root = createRoot(rootCandidate);
return new UrlTree(root, queryParams, fragment);
}
@@ -475,39 +473,3 @@ class ApplyRedirects {
return redirectToUrlSegment;
}
}
/**
* When possible, merges the primary outlet child into the parent `UrlSegmentGroup`.
*
* When a segment group has only one child which is a primary outlet, merges that child into the
* parent. That is, the child segment group's segments are merged into the `s` and the child's
* children become the children of `s`. Think of this like a 'squash', merging the child segment
* group into the parent.
*/
function mergeTrivialChildren(s: UrlSegmentGroup): UrlSegmentGroup {
if (s.numberOfChildren === 1 && s.children[PRIMARY_OUTLET]) {
const c = s.children[PRIMARY_OUTLET];
return new UrlSegmentGroup(s.segments.concat(c.segments), c.children);
}
return s;
}
/**
* Recursively merges primary segment children into their parents and also drops empty children
* (those which have no segments and no children themselves). The latter prevents serializing a
* group into something like `/a(aux:)`, where `aux` is an empty child segment.
*/
function squashSegmentGroup(segmentGroup: UrlSegmentGroup): UrlSegmentGroup {
const newChildren = {} as any;
for (const childOutlet of Object.keys(segmentGroup.children)) {
const child = segmentGroup.children[childOutlet];
const childCandidate = squashSegmentGroup(child);
// don't add empty children
if (childCandidate.segments.length > 0 || childCandidate.hasChildren()) {
newChildren[childOutlet] = childCandidate;
}
}
const s = new UrlSegmentGroup(segmentGroup.segments, newChildren);
return mergeTrivialChildren(s);
}
+142 -4
View File
@@ -6,11 +6,117 @@
* found in the LICENSE file at https://angular.io/license
*/
import {ActivatedRoute} from './router_state';
import {ActivatedRoute, ActivatedRouteSnapshot} from './router_state';
import {Params, PRIMARY_OUTLET} from './shared';
import {UrlSegment, UrlSegmentGroup, UrlTree} from './url_tree';
import {createRoot, squashSegmentGroup, UrlSegment, UrlSegmentGroup, UrlTree} from './url_tree';
import {forEach, last, shallowEqual} from './utils/collection';
/**
* Creates a `UrlTree` relative to an `ActivatedRouteSnapshot`.
*
* @publicApi
*
*
* @param relativeTo The `ActivatedRouteSnapshot` to apply the commands to
* @param commands An array of URL fragments with which to construct the new URL tree.
* If the path is static, can be the literal URL string. For a dynamic path, pass an array of path
* segments, followed by the parameters for each segment.
* The fragments are applied to the one provided in the `relativeTo` parameter.
* @param queryParams The query parameters for the `UrlTree`. `null` if the `UrlTree` does not have
* any query parameters.
* @param fragment The fragment for the `UrlTree`. `null` if the `UrlTree` does not have a fragment.
*
* @usageNotes
*
* ```
* // create /team/33/user/11
* createUrlTreeFromSnapshot(snapshot, ['/team', 33, 'user', 11]);
*
* // create /team/33;expand=true/user/11
* createUrlTreeFromSnapshot(snapshot, ['/team', 33, {expand: true}, 'user', 11]);
*
* // you can collapse static segments like this (this works only with the first passed-in value):
* createUrlTreeFromSnapshot(snapshot, ['/team/33/user', userId]);
*
* // If the first segment can contain slashes, and you do not want the router to split it,
* // you can do the following:
* createUrlTreeFromSnapshot(snapshot, [{segmentPath: '/one/two'}]);
*
* // create /team/33/(user/11//right:chat)
* createUrlTreeFromSnapshot(snapshot, ['/team', 33, {outlets: {primary: 'user/11', right:
* 'chat'}}], null, null);
*
* // remove the right secondary node
* createUrlTreeFromSnapshot(snapshot, ['/team', 33, {outlets: {primary: 'user/11', right: null}}]);
*
* // For the examples below, assume the current URL is for the `/team/33/user/11` and the
* `ActivatedRouteSnapshot` points to `user/11`:
*
* // navigate to /team/33/user/11/details
* createUrlTreeFromSnapshot(snapshot, ['details']);
*
* // navigate to /team/33/user/22
* createUrlTreeFromSnapshot(snapshot, ['../22']);
*
* // navigate to /team/44/user/22
* createUrlTreeFromSnapshot(snapshot, ['../../team/44/user/22']);
* ```
*/
export function createUrlTreeFromSnapshot(
relativeTo: ActivatedRouteSnapshot, commands: any[], queryParams: Params|null = null,
fragment: string|null = null): UrlTree {
const relativeToUrlSegmentGroup = createSegmentGroupFromRoute(relativeTo);
return createUrlTreeFromSegmentGroup(relativeToUrlSegmentGroup, commands, queryParams, fragment);
}
function createSegmentGroupFromRoute(route: ActivatedRouteSnapshot): UrlSegmentGroup {
let targetGroup: UrlSegmentGroup|undefined;
function createSegmentGroupFromRouteRecursive(currentRoute: ActivatedRouteSnapshot) {
const childOutlets: {[outlet: string]: UrlSegmentGroup} = {};
for (const childSnapshot of currentRoute.children) {
const root = createSegmentGroupFromRouteRecursive(childSnapshot);
childOutlets[childSnapshot.outlet] = root;
}
const segmentGroup = new UrlSegmentGroup(currentRoute.url, childOutlets);
if (currentRoute === route) {
targetGroup = segmentGroup;
}
return segmentGroup;
}
const rootCandidate = createSegmentGroupFromRouteRecursive(route.root);
const rootSegmentGroup = createRoot(rootCandidate);
return targetGroup ?? rootSegmentGroup;
}
export function createUrlTreeFromSegmentGroup(
relativeTo: UrlSegmentGroup, commands: any[], queryParams: Params|null,
fragment: string|null): UrlTree {
let root = relativeTo;
while (root.parent) {
root = root.parent;
}
// There are no commands so the `UrlTree` goes to the same path as the one created from the
// `UrlSegmentGroup`. All we need to do is update the `queryParams` and `fragment` without
// applying any other logic.
if (commands.length === 0) {
return tree(root, root, root, queryParams, fragment);
}
const nav = computeNavigation(commands);
if (nav.toRoot()) {
return tree(root, root, new UrlSegmentGroup([], {}), queryParams, fragment);
}
const position = findStartingPositionForTargetGroup(nav, root, relativeTo);
const newSegmentGroup = position.processChildren ?
updateSegmentGroupChildren(position.segmentGroup, position.index, nav.commands) :
updateSegmentGroup(position.segmentGroup, position.index, nav.commands);
return tree(root, position.segmentGroup, newSegmentGroup, queryParams, fragment);
}
export function createUrlTree(
route: ActivatedRoute, urlTree: UrlTree, commands: any[], queryParams: Params|null,
fragment: string|null): UrlTree {
@@ -76,14 +182,24 @@ function tree(
});
}
let rootCandidate: UrlSegmentGroup;
if (oldRoot === oldSegmentGroup) {
return new UrlTree(newSegmentGroup, qp, fragment);
rootCandidate = newSegmentGroup;
} else {
rootCandidate = replaceSegment(oldRoot, oldSegmentGroup, newSegmentGroup);
}
const newRoot = replaceSegment(oldRoot, oldSegmentGroup, newSegmentGroup);
const newRoot = createRoot(squashSegmentGroup(rootCandidate));
return new UrlTree(newRoot, qp, fragment);
}
/**
* Replaces the `oldSegment` which is located in some child of the `current` with the `newSegment`.
* This also has the effect of creating new `UrlSegmentGroup` copies to update references. This
* shouldn't be necessary but the fallback logic for an invalid ActivatedRoute in the creation uses
* the Router's current url tree. If we don't create new segment groups, we end up modifying that
* value.
*/
function replaceSegment(
current: UrlSegmentGroup, oldSegment: UrlSegmentGroup,
newSegment: UrlSegmentGroup): UrlSegmentGroup {
@@ -172,6 +288,28 @@ class Position {
}
}
function findStartingPositionForTargetGroup(
nav: Navigation, root: UrlSegmentGroup, target: UrlSegmentGroup): Position {
if (nav.isAbsolute) {
return new Position(root, true, 0);
}
if (!target) {
// `NaN` is used only to maintain backwards compatibility with incorrectly mocked
// `ActivatedRouteSnapshot` in tests. In prior versions of this code, the position here was
// determined based on an internal property that was rarely mocked, resulting in `NaN`. In
// reality, this code path should _never_ be touched since `target` is not allowed to be falsey.
return new Position(root, false, NaN);
}
if (target.parent === null) {
return new Position(target, true, 0);
}
const modifier = isMatrixParams(nav.commands[0]) ? 0 : 1;
const index = target.segments.length - 1 + modifier;
return createPositionApplyingDoubleDots(target, index, nav.numberOfDoubleDots);
}
function findStartingPosition(
nav: Navigation, tree: UrlTree, segmentGroup: UrlSegmentGroup,
lastPathIndex: number): Position {
+1
View File
@@ -7,6 +7,7 @@
*/
export {createUrlTreeFromSnapshot} from './create_url_tree';
export {RouterLink, RouterLinkWithHref} from './directives/router_link';
export {RouterLinkActive} from './directives/router_link_active';
export {RouterOutlet, RouterOutletContract} from './directives/router_outlet';
+42
View File
@@ -728,3 +728,45 @@ class UrlParser {
}
}
}
export function createRoot(rootCandidate: UrlSegmentGroup) {
return rootCandidate.segments.length > 0 ?
new UrlSegmentGroup([], {[PRIMARY_OUTLET]: rootCandidate}) :
rootCandidate;
}
/**
* Recursively merges primary segment children into their parents and also drops empty children
* (those which have no segments and no children themselves). The latter prevents serializing a
* group into something like `/a(aux:)`, where `aux` is an empty child segment.
*/
export function squashSegmentGroup(segmentGroup: UrlSegmentGroup): UrlSegmentGroup {
const newChildren = {} as any;
for (const childOutlet of Object.keys(segmentGroup.children)) {
const child = segmentGroup.children[childOutlet];
const childCandidate = squashSegmentGroup(child);
// don't add empty children
if (childCandidate.segments.length > 0 || childCandidate.hasChildren()) {
newChildren[childOutlet] = childCandidate;
}
}
const s = new UrlSegmentGroup(segmentGroup.segments, newChildren);
return mergeTrivialChildren(s);
}
/**
* When possible, merges the primary outlet child into the parent `UrlSegmentGroup`.
*
* When a segment group has only one child which is a primary outlet, merges that child into the
* parent. That is, the child segment group's segments are merged into the `s` and the child's
* children become the children of `s`. Think of this like a 'squash', merging the child segment
* group into the parent.
*/
function mergeTrivialChildren(s: UrlSegmentGroup): UrlSegmentGroup {
if (s.numberOfChildren === 1 && s.children[PRIMARY_OUTLET]) {
const c = s.children[PRIMARY_OUTLET];
return new UrlSegmentGroup(s.segments.concat(c.segments), c.children);
}
return s;
}
+115 -1
View File
@@ -6,12 +6,20 @@
* found in the LICENSE file at https://angular.io/license
*/
import {Component, Injectable} from '@angular/core';
import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {BehaviorSubject} from 'rxjs';
import {createUrlTree} from '../src/create_url_tree';
import {createUrlTree, createUrlTreeFromSnapshot} from '../src/create_url_tree';
import {RouterLink} from '../src/directives/router_link';
import {Routes} from '../src/models';
import {Router} from '../src/router';
import {RouterModule, routerNgProbeToken} from '../src/router_module';
import {ActivatedRoute, ActivatedRouteSnapshot, advanceActivatedRoute} from '../src/router_state';
import {Params, PRIMARY_OUTLET} from '../src/shared';
import {DefaultUrlSerializer, UrlSegment, UrlSegmentGroup, UrlTree} from '../src/url_tree';
import {RouterTestingModule} from '../testing';
describe('createUrlTree', () => {
const serializer = new DefaultUrlSerializer();
@@ -434,3 +442,109 @@ function create(
advanceActivatedRoute(a);
return createUrlTree(a, tree, commands, queryParams ?? null, fragment ?? null);
}
describe('createUrlTreeFromSnapshot', () => {
it('can create a UrlTree relative to empty path named parent', fakeAsync(() => {
@Component({
template: `<router-outlet></router-outlet>`,
standalone: true,
imports: [RouterModule],
})
class MainPageComponent {
constructor(private route: ActivatedRoute, private router: Router) {}
navigate() {
this.router.navigateByUrl(
createUrlTreeFromSnapshot(this.route.snapshot, ['innerRoute'], null, null));
}
}
@Component({template: 'child works!'})
class ChildComponent {
}
@Component({
template: '<router-outlet name="main-page"></router-outlet>',
standalone: true,
imports: [RouterModule]
})
class RootCmp {
}
const routes: Routes = [{
path: '',
component: MainPageComponent,
outlet: 'main-page',
children: [{path: 'innerRoute', component: ChildComponent}]
}];
TestBed.configureTestingModule({imports: [RouterTestingModule.withRoutes(routes)]});
const router = TestBed.inject(Router);
const fixture = TestBed.createComponent(RootCmp);
router.initialNavigation();
advance(fixture);
fixture.debugElement.query(By.directive(MainPageComponent)).componentInstance.navigate();
advance(fixture);
expect(fixture.nativeElement.innerHTML).toContain('child works!');
}));
it('can navigate to relative to `ActivatedRouteSnapshot` in guard', fakeAsync(() => {
@Injectable({providedIn: 'root'})
class Guard {
constructor(private readonly router: Router) {}
canActivate(snapshot: ActivatedRouteSnapshot) {
this.router.navigateByUrl(
createUrlTreeFromSnapshot(snapshot, ['../sibling'], null, null));
}
}
@Component({
template: `main`,
standalone: true,
imports: [RouterModule],
})
class GuardedComponent {
}
@Component({template: 'sibling', standalone: true})
class SiblingComponent {
}
@Component(
{template: '<router-outlet></router-outlet>', standalone: true, imports: [RouterModule]})
class RootCmp {
}
const routes: Routes = [
{
path: 'parent',
component: RootCmp,
children: [
{
path: 'guarded',
component: GuardedComponent,
canActivate: [Guard],
},
{
path: 'sibling',
component: SiblingComponent,
}
],
},
];
TestBed.configureTestingModule({imports: [RouterTestingModule.withRoutes(routes)]});
const router = TestBed.inject(Router);
const fixture = TestBed.createComponent(RootCmp);
router.navigateByUrl('parent/guarded');
advance(fixture);
expect(router.url).toEqual('/parent/sibling');
}));
});
function advance(fixture: ComponentFixture<unknown>) {
tick();
fixture.detectChanges();
}