Skip to content

Commit 40d1964

Browse files
authored
perf: cache rendered payloads (#1643)
1 parent 4efd7ae commit 40d1964

File tree

10 files changed

+373
-13
lines changed

10 files changed

+373
-13
lines changed

app/composables/npm/useResolvedVersion.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,6 @@ export function useResolvedVersion(
1515
const data = await $fetch<ResolvedPackageVersion>(url)
1616
return data.version
1717
},
18-
{ default: () => null },
18+
{ default: () => undefined },
1919
)
2020
}

app/pages/package/[[org]]/[name].vue

Lines changed: 84 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -210,21 +210,79 @@ const { data: skillsData } = useLazyFetch<SkillsListResponse>(
210210
const { data: packageAnalysis } = usePackageAnalysis(packageName, requestedVersion)
211211
const { data: moduleReplacement } = useModuleReplacement(packageName)
212212
213-
const { data: resolvedVersion } = await useResolvedVersion(packageName, requestedVersion)
213+
const { data: resolvedVersion, status: resolvedStatus } = await useResolvedVersion(
214+
packageName,
215+
requestedVersion,
216+
)
214217
215-
if (resolvedVersion.value === null) {
218+
if (
219+
import.meta.server &&
220+
!resolvedVersion.value &&
221+
['success', 'error'].includes(resolvedStatus.value)
222+
) {
216223
throw createError({
217224
statusCode: 404,
218225
statusMessage: $t('package.not_found'),
219226
message: $t('package.not_found_message'),
220227
})
221228
}
222229
230+
watch(
231+
[resolvedStatus, resolvedVersion],
232+
([status, version]) => {
233+
if ((!version && status === 'success') || status === 'error') {
234+
showError({
235+
statusCode: 404,
236+
statusMessage: $t('package.not_found'),
237+
message: $t('package.not_found_message'),
238+
})
239+
}
240+
},
241+
{ immediate: true },
242+
)
243+
223244
const {
224245
data: pkg,
225246
status,
226247
error,
227248
} = usePackage(packageName, () => resolvedVersion.value ?? requestedVersion.value)
249+
250+
// Detect two hydration scenarios where the external _payload.json is missing:
251+
//
252+
// 1. SPA fallback (200.html): No real content was server-rendered.
253+
// → Show skeleton while data fetches on the client.
254+
//
255+
// 2. SSR-rendered HTML with missing payload: Content was rendered but the external _payload.json
256+
// returned an ISR fallback.
257+
// → Preserve the server-rendered DOM, don't flash to skeleton.
258+
const nuxtApp = useNuxtApp()
259+
const route = useRoute()
260+
const hasEmptyPayload =
261+
import.meta.client &&
262+
nuxtApp.isHydrating &&
263+
nuxtApp.payload.serverRendered &&
264+
!Object.keys(nuxtApp.payload.data ?? {}).length
265+
const isSpaFallback = shallowRef(hasEmptyPayload && !nuxtApp.payload.path)
266+
const isHydratingWithServerContent = shallowRef(
267+
hasEmptyPayload && nuxtApp.payload.path === route.path,
268+
)
269+
// When we have server-rendered content but no payload data, capture the server
270+
// DOM before Vue's hydration replaces it. This lets us show the server-rendered
271+
// HTML as a static snapshot while data refetches, avoiding any visual flash.
272+
const serverRenderedHtml = shallowRef<string | null>(
273+
isHydratingWithServerContent.value
274+
? (document.getElementById('package-article')?.innerHTML ?? null)
275+
: null,
276+
)
277+
278+
if (isSpaFallback.value || isHydratingWithServerContent.value) {
279+
nuxtApp.hooks.hookOnce('app:suspense:resolve', () => {
280+
isSpaFallback.value = false
281+
isHydratingWithServerContent.value = false
282+
serverRenderedHtml.value = null
283+
})
284+
}
285+
228286
const displayVersion = computed(() => pkg.value?.requestedVersion ?? null)
229287
const versionSecurityMetadata = computed<PackageVersionInfo[]>(() => {
230288
if (!pkg.value) return []
@@ -672,9 +730,30 @@ const showSkeleton = shallowRef(false)
672730
</ButtonBase>
673731
</DevOnly>
674732
<main class="container flex-1 w-full py-8">
675-
<PackageSkeleton v-if="showSkeleton || status === 'pending'" />
676-
677-
<article v-else-if="status === 'success' && pkg" :class="$style.packagePage">
733+
<!-- Scenario 1: SPA fallback — show skeleton (no real content to preserve) -->
734+
<!-- Scenario 2: SSR with missing payload — preserve server DOM, skip skeleton -->
735+
<PackageSkeleton
736+
v-if="
737+
isSpaFallback || (!isHydratingWithServerContent && (showSkeleton || status === 'pending'))
738+
"
739+
/>
740+
741+
<!-- During hydration without payload, show captured server HTML as a static snapshot.
742+
This avoids a visual flash: the user sees the server content while data refetches.
743+
v-html is safe here: the content originates from the server's own SSR output,
744+
captured from the DOM before hydration — it is not user-controlled input. -->
745+
<article
746+
v-else-if="isHydratingWithServerContent && serverRenderedHtml"
747+
id="package-article"
748+
:class="$style.packagePage"
749+
v-html="serverRenderedHtml"
750+
/>
751+
752+
<article
753+
v-else-if="status === 'success' && pkg"
754+
id="package-article"
755+
:class="$style.packagePage"
756+
>
678757
<!-- Package header -->
679758
<header
680759
class="sticky top-14 z-1 bg-[--bg] py-2 border-border"

app/plugins/fix.client.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,19 @@ export default defineNuxtPlugin({
33
setup(nuxtApp) {
44
// TODO: investigate why this is needed
55
nuxtApp.payload.data ||= {}
6+
7+
// When a _payload.json returns an ISR fallback (empty payload), data fetching composables
8+
// with non-undefined defaults skip refetching during hydration. After hydration completes,
9+
// refresh all async data so these composables (e.g. README, skills, package analysis) fetch
10+
// fresh data.
11+
if (
12+
nuxtApp.isHydrating &&
13+
nuxtApp.payload.serverRendered &&
14+
!Object.keys(nuxtApp.payload.data).length
15+
) {
16+
nuxtApp.hooks.hookOnce('app:suspense:resolve', () => {
17+
refreshNuxtData()
18+
})
19+
}
620
},
721
})
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { stringify } from 'devalue'
2+
3+
/**
4+
* Nuxt server plugin that serializes the payload after SSR rendering
5+
* and stashes it on the request event context.
6+
*
7+
* This allows the Nitro payload-cache plugin to cache the payload
8+
* when rendering HTML pages, so that subsequent _payload.json requests
9+
* for the same route can be served from cache without a full re-render.
10+
*
11+
* This mirrors what Nuxt does during pre-rendering (via `payloadCache`),
12+
* but extends it to runtime for ISR-enabled routes.
13+
*/
14+
export default defineNuxtPlugin({
15+
name: 'payload-cache',
16+
setup(nuxtApp) {
17+
// Only run on the server during SSR
18+
if (import.meta.client) return
19+
20+
nuxtApp.hooks.hook('app:rendered', () => {
21+
const ssrContext = nuxtApp.ssrContext
22+
if (!ssrContext) return
23+
24+
// Don't cache error responses or noSSR renders
25+
if (ssrContext.noSSR || ssrContext.error || ssrContext.payload?.error) return
26+
27+
// Don't cache if payload data is empty
28+
const payloadData = ssrContext.payload?.data
29+
if (!payloadData || Object.keys(payloadData).length === 0) return
30+
31+
try {
32+
// Serialize the payload using devalue (same as Nuxt's renderPayloadResponse)
33+
// splitPayload extracts only { data, prerenderedAt } for the external payload
34+
const payload = {
35+
data: ssrContext.payload.data,
36+
prerenderedAt: ssrContext.payload.prerenderedAt,
37+
}
38+
const reducers = ssrContext['~payloadReducers'] ?? {}
39+
const body = stringify(payload, reducers)
40+
41+
// Stash the serialized payload on the event context
42+
// The Nitro payload-cache plugin will pick this up in render:response
43+
const event = ssrContext.event
44+
if (event) {
45+
event.context._cachedPayloadResponse = {
46+
body,
47+
statusCode: 200,
48+
headers: {
49+
'content-type': 'application/json;charset=utf-8',
50+
'x-powered-by': 'Nuxt',
51+
},
52+
}
53+
}
54+
} catch (error) {
55+
// Serialization failed — don't cache, but don't break the render
56+
if (import.meta.dev) {
57+
// eslint-disable-next-line no-console
58+
console.warn('[payload-cache] Failed to serialize payload:', error)
59+
}
60+
}
61+
})
62+
},
63+
})

modules/cache.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import { provider } from 'std-env'
55
// Storage key for fetch cache - must match shared/utils/fetch-cache-config.ts
66
const FETCH_CACHE_STORAGE_BASE = 'fetch-cache'
77

8+
// Storage key for payload cache - must match server/plugins/payload-cache.ts
9+
const PAYLOAD_CACHE_STORAGE_KEY = 'payload-cache'
10+
811
export default defineNuxtModule({
912
meta: {
1013
name: 'vercel-cache',
@@ -37,6 +40,12 @@ export default defineNuxtModule({
3740
...nitroConfig.storage[FETCH_CACHE_STORAGE_BASE],
3841
driver: 'vercel-runtime-cache',
3942
}
43+
44+
// Payload cache storage (for runtime payload caching)
45+
nitroConfig.storage[PAYLOAD_CACHE_STORAGE_KEY] = {
46+
...nitroConfig.storage[PAYLOAD_CACHE_STORAGE_KEY],
47+
driver: 'vercel-runtime-cache',
48+
}
4049
}
4150

4251
const env = process.env.VERCEL_ENV

modules/isr-fallback.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export default defineNuxtModule({
3131
const outputPath = resolve(nitro.options.output.serverDir, '..', path, htmlFallback)
3232
mkdirSync(resolve(nitro.options.output.serverDir, '..', path), { recursive: true })
3333
writeFileSync(outputPath, spaTemplate)
34-
writeFileSync(outputPath.replace(htmlFallback, jsonFallback), '{}')
34+
writeFileSync(outputPath.replace(htmlFallback, jsonFallback), '[{}]')
3535
}
3636
})
3737
})

nuxt.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,10 @@ export default defineNuxtConfig({
205205
driver: 'fsLite',
206206
base: './.cache/fetch',
207207
},
208+
'payload-cache': {
209+
driver: 'fsLite',
210+
base: './.cache/payload',
211+
},
208212
'atproto': {
209213
driver: 'fsLite',
210214
base: './.cache/atproto',

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@
126126
"@vue/test-utils": "2.4.6",
127127
"axe-core": "4.11.1",
128128
"defu": "6.1.4",
129+
"devalue": "5.6.3",
129130
"eslint-plugin-regexp": "3.0.0",
130131
"fast-check": "4.5.3",
131132
"h3": "1.15.5",

pnpm-lock.yaml

Lines changed: 9 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)