diff --git a/goldens/circular-deps/packages.json b/goldens/circular-deps/packages.json
index bcb031488b0..be71ff28360 100644
--- a/goldens/circular-deps/packages.json
+++ b/goldens/circular-deps/packages.json
@@ -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"
]
]
\ No newline at end of file
diff --git a/goldens/public-api/router/index.md b/goldens/public-api/router/index.md
index 5e8d26c2bf4..b401510d0bd 100644
--- a/goldens/public-api/router/index.md
+++ b/goldens/public-api/router/index.md
@@ -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;
diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json
index 3bf276c6813..2eff085cae6 100644
--- a/packages/core/test/bundling/router/bundle.golden_symbols.json
+++ b/packages/core/test/bundling/router/bundle.golden_symbols.json
@@ -962,6 +962,9 @@
{
"name": "createResultForNode"
},
+ {
+ "name": "createRoot"
+ },
{
"name": "createRouterScroller"
},
diff --git a/packages/router/src/apply_redirects.ts b/packages/router/src/apply_redirects.ts
index 359bea0d55d..ffd4bdd9540 100644
--- a/packages/router/src/apply_redirects.ts
+++ b/packages/router/src/apply_redirects.ts
@@ -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);
-}
diff --git a/packages/router/src/create_url_tree.ts b/packages/router/src/create_url_tree.ts
index 792914a626e..e67f31df59b 100644
--- a/packages/router/src/create_url_tree.ts
+++ b/packages/router/src/create_url_tree.ts
@@ -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 {
diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts
index 9c0ad908ddd..0d17f5ea9ed 100644
--- a/packages/router/src/index.ts
+++ b/packages/router/src/index.ts
@@ -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';
diff --git a/packages/router/src/url_tree.ts b/packages/router/src/url_tree.ts
index 14f55d1b795..be0f88cf063 100644
--- a/packages/router/src/url_tree.ts
+++ b/packages/router/src/url_tree.ts
@@ -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;
+}
diff --git a/packages/router/test/create_url_tree.spec.ts b/packages/router/test/create_url_tree.spec.ts
index 8fd407906a6..d38898d27f5 100644
--- a/packages/router/test/create_url_tree.spec.ts
+++ b/packages/router/test/create_url_tree.spec.ts
@@ -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: ``,
+ 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: '',
+ 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: '', 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) {
+ tick();
+ fixture.detectChanges();
+}