diff --git a/src/data-loaders/createDataLoader.ts b/src/data-loaders/createDataLoader.ts index f090fa405..36bed5fd5 100644 --- a/src/data-loaders/createDataLoader.ts +++ b/src/data-loaders/createDataLoader.ts @@ -93,7 +93,12 @@ export interface DefineDataLoaderOptionsBase { * * @defaultValue `false` */ - lazy?: isLazy + lazy?: + | isLazy + | (( + to: RouteLocationNormalizedLoaded, + from?: RouteLocationNormalizedLoaded + ) => boolean) /** * Whether this loader should be awaited on the server side or not. Combined with the `lazy` option, this gives full @@ -118,6 +123,18 @@ export interface DefineDataLoaderOptionsBase { errors?: Array any> } +export const toLazyValue = ( + lazy: + | boolean + | undefined + | (( + to: RouteLocationNormalizedLoaded, + from?: RouteLocationNormalizedLoaded + ) => boolean), + to: RouteLocationNormalizedLoaded, + from?: RouteLocationNormalizedLoaded +) => (typeof lazy === 'function' ? lazy(to, from) : lazy) + /** * When the data should be committed to the entry. * - `immediate`: the data is committed as soon as it is loaded. @@ -200,13 +217,15 @@ export interface UseDataLoaderInternals< /** * Loads the data from the cache if possible, otherwise loads it from the loader and awaits it. * - * @param route - route location to load the data for + * @param to - route location to load the data for * @param router - router instance + * @param from - route location we are coming from * @param parent - parent data loader entry */ load: ( - route: RouteLocationNormalizedLoaded, + to: RouteLocationNormalizedLoaded, router: Router, + from?: RouteLocationNormalizedLoaded, parent?: DataLoaderEntryBase ) => Promise diff --git a/src/data-loaders/defineColadaLoader.ts b/src/data-loaders/defineColadaLoader.ts index 76642891c..6aed1a02a 100644 --- a/src/data-loaders/defineColadaLoader.ts +++ b/src/data-loaders/defineColadaLoader.ts @@ -41,6 +41,7 @@ import { type UseQueryReturn, useQuery, } from '@pinia/colada' +import { toLazyValue } from './createDataLoader' /** * Creates a data loader composable that can be exported by pages to attach the data loading to a route. This returns a @@ -93,6 +94,7 @@ export function defineColadaLoader( function load( to: RouteLocationNormalizedLoaded, router: Router, + from?: RouteLocationNormalizedLoaded, parent?: DataLoaderEntryBase, reload?: boolean ): Promise { @@ -229,7 +231,7 @@ export function defineColadaLoader( entry.stagedError = newError // propagate error if non lazy or during SSR // NOTE: Cannot be handled at the guard level because of nested loaders - if (!options.lazy || isSSR) { + if (!toLazyValue(options.lazy, to, from) || isSSR) { throw newError } } else { @@ -352,7 +354,7 @@ export function defineColadaLoader( // ) router[APP_KEY].runWithContext(() => // in this case we always need to run the functions for nested loaders consistency - load(route, router, parentEntry, true) + load(route, router, undefined, parentEntry, true) ) } @@ -399,14 +401,14 @@ export function defineColadaLoader( isLoading, reload: (to: RouteLocationNormalizedLoaded = router.currentRoute.value) => router[APP_KEY].runWithContext(() => - load(to, router, undefined, true) + load(to, router, undefined, undefined, true) ).then(() => entry!.commit(to)), // pinia colada refetch: ( to: RouteLocationNormalizedLoaded = router.currentRoute.value ) => router[APP_KEY].runWithContext(() => - load(to, router, undefined, true) + load(to, router, undefined, undefined, true) ).then(() => entry!.commit(to)), refresh: ( to: RouteLocationNormalizedLoaded = router.currentRoute.value diff --git a/src/data-loaders/defineLoader.ts b/src/data-loaders/defineLoader.ts index 1e24a46de..3710a7f97 100644 --- a/src/data-loaders/defineLoader.ts +++ b/src/data-loaders/defineLoader.ts @@ -29,6 +29,7 @@ import { } from 'unplugin-vue-router/runtime' import { shallowRef } from 'vue' +import { toLazyValue } from './createDataLoader' /** * Creates a data loader composable that can be exported by pages to attach the data loading to a route. This returns a @@ -88,6 +89,7 @@ export function defineBasicLoader( function load( to: RouteLocationNormalizedLoaded, router: Router, + from?: RouteLocationNormalizedLoaded, parent?: DataLoaderEntryBase ): Promise { const entries = router[LOADER_ENTRIES_KEY]! @@ -197,7 +199,7 @@ export function defineBasicLoader( entry.stagedError = e // propagate error if non lazy or during SSR // NOTE: Cannot be handled at the guard level because of nested loaders - if (!options.lazy || isSSR) { + if (!toLazyValue(options.lazy, to, from) || isSSR) { throw e } } @@ -309,7 +311,9 @@ export function defineBasicLoader( // console.log( // `🔁 loading from useData for "${options.key}": "${route.fullPath}"` // ) - router[APP_KEY].runWithContext(() => load(route, router, parentEntry)) + router[APP_KEY].runWithContext(() => + load(route, router, undefined, parentEntry) + ) } entry = entries.get(loader)! diff --git a/src/data-loaders/navigation-guard.ts b/src/data-loaders/navigation-guard.ts index 2e7aeea27..dac80565d 100644 --- a/src/data-loaders/navigation-guard.ts +++ b/src/data-loaders/navigation-guard.ts @@ -17,7 +17,7 @@ import type { Router, } from 'vue-router' import { type _Awaitable } from '../utils' -import { type UseDataLoader } from './createDataLoader' +import { toLazyValue, type UseDataLoader } from './createDataLoader' /** * TODO: export functions that allow preloading outside of a navigation guard @@ -131,7 +131,7 @@ export function setupLoaderGuard({ }) }) - const removeDataLoaderGuard = router.beforeResolve((to) => { + const removeDataLoaderGuard = router.beforeResolve((to, from) => { // if we reach this guard, all properties have been set const loaders = Array.from(to.meta[LOADER_SET_KEY]!) as UseDataLoader[] @@ -154,13 +154,13 @@ export function setupLoaderGuard({ app // allows inject and provide APIs .runWithContext(() => - loader._.load(to as RouteLocationNormalizedLoaded, router) + loader._.load(to as RouteLocationNormalizedLoaded, router, from) ) )! // on client-side, lazy loaders are not awaited, but on server they are // we already checked for the `server` option above - return !isSSR && lazy + return !isSSR && toLazyValue(lazy, to, from) ? undefined : // return the non-lazy loader to commit changes after all loaders are done ret.catch((reason) => @@ -196,7 +196,7 @@ export function setupLoaderGuard({ // listen to duplicated navigation failures to reset the pendingTo and pendingLoad // since they won't trigger the beforeEach or beforeResolve defined above - const removeAfterEach = router.afterEach((to, _from, failure) => { + const removeAfterEach = router.afterEach((to, from, failure) => { // console.log( // `🔚 afterEach "${_from.fullPath}" -> "${to.fullPath}": ${failure?.message}` // ) @@ -223,7 +223,10 @@ export function setupLoaderGuard({ // lazy loaders do not block the navigation so the navigation guard // might call commit before the loader is ready // on the server, entries might not even exist - if (entry && (!lazy || !entry.isLoading.value)) { + if ( + entry && + (!toLazyValue(lazy, to, from) || !entry.isLoading.value) + ) { entry.commit(to as RouteLocationNormalizedLoaded) } } diff --git a/tests/data-loaders/tester.ts b/tests/data-loaders/tester.ts index c0b24b064..3fb2d33e5 100644 --- a/tests/data-loaders/tester.ts +++ b/tests/data-loaders/tester.ts @@ -1,7 +1,7 @@ /** * @vitest-environment happy-dom */ -import { type App, defineComponent, inject, type Plugin } from 'vue' +import { type App, defineComponent, inject, type Plugin, toValue } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' import { getRouter } from 'vue-router-mock' @@ -127,188 +127,188 @@ export function testDefineLoader( } } + const lazyFnTrue = () => true + const lazyFnFalse = () => false + describe.each(['immediate', 'after-load'] as const)( 'commit: %s', (commit) => { - describe.each([true, false] as const)('lazy: %s', (lazy) => { - it(`can resolve a "null" value`, async () => { - const spy = vi - .fn>() - .mockResolvedValueOnce(null) - const { useData, router } = singleLoaderOneRoute( - loaderFactory({ lazy, commit, fn: spy }) - ) - await router.push('/fetch') - expect(spy).toHaveBeenCalledTimes(1) - const { data } = useData() - expect(data.value).toEqual(null) - }) + describe.each([true, false, lazyFnFalse, lazyFnTrue] as const)( + 'lazy: %s', + (lazy) => { + it(`can resolve a "null" value`, async () => { + const spy = vi + .fn>() + .mockResolvedValueOnce(null) + const { useData, router } = singleLoaderOneRoute( + loaderFactory({ lazy, commit, fn: spy }) + ) + await router.push('/fetch') + expect(spy).toHaveBeenCalledTimes(1) + const { data } = useData() + expect(data.value).toEqual(null) + }) - it('can reject outside of a navigation', async () => { - const spy = vi - .fn>() - .mockResolvedValue('ko') + it('can reject outside of a navigation', async () => { + const spy = vi + .fn>() + .mockResolvedValue('ko') - const { useData, router } = singleLoaderOneRoute( - loaderFactory({ lazy, commit, fn: spy }) - ) - // initial navigation - await router.push('/fetch') - const { error, reload } = useData() - spy.mockRejectedValueOnce(new Error('ok')) - await reload().catch(() => {}) - await vi.runAllTimersAsync() - expect(spy).toHaveBeenCalledTimes(2) - expect(error.value).toEqual(new Error('ok')) - }) - - it('can return a NavigationResult without affecting initial data', async () => { - let calls = 0 - const spy = vi.fn(async (to: RouteLocationNormalizedLoaded) => { - return calls++ === 0 ? new NavigationResult('/other') : to.query.p + const { useData, router } = singleLoaderOneRoute( + loaderFactory({ lazy, commit, fn: spy }) + ) + // initial navigation + await router.push('/fetch') + const { error, reload } = useData() + spy.mockRejectedValueOnce(new Error('ok')) + await reload().catch(() => {}) + await vi.runAllTimersAsync() + expect(spy).toHaveBeenCalledTimes(2) + expect(error.value).toEqual(new Error('ok')) }) - const { useData, router } = singleLoaderOneRoute( - loaderFactory({ lazy, commit, fn: spy }) - ) - await router.push('/fetch?p=ko') - expect(spy).toHaveBeenCalled() - const { data } = useData() - expect(data.value).toEqual(undefined) - }) - it('can return a NavigationResult without affecting loaded data', async () => { - let calls = 0 - const spy = vi.fn(async (to: RouteLocationNormalizedLoaded) => { - return calls++ > 0 ? new NavigationResult('/other') : to.query.p + it('can return a NavigationResult without affecting initial data', async () => { + let calls = 0 + const spy = vi.fn(async (to: RouteLocationNormalizedLoaded) => { + return calls++ === 0 ? new NavigationResult('/other') : to.query.p + }) + const { useData, router } = singleLoaderOneRoute( + loaderFactory({ lazy, commit, fn: spy }) + ) + await router.push('/fetch?p=ko') + expect(spy).toHaveBeenCalled() + const { data } = useData() + expect(data.value).toEqual(undefined) }) - const { useData, router } = singleLoaderOneRoute( - loaderFactory({ lazy, commit, fn: spy }) - ) - await router.push('/fetch?p=ok') - const { data } = useData() - expect(spy).toHaveBeenCalled() - expect(data.value).toEqual('ok') - await router.push('/fetch?p=ko') - expect(data.value).toEqual('ok') - }) - // NOTE: not sure about what would be expected in this case. - // in lazy false, commit after-load, the error prevents the navigation - // so the error doesn't even get a chance to be used - it.skipIf(!lazy && commit === 'after-load')( - 'can return a NavigationResult without affecting the last error', - async () => { + it('can return a NavigationResult without affecting loaded data', async () => { let calls = 0 const spy = vi.fn(async (to: RouteLocationNormalizedLoaded) => { - return calls++ > 0 - ? new NavigationResult('/other') - : Promise.reject(new Error(to.query.p as string)) + return calls++ > 0 ? new NavigationResult('/other') : to.query.p }) const { useData, router } = singleLoaderOneRoute( loaderFactory({ lazy, commit, fn: spy }) ) + await router.push('/fetch?p=ok') + const { data } = useData() + expect(spy).toHaveBeenCalled() + expect(data.value).toEqual('ok') + await router.push('/fetch?p=ko') + expect(data.value).toEqual('ok') + }) + + // in lazy false, commit after-load, the error prevents the navigation + // so the error doesn't even get a chance to be used if we navigate. This is why we do a first regular navigation and then a reload: to force the fetch again + it('can return a NavigationResult without affecting the last error', async () => { + const spy = vi.fn().mockResolvedValueOnce('ko') + const { useData, router } = singleLoaderOneRoute( + loaderFactory({ lazy, commit, fn: spy }) + ) await router.push('/fetch?p=ok').catch(() => {}) - const { error } = useData() + const { error, reload } = useData() + spy.mockRejectedValueOnce(new Error('ok')) + await reload().catch(() => {}) expect(spy).toHaveBeenCalled() expect(error.value).toEqual(new Error('ok')) + spy.mockResolvedValueOnce(new NavigationResult('/other')) await router.push('/fetch?p=ko').catch(() => {}) expect(error.value).toEqual(new Error('ok')) - } - ) - - it(`the resolved data is present after navigation`, async () => { - const spy = vi - .fn>() - .mockResolvedValueOnce('resolved') - const { wrapper, useData, router } = singleLoaderOneRoute( - // loaders are not require to allow sync return values - loaderFactory({ lazy, commit, fn: spy }) - ) - expect(spy).not.toHaveBeenCalled() - await router.push('/fetch') - expect(wrapper.get('#error').text()).toBe('') - expect(wrapper.get('#isLoading').text()).toBe('false') - expect(wrapper.get('#data').text()).toBe('resolved') - expect(spy).toHaveBeenCalledTimes(1) - const { data } = useData() - expect(data.value).toEqual('resolved') - }) + }) - it(`can be forced reloaded`, async () => { - const spy = vi - .fn>() - .mockResolvedValueOnce('resolved 1') - const { router, useData } = singleLoaderOneRoute( - loaderFactory({ lazy, commit, fn: spy }) - ) - await router.push('/fetch') - expect(spy).toHaveBeenCalledTimes(1) - const { data, reload } = useData() - expect(data.value).toEqual('resolved 1') - spy.mockResolvedValueOnce('resolved 2') - await reload() - expect(data.value).toEqual('resolved 2') - expect(spy).toHaveBeenCalledTimes(2) - spy.mockResolvedValueOnce('resolved 3') - await reload() - expect(spy).toHaveBeenCalledTimes(3) - expect(data.value).toEqual('resolved 3') - }) + it(`the resolved data is present after navigation`, async () => { + const spy = vi + .fn>() + .mockResolvedValueOnce('resolved') + const { wrapper, useData, router } = singleLoaderOneRoute( + // loaders are not require to allow sync return values + loaderFactory({ lazy, commit, fn: spy }) + ) + expect(spy).not.toHaveBeenCalled() + await router.push('/fetch') + expect(wrapper.get('#error').text()).toBe('') + expect(wrapper.get('#isLoading').text()).toBe('false') + expect(wrapper.get('#data').text()).toBe('resolved') + expect(spy).toHaveBeenCalledTimes(1) + const { data } = useData() + expect(data.value).toEqual('resolved') + }) - it('always reloads if the previous result is an error', async () => { - let calls = 0 - const spy = vi.fn(async () => { - if (calls++ === 0) { - throw new Error('nope') - } else { - return 'ok' - } + it(`can be forced reloaded`, async () => { + const spy = vi + .fn>() + .mockResolvedValueOnce('resolved 1') + const { router, useData } = singleLoaderOneRoute( + loaderFactory({ lazy, commit, fn: spy }) + ) + await router.push('/fetch') + expect(spy).toHaveBeenCalledTimes(1) + const { data, reload } = useData() + expect(data.value).toEqual('resolved 1') + spy.mockResolvedValueOnce('resolved 2') + await reload() + expect(data.value).toEqual('resolved 2') + expect(spy).toHaveBeenCalledTimes(2) + spy.mockResolvedValueOnce('resolved 3') + await reload() + expect(spy).toHaveBeenCalledTimes(3) + expect(data.value).toEqual('resolved 3') }) - const { useData, router } = singleLoaderOneRoute( - loaderFactory({ - fn: spy, - lazy, - commit, + + it('always reloads if the previous result is an error', async () => { + let calls = 0 + const spy = vi.fn(async () => { + if (calls++ === 0) { + throw new Error('nope') + } else { + return 'ok' + } }) - ) - await router.push('/fetch').catch(() => {}) - // await vi.runAllTimersAsync() - expect(spy).toHaveBeenCalledTimes(1) - - // for lazy loaders we need to navigate to trigger the loader - // so we add a hash to enforce that - await router.push('/fetch#two').catch(() => {}) - const { data, error } = useData() - // await vi.runAllTimersAsync() - expect(spy).toHaveBeenCalledTimes(2) - expect(data.value).toBe('ok') - expect(error.value).toBe(null) - }) + const { useData, router } = singleLoaderOneRoute( + loaderFactory({ + fn: spy, + lazy, + commit, + }) + ) + await router.push('/fetch').catch(() => {}) + // await vi.runAllTimersAsync() + expect(spy).toHaveBeenCalledTimes(1) + + // for lazy loaders we need to navigate to trigger the loader + // so we add a hash to enforce that + await router.push('/fetch#two').catch(() => {}) + const { data, error } = useData() + // await vi.runAllTimersAsync() + expect(spy).toHaveBeenCalledTimes(2) + expect(data.value).toBe('ok') + expect(error.value).toBe(null) + }) - it('keeps the existing error until the new data is resolved', async () => { - const l = mockedLoader({ lazy, commit }) - const { useData, router } = singleLoaderOneRoute(l.loader) - l.spy.mockResolvedValueOnce('initial') - // initiate the loader and then force an error - await router.push('/fetch?p=ko') - const { data, error, reload } = useData() - // force the error - l.spy.mockRejectedValueOnce(new Error('ok')) - await reload().catch(() => {}) - await vi.runAllTimersAsync() - expect(error.value).toEqual(new Error('ok')) - - // trigger a new navigation - router.push('/fetch?p=ok') - await vi.runAllTimersAsync() - // we still see the error - expect(error.value).toEqual(new Error('ok')) - l.resolve('resolved') - await vi.runAllTimersAsync() - // not anymore - expect(data.value).toBe('resolved') - }) - }) + it('keeps the existing error until the new data is resolved', async () => { + const l = mockedLoader({ lazy, commit }) + const { useData, router } = singleLoaderOneRoute(l.loader) + l.spy.mockResolvedValueOnce('initial') + // initiate the loader and then force an error + await router.push('/fetch?p=ko') + const { data, error, reload } = useData() + // force the error + l.spy.mockRejectedValueOnce(new Error('ok')) + await reload().catch(() => {}) + await vi.runAllTimersAsync() + expect(error.value).toEqual(new Error('ok')) + + // trigger a new navigation + router.push('/fetch?p=ok') + await vi.runAllTimersAsync() + // we still see the error + expect(error.value).toEqual(new Error('ok')) + l.resolve('resolved') + await vi.runAllTimersAsync() + // not anymore + expect(data.value).toBe('resolved') + }) + } + ) it(`should abort the navigation if a non lazy loader throws, commit: ${commit}`, async () => { const { router } = singleLoaderOneRoute( @@ -1106,6 +1106,10 @@ export function testDefineLoader( } ) + it.todo('passes to and from to the function version of lazy', async () => {}) + it.todo('can be first non-lazy then lazy', async () => {}) + it.todo('can be first non-lazy then lazy', async () => {}) + describe('app.runWithContext()', () => { it('can inject globals', async () => { const { router, useData, app } = singleLoaderOneRoute(