From 62de7d930a5d2f3cc39b6bf38dedbe3e9d938842 Mon Sep 17 00:00:00 2001 From: MeddahAbdellah Date: Mon, 14 Apr 2025 11:10:52 +0200 Subject: [PATCH] feat(router): add asynchronous redirects (#60863) Adds support for asynchronous redirects in the router, allowing redirect logic to be resolved dynamically (e.g., via API or async function). This enhances routing flexibility and supports more complex navigation scenarios. BREAKING CHANGE: The `RedirectFn` can now return `Observable` or `Promise`. Any code that directly calls functions returning this type may need to be adjusted to account for this. PR Close #60863 --- goldens/public-api/router/index.api.md | 2 +- packages/router/src/apply_redirects.ts | 58 +- packages/router/src/models.ts | 2 +- packages/router/src/recognize.ts | 5 +- packages/router/test/apply_redirects.spec.ts | 632 ++++++++++++++++++- 5 files changed, 659 insertions(+), 40 deletions(-) diff --git a/goldens/public-api/router/index.api.md b/goldens/public-api/router/index.api.md index b01554279ee..bac40850387 100644 --- a/goldens/public-api/router/index.api.md +++ b/goldens/public-api/router/index.api.md @@ -606,7 +606,7 @@ export class RedirectCommand { } // @public -export type RedirectFunction = (redirectData: Pick) => string | UrlTree; +export type RedirectFunction = (redirectData: Pick) => MaybeAsync; // @public export interface Resolve { diff --git a/packages/router/src/apply_redirects.ts b/packages/router/src/apply_redirects.ts index cc602f1bf62..502ba917a54 100644 --- a/packages/router/src/apply_redirects.ts +++ b/packages/router/src/apply_redirects.ts @@ -8,6 +8,7 @@ import {Injector, runInInjectionContext, ɵRuntimeError as RuntimeError} from '@angular/core'; import {Observable, of, throwError} from 'rxjs'; +import {map} from 'rxjs/operators'; import {RuntimeErrorCode} from './errors'; import {NavigationCancellationCode} from './events'; @@ -16,6 +17,7 @@ import {navigationCancelingError} from './navigation_canceling_error'; import {ActivatedRouteSnapshot} from './router_state'; import {Params, PRIMARY_OUTLET} from './shared'; import {UrlSegment, UrlSegmentGroup, UrlSerializer, UrlTree} from './url_tree'; +import {wrapIntoObservable} from './utils/collection'; export class NoMatch { public segmentGroup: UrlSegmentGroup | null; @@ -88,31 +90,26 @@ export class ApplyRedirects { posParams: {[k: string]: UrlSegment}, currentSnapshot: ActivatedRouteSnapshot, injector: Injector, - ): UrlTree { - if (typeof redirectTo !== 'string') { - const redirectToFn = redirectTo; - const {queryParams, fragment, routeConfig, url, outlet, params, data, title} = - currentSnapshot; - const newRedirect = runInInjectionContext(injector, () => - redirectToFn({params, data, queryParams, fragment, routeConfig, url, outlet, title}), - ); - if (newRedirect instanceof UrlTree) { - throw new AbsoluteRedirect(newRedirect); - } + ): Observable { + return getRedirectResult(redirectTo, currentSnapshot, injector).pipe( + map((redirect) => { + if (redirect instanceof UrlTree) { + throw new AbsoluteRedirect(redirect); + } - redirectTo = newRedirect; - } + const newTree = this.applyRedirectCreateUrlTree( + redirect, + this.urlSerializer.parse(redirect), + segments, + posParams, + ); - const newTree = this.applyRedirectCreateUrlTree( - redirectTo, - this.urlSerializer.parse(redirectTo), - segments, - posParams, + if (redirect[0] === '/') { + throw new AbsoluteRedirect(newTree); + } + return newTree; + }), ); - if (redirectTo[0] === '/') { - throw new AbsoluteRedirect(newTree); - } - return newTree; } applyRedirectCreateUrlTree( @@ -199,3 +196,20 @@ export class ApplyRedirects { return redirectToUrlSegment; } } + +function getRedirectResult( + redirectTo: string | RedirectFunction, + currentSnapshot: ActivatedRouteSnapshot, + injector: Injector, +): Observable { + if (typeof redirectTo === 'string') { + return of(redirectTo); + } + const redirectToFn = redirectTo; + const {queryParams, fragment, routeConfig, url, outlet, params, data, title} = currentSnapshot; + return wrapIntoObservable( + runInInjectionContext(injector, () => + redirectToFn({params, data, queryParams, fragment, routeConfig, url, outlet, title}), + ), + ); +} diff --git a/packages/router/src/models.ts b/packages/router/src/models.ts index 60aaafe32ec..d0007c5f62f 100644 --- a/packages/router/src/models.ts +++ b/packages/router/src/models.ts @@ -316,7 +316,7 @@ export type RedirectFunction = ( ActivatedRouteSnapshot, 'routeConfig' | 'url' | 'params' | 'queryParams' | 'fragment' | 'data' | 'outlet' | 'title' >, -) => string | UrlTree; +) => MaybeAsync; /** * A policy for when to run guards and resolvers on a route. diff --git a/packages/router/src/recognize.ts b/packages/router/src/recognize.ts index d97ad1d771b..2381e50b365 100644 --- a/packages/router/src/recognize.ts +++ b/packages/router/src/recognize.ts @@ -382,7 +382,7 @@ export class Recognizer { const inherited = getInherited(currentSnapshot, parentRoute, this.paramsInheritanceStrategy); currentSnapshot.params = Object.freeze(inherited.params); currentSnapshot.data = Object.freeze(inherited.data); - const newTree = this.applyRedirects.applyRedirectCommands( + const newTree$: Observable = this.applyRedirects.applyRedirectCommands( consumedSegments, route.redirectTo!, positionalParamSegments, @@ -390,7 +390,8 @@ export class Recognizer { injector, ); - return this.applyRedirects.lineralizeSegments(route, newTree).pipe( + return newTree$.pipe( + switchMap((newTree) => this.applyRedirects.lineralizeSegments(route, newTree)), mergeMap((newSegments: UrlSegment[]) => { return this.processSegment( injector, diff --git a/packages/router/test/apply_redirects.spec.ts b/packages/router/test/apply_redirects.spec.ts index ae1e16ac50f..0b9bfc44b96 100644 --- a/packages/router/test/apply_redirects.spec.ts +++ b/packages/router/test/apply_redirects.spec.ts @@ -9,7 +9,7 @@ import {EnvironmentInjector, inject, Injectable} from '@angular/core'; import {fakeAsync, TestBed, tick} from '@angular/core/testing'; import {Observable, of} from 'rxjs'; -import {delay, tap} from 'rxjs/operators'; +import {delay, tap, timeout} from 'rxjs/operators'; import {Route, Routes} from '../src/models'; import {recognize} from '../src/recognize'; @@ -1577,7 +1577,7 @@ describe('redirects', () => { ); }); - it('with a simple function returning a UrlTree', () => { + it('but it would cause an infinit loop if redirect route is sub route of the path containing the redirectTo', () => { checkRedirect( [ { @@ -1586,7 +1586,7 @@ describe('redirects', () => { new UrlTree( new UrlSegmentGroup([], { 'primary': new UrlSegmentGroup( - [new UrlSegment('a', {}), new UrlSegment('b', {}), new UrlSegment('c', {})], + [new UrlSegment('a', {}), new UrlSegment('b', {}), new UrlSegment('d', {})], {}, ), }), @@ -1596,7 +1596,35 @@ describe('redirects', () => { ], '/a/b', (t: UrlTree) => { - expectTreeToBe(t, '/a/b/c'); + throw 'Should not reach'; + }, + undefined, + (e) => { + expect(e).toBeDefined(); + }, + ); + }); + + it('with a simple function returning a UrlTree', () => { + checkRedirect( + [ + { + path: 'a/b', + redirectTo: () => + new UrlTree( + new UrlSegmentGroup([], { + 'primary': new UrlSegmentGroup( + [new UrlSegment('c', {}), new UrlSegment('d', {}), new UrlSegment('e', {})], + {}, + ), + }), + ), + }, + {path: '**', component: ComponentC}, + ], + '/a/b', + (t: UrlTree) => { + expectTreeToBe(t, '/c/d/e'); }, ); }); @@ -1604,12 +1632,12 @@ describe('redirects', () => { it('with a function using inject and returning a UrlTree', () => { checkRedirect( [ - {path: 'a/b', redirectTo: () => inject(Router).parseUrl('/a/b/c')}, + {path: 'a/b', redirectTo: () => inject(Router).parseUrl('/c/d/e')}, {path: '**', component: ComponentC}, ], '/a/b', (t: UrlTree) => { - expectTreeToBe(t, '/a/b/c'); + expectTreeToBe(t, '/c/d/e'); }, ); }); @@ -1637,12 +1665,12 @@ describe('redirects', () => { it('with a function using inject and returning a UrlTree with params', () => { checkRedirect( [ - {path: 'a/b', redirectTo: () => inject(Router).parseUrl('/a;a1=1,a2=2/b/c?qp=123')}, + {path: 'a/b', redirectTo: () => inject(Router).parseUrl('/c;a1=1,a2=2/d/e?qp=123')}, {path: '**', component: ComponentC}, ], '/a/b', (t: UrlTree) => { - expectTreeToBe(t, '/a;a1=1,a2=2/b/c?qp=123'); + expectTreeToBe(t, '/c;a1=1,a2=2/d/e?qp=123'); }, ); }); @@ -1793,6 +1821,575 @@ describe('redirects', () => { }); }); + describe('can use redirectTo as an observable', () => { + it('with a simple function returning an observable that emits a string', () => { + checkRedirect( + [ + {path: 'a/b', redirectTo: () => of('other')}, + {path: '**', component: ComponentC}, + ], + '/a/b', + (t: UrlTree) => { + expectTreeToBe(t, '/other'); + }, + ); + }); + + it('but it would cause an infinit loop if redirect route is sub route of the path containing the redirectTo', () => { + checkRedirect( + [ + { + path: 'a/b', + redirectTo: () => + of( + new UrlTree( + new UrlSegmentGroup([], { + 'primary': new UrlSegmentGroup( + [new UrlSegment('a', {}), new UrlSegment('b', {}), new UrlSegment('d', {})], + {}, + ), + }), + ), + ), + }, + {path: '**', component: ComponentC}, + ], + '/a/b', + (t: UrlTree) => { + throw 'Should not reach'; + }, + undefined, + (e) => { + expect(e).toBeDefined(); + }, + ); + }); + + it('with a simple function returning an observable that emits a UrlTree', () => { + checkRedirect( + [ + { + path: 'a/b', + redirectTo: () => + of( + new UrlTree( + new UrlSegmentGroup([], { + 'primary': new UrlSegmentGroup( + [new UrlSegment('c', {}), new UrlSegment('d', {}), new UrlSegment('e', {})], + {}, + ), + }), + ), + ), + }, + {path: '**', component: ComponentC}, + ], + '/a/b', + (t: UrlTree) => { + expectTreeToBe(t, '/c/d/e'); + }, + ); + }); + + it('with a function using inject and returning an observable that emits a UrlTree', () => { + checkRedirect( + [ + { + path: 'a/b', + redirectTo: () => { + const tree = inject(Router).parseUrl('/c/d/e'); + return of(tree); + }, + }, + {path: '**', component: ComponentC}, + ], + '/a/b', + (t: UrlTree) => { + expectTreeToBe(t, '/c/d/e'); + }, + ); + }); + + it('can access query params and redirect using them', () => { + checkRedirect( + [ + { + path: 'a/b', + redirectTo: ({queryParams}) => { + const tree = inject(Router).parseUrl('other'); + tree.queryParams = queryParams; + return of(tree); + }, + }, + {path: '**', component: ComponentC}, + ], + '/a/b?hl=en&q=hello', + (t: UrlTree) => { + expectTreeToBe(t, 'other?hl=en&q=hello'); + }, + ); + }); + + it('with a function using inject and returning a UrlTree with params', () => { + checkRedirect( + [ + { + path: 'a/b', + redirectTo: () => { + const tree = inject(Router).parseUrl('/c;a1=1,a2=2/d/e?qp=123'); + return of(tree); + }, + }, + {path: '**', component: ComponentC}, + ], + '/a/b', + (t: UrlTree) => { + expectTreeToBe(t, '/c;a1=1,a2=2/d/e?qp=123'); + }, + ); + }); + + it('receives positional params from the current route', () => { + checkRedirect( + [ + { + path: ':id1/:id2', + redirectTo: ({params}) => { + const tree = inject(Router).parseUrl( + `/redirect?id1=${params['id1']}&id2=${params['id2']}`, + ); + return of(tree); + }, + }, + {path: '**', component: ComponentC}, + ], + '/a/b', + (t: UrlTree) => { + expectTreeToBe(t, 'redirect?id1=a&id2=b'); + }, + ); + }); + + it('receives params from the parent route', () => { + checkRedirect( + [ + { + path: ':id1/:id2', + children: [ + { + path: 'c', + redirectTo: ({params}) => { + const tree = inject(Router).parseUrl( + `/redirect?id1=${params['id1']}&id2=${params['id2']}`, + ); + return of(tree); + }, + }, + ], + }, + {path: '**', component: ComponentC}, + ], + '/a/b/c', + (t: UrlTree) => { + expectTreeToBe(t, 'redirect?id1=a&id2=b'); + }, + ); + }); + + it('receives data from the parent componentless route', () => { + checkRedirect( + [ + { + path: 'a/b', + data: {data1: 'hello', data2: 'world'}, + children: [ + { + path: 'c', + redirectTo: ({data}) => of(`/redirect?id1=${data['data1']}&id2=${data['data2']}`), + }, + ], + }, + {path: '**', component: ComponentC}, + ], + '/a/b/c', + (t: UrlTree) => { + expectTreeToBe(t, 'redirect?id1=hello&id2=world'); + }, + ); + }); + + it('does not receive data from the parent route with component (default paramsInheritanceStrategy is emptyOnly)', () => { + checkRedirect( + [ + { + path: 'a/b', + data: {data1: 'hello', data2: 'world'}, + component: ComponentA, + children: [ + { + path: 'c', + redirectTo: ({data}) => { + expect(data['data1']).toBeUndefined(); + expect(data['data2']).toBeUndefined(); + return of(`/redirect`); + }, + }, + ], + }, + {path: '**', component: ComponentC}, + ], + '/a/b/c', + (t: UrlTree) => { + expectTreeToBe(t, 'redirect'); + }, + ); + }); + + it('has access to inherited data from all ancestor routes with paramsInheritanceStrategy always', () => { + checkRedirect( + [ + { + path: 'a', + data: {data1: 'hello'}, + component: ComponentA, + children: [ + { + path: 'b', + data: {data2: 'world'}, + component: ComponentB, + children: [ + { + path: 'c', + redirectTo: ({data}) => { + expect(data['data1']).toBe('hello'); + expect(data['data2']).toBe('world'); + return of(`/redirect`); + }, + }, + ], + }, + ], + }, + {path: '**', component: ComponentC}, + ], + '/a/b/c', + (t: UrlTree) => { + expectTreeToBe(t, 'redirect'); + }, + 'always', + ); + }); + + it('has access to path params', () => { + checkRedirect( + [ + { + path: 'a', + children: [ + { + path: 'b', + redirectTo: ({params}) => + of( + `/redirect?k1=${params['k1']}&k2=${params['k2']}&k3=${params['k3']}&k4=${params['k4']}`, + ), + }, + ], + }, + {path: '**', component: ComponentC}, + ], + '/a;k1=v1;k2=v2/b;k3=v3;k4=v4', + (t: UrlTree) => { + expectTreeToBe(t, 'redirect?k1=v1&k2=v2&k3=v3&k4=v4'); + }, + ); + }); + }); + + describe('can use redirectTo as a promise', () => { + it('with a simple function returning a promise that emits a string', () => { + checkRedirect( + [ + {path: 'a/b', redirectTo: async () => Promise.resolve('other')}, + {path: '**', component: ComponentC}, + ], + '/a/b', + (t: UrlTree) => { + expectTreeToBe(t, '/other'); + }, + ); + }); + + it('but it would cause an infinit loop if redirect route is sub route of the path containing the redirectTo', () => { + checkRedirect( + [ + { + path: 'a/b', + redirectTo: async () => + Promise.resolve( + new UrlTree( + new UrlSegmentGroup([], { + 'primary': new UrlSegmentGroup( + [new UrlSegment('a', {}), new UrlSegment('b', {}), new UrlSegment('d', {})], + {}, + ), + }), + ), + ), + }, + {path: '**', component: ComponentC}, + ], + '/a/b', + (t: UrlTree) => { + throw 'Should not reach'; + }, + undefined, + (e) => { + expect(e).toBeDefined(); + }, + ); + }); + + it('with a simple function returning an observable that emits a UrlTree', () => { + checkRedirect( + [ + { + path: 'a/b', + redirectTo: async () => + Promise.resolve( + new UrlTree( + new UrlSegmentGroup([], { + 'primary': new UrlSegmentGroup( + [new UrlSegment('c', {}), new UrlSegment('d', {}), new UrlSegment('e', {})], + {}, + ), + }), + ), + ), + }, + {path: '**', component: ComponentC}, + ], + '/a/b', + (t: UrlTree) => { + expectTreeToBe(t, '/c/d/e'); + }, + ); + }); + + it('with a function using inject and returning an observable that emits a UrlTree', () => { + checkRedirect( + [ + { + path: 'a/b', + redirectTo: async () => { + const tree = inject(Router).parseUrl('/c/d/e'); + return Promise.resolve(tree); + }, + }, + {path: '**', component: ComponentC}, + ], + '/a/b', + (t: UrlTree) => { + expectTreeToBe(t, '/c/d/e'); + }, + ); + }); + + it('can access query params and redirect using them', () => { + checkRedirect( + [ + { + path: 'a/b', + redirectTo: async ({queryParams}) => { + const tree = inject(Router).parseUrl('other'); + tree.queryParams = queryParams; + return Promise.resolve(tree); + }, + }, + {path: '**', component: ComponentC}, + ], + '/a/b?hl=en&q=hello', + (t: UrlTree) => { + expectTreeToBe(t, 'other?hl=en&q=hello'); + }, + ); + }); + + it('with a function using inject and returning a UrlTree with params', () => { + checkRedirect( + [ + { + path: 'a/b', + redirectTo: async () => { + const tree = inject(Router).parseUrl('/c;a1=1,a2=2/d/e?qp=123'); + return Promise.resolve(tree); + }, + }, + {path: '**', component: ComponentC}, + ], + '/a/b', + (t: UrlTree) => { + expectTreeToBe(t, '/c;a1=1,a2=2/d/e?qp=123'); + }, + ); + }); + + it('receives positional params from the current route', () => { + checkRedirect( + [ + { + path: ':id1/:id2', + redirectTo: async ({params}) => { + const tree = inject(Router).parseUrl( + `/redirect?id1=${params['id1']}&id2=${params['id2']}`, + ); + return Promise.resolve(tree); + }, + }, + {path: '**', component: ComponentC}, + ], + '/a/b', + (t: UrlTree) => { + expectTreeToBe(t, 'redirect?id1=a&id2=b'); + }, + ); + }); + + it('receives params from the parent route', () => { + checkRedirect( + [ + { + path: ':id1/:id2', + children: [ + { + path: 'c', + redirectTo: async ({params}) => { + const tree = inject(Router).parseUrl( + `/redirect?id1=${params['id1']}&id2=${params['id2']}`, + ); + return Promise.resolve(tree); + }, + }, + ], + }, + {path: '**', component: ComponentC}, + ], + '/a/b/c', + (t: UrlTree) => { + expectTreeToBe(t, 'redirect?id1=a&id2=b'); + }, + ); + }); + + it('receives data from the parent componentless route', () => { + checkRedirect( + [ + { + path: 'a/b', + data: {data1: 'hello', data2: 'world'}, + children: [ + { + path: 'c', + redirectTo: async ({data}) => + Promise.resolve(`/redirect?id1=${data['data1']}&id2=${data['data2']}`), + }, + ], + }, + {path: '**', component: ComponentC}, + ], + '/a/b/c', + (t: UrlTree) => { + expectTreeToBe(t, 'redirect?id1=hello&id2=world'); + }, + ); + }); + + it('does not receive data from the parent route with component (default paramsInheritanceStrategy is emptyOnly)', () => { + checkRedirect( + [ + { + path: 'a/b', + data: {data1: 'hello', data2: 'world'}, + component: ComponentA, + children: [ + { + path: 'c', + redirectTo: async ({data}) => { + expect(data['data1']).toBeUndefined(); + expect(data['data2']).toBeUndefined(); + return Promise.resolve(`/redirect`); + }, + }, + ], + }, + {path: '**', component: ComponentC}, + ], + '/a/b/c', + (t: UrlTree) => { + expectTreeToBe(t, 'redirect'); + }, + ); + }); + + it('has access to inherited data from all ancestor routes with paramsInheritanceStrategy always', () => { + checkRedirect( + [ + { + path: 'a', + data: {data1: 'hello'}, + component: ComponentA, + children: [ + { + path: 'b', + data: {data2: 'world'}, + component: ComponentB, + children: [ + { + path: 'c', + redirectTo: async ({data}) => { + expect(data['data1']).toBe('hello'); + expect(data['data2']).toBe('world'); + return Promise.resolve(`/redirect`); + }, + }, + ], + }, + ], + }, + {path: '**', component: ComponentC}, + ], + '/a/b/c', + (t: UrlTree) => { + expectTreeToBe(t, 'redirect'); + }, + 'always', + ); + }); + + it('has access to path params', () => { + checkRedirect( + [ + { + path: 'a', + children: [ + { + path: 'b', + redirectTo: async ({params}) => + Promise.resolve( + `/redirect?k1=${params['k1']}&k2=${params['k2']}&k3=${params['k3']}&k4=${params['k4']}`, + ), + }, + ], + }, + {path: '**', component: ComponentC}, + ], + '/a;k1=v1;k2=v2/b;k3=v3;k4=v4', + (t: UrlTree) => { + expectTreeToBe(t, 'redirect?k1=v1&k2=v2&k3=v3&k4=v4'); + }, + ); + }); + }); + // internal failure b/165719418 it('does not fail with large configs', () => { const config: Routes = []; @@ -1818,7 +2415,9 @@ function checkRedirect( url: string, callback: (t: UrlTree, state: RouterStateSnapshot) => void, paramsInheritanceStrategy?: ParamsInheritanceStrategy, + errorCallback?: (e: unknown) => void, ): void { + const redirectionTimeout = 10; recognize( TestBed.inject(EnvironmentInjector), TestBed.inject(RouterConfigLoader), @@ -1827,12 +2426,17 @@ function checkRedirect( tree(url), new DefaultUrlSerializer(), paramsInheritanceStrategy, - ).subscribe({ - next: (v) => callback(v.tree, v.state), - error: (e) => { - throw e; - }, - }); + ) + .pipe(timeout(redirectionTimeout)) + .subscribe({ + next: (v) => callback(v.tree, v.state), + error: (e) => { + if (!errorCallback) { + throw e; + } + errorCallback(e); + }, + }); } function tree(url: string): UrlTree {