From a470a513629428490763207f3b55a6a38e636707 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Mon, 8 Jul 2024 12:51:42 +0800 Subject: [PATCH] feat(data-loaders): expected errors --- src/data-loaders/createDataLoader.ts | 6 ++ src/data-loaders/navigation-guard.spec.ts | 37 +++++----- src/data-loaders/navigation-guard.ts | 17 ++++- tests/data-loaders/tester.ts | 88 +++++++++++++++++++++++ 4 files changed, 129 insertions(+), 19 deletions(-) diff --git a/src/data-loaders/createDataLoader.ts b/src/data-loaders/createDataLoader.ts index 6ae638d9c..f090fa405 100644 --- a/src/data-loaders/createDataLoader.ts +++ b/src/data-loaders/createDataLoader.ts @@ -110,6 +110,12 @@ export interface DefineDataLoaderOptionsBase { * @defaultValue `'after-load'` */ commit?: DefineDataLoaderCommit + + /** + * List of _expected_ errors that shouldn't abort the navigation (for non-lazy loaders). Provide a list of + * constructors that can be checked with `instanceof`. + */ + errors?: Array any> } /** diff --git a/src/data-loaders/navigation-guard.spec.ts b/src/data-loaders/navigation-guard.spec.ts index 38d40e4c6..7dac48c44 100644 --- a/src/data-loaders/navigation-guard.spec.ts +++ b/src/data-loaders/navigation-guard.spec.ts @@ -19,6 +19,7 @@ import { setCurrentContext, DataLoaderPlugin, NavigationResult, + DataLoaderPluginOptions, } from 'unplugin-vue-router/runtime' import { mockPromise } from '../../tests/utils' import { @@ -47,7 +48,7 @@ function mockedLoader( describe('navigation-guard', () => { let globalApp: App | undefined - function setupApp(isSSR: boolean) { + function setupApp({ isSSR }: Omit) { const app = createApp({ render: () => null }) const selectNavigationResult = vi .fn() @@ -90,7 +91,7 @@ describe('navigation-guard', () => { const loader3 = defineBasicLoader(async () => {}) it('creates a set of loaders during navigation', async () => { - setupApp(false) + setupApp({ isSSR: false }) const router = getRouter() router.addRoute({ name: '_test', @@ -104,7 +105,7 @@ describe('navigation-guard', () => { }) it('collects loaders from the matched route', async () => { - setupApp(false) + setupApp({ isSSR: false }) const router = getRouter() router.addRoute({ name: '_test', @@ -131,7 +132,7 @@ describe('navigation-guard', () => { }) it('collect loaders from nested routes', async () => { - setupApp(false) + setupApp({ isSSR: false }) const router = getRouter() router.addRoute({ name: '_test', @@ -157,7 +158,7 @@ describe('navigation-guard', () => { }) it('collects all loaders from lazy loaded pages', async () => { - setupApp(false) + setupApp({ isSSR: false }) const router = getRouter() router.addRoute({ name: '_test', @@ -171,7 +172,7 @@ describe('navigation-guard', () => { }) it('awaits for all loaders to be resolved', async () => { - setupApp(false) + setupApp({ isSSR: false }) const router = getRouter() const l1 = mockedLoader() const l2 = mockedLoader() @@ -195,7 +196,7 @@ describe('navigation-guard', () => { }) it('does not await for lazy loaders on client-side navigation', async () => { - setupApp(false) + setupApp({ isSSR: false }) const router = getRouter() const l1 = mockedLoader({ lazy: true }) const l2 = mockedLoader({ lazy: false }) @@ -220,7 +221,7 @@ describe('navigation-guard', () => { }) it('awaits for lazy loaders on server-side navigation', async () => { - setupApp(true) + setupApp({ isSSR: true }) const router = getRouter() const l1 = mockedLoader({ lazy: true }) const l2 = mockedLoader({ lazy: false }) @@ -246,7 +247,7 @@ describe('navigation-guard', () => { }) it('does not run loaders on server side if server: false', async () => { - setupApp(true) + setupApp({ isSSR: true }) const router = getRouter() const l1 = mockedLoader({ lazy: true, server: false }) const l2 = mockedLoader({ lazy: false, server: false }) @@ -268,7 +269,7 @@ describe('navigation-guard', () => { it.each([true, false] as const)( 'throws if a non lazy loader rejects, isSSR: %s', async (isSSR) => { - setupApp(isSSR) + setupApp({ isSSR }) const router = getRouter() const l1 = mockedLoader({ lazy: false }) router.addRoute({ @@ -289,7 +290,7 @@ describe('navigation-guard', () => { ) it('does not throw if a lazy loader rejects', async () => { - setupApp(false) + setupApp({ isSSR: false }) const router = getRouter() const l1 = mockedLoader({ lazy: true }) router.addRoute({ @@ -309,7 +310,7 @@ describe('navigation-guard', () => { }) it('throws if a lazy loader rejects on server-side', async () => { - setupApp(true) + setupApp({ isSSR: true }) const router = getRouter() const l1 = mockedLoader({ lazy: true }) router.addRoute({ @@ -334,7 +335,7 @@ describe('navigation-guard', () => { describe('signal', () => { it('aborts the signal if the navigation throws', async () => { - setupApp(false) + setupApp({ isSSR: false }) const router = getRouter() router.setNextGuardReturn(new Error('canceled')) @@ -352,7 +353,7 @@ describe('navigation-guard', () => { }) it('aborts the signal if the navigation is canceled', async () => { - setupApp(false) + setupApp({ isSSR: false }) const router = getRouter() router.setNextGuardReturn(false) @@ -376,7 +377,7 @@ describe('navigation-guard', () => { describe('selectNavigationResult', () => { it('can change the navigation result within a loader', async () => { - const { selectNavigationResult } = setupApp(false) + const { selectNavigationResult } = setupApp({ isSSR: false }) const router = getRouter() const l1 = mockedLoader() router.addRoute({ @@ -397,7 +398,7 @@ describe('navigation-guard', () => { }) it('selectNavigationResult is called with an array of all the results returned by the loaders', async () => { - const { selectNavigationResult } = setupApp(false) + const { selectNavigationResult } = setupApp({ isSSR: false }) const router = getRouter() const l1 = mockedLoader() const l2 = mockedLoader() @@ -424,7 +425,7 @@ describe('navigation-guard', () => { }) it('can change the navigation result returned by multiple loaders', async () => { - const { selectNavigationResult } = setupApp(false) + const { selectNavigationResult } = setupApp({ isSSR: false }) const router = getRouter() const l1 = mockedLoader() const l2 = mockedLoader() @@ -449,7 +450,7 @@ describe('navigation-guard', () => { }) it('immediately stops if a NavigationResult is thrown instead of returned inside the loader', async () => { - const { selectNavigationResult } = setupApp(false) + const { selectNavigationResult } = setupApp({ isSSR: false }) const router = getRouter() const l1 = mockedLoader() router.addRoute({ diff --git a/src/data-loaders/navigation-guard.ts b/src/data-loaders/navigation-guard.ts index 1431ffd66..2e7aeea27 100644 --- a/src/data-loaders/navigation-guard.ts +++ b/src/data-loaders/navigation-guard.ts @@ -36,6 +36,7 @@ export function setupLoaderGuard({ app, effect, isSSR, + errors = [], selectNavigationResult = (results) => results[0]!.value, }: SetupLoaderGuardOptions) { // avoid creating the guards multiple times @@ -162,7 +163,15 @@ export function setupLoaderGuard({ return !isSSR && lazy ? undefined : // return the non-lazy loader to commit changes after all loaders are done - ret + ret.catch((reason) => + // Check if the error is an expected error to discard it + loader._.options.errors?.some((Err) => reason instanceof Err) || + (Array.isArray(errors) + ? errors.some((Err) => reason instanceof Err) + : errors(reason)) + ? undefined + : Promise.reject(reason) + ) }) ) // let the navigation go through by returning true or void .then(() => { @@ -361,4 +370,10 @@ export interface DataLoaderPluginOptions { selectNavigationResult?: ( results: NavigationResult[] ) => _Awaitable>> + + /** + * List of _expected_ errors that shouldn't abort the navigation (for non-lazy loaders). Provide a list of + * constructors that can be checked with `instanceof` or a custom function that returns `true` for expected errors. + */ + errors?: Array any> | ((reason?: unknown) => boolean) } diff --git a/tests/data-loaders/tester.ts b/tests/data-loaders/tester.ts index eee05623c..c0b24b064 100644 --- a/tests/data-loaders/tester.ts +++ b/tests/data-loaders/tester.ts @@ -345,6 +345,94 @@ export function testDefineLoader( expect(router.currentRoute.value.path).toBe('/fetch') }) + describe('custom errors', () => { + class CustomError extends Error { + override name = 'CustomError' + } + it('should complete the navigation if a non-lazy loader throws an expected error', async () => { + const { wrapper, useData, router } = singleLoaderOneRoute( + loaderFactory({ + fn: async () => { + throw new CustomError() + }, + lazy: false, + commit, + errors: [CustomError], + }) + ) + await router.push('/fetch') + const { data, error, isLoading } = useData() + expect(wrapper.get('#error').text()).toBe('CustomError') + expect(isLoading.value).toBe(false) + expect(data.value).toBe(undefined) + expect(error.value).toBeInstanceOf(CustomError) + expect(router.currentRoute.value.path).toBe('/fetch') + }) + + it('should complete the navigation if a non-lazy loader throws an expected global error', async () => { + const { wrapper, useData, router } = singleLoaderOneRoute( + loaderFactory({ + fn: async () => { + throw new CustomError() + }, + lazy: false, + commit, + // errors: [], + }), + { errors: [CustomError] } + ) + await router.push('/fetch') + const { data, error, isLoading } = useData() + expect(wrapper.get('#error').text()).toBe('CustomError') + expect(isLoading.value).toBe(false) + expect(data.value).toBe(undefined) + expect(error.value).toBeInstanceOf(CustomError) + expect(router.currentRoute.value.path).toBe('/fetch') + }) + + it('accepts a function as a global setting instead of a constructor', async () => { + const { wrapper, useData, router } = singleLoaderOneRoute( + loaderFactory({ + fn: async () => { + throw new CustomError() + }, + lazy: false, + commit, + // errors: [], + }), + { errors: (reason) => reason instanceof CustomError } + ) + await router.push('/fetch') + const { data, error, isLoading } = useData() + expect(wrapper.get('#error').text()).toBe('CustomError') + expect(isLoading.value).toBe(false) + expect(data.value).toBe(undefined) + expect(error.value).toBeInstanceOf(CustomError) + expect(router.currentRoute.value.path).toBe('/fetch') + }) + + it('local errors take priority over a global function that returns false', async () => { + const { wrapper, useData, router } = singleLoaderOneRoute( + loaderFactory({ + fn: async () => { + throw new CustomError() + }, + lazy: false, + commit, + errors: [CustomError], + }), + { errors: () => false } + ) + await router.push('/fetch') + const { data, error, isLoading } = useData() + expect(wrapper.get('#error').text()).toBe('CustomError') + expect(isLoading.value).toBe(false) + expect(data.value).toBe(undefined) + expect(error.value).toBeInstanceOf(CustomError) + expect(router.currentRoute.value.path).toBe('/fetch') + }) + }) + it('propagates errors from nested loaders', async () => { const l1 = mockedLoader({ key: 'nested',