Skip to content

Update Router's recognize step to use async/await #62994

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
refactor(router): Update recognize stage to use internally async/await
This is effectively a revert of 72e6a94.
Debugging the recognize stage is considerably easier with async/await
stacks compared to rxjs. This also improves maintainability and is a
better 1:1 with server-side logic that has been implemented to match
and can be more easily kept in sync.

BREAKING CHANGE: Router navigations may take several additional
microtasks to complete. Tests have been found to often be highly
dependent on the exact timing of navigation completions with respect to
the microtask queue. The most common fix for tests is to ensure all
navigations have been completed before making assertions.
  • Loading branch information
atscott committed Aug 7, 2025
commit aa23d80510016a6b9c52e2b3a65e8e8d063d3c6b
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,7 @@
"_stripIndexHtml",
"_stripOrigin",
"_wasLastNodeCreated",
"abortSignalToObservable",
"activateRoutes",
"activeConsumer",
"addAfterRenderSequencesForView",
Expand Down Expand Up @@ -865,7 +866,6 @@
"joinWithSlash",
"last",
"last2",
"last3",
"lastNodeWasCreated",
"lastSelectedElementIdx",
"leaveDI",
Expand Down Expand Up @@ -929,7 +929,6 @@
"ngZoneInstanceId",
"noLeftoversInUrl",
"noMatch",
"noMatch",
"noSideEffects",
"nodeChildrenAsMap",
"noop",
Expand Down Expand Up @@ -1027,8 +1026,6 @@
"saveContentQueryAndDirectiveIndex",
"saveNameToExportMap",
"saveResolvedLocalsInData",
"scan",
"scanInternals",
"scheduleArray",
"scheduleAsyncIterable",
"scheduleCallbackWithMicrotask",
Expand Down
85 changes: 35 additions & 50 deletions packages/router/src/apply_redirects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

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';
Expand All @@ -19,10 +18,11 @@ import {Params, PRIMARY_OUTLET} from './shared';
import {UrlSegment, UrlSegmentGroup, UrlSerializer, UrlTree} from './url_tree';
import {wrapIntoObservable} from './utils/collection';

export class NoMatch {
export class NoMatch extends Error {
public segmentGroup: UrlSegmentGroup | null;

constructor(segmentGroup?: UrlSegmentGroup) {
super();
this.segmentGroup = segmentGroup || null;
}
}
Expand All @@ -33,31 +33,19 @@ export class AbsoluteRedirect extends Error {
}
}

export function noMatch(segmentGroup: UrlSegmentGroup): Observable<any> {
return throwError(new NoMatch(segmentGroup));
}

export function absoluteRedirect(newTree: UrlTree): Observable<any> {
return throwError(new AbsoluteRedirect(newTree));
}

export function namedOutletsRedirect(redirectTo: string): Observable<any> {
return throwError(
new RuntimeError(
RuntimeErrorCode.NAMED_OUTLET_REDIRECT,
(typeof ngDevMode === 'undefined' || ngDevMode) &&
`Only absolute redirects can have named outlets. redirectTo: '${redirectTo}'`,
),
export function namedOutletsRedirect(redirectTo: string): never {
throw new RuntimeError(
RuntimeErrorCode.NAMED_OUTLET_REDIRECT,
(typeof ngDevMode === 'undefined' || ngDevMode) &&
`Only absolute redirects can have named outlets. redirectTo: '${redirectTo}'`,
);
}

export function canLoadFails(route: Route): Observable<LoadedRouterConfig> {
return throwError(
navigationCancelingError(
(typeof ngDevMode === 'undefined' || ngDevMode) &&
`Cannot load children because the guard of the route "path: '${route.path}'" returned false`,
NavigationCancellationCode.GuardRejected,
),
export function canLoadFails(route: Route): never {
throw navigationCancelingError(
(typeof ngDevMode === 'undefined' || ngDevMode) &&
`Cannot load children because the guard of the route "path: '${route.path}'" returned false`,
NavigationCancellationCode.GuardRejected,
);
}

Expand All @@ -67,49 +55,46 @@ export class ApplyRedirects {
private urlTree: UrlTree,
) {}

lineralizeSegments(route: Route, urlTree: UrlTree): Observable<UrlSegment[]> {
async lineralizeSegments(route: Route, urlTree: UrlTree): Promise<UrlSegment[]> {
let res: UrlSegment[] = [];
let c = urlTree.root;
while (true) {
res = res.concat(c.segments);
if (c.numberOfChildren === 0) {
return of(res);
return res;
}

if (c.numberOfChildren > 1 || !c.children[PRIMARY_OUTLET]) {
return namedOutletsRedirect(`${route.redirectTo!}`);
throw namedOutletsRedirect(`${route.redirectTo!}`);
}

c = c.children[PRIMARY_OUTLET];
}
}

applyRedirectCommands(
async applyRedirectCommands(
segments: UrlSegment[],
redirectTo: string | RedirectFunction,
posParams: {[k: string]: UrlSegment},
currentSnapshot: ActivatedRouteSnapshot,
injector: Injector,
): Observable<UrlTree> {
return getRedirectResult(redirectTo, currentSnapshot, injector).pipe(
map((redirect) => {
if (redirect instanceof UrlTree) {
throw new AbsoluteRedirect(redirect);
}

const newTree = this.applyRedirectCreateUrlTree(
redirect,
this.urlSerializer.parse(redirect),
segments,
posParams,
);

if (redirect[0] === '/') {
throw new AbsoluteRedirect(newTree);
}
return newTree;
}),
): Promise<UrlTree> {
const redirect = await getRedirectResult(redirectTo, currentSnapshot, injector);
if (redirect instanceof UrlTree) {
throw new AbsoluteRedirect(redirect);
}

const newTree = this.applyRedirectCreateUrlTree(
redirect,
this.urlSerializer.parse(redirect),
segments,
posParams,
);

if (redirect[0] === '/') {
throw new AbsoluteRedirect(newTree);
}
return newTree;
}

applyRedirectCreateUrlTree(
Expand Down Expand Up @@ -201,15 +186,15 @@ function getRedirectResult(
redirectTo: string | RedirectFunction,
currentSnapshot: ActivatedRouteSnapshot,
injector: Injector,
): Observable<string | UrlTree> {
): Promise<string | UrlTree> {
if (typeof redirectTo === 'string') {
return of(redirectTo);
return Promise.resolve(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}),
),
);
).toPromise() as Promise<string | UrlTree>;
}
10 changes: 4 additions & 6 deletions packages/router/src/navigation_transition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import {isUrlTree, UrlSerializer, UrlTree} from './url_tree';
import {Checks, getAllRouteGuards} from './utils/preactivation';
import {CREATE_VIEW_TRANSITION} from './utils/view_transition';
import {getClosestRouteInjector} from './utils/config';
import {abortSignalToObservable} from './utils/abort_signal_to_observable';

/**
* @description
Expand Down Expand Up @@ -538,6 +539,7 @@ export class NavigationTransitions {
router.config,
this.urlSerializer,
this.paramsInheritanceStrategy,
overallTransitionState.abortController.signal,
),

// Update URL if in `eager` update mode
Expand Down Expand Up @@ -777,12 +779,7 @@ export class NavigationTransitions {
take(1),

takeUntil(
new Observable<void>((subscriber) => {
const abortSignal = overallTransitionState.abortController.signal;
const handler = () => subscriber.next();
abortSignal.addEventListener('abort', handler);
return () => abortSignal.removeEventListener('abort', handler);
}).pipe(
abortSignalToObservable(overallTransitionState.abortController.signal).pipe(
// Ignore aborts if we are already completed, canceled, or are in the activation stage (we have targetRouterState)
filter(() => !completedOrAborted && !overallTransitionState.targetRouterState),
tap(() => {
Expand Down Expand Up @@ -830,6 +827,7 @@ export class NavigationTransitions {
),

finalize(() => {
overallTransitionState.abortController.abort();
/* When the navigation stream finishes either through error or success,
* we set the `completed` or `errored` flag. However, there are some
* situations where we could get here without either of those being set.
Expand Down
7 changes: 5 additions & 2 deletions packages/router/src/operators/check_guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
} from '../utils/type_guards';

import {prioritizedGuardValue} from './prioritized_guard_value';
import {takeUntilAbort} from '../utils/abort_signal_to_observable';

export function checkGuards(
injector: EnvironmentInjector,
Expand Down Expand Up @@ -244,6 +245,7 @@ export function runCanLoadGuards(
route: Route,
segments: UrlSegment[],
urlSerializer: UrlSerializer,
abortSignal: AbortSignal,
): Observable<boolean> {
const canLoad = route.canLoad;
if (canLoad === undefined || canLoad.length === 0) {
Expand All @@ -255,7 +257,7 @@ export function runCanLoadGuards(
const guardVal = isCanLoad(guard)
? guard.canLoad(route, segments)
: runInInjectionContext(injector, () => (guard as CanLoadFn)(route, segments));
return wrapIntoObservable(guardVal);
return wrapIntoObservable(guardVal).pipe(takeUntilAbort(abortSignal));
});

return of(canLoadObservables).pipe(prioritizedGuardValue(), redirectIfUrlTree(urlSerializer));
Expand All @@ -277,6 +279,7 @@ export function runCanMatchGuards(
route: Route,
segments: UrlSegment[],
urlSerializer: UrlSerializer,
abortSignal: AbortSignal,
): Observable<GuardResult> {
const canMatch = route.canMatch;
if (!canMatch || canMatch.length === 0) return of(true);
Expand All @@ -286,7 +289,7 @@ export function runCanMatchGuards(
const guardVal = isCanMatch(guard)
? guard.canMatch(route, segments)
: runInInjectionContext(injector, () => (guard as CanMatchFn)(route, segments));
return wrapIntoObservable(guardVal);
return wrapIntoObservable(guardVal).pipe(takeUntilAbort(abortSignal));
});

return of(canMatchObservables).pipe(prioritizedGuardValue(), redirectIfUrlTree(urlSerializer));
Expand Down
15 changes: 7 additions & 8 deletions packages/router/src/operators/recognize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,19 @@ export function recognize(
config: Route[],
serializer: UrlSerializer,
paramsInheritanceStrategy: 'emptyOnly' | 'always',
abortSignal: AbortSignal,
): MonoTypeOperatorFunction<NavigationTransition> {
return mergeMap((t) =>
recognizeFn(
return mergeMap(async (t) => {
const {state: targetSnapshot, tree: urlAfterRedirects} = await recognizeFn(
injector,
configLoader,
rootComponentType,
config,
t.extractedUrl,
serializer,
paramsInheritanceStrategy,
).pipe(
map(({state: targetSnapshot, tree: urlAfterRedirects}) => {
return {...t, targetSnapshot, urlAfterRedirects};
}),
),
);
abortSignal,
);
return {...t, targetSnapshot, urlAfterRedirects};
});
}
Loading