From 53ca936366fb908278571bae5fcc7fa08b19a5a0 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Wed, 4 May 2022 13:30:02 -0700 Subject: [PATCH] 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 --- goldens/circular-deps/packages.json | 7 +- goldens/public-api/router/index.md | 3 + .../router/bundle.golden_symbols.json | 3 + packages/router/src/apply_redirects.ts | 42 +---- packages/router/src/create_url_tree.ts | 146 +++++++++++++++++- packages/router/src/index.ts | 1 + packages/router/src/url_tree.ts | 42 +++++ packages/router/test/create_url_tree.spec.ts | 116 +++++++++++++- 8 files changed, 309 insertions(+), 51 deletions(-) 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(); +}