mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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:
committed by
Alex Rickabaugh
parent
7efa09b401
commit
53ca936366
@@ -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"
|
||||
]
|
||||
]
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user