diff --git a/docs/content/3.api/1.composables/use-async-data.md b/docs/content/3.api/1.composables/use-async-data.md index 17806001ea0..089d81cf8d9 100644 --- a/docs/content/3.api/1.composables/use-async-data.md +++ b/docs/content/3.api/1.composables/use-async-data.md @@ -51,7 +51,7 @@ Under the hood, `lazy: false` uses `` to block the loading of the rout * **refresh**: a function that can be used to refresh the data returned by the `handler` function * **error**: an error object if the data fetching failed -By default, Nuxt waits until a `refresh` is finished before it can be executed again. Passing `true` as parameter skips that wait. +By default, Nuxt waits until a `refresh` is finished before it can be executed again. Passing `{ force: true }` as parameter skips that wait. ## Example diff --git a/packages/nuxt/src/app/composables/asyncData.ts b/packages/nuxt/src/app/composables/asyncData.ts index e4ef53c8192..1a3fb46a0bc 100644 --- a/packages/nuxt/src/app/composables/asyncData.ts +++ b/packages/nuxt/src/app/composables/asyncData.ts @@ -33,7 +33,8 @@ export interface AsyncDataOptions< } export interface RefreshOptions { - _initial?: boolean + _initial?: boolean, + force?: boolean } export interface _AsyncData { @@ -45,8 +46,6 @@ export interface _AsyncData { export type AsyncData = _AsyncData & Promise<_AsyncData> -const getDefault = () => null - export function useAsyncData< DataT, DataE = Error, @@ -66,7 +65,7 @@ export function useAsyncData< } // Apply defaults - options = { server: true, default: getDefault, ...options } + options = { server: true, default: () => null, ...options } // TODO: remove support for `defer` in Nuxt 3 RC if ((options as any).defer) { console.warn('[useAsyncData] `defer` has been renamed to `lazy`. Support for `defer` will be removed in RC.') @@ -77,106 +76,147 @@ export function useAsyncData< // Setup nuxt instance payload const nuxt = useNuxtApp() - // Setup hook callbacks once per instance - const instance = getCurrentInstance() - if (instance && !instance._nuxtOnBeforeMountCbs) { - const cbs = instance._nuxtOnBeforeMountCbs = [] - if (instance && process.client) { - onBeforeMount(() => { - cbs.forEach((cb) => { cb() }) - cbs.splice(0, cbs.length) - }) - onUnmounted(() => cbs.splice(0, cbs.length)) + // Grab the payload for this key + let asyncData: AsyncData = nuxt._asyncDataPayloads[key] + + // If there's no payload + if (!asyncData) { + asyncData = { + data: wrapInRef(unref(options.default())), + pending: ref(true), + error: ref(null) + } as AsyncData + + nuxt._asyncDataPayloads[key] = asyncData + + nuxt.hook('app:rendered', () => { + delete nuxt._asyncDataPayloads[key] + }) + } + + const tagAlongOrRun = ( + run: (ctx?: NuxtApp) => Promise + ): [boolean, Promise] => { + // Nobody else has run the promise yet! Let everyone know + if (nuxt._asyncDataPromises[key] === undefined) { + // Remove the promise when its finished + nuxt._asyncDataPromises[key] = run(nuxt) + return [false, nuxt._asyncDataPromises[key]] + } else { + return [true, nuxt._asyncDataPromises[key]] } } - const useInitialCache = () => options.initialCache && nuxt.payload.data[key] !== undefined + asyncData.refresh = async (refreshOptions: RefreshOptions) => { + refreshOptions = refreshOptions ?? { _initial: false, force: false } - const asyncData = { - data: wrapInRef(nuxt.payload.data[key] ?? options.default()), - pending: ref(!useInitialCache()), - error: ref(nuxt.payload._errors[key] ?? null) - } as AsyncData + // Check if a refresh is already in progress, if it is just tag along + // Otherwise grab a new promise from the handler + let promise: Promise = null + let isTagAlong = false - asyncData.refresh = (opts = {}) => { - // Avoid fetching same key more than once at a time - if (nuxt._asyncDataPromises[key]) { - return nuxt._asyncDataPromises[key] + if (refreshOptions.force) { + promise = handler(nuxt) + } else { + [isTagAlong, promise] = tagAlongOrRun(handler) } - // Avoid fetching same key that is already fetched - if (opts._initial && useInitialCache()) { - return nuxt.payload.data[key] + + promise = promise.then(x => [true, x]) + + // Catch any errors and return + const [success, result]: [boolean, DataT | DataE] = await promise.catch(x => [false, x as DataE]) + + // If we just tagged along, we don't need to do any processing, the original request will do it + if (isTagAlong) { return } + + // If the promise is rejected give the user the error + // Also tell nuxt something went wrong + if (!success) { + asyncData.error.value = result as DataE + nuxt.payload._errors[key] = true + } else { + // If our promise resolved update our payload! + let data = result as DataT + + if (options.transform) { data = options.transform(data) } + + if (options.pick) { data = pick(data, options.pick) as DataT } + + asyncData.data.value = data + asyncData.error.value = null } - asyncData.pending.value = true - // TODO: Cancel previous promise - // TODO: Handle immediate errors - nuxt._asyncDataPromises[key] = Promise.resolve(handler(nuxt)) - .then((result) => { - if (options.transform) { - result = options.transform(result) - } - if (options.pick) { - result = pick(result, options.pick) as DataT - } - asyncData.data.value = result - asyncData.error.value = null - }) - .catch((error: any) => { - asyncData.error.value = error - asyncData.data.value = unref(options.default()) - }) - .finally(() => { - asyncData.pending.value = false - nuxt.payload.data[key] = asyncData.data.value - if (asyncData.error.value) { - nuxt.payload._errors[key] = true - } - delete nuxt._asyncDataPromises[key] - }) - return nuxt._asyncDataPromises[key] - } - const initialFetch = () => asyncData.refresh({ _initial: true }) + // Cache our data if its the inital fetch, or we're running on the server + // We don't need to cache client `refresh` calls, but we should cache server `refresh` calls + if (refreshOptions._initial || process.server) { + nuxt.payload.data[key] = asyncData.data.value + } + + // No matter what the promise has now resolved. + asyncData.pending.value = false + // Remove the promise so we don't try tag along + delete nuxt._asyncDataPromises[key] + } + + // Should/Have we fetched on the server? const fetchOnServer = options.server !== false && nuxt.payload.serverRendered - // Server side - if (process.server && fetchOnServer) { - const promise = initialFetch() + let promise = Promise.resolve() + + // If we are using the cache + if (options.initialCache && nuxt.payload.data[key]) { + asyncData.data.value = nuxt.payload.data[key] + asyncData.pending.value = false + } else if (process.server && fetchOnServer) { + // Make our promise resolve when the refresh is complete + promise = asyncData.refresh({ _initial: true }) + onServerPrefetch(() => promise) - } + } else if (process.client) { + const instance = getCurrentInstance() + if (instance && !instance._nuxtOnBeforeMountCbs) { + const cbs = (instance._nuxtOnBeforeMountCbs = []) + + onBeforeMount(() => { + cbs.forEach((cb) => { + cb() + }) + cbs.splice(0, cbs.length) + }) + + onUnmounted(() => cbs.splice(0, cbs.length)) + } - // Client side - if (process.client) { if (fetchOnServer && nuxt.isHydrating && key in nuxt.payload.data) { // 1. Hydration (server: true): no fetch asyncData.pending.value = false } else if (instance && nuxt.payload.serverRendered && (nuxt.isHydrating || options.lazy)) { // 2. Initial load (server: false): fetch on mounted // 3. Navigation (lazy: true): fetch on mounted - instance._nuxtOnBeforeMountCbs.push(initialFetch) + instance._nuxtOnBeforeMountCbs.push(() => asyncData.refresh({ _initial: true })) } else { // 4. Navigation (lazy: false) - or plugin usage: await fetch - initialFetch() + promise = asyncData.refresh({ _initial: true }) } + if (options.watch) { watch(options.watch, () => asyncData.refresh()) } + const off = nuxt.hook('app:data:refresh', (keys) => { if (!keys || keys.includes(key)) { return asyncData.refresh() } }) + if (instance) { onUnmounted(off) } } - // Allow directly awaiting on asyncData - const asyncDataPromise = Promise.resolve(nuxt._asyncDataPromises[key]).then(() => asyncData) as AsyncData - Object.assign(asyncDataPromise, asyncData) - - return asyncDataPromise as AsyncData, PickKeys>, DataE> + // Combine our promise with the payload so pending is still returned if the function is not awaited + return Object.assign(promise.then(() => asyncData), asyncData) as AsyncData, PickKeys>, DataE> } export function useLazyAsyncData< diff --git a/packages/nuxt/src/app/nuxt.ts b/packages/nuxt/src/app/nuxt.ts index 616a77a8c12..e0d94522f5f 100644 --- a/packages/nuxt/src/app/nuxt.ts +++ b/packages/nuxt/src/app/nuxt.ts @@ -7,6 +7,7 @@ import { getContext } from 'unctx' import type { SSRContext } from 'vue-bundle-renderer' import type { CompatibilityEvent } from 'h3' import { legacyPlugin, LegacyContext } from './compat/legacy-app' +import { AsyncData } from './composables' const nuxtAppCtx = getContext('nuxt-app') @@ -48,6 +49,7 @@ interface _NuxtApp { [key: string]: any _asyncDataPromises?: Record> + _asyncDataPayloads?: Record> _legacyContext?: LegacyContext ssrContext?: SSRContext & { @@ -105,6 +107,7 @@ export function createNuxtApp (options: CreateOptions) { }), isHydrating: process.client, _asyncDataPromises: {}, + _asyncDataPayloads: {}, ...options } as any as NuxtApp diff --git a/test/basic.test.ts b/test/basic.test.ts index ea4ce3a0ca0..ddc988a230d 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -127,6 +127,24 @@ describe('pages', () => { }) }) +describe('useAsyncData', () => { + it('single request resolves', async () => { + await expectNoClientErrors('/useAsyncData/single') + }) + + it('two requests resolve', async () => { + await expectNoClientErrors('/useAsyncData/double') + }) + + it('two requests resolve and sync', async () => { + await $fetch('/useAsyncData/refresh') + }) + + it('two requests made at once resolve and sync', async () => { + await expectNoClientErrors('/useAsyncData/promise-all') + }) +}) + describe('head tags', () => { it('should render tags', async () => { const html = await $fetch('/head') diff --git a/test/fixtures/basic/composables/asyncDataTests.ts b/test/fixtures/basic/composables/asyncDataTests.ts new file mode 100644 index 00000000000..07dd078ae87 --- /dev/null +++ b/test/fixtures/basic/composables/asyncDataTests.ts @@ -0,0 +1,7 @@ +export const useSleep = () => useAsyncData('sleep', async () => { + await new Promise(resolve => setTimeout(resolve, 50)) + + return 'Slept!' +}) + +export const useCounter = () => useFetch('/api/useAsyncData/count') diff --git a/test/fixtures/basic/pages/useAsyncData/double.vue b/test/fixtures/basic/pages/useAsyncData/double.vue new file mode 100644 index 00000000000..2c7c3a86cd8 --- /dev/null +++ b/test/fixtures/basic/pages/useAsyncData/double.vue @@ -0,0 +1,26 @@ + + + diff --git a/test/fixtures/basic/pages/useAsyncData/promise-all.vue b/test/fixtures/basic/pages/useAsyncData/promise-all.vue new file mode 100644 index 00000000000..426ebba9519 --- /dev/null +++ b/test/fixtures/basic/pages/useAsyncData/promise-all.vue @@ -0,0 +1,31 @@ + + + diff --git a/test/fixtures/basic/pages/useAsyncData/refresh.vue b/test/fixtures/basic/pages/useAsyncData/refresh.vue new file mode 100644 index 00000000000..5f312fd434c --- /dev/null +++ b/test/fixtures/basic/pages/useAsyncData/refresh.vue @@ -0,0 +1,39 @@ + + + diff --git a/test/fixtures/basic/pages/useAsyncData/single.vue b/test/fixtures/basic/pages/useAsyncData/single.vue new file mode 100644 index 00000000000..b4dd2758880 --- /dev/null +++ b/test/fixtures/basic/pages/useAsyncData/single.vue @@ -0,0 +1,16 @@ + + + diff --git a/test/fixtures/basic/server/api/useAsyncData/count.ts b/test/fixtures/basic/server/api/useAsyncData/count.ts new file mode 100644 index 00000000000..8c33e3545d9 --- /dev/null +++ b/test/fixtures/basic/server/api/useAsyncData/count.ts @@ -0,0 +1,3 @@ +let counter = 0 + +export default () => ({ count: counter++ })