Skip to content
This repository has been archived by the owner on Apr 6, 2023. It is now read-only.

feat(nuxt): share asyncData between calls #5738

Closed
wants to merge 9 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/content/3.api/1.composables/use-async-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Under the hood, `lazy: false` uses `<Suspense>` 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

Expand Down
178 changes: 109 additions & 69 deletions packages/nuxt/src/app/composables/asyncData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ export interface AsyncDataOptions<
}

export interface RefreshOptions {
_initial?: boolean
_initial?: boolean,
force?: boolean
}

export interface _AsyncData<DataT, ErrorT> {
Expand All @@ -45,8 +46,6 @@ export interface _AsyncData<DataT, ErrorT> {

export type AsyncData<Data, Error> = _AsyncData<Data, Error> & Promise<_AsyncData<Data, Error>>

const getDefault = () => null

export function useAsyncData<
DataT,
DataE = Error,
Expand All @@ -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.')
Expand All @@ -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<DataT, DataE> = nuxt._asyncDataPayloads[key]

// If there's no payload
if (!asyncData) {
asyncData = {
data: wrapInRef(unref(options.default())),
pending: ref(true),
error: ref(null)
} as AsyncData<DataT, DataE>

nuxt._asyncDataPayloads[key] = asyncData

nuxt.hook('app:rendered', () => {
delete nuxt._asyncDataPayloads[key]
})
}

const tagAlongOrRun = <T>(
run: (ctx?: NuxtApp) => Promise<T>
): [boolean, Promise<T>] => {
// 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<DataT, DataE>
// 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<any> = 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<DataT, DataE>
Object.assign(asyncDataPromise, asyncData)

return asyncDataPromise as AsyncData<PickFrom<ReturnType<Transform>, 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<PickFrom<ReturnType<Transform>, PickKeys>, DataE>
}

export function useLazyAsyncData<
Expand Down
3 changes: 3 additions & 0 deletions packages/nuxt/src/app/nuxt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NuxtApp>('nuxt-app')

Expand Down Expand Up @@ -48,6 +49,7 @@ interface _NuxtApp {
[key: string]: any

_asyncDataPromises?: Record<string, Promise<any>>
_asyncDataPayloads?: Record<string, AsyncData<any, any>>
_legacyContext?: LegacyContext

ssrContext?: SSRContext & {
Expand Down Expand Up @@ -105,6 +107,7 @@ export function createNuxtApp (options: CreateOptions) {
}),
isHydrating: process.client,
_asyncDataPromises: {},
_asyncDataPayloads: {},
...options
} as any as NuxtApp

Expand Down
18 changes: 18 additions & 0 deletions test/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
7 changes: 7 additions & 0 deletions test/fixtures/basic/composables/asyncDataTests.ts
Original file line number Diff line number Diff line change
@@ -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')
26 changes: 26 additions & 0 deletions test/fixtures/basic/pages/useAsyncData/double.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<template>
<div>
Single
<div>
data1: {{ data1 }}
data2: {{ data2 }}
</div>
</div>
</template>

<script setup lang="ts">
const { data: data1 } = await useSleep()
const { data: data2 } = await useSleep()

if (data1.value === null || data1.value === undefined || data1.value.length <= 0) {
throw new Error('Data should never be null or empty.')
}

if (data2.value === null || data2.value === undefined || data2.value.length <= 0) {
throw new Error('Data should never be null or empty.')
}

if (data1.value !== data2.value) {
throw new Error('AsyncData not synchronised')
}
</script>
31 changes: 31 additions & 0 deletions test/fixtures/basic/pages/useAsyncData/promise-all.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<template>
<div>
Single
<div>
data1: {{ result1.data.value }}
data2: {{ result2.data.value }}
</div>
</div>
</template>

<script setup lang="ts">
const [result1, result2] = await Promise.all([useSleep(), useSleep()])

if (result1.data.value === null || result1.data.value === undefined || result1.data.value.length <= 0) {
throw new Error('Data should never be null or empty.')
}

if (result2.data.value === null || result2.data.value === undefined || result2.data.value.length <= 0) {
throw new Error('Data should never be null or empty.')
}

if (result1.data.value !== result2.data.value) {
throw new Error('AsyncData not synchronised')
}

await result1.refresh()

if (result1.data.value !== result2.data.value) {
throw new Error('AsyncData not synchronised')
}
</script>
Loading