perf(nuxt): use browser cache for payloads (#31379)

This commit is contained in:
Daniel Roe 2025-03-17 11:18:46 +00:00
parent c0785b1d38
commit 8727e40c59
No known key found for this signature in database
GPG Key ID: CBC814C393D93268
7 changed files with 94 additions and 31 deletions

View File

@ -644,3 +644,20 @@ class SomeClass {
const value = new SomeClass().someMethod()
// this will return 'decorated'
```
## purgeCachedData
Nuxt will automatically purge cached data from `useAsyncData` and `nuxtApp.static.data`. This helps prevent memory leaks
and ensures fresh data is loaded when needed, but it is possible to disable it:
```ts twoslash [nuxt.config.ts]
export default defineNuxtConfig({
experimental: {
purgeCachedData: false
}
})
```
::read-more{icon="i-simple-icons-github" color="gray" to="https://github.com/nuxt/nuxt/pull/31379" target="_blank"}
See PR #31379 for implementation details.
::

View File

@ -19,35 +19,43 @@ interface LoadPayloadOptions {
/** @since 3.0.0 */
export async function loadPayload (url: string, opts: LoadPayloadOptions = {}): Promise<Record<string, any> | null> {
if (import.meta.server || !payloadExtraction) { return null }
const payloadURL = await _getPayloadURL(url, opts)
const nuxtApp = useNuxtApp()
const cache = nuxtApp._payloadCache ||= {}
if (payloadURL in cache) {
return cache[payloadURL] || null
// TODO: allow payload extraction for non-prerendered URLs
const shouldLoadPayload = await isPrerendered(url)
if (!shouldLoadPayload) {
return null
}
cache[payloadURL] = isPrerendered(url).then((prerendered) => {
if (!prerendered) {
cache[payloadURL] = null
return null
}
return _importPayload(payloadURL).then((payload) => {
if (payload) { return payload }
delete cache[payloadURL]
return null
})
})
return cache[payloadURL]
const payloadURL = await _getPayloadURL(url, opts)
return await _importPayload(payloadURL) || null
}
let linkRelType: string | undefined
function detectLinkRelType () {
if (import.meta.server) { return 'preload' }
if (linkRelType) { return linkRelType }
const relList = document.createElement('link').relList
linkRelType = relList && relList.supports && relList.supports('prefetch') ? 'prefetch' : 'preload'
return linkRelType
}
/** @since 3.0.0 */
export function preloadPayload (url: string, opts: LoadPayloadOptions = {}): Promise<void> {
const nuxtApp = useNuxtApp()
const promise = _getPayloadURL(url, opts).then((payloadURL) => {
nuxtApp.runWithContext(() => useHead({
link: [
{ rel: 'modulepreload', href: payloadURL },
],
}))
const link = renderJsonPayloads
? { rel: detectLinkRelType(), as: 'fetch', crossorigin: 'anonymous', href: payloadURL } as const
: { rel: 'modulepreload', crossorigin: '', href: payloadURL } as const
if (import.meta.server) {
nuxtApp.runWithContext(() => useHead({ link: [link] }))
} else {
const linkEl = document.createElement('link')
for (const key of Object.keys(link) as Array<keyof typeof link>) {
linkEl[key === 'crossorigin' ? 'crossOrigin' : key] = link[key]!
}
document.head.appendChild(linkEl)
return new Promise<void>((resolve, reject) => {
linkEl.addEventListener('load', () => resolve())
linkEl.addEventListener('error', () => reject())
})
}
})
if (import.meta.server) {
onServerPrefetch(() => promise)
@ -73,7 +81,7 @@ async function _getPayloadURL (url: string, opts: LoadPayloadOptions = {}) {
async function _importPayload (payloadURL: string) {
if (import.meta.server || !payloadExtraction) { return null }
const payloadPromise = renderJsonPayloads
? fetch(payloadURL).then(res => res.text().then(parsePayload))
? fetch(payloadURL, { cache: 'force-cache' }).then(res => res.text().then(parsePayload))
: import(/* webpackIgnore: true */ /* @vite-ignore */ payloadURL).then(r => r.default || r)
try {

View File

@ -154,8 +154,6 @@ interface _NuxtApp {
/** @internal */
_observer?: { observe: (element: Element, callback: () => void) => () => void }
/** @internal */
_payloadCache?: Record<string, Promise<Record<string, any>> | Record<string, any> | null>
/** @internal */
_appConfig: AppConfig

View File

@ -3,8 +3,9 @@ import { loadPayload } from '../composables/payload'
import { onNuxtReady } from '../composables/ready'
import { useRouter } from '../composables/router'
import { getAppManifest } from '../composables/manifest'
// @ts-expect-error virtual file
import { appManifest as isAppManifestEnabled } from '#build/nuxt.config.mjs'
import { appManifest as isAppManifestEnabled, purgeCachedData } from '#build/nuxt.config.mjs'
export default defineNuxtPlugin({
name: 'nuxt:payload',
@ -13,11 +14,22 @@ export default defineNuxtPlugin({
if (import.meta.dev) { return }
// Load payload after middleware & once final route is resolved
const staticKeysToRemove = new Set<string>()
useRouter().beforeResolve(async (to, from) => {
if (to.path === from.path) { return }
const payload = await loadPayload(to.path)
if (!payload) { return }
Object.assign(nuxtApp.static.data, payload.data)
for (const key of staticKeysToRemove) {
if (purgeCachedData) {
delete nuxtApp.static.data[key]
}
}
for (const key in payload.data) {
if (!(key in nuxtApp.static.data)) {
staticKeysToRemove.add(key)
}
nuxtApp.static.data[key] = payload.data[key]
}
})
onNuxtReady(() => {
@ -25,7 +37,8 @@ export default defineNuxtPlugin({
nuxtApp.hooks.hook('link:prefetch', async (url) => {
const { hostname } = new URL(url, window.location.href)
if (hostname === window.location.hostname) {
await loadPayload(url)
// TODO: use preloadPayload instead once we can support preloading islands too
await loadPayload(url).catch(() => { console.warn('[nuxt] Error preloading payload for', url) })
}
})
if (isAppManifestEnabled && navigator.connection?.effectiveType !== 'slow-2g') {

View File

@ -562,6 +562,7 @@ export const nuxtConfigTemplate: NuxtTemplate = {
`export const chunkErrorEvent = ${ctx.nuxt.options.experimental.emitRouteChunkError ? ctx.nuxt.options.builder === '@nuxt/vite-builder' ? '"vite:preloadError"' : '"nuxt:preloadError"' : 'false'}`,
`export const crawlLinks = ${!!((ctx.nuxt as any)._nitro as Nitro).options.prerender.crawlLinks}`,
`export const spaLoadingTemplateOutside = ${ctx.nuxt.options.experimental.spaLoadingTemplateLocation === 'body'}`,
`export const purgeCachedData = ${!!ctx.nuxt.options.experimental.purgeCachedData}`,
].join('\n\n')
},
}

View File

@ -723,5 +723,30 @@ export default defineResolvers({
* @see [PR #31175](https://github.com/nuxt/nuxt/pull/31175)
*/
templateImportResolution: true,
/**
* Whether to clean up Nuxt static and asyncData caches on route navigation.
*
* Nuxt will automatically purge cached data from `useAsyncData` and `nuxtApp.static.data`. This helps prevent memory leaks
* and ensures fresh data is loaded when needed, but it is possible to disable it.
*
* @example
* ```ts
* // nuxt.config.ts
* export default defineNuxtConfig({
* experimental: {
* // Disable automatic cache cleanup (default is true)
* purgeCachedData: false
* }
* })
* ```
*
* @see [PR #31379](https://github.com/nuxt/nuxt/pull/31379)
*/
purgeCachedData: {
$resolve: (val) => {
return typeof val === 'boolean' ? val : true
},
},
},
})

View File

@ -2079,13 +2079,14 @@ describe.skipIf(isDev() || isWindows || !isRenderingJson)('prefetching', () => {
await gotoPath(page, '/prefetch')
await page.waitForLoadState('networkidle')
const snapshot = [...requests]
expect(requests.some(req => req.startsWith('/__nuxt_island/AsyncServerComponent'))).toBe(true)
requests.length = 0
await page.click('[href="/prefetch/server-components"]')
await page.waitForLoadState('networkidle')
expect(await page.innerHTML('#async-server-component-count')).toBe('34')
expect(requests).toEqual(snapshot)
expect(requests.some(req => req.startsWith('/__nuxt_island/AsyncServerComponent'))).toBe(false)
await page.close()
})