diff --git a/src/services/createExternalRoute.ts b/src/services/createExternalRoute.ts index 89f7c7f7..53ae5e8c 100644 --- a/src/services/createExternalRoute.ts +++ b/src/services/createExternalRoute.ts @@ -27,7 +27,7 @@ export function createExternalRoute(options: CreateRouteOptions & (WithoutHost | const meta = options.meta ?? {} const host = toWithParams(options.host) const context = options.context ?? [] - const { store, onBeforeRouteEnter } = createHooksFactory() + const { store, onBeforeRouteEnter, onError } = createHooksFactory() const rawRoute = markRaw({ id, meta: {}, state: {}, ...options }) const route = { @@ -45,6 +45,7 @@ export function createExternalRoute(options: CreateRouteOptions & (WithoutHost | state: {}, context, onBeforeRouteEnter, + onError, } satisfies Route & ExternalRouteHooks const merged = isWithParent(options) ? combineRoutes(options.parent, route) : route diff --git a/src/services/getRouteHooks.ts b/src/services/getRouteHooks.ts index 79671790..9a895f56 100644 --- a/src/services/getRouteHooks.ts +++ b/src/services/getRouteHooks.ts @@ -1,23 +1,42 @@ import { ResolvedRoute } from '@/types/resolved' import { isRouteEnter, isRouteLeave, isRouteUpdate } from '@/services/hooks' import { Hooks } from '@/models/hooks' +import { AfterHook, BeforeHook } from '@/types/hooks' + +function wrapHookCatch(route: ResolvedRoute | null): (hook: T) => T { + const hooks = route?.hooks.flatMap((store) => Array.from(store.onError)) ?? [] + + if (!hooks.length) { + return (hook) => hook + } + + return (hook: T): T => ((to: any, context: any) => { + try { + return hook(to, context) + } catch (error) { + hooks.forEach((runErrorHook) => runErrorHook(error, { ...context, to, source: 'hook' })) + } + }) as T +} export function getBeforeHooksFromRoutes(to: ResolvedRoute, from: ResolvedRoute | null): Hooks { const hooks = new Hooks() + const toErrorWrapper = wrapHookCatch(to) + const fromErrorWrapper = wrapHookCatch(from) to.hooks.forEach((store, depth) => { if (isRouteEnter(to, from, depth)) { - return store.onBeforeRouteEnter.forEach((hook) => hooks.onBeforeRouteEnter.add(hook)) + return store.onBeforeRouteEnter.forEach((hook) => hooks.onBeforeRouteEnter.add(toErrorWrapper(hook))) } if (isRouteUpdate(to, from, depth)) { - return store.onBeforeRouteUpdate.forEach((hook) => hooks.onBeforeRouteUpdate.add(hook)) + return store.onBeforeRouteUpdate.forEach((hook) => hooks.onBeforeRouteUpdate.add(toErrorWrapper(hook))) } }) from?.hooks.forEach((store, depth) => { if (isRouteLeave(to, from, depth)) { - return store.onBeforeRouteLeave.forEach((hook) => hooks.onBeforeRouteLeave.add(hook)) + return store.onBeforeRouteLeave.forEach((hook) => hooks.onBeforeRouteLeave.add(fromErrorWrapper(hook))) } }) @@ -26,20 +45,22 @@ export function getBeforeHooksFromRoutes(to: ResolvedRoute, from: ResolvedRoute export function getAfterHooksFromRoutes(to: ResolvedRoute, from: ResolvedRoute | null): Hooks { const hooks = new Hooks() + const toErrorWrapper = wrapHookCatch(to) + const fromErrorWrapper = wrapHookCatch(from) to.hooks.forEach((store, depth) => { if (isRouteEnter(to, from, depth)) { - return store.onAfterRouteEnter.forEach((hook) => hooks.onAfterRouteEnter.add(hook)) + return store.onAfterRouteEnter.forEach((hook) => hooks.onAfterRouteEnter.add(toErrorWrapper(hook))) } if (isRouteUpdate(to, from, depth)) { - return store.onAfterRouteUpdate.forEach((hook) => hooks.onAfterRouteUpdate.add(hook)) + return store.onAfterRouteUpdate.forEach((hook) => hooks.onAfterRouteUpdate.add(toErrorWrapper(hook))) } }) from?.hooks.forEach((store, depth) => { if (isRouteLeave(to, from, depth)) { - return store.onAfterRouteLeave.forEach((hook) => hooks.onAfterRouteLeave.add(hook)) + return store.onAfterRouteLeave.forEach((hook) => hooks.onAfterRouteLeave.add(fromErrorWrapper(hook))) } }) diff --git a/src/services/hooks.spec.ts b/src/services/hooks.spec.ts index 48fff9e2..854c6ecb 100644 --- a/src/services/hooks.spec.ts +++ b/src/services/hooks.spec.ts @@ -244,3 +244,52 @@ test('when onError callback calls replace, other onError callbacks do not run', expect(errorHook2).not.toHaveBeenCalled() expect(errorHook3).not.toHaveBeenCalled() }) + +test('onError hooks on routes only called when that route hooks error', () => { + const errorHook = vi.fn() + + const routeWithErrorHook = createRoute({ + name: 'route-with-hook', + }) + + const routeWithoutErrorInHook = createRoute({ + name: 'route-without-hook', + }) + + routeWithErrorHook.onBeforeRouteEnter(() => { + throw new Error('Test error') + }) + + routeWithErrorHook.onBeforeRouteLeave(() => { + throw new Error('Test error') + }) + + routeWithoutErrorInHook.onBeforeRouteEnter(() => { + throw new Error('Test error') + }) + + routeWithErrorHook.onError(() => errorHook()) + + const { runBeforeRouteHooks } = createRouterHooks() + + // neither route has error hook + runBeforeRouteHooks({ + to: createResolvedRoute(routeWithoutErrorInHook, {}), + from: null, + }) + expect(errorHook).not.toHaveBeenCalled() + + // to route has error hook + runBeforeRouteHooks({ + to: createResolvedRoute(routeWithErrorHook, {}), + from: null, + }) + expect(errorHook).toHaveBeenCalledTimes(1) + + // from route has error hook + runBeforeRouteHooks({ + to: createResolvedRoute(createRoute({ name: 'random-route' }), {}), + from: createResolvedRoute(routeWithErrorHook, {}), + }) + expect(errorHook).toHaveBeenCalledTimes(2) +}) diff --git a/src/types/hooks.ts b/src/types/hooks.ts index e191386f..cb384735 100644 --- a/src/types/hooks.ts +++ b/src/types/hooks.ts @@ -35,6 +35,11 @@ export type InternalRouteHooks, RouteContextToRejection>, + /** + * Registers a hook to be called when an error occurs. + * If the hook returns true, the error is considered handled and the other hooks are not run. If all hooks return false the error is rethrown + */ + onError: AddErrorHook, RouteContextToRejection>, } export type ExternalRouteHooks = { @@ -42,6 +47,11 @@ export type ExternalRouteHooks, RouteContextToRejection>, + /** + * Registers a hook to be called when an error occurs. + * If the hook returns true, the error is considered handled and the other hooks are not run. If all hooks return false the error is rethrown + */ + onError: AddErrorHook, RouteContextToRejection>, } export type HookTiming = 'global' | 'component'