From 8727e40c59b07b26dc364d315e1a6a99a2b4434c Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 17 Mar 2025 11:18:46 +0000 Subject: [PATCH] perf(nuxt): use browser cache for payloads (#31379) --- .../1.experimental-features.md | 17 ++++++ packages/nuxt/src/app/composables/payload.ts | 56 +++++++++++-------- packages/nuxt/src/app/nuxt.ts | 2 - .../nuxt/src/app/plugins/payload.client.ts | 19 ++++++- packages/nuxt/src/core/templates.ts | 1 + packages/schema/src/config/experimental.ts | 25 +++++++++ test/basic.test.ts | 5 +- 7 files changed, 94 insertions(+), 31 deletions(-) diff --git a/docs/2.guide/3.going-further/1.experimental-features.md b/docs/2.guide/3.going-further/1.experimental-features.md index e0135f1310..7fb984b799 100644 --- a/docs/2.guide/3.going-further/1.experimental-features.md +++ b/docs/2.guide/3.going-further/1.experimental-features.md @@ -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. +:: diff --git a/packages/nuxt/src/app/composables/payload.ts b/packages/nuxt/src/app/composables/payload.ts index d16f10e4cd..cbdf3f36ac 100644 --- a/packages/nuxt/src/app/composables/payload.ts +++ b/packages/nuxt/src/app/composables/payload.ts @@ -19,35 +19,43 @@ interface LoadPayloadOptions { /** @since 3.0.0 */ export async function loadPayload (url: string, opts: LoadPayloadOptions = {}): Promise | 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 { 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) { + linkEl[key === 'crossorigin' ? 'crossOrigin' : key] = link[key]! + } + document.head.appendChild(linkEl) + return new Promise((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 { diff --git a/packages/nuxt/src/app/nuxt.ts b/packages/nuxt/src/app/nuxt.ts index 6f441d0b94..64fbaea88e 100644 --- a/packages/nuxt/src/app/nuxt.ts +++ b/packages/nuxt/src/app/nuxt.ts @@ -154,8 +154,6 @@ interface _NuxtApp { /** @internal */ _observer?: { observe: (element: Element, callback: () => void) => () => void } - /** @internal */ - _payloadCache?: Record> | Record | null> /** @internal */ _appConfig: AppConfig diff --git a/packages/nuxt/src/app/plugins/payload.client.ts b/packages/nuxt/src/app/plugins/payload.client.ts index 48486c1a3e..0b5459adb2 100644 --- a/packages/nuxt/src/app/plugins/payload.client.ts +++ b/packages/nuxt/src/app/plugins/payload.client.ts @@ -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() 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') { diff --git a/packages/nuxt/src/core/templates.ts b/packages/nuxt/src/core/templates.ts index ec174960b8..8be35ab070 100644 --- a/packages/nuxt/src/core/templates.ts +++ b/packages/nuxt/src/core/templates.ts @@ -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') }, } diff --git a/packages/schema/src/config/experimental.ts b/packages/schema/src/config/experimental.ts index 629f5ac7c1..5354993dd2 100644 --- a/packages/schema/src/config/experimental.ts +++ b/packages/schema/src/config/experimental.ts @@ -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 + }, + }, }, }) diff --git a/test/basic.test.ts b/test/basic.test.ts index 3652626ec1..cdb7226599 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -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() })