Skip to content

Commit

Permalink
feat: track hash reads
Browse files Browse the repository at this point in the history
  • Loading branch information
posva committed Aug 2, 2022
1 parent 71d5a21 commit e5583a4
Show file tree
Hide file tree
Showing 3 changed files with 148 additions and 103 deletions.
5 changes: 4 additions & 1 deletion src/data-fetching/dataCache.ts
Expand Up @@ -16,6 +16,7 @@ export interface _DataLoaderCacheEntryBase {

params: Partial<RouteParams>
query: Partial<LocationQuery>
hash: string | null
loaders: Set<DataLoaderCacheEntry>
// TODO: hash

Expand Down Expand Up @@ -103,11 +104,13 @@ export function updateDataCacheEntry<T>(
entry: DataLoaderCacheEntry<T>,
data: T,
params: Partial<RouteParams>,
query: Partial<LocationQuery>
query: Partial<LocationQuery>,
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
Expand Down
233 changes: 134 additions & 99 deletions src/data-fetching/defineLoader.spec.ts
Expand Up @@ -22,6 +22,7 @@ import {
it,
vi,
} from 'vitest'
import { setCurrentContext } from './dataCache'

vi.mock('vue-router', async () => {
const { createRouter, createMemoryHistory, START_LOCATION, ...rest } =
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -154,113 +157,115 @@ describe('defineLoader', () => {
await expect(p).rejects.toBe(e)
})

it('can call nested loaders', async () => {
const spy = vi
.fn<any[], Promise<{ user: { name: string } }>>()
.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<any[], Promise<{ user: { name: string } }>>()
.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<any[], Promise<{ user: { name: string } }>>()
.mockResolvedValue({ user: { name: 'edu' } })
const useOne = defineLoader(one)
const two = vi
.fn<any[], Promise<{ user: { name: string }; local: string }>>()
.mockImplementation(async () => {
it('can call deeply nested loaders', async () => {
const one = vi
.fn<any[], Promise<{ user: { name: string } }>>()
.mockResolvedValue({ user: { name: 'edu' } })
const useOne = defineLoader(one)
const two = vi
.fn<any[], Promise<{ user: { name: string }; local: string }>>()
.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<any[], Promise<{ user: { name: string } }>>()
.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<any[], Promise<{ user: { name: string } }>>()
.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<any[], Promise<{ user: { name: string } }>>()
.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<any[], Promise<{ user: { name: string } }>>()
.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<any[], Promise<{ user: { name: string } }>>()
.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<any[], Promise<{ user: { name: string } }>>()
.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 () => {
Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -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 () => {
Expand Down
13 changes: 10 additions & 3 deletions src/data-fetching/defineLoader.ts
Expand Up @@ -209,15 +209,15 @@ export function defineLoader<P extends Promise<any>, 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])
}
const thisPromise = (pendingPromise = loader(trackedRoute)
.then((data) => {
if (pendingPromise === thisPromise) {
updateDataCacheEntry(entry, data, params, query)
updateDataCacheEntry(entry, data, params, query, hash)
}
})
.catch((err) => {
Expand Down Expand Up @@ -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)
)
}

Expand Down Expand Up @@ -391,15 +392,21 @@ export interface _DataLoaderResultLazy<T> 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
}

Expand Down

0 comments on commit e5583a4

Please sign in to comment.