From 815f8759eaff8c293a1cac832f3c020d3c39ce8b Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Wed, 27 Jul 2022 19:01:37 +0200 Subject: [PATCH] feat: add lazy loaders --- src/data-fetching/dataCache.ts | 57 ++++++++--- src/data-fetching/defineLoader.spec.ts | 42 ++++++++ src/data-fetching/defineLoader.ts | 132 ++++++++++++++++++------- 3 files changed, 181 insertions(+), 50 deletions(-) diff --git a/src/data-fetching/dataCache.ts b/src/data-fetching/dataCache.ts index 0121d5311..19d941c3f 100644 --- a/src/data-fetching/dataCache.ts +++ b/src/data-fetching/dataCache.ts @@ -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 { +export interface _DataLoaderCacheEntryBase { /** * When was the data loaded in ms (Date.now()). * @internal @@ -12,11 +20,6 @@ export interface DataLoaderCacheEntry { params: Partial query: Partial - /** - * Data stored in the cache. - */ - data: ToRefs - /** * Whether there is an ongoing request. */ @@ -30,6 +33,26 @@ export interface DataLoaderCacheEntry { error: Ref // any is simply more convenient for errors } +export interface DataLoaderCacheEntryNonLazy + extends _DataLoaderCacheEntryBase { + /** + * Data stored in the cache. + */ + data: ToRefs +} + +export interface DataLoaderCacheEntryLazy + extends _DataLoaderCacheEntryBase { + /** + * Data stored in the cache. + */ + data: { data: Ref> } +} + +export type DataLoaderCacheEntry = + | DataLoaderCacheEntryNonLazy + | DataLoaderCacheEntryLazy + export function isCacheExpired( entry: DataLoaderCacheEntry, { cacheTime }: Required @@ -41,22 +64,29 @@ export function createOrUpdateDataCacheEntry( entry: DataLoaderCacheEntry | undefined, data: T, params: Partial, - query: Partial + query: Partial, + { lazy }: Required ): DataLoaderCacheEntry { if (!entry) { return withinScope(() => ({ pending: ref(false), error: ref(), when: Date.now(), - data: refsFromObject(data), + data: lazy ? { data: ref(data) } : refsFromObject(data), params, query, - })) + // this was just to annoying to type + })) as DataLoaderCacheEntry } else { entry.when = Date.now() entry.params = params entry.query = query - transferData(entry, data) + if (lazy) { + ;(entry as DataLoaderCacheEntryLazy).data.data.value = + data as UnwrapRef + } else { + transferData(entry as DataLoaderCacheEntryNonLazy, data) + } return entry } } @@ -73,7 +103,10 @@ function refsFromObject(data: T): ToRefs { return result } -export function transferData(entry: DataLoaderCacheEntry, data: T) { +export function transferData( + entry: DataLoaderCacheEntryNonLazy, + 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 diff --git a/src/data-fetching/defineLoader.spec.ts b/src/data-fetching/defineLoader.spec.ts index de6bd1a08..2d073cfc2 100644 --- a/src/data-fetching/defineLoader.spec.ts +++ b/src/data-fetching/defineLoader.spec.ts @@ -39,6 +39,8 @@ vi.mock('vue-router', async () => { } }) +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + function mockPromise(resolved: T, rejected?: E) { let _resolve: null | ((resolvedValue: T) => void) = null let _reject: null | ((rejectedValue?: E) => void) = null @@ -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 () => { @@ -442,4 +462,26 @@ dts(async () => { pending: Ref refresh: () => Promise }>(useWithRef()) + + async function loaderUser() { + const user = { + id: 'one', + name: 'Edu', + } + + return { user } + } + + expectType<{ data: Ref<{ user: UserData }> }>( + defineLoader(loaderUser, { lazy: true })() + ) + expectType<{ user: Ref }>( + defineLoader(loaderUser, { cacheTime: 20000 })() + ) + expectType<{ user: Ref }>( + defineLoader(loaderUser, { cacheTime: 20000, lazy: false })() + ) + expectType<{ user: Ref }>( + defineLoader(loaderUser, { lazy: false })() + ) }) diff --git a/src/data-fetching/defineLoader.ts b/src/data-fetching/defineLoader.ts index 68ebde458..988cd9f3c 100644 --- a/src/data-fetching/defineLoader.ts +++ b/src/data-fetching/defineLoader.ts @@ -11,20 +11,28 @@ import { createOrUpdateDataCacheEntry, DataLoaderCacheEntry, isCacheExpired, - transferData, } from './dataCache' import { _RouteMapGeneric } from '../codegen/generateRouteMap' -export interface DefineLoaderOptions { +export interface DefineLoaderOptions { /** * 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 = { cacheTime: 1000 * 5, + lazy: false, // cacheTime: 1000 * 60 * 5, } @@ -34,20 +42,28 @@ export interface DefineLoaderFn { : Promise } -export function defineLoader

>( +export function defineLoader< + P extends Promise, + isLazy extends boolean = false +>( name: RouteRecordName, loader: DefineLoaderFn

, - options?: DefineLoaderOptions -): DataLoader> -export function defineLoader

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

, - options?: DefineLoaderOptions -): DataLoader> -export function defineLoader

>( + options?: DefineLoaderOptions +): DataLoader, isLazy> + +export function defineLoader

, isLazy extends boolean>( nameOrLoader: RouteRecordName | ((route: RouteLocationNormalizedLoaded) => P), - _loaderOrOptions?: DefineLoaderOptions | DefineLoaderFn

, - opts?: DefineLoaderOptions -): DataLoader> { + _loaderOrOptions?: DefineLoaderOptions | DefineLoaderFn

, + opts?: DefineLoaderOptions +): DataLoader, isLazy> { // TODO: make it DEV only and remove the first argument in production mode const loader = typeof nameOrLoader === 'function' @@ -56,27 +72,34 @@ export function defineLoader

>( opts = typeof _loaderOrOptions === 'object' ? _loaderOrOptions : opts const options = { ...DEFAULT_DEFINE_LOADER_OPTIONS, ...opts } - const dataLoader: DataLoader> = (() => { + const dataLoader: DataLoader, 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() @@ -96,7 +119,7 @@ export function defineLoader

>( } return Object.assign(commonData, data) - }) as DataLoader> + }) as DataLoader, isLazy> const cache = new WeakMap>>() @@ -105,6 +128,7 @@ export function defineLoader

>( function load(route: RouteLocationNormalizedLoaded, router: Router) { let entry = cache.get(router) + const { lazy } = options const needsNewLoad = shouldFetchAgain(entry, route) @@ -116,9 +140,13 @@ export function defineLoader

>( // 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 @@ -128,14 +156,32 @@ export function defineLoader

>( 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( + 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) } }) @@ -154,13 +200,17 @@ export function defineLoader

>( } } })) - - 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 @@ -222,8 +272,10 @@ function includesParams( const IsLoader = Symbol() -export interface DataLoader { - (): _DataLoaderResult & ToRefs +export interface DataLoader { + (): true extends isLazy + ? _DataLoaderResultLazy + : _DataLoaderResult & ToRefs [IsLoader]: true @@ -279,6 +331,10 @@ export interface _DataLoaderResult { invalidate: () => void } +export interface _DataLoaderResultLazy extends _DataLoaderResult { + data: Ref +} + export function isDataLoader(loader: any): loader is DataLoader { return loader && loader[IsLoader] }