diff --git a/src/data-fetching/dataCache.ts b/src/data-fetching/dataCache.ts index 32be17e82..a8571fca3 100644 --- a/src/data-fetching/dataCache.ts +++ b/src/data-fetching/dataCache.ts @@ -16,6 +16,7 @@ export interface _DataLoaderCacheEntryBase { params: Partial query: Partial + hash: string | null loaders: Set // TODO: hash @@ -103,11 +104,13 @@ export function updateDataCacheEntry( entry: DataLoaderCacheEntry, data: T, params: Partial, - query: Partial + query: Partial, + hash: { v: string | null } ) { entry.when = Date.now() entry.params = params entry.query = query + entry.hash = hash.v entry.isReady = true if (isDataCacheEntryLazy(entry)) { entry.data.value = data diff --git a/src/data-fetching/defineLoader.spec.ts b/src/data-fetching/defineLoader.spec.ts index 88ad0a159..47174ce96 100644 --- a/src/data-fetching/defineLoader.spec.ts +++ b/src/data-fetching/defineLoader.spec.ts @@ -22,6 +22,7 @@ import { it, vi, } from 'vitest' +import { setCurrentContext } from './dataCache' vi.mock('vue-router', async () => { const { createRouter, createMemoryHistory, START_LOCATION, ...rest } = @@ -96,6 +97,8 @@ describe('defineLoader', () => { resetRouter() router = useRouter() route = useRoute() + // invalidate current context + setCurrentContext(undefined) }) // we use fake timers to ensure debugging tests do not rely on timers @@ -154,113 +157,115 @@ describe('defineLoader', () => { await expect(p).rejects.toBe(e) }) - it('can call nested loaders', async () => { - const spy = vi - .fn>() - .mockResolvedValue({ user: { name: 'edu' } }) - const useOne = defineLoader(spy) - const useLoader = defineLoader(async () => { - const { user } = await useOne() - return { user, local: user.value.name } + describe('sequential loading', () => { + it('can call nested loaders', async () => { + const spy = vi + .fn>() + .mockResolvedValue({ user: { name: 'edu' } }) + const useOne = defineLoader(spy) + const useLoader = defineLoader(async () => { + const { user } = await useOne() + return { user, local: user.value.name } + }) + expect(spy).not.toHaveBeenCalled() + await useLoader._.load(route, router) + expect(spy).toHaveBeenCalledTimes(1) + const { user } = useLoader() + // even though we returned a ref + expectType<{ name: string }>(user.value) + expect(user.value).toEqual({ name: 'edu' }) }) - expect(spy).not.toHaveBeenCalled() - await useLoader._.load(route, router) - expect(spy).toHaveBeenCalledTimes(1) - const { user } = useLoader() - // even though we returned a ref - expectType<{ name: string }>(user.value) - expect(user.value).toEqual({ name: 'edu' }) - }) - it('can call deeply nested loaders', async () => { - const one = vi - .fn>() - .mockResolvedValue({ user: { name: 'edu' } }) - const useOne = defineLoader(one) - const two = vi - .fn>() - .mockImplementation(async () => { + it('can call deeply nested loaders', async () => { + const one = vi + .fn>() + .mockResolvedValue({ user: { name: 'edu' } }) + const useOne = defineLoader(one) + const two = vi + .fn>() + .mockImplementation(async () => { + const { user } = await useOne() + // force the type for the mock + return { + user: user as unknown as { name: string }, + local: user.value.name, + } + }) + const useTwo = defineLoader(two) + const useLoader = defineLoader(async () => { const { user } = await useOne() - // force the type for the mock - return { - user: user as unknown as { name: string }, - local: user.value.name, - } + const { local } = await useTwo() + return { user, local, when: Date.now() } }) - const useTwo = defineLoader(two) - const useLoader = defineLoader(async () => { - const { user } = await useOne() - const { local } = await useTwo() - return { user, local, when: Date.now() } - }) - expect(one).not.toHaveBeenCalled() - expect(two).not.toHaveBeenCalled() - await useLoader._.load(route, router) - expect(one).toHaveBeenCalledTimes(1) - expect(two).toHaveBeenCalledTimes(1) - const { user } = useLoader() - expect(user.value).toEqual({ name: 'edu' }) - }) + expect(one).not.toHaveBeenCalled() + expect(two).not.toHaveBeenCalled() + await useLoader._.load(route, router) + expect(one).toHaveBeenCalledTimes(1) + expect(two).toHaveBeenCalledTimes(1) + const { user } = useLoader() + expect(user.value).toEqual({ name: 'edu' }) + }) - it('invalidated nested loaders invalidate a loader (by cache)', async () => { - const spy = vi - .fn>() - .mockResolvedValue({ user: { name: 'edu' } }) - const useOne = defineLoader(spy) - const useLoader = defineLoader(async () => { - const { user } = await useOne() - return { user, local: user.value.name } + it('invalidated nested loaders invalidate a loader (by cache)', async () => { + const spy = vi + .fn>() + .mockResolvedValue({ user: { name: 'edu' } }) + const useOne = defineLoader(spy) + const useLoader = defineLoader(async () => { + const { user } = await useOne() + return { user, local: user.value.name } + }) + await useLoader._.load(route, router) + const { user, refresh } = useLoader() + const { invalidate } = useOne() + expect(spy).toHaveBeenCalledTimes(1) + invalidate() // the child + await refresh() // the parent + expect(spy).toHaveBeenCalledTimes(2) }) - await useLoader._.load(route, router) - const { user, refresh } = useLoader() - const { invalidate } = useOne() - expect(spy).toHaveBeenCalledTimes(1) - invalidate() // the child - await refresh() // the parent - expect(spy).toHaveBeenCalledTimes(2) - }) - it('invalidated nested loaders invalidate a loader (by route params)', async () => { - const spy = vi - .fn>() - .mockImplementation(async (route: RouteLocationNormalizedLoaded) => ({ - user: { name: route.params.id as string }, - })) - const useOne = defineLoader(spy) - const useLoader = defineLoader(async () => { - const { user } = await useOne() - return { user, local: user.value.name } + it('invalidated nested loaders invalidate a loader (by route params)', async () => { + const spy = vi + .fn>() + .mockImplementation(async (route: RouteLocationNormalizedLoaded) => ({ + user: { name: route.params.id as string }, + })) + const useOne = defineLoader(spy) + const useLoader = defineLoader(async () => { + const { user } = await useOne() + return { user, local: user.value.name } + }) + await useLoader._.load(setRoute({ params: { id: 'edu' } }), router) + expect(spy).toHaveBeenCalledTimes(1) + // same id + await useLoader._.load(setRoute({ params: { id: 'edu' } }), router) + expect(spy).toHaveBeenCalledTimes(1) + // same id + await useLoader._.load(setRoute({ params: { id: 'bob' } }), router) + expect(spy).toHaveBeenCalledTimes(2) }) - await useLoader._.load(setRoute({ params: { id: 'edu' } }), router) - expect(spy).toHaveBeenCalledTimes(1) - // same id - await useLoader._.load(setRoute({ params: { id: 'edu' } }), router) - expect(spy).toHaveBeenCalledTimes(1) - // same id - await useLoader._.load(setRoute({ params: { id: 'bob' } }), router) - expect(spy).toHaveBeenCalledTimes(2) - }) - it('nested loaders changes propagate to parent', async () => { - const spy = vi - .fn>() - .mockResolvedValue({ user: { name: 'edu' } }) - const useOne = defineLoader(spy) - const useLoader = defineLoader(async () => { - const { user } = await useOne() - return { user, local: user.value.name } + it('nested loaders changes propagate to parent', async () => { + const spy = vi + .fn>() + .mockResolvedValue({ user: { name: 'edu' } }) + const useOne = defineLoader(spy) + const useLoader = defineLoader(async () => { + const { user } = await useOne() + return { user, local: user.value.name } + }) + await useLoader._.load(route, router) + const { user } = useLoader() + const { invalidate, refresh, user: userFromOne } = useOne() + expect(user.value).toEqual({ name: 'edu' }) + expect(userFromOne.value).toEqual({ name: 'edu' }) + spy.mockResolvedValueOnce({ user: { name: 'bob' } }) + invalidate() + await refresh() + expect(user.value).toEqual({ name: 'bob' }) + expect(userFromOne.value).toEqual({ name: 'bob' }) }) - await useLoader._.load(route, router) - const { user } = useLoader() - const { invalidate, refresh, user: userFromOne } = useOne() - expect(user.value).toEqual({ name: 'edu' }) - expect(userFromOne.value).toEqual({ name: 'edu' }) - spy.mockResolvedValueOnce({ user: { name: 'bob' } }) - invalidate() - await refresh() - expect(user.value).toEqual({ name: 'bob' }) - expect(userFromOne.value).toEqual({ name: 'bob' }) }) it('can be refreshed and awaited', async () => { @@ -401,7 +406,7 @@ describe('defineLoader', () => { expect(user.value).toEqual({ name: 'bob' }) }) - it('skips loader if used params did not change', async () => { + it('skips loader if used params/query/hash did not change', async () => { const spy = vi.fn().mockResolvedValue({ name: 'edu' }) const useLoader = defineLoader(async ({ params, query }) => { return { user: await spy(params.id, query.other) } @@ -462,9 +467,39 @@ describe('defineLoader', () => { expect(pending.value).toBe(false) expect(error.value).toBeFalsy() expect(user.value).toEqual({ name: 'edu' }) + }) - await refresh() - expect(spy).toHaveBeenCalledTimes(6) + it('skips loader if used hash did not change', async () => { + const spy = vi.fn().mockResolvedValue({ name: 'edu' }) + const useLoader = defineLoader(async ({ params, query, hash }) => { + return { user: await spy(hash) } + }) + + await useLoader._.load(setRoute({ hash: '#one' }), router) + // same param as before + await useLoader._.load(setRoute({}), router) + // new hash + await useLoader._.load(setRoute({ hash: '' }), router) + expect(spy).toHaveBeenCalledTimes(2) + }) + + it('skips nested loader if route is unused', async () => { + const spy = vi.fn().mockResolvedValue({ name: 'edu' }) + const useOne = defineLoader(async () => { + return { user: await spy() } + }) + + const useLoader = defineLoader(async () => { + const { user } = await useOne() + return { name: user.value.name } + }) + + await useLoader._.load(setRoute({ hash: '#one' }), router) + // same param as before + await useLoader._.load(setRoute({}), router) + // new hash + await useLoader._.load(setRoute({ hash: '' }), router) + expect(spy).toHaveBeenCalledTimes(1) }) it('reloads if lazy loader is called with different params', async () => { diff --git a/src/data-fetching/defineLoader.ts b/src/data-fetching/defineLoader.ts index f63b82e34..f6772226e 100644 --- a/src/data-fetching/defineLoader.ts +++ b/src/data-fetching/defineLoader.ts @@ -209,7 +209,7 @@ export function defineLoader

, isLazy extends boolean>( currentNavigation = route // TODO: ensure others useUserData() (loaders) can be called with a similar approach as pinia - const [trackedRoute, params, query] = trackRoute(route) + const [trackedRoute, params, query, hash] = trackRoute(route) // if there isn't a pending promise, we set the current context so nested loaders can use it if (!pendingPromise) { setCurrentContext([entry, router, trackedRoute]) @@ -217,7 +217,7 @@ export function defineLoader

, isLazy extends boolean>( const thisPromise = (pendingPromise = loader(trackedRoute) .then((data) => { if (pendingPromise === thisPromise) { - updateDataCacheEntry(entry, data, params, query) + updateDataCacheEntry(entry, data, params, query, hash) } }) .catch((err) => { @@ -270,7 +270,8 @@ function shouldFetchAgain( // manually invalidated !entry.when || !includesParams(route.params, entry.params) || - !includesParams(route.query, entry.query) + !includesParams(route.query, entry.query) || + (entry.hash != null && entry.hash !== route.hash) ) } @@ -391,15 +392,21 @@ export interface _DataLoaderResultLazy extends _DataLoaderResult { function trackRoute(route: RouteLocationNormalizedLoaded) { const [params, paramReads] = trackObjectReads(route.params) const [query, queryReads] = trackObjectReads(route.query) + let hash: { v: string | null } = { v: null } // TODO: track `hash` return [ { ...route, + // track the hash + get hash() { + return (hash.v = route.hash) + }, params, query, }, paramReads, queryReads, + hash, ] as const }