Skip to content

Commit

Permalink
feat(router): withNavigationErrorHandler can convert errors to redi…
Browse files Browse the repository at this point in the history
…rects (#55370)

This commit adds the ability to return `RedirectCommand` from the error
handler provided by `withNavigationErrorHandler`. This will prevent the
error from being surfaced in the `events` observable of the Router and
instead convert the error to a redirect. This allows developers to
have more control over how the Router handles navigation errors. There
are some cases when the application _does not_ want the URL to be reset
when an error occurs.

resolves #42915

PR Close #55370
  • Loading branch information
atscott authored and alxhub committed Apr 17, 2024
1 parent 18a43b5 commit 4a42961
Show file tree
Hide file tree
Showing 4 changed files with 91 additions and 12 deletions.
2 changes: 1 addition & 1 deletion goldens/public-api/router/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -1148,7 +1148,7 @@ export function withHashLocation(): RouterHashLocationFeature;
export function withInMemoryScrolling(options?: InMemoryScrollingOptions): InMemoryScrollingFeature;

// @public
export function withNavigationErrorHandler(handler: (error: NavigationError) => void): NavigationErrorHandlerFeature;
export function withNavigationErrorHandler(handler: (error: NavigationError) => unknown | RedirectCommand): NavigationErrorHandlerFeature;

// @public
export function withPreloading(preloadingStrategy: Type<PreloadingStrategy>): PreloadingFeature;
Expand Down
51 changes: 42 additions & 9 deletions packages/router/src/navigation_transition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,14 @@ import {
RouteConfigLoadStart,
RoutesRecognized,
} from './events';
import {GuardResult, NavigationBehaviorOptions, QueryParamsHandling, Route, Routes} from './models';
import {
GuardResult,
NavigationBehaviorOptions,
QueryParamsHandling,
RedirectCommand,
Route,
Routes,
} from './models';
import {
isNavigationCancelingError,
isRedirectingNavigationCancelingError,
Expand Down Expand Up @@ -324,9 +331,9 @@ interface InternalRouterInterface {
onSameUrlNavigation: 'reload' | 'ignore';
}

export const NAVIGATION_ERROR_HANDLER = new InjectionToken<(error: NavigationError) => void>(
typeof ngDevMode === 'undefined' || ngDevMode ? 'navigation error handler' : '',
);
export const NAVIGATION_ERROR_HANDLER = new InjectionToken<
(error: NavigationError) => unknown | RedirectCommand
>(typeof ngDevMode === 'undefined' || ngDevMode ? 'navigation error handler' : '');

@Injectable({providedIn: 'root'})
export class NavigationTransitions {
Expand Down Expand Up @@ -849,13 +856,38 @@ export class NavigationTransitions {
overallTransitionState.targetSnapshot ?? undefined,
);

this.events.next(navigationError);
try {
runInInjectionContext(this.environmentInjector, () =>
this.navigationErrorHandler?.(navigationError),
const navigationErrorHandlerResult = runInInjectionContext(
this.environmentInjector,
() => this.navigationErrorHandler?.(navigationError),
);
const errorHandlerResult = router.errorHandler(e);
overallTransitionState.resolve(!!errorHandlerResult);

if (navigationErrorHandlerResult instanceof RedirectCommand) {
const {message, cancellationCode} = redirectingNavigationError(
this.urlSerializer,
navigationErrorHandlerResult,
);
this.events.next(
new NavigationCancel(
overallTransitionState.id,
this.urlSerializer.serialize(overallTransitionState.extractedUrl),
message,
cancellationCode,
),
);
this.events.next(
new RedirectRequest(
navigationErrorHandlerResult.redirectTo,
navigationErrorHandlerResult.navigationBehaviorOptions,
),
);
} else {
this.events.next(navigationError);
// TODO(atscott): remove deprecation on errorHandler in RouterModule.forRoot and change behavior to provide NAVIGATION_ERROR_HANDLER
// Note: Still remove public `Router.errorHandler` property, as this is supposed to be configured in DI.
const errorHandlerResult = router.errorHandler(e);
overallTransitionState.resolve(!!errorHandlerResult);
}
} catch (ee) {
// TODO(atscott): consider flipping the default behavior of
// resolveNavigationPromiseOnError to be `resolve(false)` when
Expand All @@ -873,6 +905,7 @@ export class NavigationTransitions {
}
}
}

return EMPTY;
}),
);
Expand Down
10 changes: 8 additions & 2 deletions packages/router/src/provide_router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {of, Subject} from 'rxjs';

import {INPUT_BINDER, RoutedComponentInputBinder} from './directives/router_outlet';
import {Event, NavigationError, stringifyEvent} from './events';
import {Routes} from './models';
import {RedirectCommand, Routes} from './models';
import {NAVIGATION_ERROR_HANDLER, NavigationTransitions} from './navigation_transition';
import {Router} from './router';
import {InMemoryScrollingOptions, ROUTER_CONFIGURATION, RouterConfigOptions} from './router_config';
Expand Down Expand Up @@ -648,6 +648,12 @@ export type NavigationErrorHandlerFeature =
* This function is run inside application's [injection context](guide/di/dependency-injection-context)
* so you can use the [`inject`](api/core/inject) function.
*
* This function can return a `RedirectCommand` to convert the error to a redirect, similar to returning
* a `UrlTree` or `RedirectCommand` from a guard. This will also prevent the `Router` from emitting
* `NavigationError`; it will instead emit `NavigationCancel` with code NavigationCancellationCode.Redirect.
* Return values other than `RedirectCommand` are ignored and do not change any behavior with respect to
* how the `Router` handles the error.
*
* @usageNotes
*
* Basic example of how you can use the error handler option:
Expand All @@ -672,7 +678,7 @@ export type NavigationErrorHandlerFeature =
* @publicApi
*/
export function withNavigationErrorHandler(
handler: (error: NavigationError) => void,
handler: (error: NavigationError) => unknown | RedirectCommand,
): NavigationErrorHandlerFeature {
const providers = [
{
Expand Down
40 changes: 40 additions & 0 deletions packages/router/test/integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1897,6 +1897,46 @@ for (const browserAPI of ['navigation', 'history'] as const) {
expect(TestBed.inject(Handler).handlerCalled).toBeTrue();
});

it('can redirect from error handler', async () => {
TestBed.configureTestingModule({
providers: [
provideRouter(
[
{
path: 'throw',
canMatch: [
() => {
throw new Error('');
},
],
component: BlankCmp,
},
{path: 'error', component: BlankCmp},
],
withRouterConfig({resolveNavigationPromiseOnError: true}),
withNavigationErrorHandler(
() => new RedirectCommand(coreInject(Router).parseUrl('/error')),
),
),
],
});
const router = TestBed.inject(Router);
let emitNavigationError = false;
let emitNavigationCancelWithRedirect = false;
router.events.subscribe((e) => {
if (e instanceof NavigationError) {
emitNavigationError = true;
}
if (e instanceof NavigationCancel && e.code === NavigationCancellationCode.Redirect) {
emitNavigationCancelWithRedirect = true;
}
});
await router.navigateByUrl('/throw');
expect(router.url).toEqual('/error');
expect(emitNavigationError).toBe(false);
expect(emitNavigationCancelWithRedirect).toBe(true);
});

it('should not break navigation if an error happens in NavigationErrorHandler', async () => {
TestBed.configureTestingModule({
providers: [
Expand Down

0 comments on commit 4a42961

Please sign in to comment.