Skip to content

Commit

Permalink
feat: add lazy loaders
Browse files Browse the repository at this point in the history
  • Loading branch information
posva committed Jul 27, 2022
1 parent 4ef4a33 commit 815f875
Show file tree
Hide file tree
Showing 3 changed files with 181 additions and 50 deletions.
57 changes: 45 additions & 12 deletions src/data-fetching/dataCache.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { EffectScope, ref, ToRefs, effectScope, Ref, unref } from 'vue'
import {
EffectScope,
ref,
ToRefs,
effectScope,
Ref,
unref,
UnwrapRef,
} from 'vue'
import { LocationQuery, RouteParams } from 'vue-router'
import { DefineLoaderOptions } from './defineLoader'

export interface DataLoaderCacheEntry<T = unknown> {
export interface _DataLoaderCacheEntryBase {
/**
* When was the data loaded in ms (Date.now()).
* @internal
Expand All @@ -12,11 +20,6 @@ export interface DataLoaderCacheEntry<T = unknown> {
params: Partial<RouteParams>
query: Partial<LocationQuery>

/**
* Data stored in the cache.
*/
data: ToRefs<T>

/**
* Whether there is an ongoing request.
*/
Expand All @@ -30,6 +33,26 @@ export interface DataLoaderCacheEntry<T = unknown> {
error: Ref<any> // any is simply more convenient for errors
}

export interface DataLoaderCacheEntryNonLazy<T = unknown>
extends _DataLoaderCacheEntryBase {
/**
* Data stored in the cache.
*/
data: ToRefs<T>
}

export interface DataLoaderCacheEntryLazy<T = unknown>
extends _DataLoaderCacheEntryBase {
/**
* Data stored in the cache.
*/
data: { data: Ref<UnwrapRef<T>> }
}

export type DataLoaderCacheEntry<T = unknown> =
| DataLoaderCacheEntryNonLazy<T>
| DataLoaderCacheEntryLazy<T>

export function isCacheExpired(
entry: DataLoaderCacheEntry,
{ cacheTime }: Required<DefineLoaderOptions>
Expand All @@ -41,22 +64,29 @@ export function createOrUpdateDataCacheEntry<T>(
entry: DataLoaderCacheEntry<T> | undefined,
data: T,
params: Partial<RouteParams>,
query: Partial<LocationQuery>
query: Partial<LocationQuery>,
{ lazy }: Required<DefineLoaderOptions>
): DataLoaderCacheEntry<T> {
if (!entry) {
return withinScope(() => ({
pending: ref(false),
error: ref<any>(),
when: Date.now(),
data: refsFromObject(data),
data: lazy ? { data: ref<T>(data) } : refsFromObject(data),
params,
query,
}))
// this was just to annoying to type
})) as DataLoaderCacheEntry<T>
} else {
entry.when = Date.now()
entry.params = params
entry.query = query
transferData(entry, data)
if (lazy) {
;(entry as DataLoaderCacheEntryLazy<T>).data.data.value =
data as UnwrapRef<T>
} else {
transferData(entry as DataLoaderCacheEntryNonLazy<T>, data)
}
return entry
}
}
Expand All @@ -73,7 +103,10 @@ function refsFromObject<T>(data: T): ToRefs<T> {
return result
}

export function transferData<T>(entry: DataLoaderCacheEntry<T>, data: T) {
export function transferData<T>(
entry: DataLoaderCacheEntryNonLazy<T>,
data: T
) {
for (const key in data) {
entry.data[key].value =
// user can pass in a ref, but we want to make sure we only get the data out of it
Expand Down
42 changes: 42 additions & 0 deletions src/data-fetching/defineLoader.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ vi.mock('vue-router', async () => {
}
})

const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

function mockPromise<T, E>(resolved: T, rejected?: E) {
let _resolve: null | ((resolvedValue: T) => void) = null
let _reject: null | ((rejectedValue?: E) => void) = null
Expand Down Expand Up @@ -93,6 +95,24 @@ describe('defineLoader', () => {
expect(user.value).toEqual({ name: 'edu' })
})

it('can be lazy', async () => {
const [spy, resolve, reject] = mockPromise({ name: 'edu' })
const useLoader = defineLoader(
async () => {
return { user: await spy() }
},
{ lazy: true }
)
expect(spy).not.toHaveBeenCalled()
// await but non blocking
await useLoader._.load(route, router)
expect(spy).toHaveBeenCalledTimes(1)
const { data } = useLoader()
resolve()
await delay(0)
expect(data.value).toEqual({ user: { name: 'edu' } })
})

it('rejects failed initial load', async () => {
const [spy, resolve, reject] = mockPromise({ name: 'edu' })
const useLoader = defineLoader(async () => {
Expand Down Expand Up @@ -442,4 +462,26 @@ dts(async () => {
pending: Ref<boolean>
refresh: () => Promise<void>
}>(useWithRef())

async function loaderUser() {
const user = {
id: 'one',
name: 'Edu',
}

return { user }
}

expectType<{ data: Ref<{ user: UserData }> }>(
defineLoader(loaderUser, { lazy: true })()
)
expectType<{ user: Ref<UserData> }>(
defineLoader(loaderUser, { cacheTime: 20000 })()
)
expectType<{ user: Ref<UserData> }>(
defineLoader(loaderUser, { cacheTime: 20000, lazy: false })()
)
expectType<{ user: Ref<UserData> }>(
defineLoader(loaderUser, { lazy: false })()
)
})
132 changes: 94 additions & 38 deletions src/data-fetching/defineLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,28 @@ import {
createOrUpdateDataCacheEntry,
DataLoaderCacheEntry,
isCacheExpired,
transferData,
} from './dataCache'
import { _RouteMapGeneric } from '../codegen/generateRouteMap'

export interface DefineLoaderOptions {
export interface DefineLoaderOptions<isLazy extends boolean = boolean> {
/**
* How long should we wait to consider the fetched data expired. Amount in ms. Defaults to 5 minutes. A value of 0
* means no cache while a value of `Infinity` means cache forever.
*/
cacheTime?: number

/**
* Whether the data should be lazy loaded without blocking the navigation or not. Defaults to false. When set to true
* or a function, the loader will no longer block the navigation and the returned composable can be called even
* without having the data ready. This also means that the data will be available as one single `ref()` named `data`
* instead of all the individual properties returned by the loader.
*/
lazy?: isLazy
}

const DEFAULT_DEFINE_LOADER_OPTIONS: Required<DefineLoaderOptions> = {
cacheTime: 1000 * 5,
lazy: false,
// cacheTime: 1000 * 60 * 5,
}

Expand All @@ -34,20 +42,28 @@ export interface DefineLoaderFn<T> {
: Promise<T>
}

export function defineLoader<P extends Promise<any>>(
export function defineLoader<
P extends Promise<any>,
isLazy extends boolean = false
>(
name: RouteRecordName,
loader: DefineLoaderFn<P>,
options?: DefineLoaderOptions
): DataLoader<Awaited<P>>
export function defineLoader<P extends Promise<any>>(
options?: DefineLoaderOptions<isLazy>
): DataLoader<Awaited<P>, isLazy>

export function defineLoader<
P extends Promise<any>,
isLazy extends boolean = false
>(
loader: DefineLoaderFn<P>,
options?: DefineLoaderOptions
): DataLoader<Awaited<P>>
export function defineLoader<P extends Promise<any>>(
options?: DefineLoaderOptions<isLazy>
): DataLoader<Awaited<P>, isLazy>

export function defineLoader<P extends Promise<any>, isLazy extends boolean>(
nameOrLoader: RouteRecordName | ((route: RouteLocationNormalizedLoaded) => P),
_loaderOrOptions?: DefineLoaderOptions | DefineLoaderFn<P>,
opts?: DefineLoaderOptions
): DataLoader<Awaited<P>> {
_loaderOrOptions?: DefineLoaderOptions<isLazy> | DefineLoaderFn<P>,
opts?: DefineLoaderOptions<isLazy>
): DataLoader<Awaited<P>, isLazy> {
// TODO: make it DEV only and remove the first argument in production mode
const loader =
typeof nameOrLoader === 'function'
Expand All @@ -56,27 +72,34 @@ export function defineLoader<P extends Promise<any>>(
opts = typeof _loaderOrOptions === 'object' ? _loaderOrOptions : opts
const options = { ...DEFAULT_DEFINE_LOADER_OPTIONS, ...opts }

const dataLoader: DataLoader<Awaited<P>> = (() => {
const dataLoader: DataLoader<Awaited<P>, isLazy> = (() => {
const route = useRoute()
const router = useRouter()
const entry = cache.get(router)
let entry = cache.get(router)

// TODO: is blocking
const { lazy } = options

// TODO: dev only
// TODO: detect if this happens during HMR or if the loader is wrongly being used without being exported by a route we are navigating to
if (!entry) {
if (import.meta.hot) {
// reload the page if the loader is new and we have no way to
// TODO: test with webpack
import.meta.hot.invalidate()
if (lazy) {
// we ensure an entry exists
load(route, router)
// we are sure that the entry exists now
entry = cache.get(router)!
} else {
// TODO: dev only
// TODO: detect if this happens during HMR or if the loader is wrongly being used without being exported by a route we are navigating to
if (!entry) {
if (import.meta.hot) {
// reload the page if the loader is new and we have no way to
// TODO: test with webpack
import.meta.hot.invalidate()
}
// with HMR, if the user changes the script section, there is a new cache entry
// we need to transfer the old cache and call refresh
throw new Error('No cache entry: reloading the page')
}
// with HMR, if the user changes the script section, there is a new cache entry
// we need to transfer the old cache and call refresh
throw new Error('No cache entry: reloading the page')
}

const { data, pending, error } = entry
const { data, pending, error } = entry!

function refresh() {
invalidate()
Expand All @@ -96,7 +119,7 @@ export function defineLoader<P extends Promise<any>>(
}

return Object.assign(commonData, data)
}) as DataLoader<Awaited<P>>
}) as DataLoader<Awaited<P>, isLazy>

const cache = new WeakMap<Router, DataLoaderCacheEntry<Awaited<P>>>()

Expand All @@ -105,6 +128,7 @@ export function defineLoader<P extends Promise<any>>(

function load(route: RouteLocationNormalizedLoaded, router: Router) {
let entry = cache.get(router)
const { lazy } = options

const needsNewLoad = shouldFetchAgain(entry, route)

Expand All @@ -116,9 +140,13 @@ export function defineLoader<P extends Promise<any>>(
// if it's a new navigation and there is no entry, we cannot rely on the pendingPromise as we don't know what
// params and query were used and could have changed. If we had an entry, then we can rely on the result of
// needsToFetchAgain()
(currentNavigation === route || entry)
(currentNavigation === route || entry) &&
true
// the lazy request still need to create the entry
// (!lazy || entry)
) {
return pendingPromise
// lazy should just resolve
return lazy ? Promise.resolve() : pendingPromise
}

// remember what was the last navigation we fetched this with
Expand All @@ -128,14 +156,32 @@ export function defineLoader<P extends Promise<any>>(
if (entry) {
entry.pending.value = true
entry.error.value = null
// lazy loaders need to create an entry right away to give access to pending and error states
} else if (lazy) {
entry = createOrUpdateDataCacheEntry<any>(
entry,
// initial value of the data
undefined,
{},
{},
options
)
cache.set(router, entry)
}

// TODO: ensure others useUserData() (loaders) can be called with a similar approach as pinia
// TODO: error handling + refactor to do it in refresh
const [trackedRoute, params, query] = trackRoute(route)
const thisPromise = (pendingPromise = loader(trackedRoute)
.then((data) => {
if (pendingPromise === thisPromise) {
entry = createOrUpdateDataCacheEntry(entry, data, params, query)
entry = createOrUpdateDataCacheEntry(
entry,
data,
params,
query,
options
)
cache.set(router, entry)
}
})
Expand All @@ -154,13 +200,17 @@ export function defineLoader<P extends Promise<any>>(
}
}
}))

return thisPromise
}
// this allows us to know that this was requested
return (pendingPromise = Promise.resolve().finally(
() => (pendingPromise = null)
))

// lazy should just resolve
return lazy
? Promise.resolve()
: // pendingPromise is thisPromise
pendingPromise ||
// the data is already loaded and we don't want to load again so we just resolve right away
(pendingPromise = Promise.resolve().finally(
() => (pendingPromise = null)
))
}

// add the context as one single object
Expand Down Expand Up @@ -222,8 +272,10 @@ function includesParams(

const IsLoader = Symbol()

export interface DataLoader<T> {
(): _DataLoaderResult & ToRefs<T>
export interface DataLoader<T, isLazy extends boolean = boolean> {
(): true extends isLazy
? _DataLoaderResultLazy<T>
: _DataLoaderResult & ToRefs<T>

[IsLoader]: true

Expand Down Expand Up @@ -279,6 +331,10 @@ export interface _DataLoaderResult {
invalidate: () => void
}

export interface _DataLoaderResultLazy<T> extends _DataLoaderResult {
data: Ref<T>
}

export function isDataLoader(loader: any): loader is DataLoader<unknown> {
return loader && loader[IsLoader]
}
Expand Down

0 comments on commit 815f875

Please sign in to comment.