-
-
Notifications
You must be signed in to change notification settings - Fork 478
perf: cache rendered payloads #1643
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
5f12b83
98aec51
be60996
4cc1bc8
1c332ef
cb9b498
50ef48e
847ac00
ef31b98
c6164f8
e116e2c
a9b656a
4ff1fe6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| import { stringify } from 'devalue' | ||
|
|
||
| /** | ||
| * Nuxt server plugin that serializes the payload after SSR rendering | ||
| * and stashes it on the request event context. | ||
| * | ||
| * This allows the Nitro payload-cache plugin to cache the payload | ||
| * when rendering HTML pages, so that subsequent _payload.json requests | ||
| * for the same route can be served from cache without a full re-render. | ||
| * | ||
| * This mirrors what Nuxt does during pre-rendering (via `payloadCache`), | ||
| * but extends it to runtime for ISR-enabled routes. | ||
| */ | ||
| export default defineNuxtPlugin({ | ||
| name: 'payload-cache', | ||
| setup(nuxtApp) { | ||
| // Only run on the server during SSR | ||
| if (import.meta.client) return | ||
|
|
||
| nuxtApp.hooks.hook('app:rendered', () => { | ||
| const ssrContext = nuxtApp.ssrContext | ||
| if (!ssrContext) return | ||
|
|
||
| // Don't cache error responses or noSSR renders | ||
| if (ssrContext.noSSR || ssrContext.error || ssrContext.payload?.error) return | ||
|
|
||
| // Don't cache if payload data is empty | ||
| const payloadData = ssrContext.payload?.data | ||
| if (!payloadData || Object.keys(payloadData).length === 0) return | ||
|
|
||
| try { | ||
| // Serialize the payload using devalue (same as Nuxt's renderPayloadResponse) | ||
| // splitPayload extracts only { data, prerenderedAt } for the external payload | ||
| const payload = { | ||
| data: ssrContext.payload.data, | ||
| prerenderedAt: ssrContext.payload.prerenderedAt, | ||
| } | ||
| const reducers = ssrContext['~payloadReducers'] ?? {} | ||
| const body = stringify(payload, reducers) | ||
|
|
||
| // Stash the serialized payload on the event context | ||
| // The Nitro payload-cache plugin will pick this up in render:response | ||
| const event = ssrContext.event | ||
| if (event) { | ||
| event.context._cachedPayloadResponse = { | ||
| body, | ||
| statusCode: 200, | ||
| headers: { | ||
| 'content-type': 'application/json;charset=utf-8', | ||
| 'x-powered-by': 'Nuxt', | ||
| }, | ||
| } | ||
| } | ||
| } catch (error) { | ||
| // Serialization failed — don't cache, but don't break the render | ||
| if (import.meta.dev) { | ||
| // eslint-disable-next-line no-console | ||
| console.warn('[payload-cache] Failed to serialize payload:', error) | ||
| } | ||
| } | ||
| }) | ||
| }, | ||
| }) | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,177 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import type { H3Event } from 'h3' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Runtime payload cache for ISR-enabled routes. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Mirrors Nuxt's pre-render `payloadCache` behavior at runtime: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * - When an HTML page is rendered, the payload is cached (serialized by the | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Nuxt app plugin `payload-cache.server.ts` and stashed on event.context) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * - When a `_payload.json` request arrives, the cache is checked first. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * If a cached payload exists, it's served immediately — completely skipping | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
alexdln marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * the full Vue SSR render. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * This eliminates redundant full SSR renders for payload requests when the | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * same route was already rendered as HTML (or as a payload) recently. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const PAYLOAD_URL_RE = /^[^?]*\/_payload\.json(?:\?.*)?$/ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const PAYLOAD_CACHE_STORAGE_KEY = 'payload-cache' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** Default TTL for cached payloads (seconds). Matches ISR expiration for package routes. */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const PAYLOAD_CACHE_TTL = 60 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| interface CachedPayload { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| body: string | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| statusCode: number | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| headers: Record<string, string> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cachedAt: number | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| buildId: string | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export default defineNitroPlugin(nitroApp => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const storage = useStorage(PAYLOAD_CACHE_STORAGE_KEY) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const buildId = useRuntimeConfig().app.buildId as string | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Get the route path from a _payload.json URL. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * e.g. "/package/vue/v/3.4.0/_payload.json?abc123" → "/package/vue/v/3.4.0" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function getRouteFromPayloadUrl(url: string): string { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const withoutQuery = url.replace(/\?.*$/, '') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return withoutQuery.substring(0, withoutQuery.lastIndexOf('/')) || '/' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Generate a cache key for a route path. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Includes the build ID to prevent serving stale payloads after deploys. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function getCacheKey(routePath: string): string { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return `${buildId}:${routePath}` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Check if a route has ISR or cache rules enabled. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function isISRRoute(event: H3Event): boolean { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const rules = getRouteRules(event) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return !!(rules.isr || rules.cache) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // ------------------------------------------------------------------------- | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // render:before — Serve cached payloads, skip full SSR render | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // ------------------------------------------------------------------------- | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| nitroApp.hooks.hook('render:before', async ctx => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Only intercept _payload.json requests | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!PAYLOAD_URL_RE.test(ctx.event.path)) return | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const routePath = getRouteFromPayloadUrl(ctx.event.path) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const cacheKey = getCacheKey(routePath) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const cached = await storage.getItem<CachedPayload>(cacheKey) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!cached) return | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Verify build ID matches (extra safety beyond cache key) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (cached.buildId !== buildId) return | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Check TTL | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const age = (Date.now() - cached.cachedAt) / 1000 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (age > PAYLOAD_CACHE_TTL) return | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+86
to
+88
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Evict expired payload entries to avoid unbounded cache growth. Line 87 skips stale entries but never deletes them. Over time this can leave old keys accumulating in storage indefinitely. 🧹 Suggested fix const age = (Date.now() - cached.cachedAt) / 1000
- if (age > PAYLOAD_CACHE_STALE_TTL) return
+ if (age > PAYLOAD_CACHE_STALE_TTL) {
+ ctx.event.waitUntil(storage.removeItem(cacheKey).catch(() => {}))
+ return
+ }Also applies to: 149-169 |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (import.meta.dev) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // eslint-disable-next-line no-console | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.log(`[payload-cache] HIT: ${routePath} (age: ${age.toFixed(1)}s)`) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Set the response — this completely skips the Nuxt render function | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ctx.response = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| body: cached.body, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| statusCode: cached.statusCode, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| statusMessage: 'OK', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| headers: cached.headers, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
alexdln marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Cache read failed — let the render proceed normally | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (import.meta.dev) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // eslint-disable-next-line no-console | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.warn(`[payload-cache] Cache read failed for ${routePath}:`, error) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // ------------------------------------------------------------------------- | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // render:response — Cache payloads after rendering | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // ------------------------------------------------------------------------- | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| nitroApp.hooks.hook('render:response', (response, ctx) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Don't cache error responses | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (response.statusCode && response.statusCode >= 400) return | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
danielroe marked this conversation as resolved.
Outdated
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const isPayloadRequest = PAYLOAD_URL_RE.test(ctx.event.path) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const isHtmlResponse = response.headers?.['content-type']?.includes('text/html') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (isPayloadRequest) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // This was a _payload.json render — cache the response body directly | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const routePath = getRouteFromPayloadUrl(ctx.event.path) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cachePayload(ctx.event, routePath, { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| body: response.body as string, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| statusCode: response.statusCode ?? 200, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| headers: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'content-type': 'application/json;charset=utf-8', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'x-powered-by': 'Nuxt', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else if (isHtmlResponse && isISRRoute(ctx.event)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+120
to
+132
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Apply the same cacheability gate to payload-response writes. The 🔒 Suggested fix if (isPayloadRequest) {
+ if (!isISRRoute(ctx.event)) return
// This was a _payload.json render — cache the response body directly
if (typeof response.body !== 'string') return
const routePath = getRouteFromPayloadUrl(ctx.event.path)
cachePayload(ctx.event, routePath, {📝 Committable suggestion
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // This was an HTML render for an ISR route — check if the Nuxt plugin | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // stashed a serialized payload on the event context | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const cachedPayload = ctx.event.context._cachedPayloadResponse | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (cachedPayload) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const routePath = ctx.event.path === '/' ? '/' : ctx.event.path.replace(/\/$/, '') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cachePayload(ctx.event, routePath, cachedPayload) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Clean up the stashed payload | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| delete ctx.event.context._cachedPayloadResponse | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
alexdln marked this conversation as resolved.
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Write a payload to the cache in the background (non-blocking). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function cachePayload( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| event: H3Event, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| routePath: string, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| payload: { body: string; statusCode: number; headers: Record<string, string> }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const cacheKey = getCacheKey(routePath) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const entry: CachedPayload = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ...payload, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cachedAt: Date.now(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| buildId, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Use waitUntil for non-blocking cache writes in serverless environments | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| event.waitUntil( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| storage.setItem(cacheKey, entry).catch(error => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (import.meta.dev) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // eslint-disable-next-line no-console | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.warn(`[payload-cache] Cache write failed for ${routePath}:`, error) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (import.meta.dev) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // eslint-disable-next-line no-console | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.log(`[payload-cache] CACHED: ${routePath}`) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Extend the H3EventContext type | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| declare module 'h3' { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| interface H3EventContext { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _cachedPayloadResponse?: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| body: string | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| statusCode: number | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| headers: Record<string, string> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid caching placeholder payloads that are effectively empty.
Line 29 only checks top-level key count. Payload shapes like placeholder entries (for example, fallback-style empty records wrapped in arrays) can still pass and get cached as valid data, which risks stale empty payload reuse and hydration inconsistencies. Please harden this emptiness check before caching.
Suggested patch
// Don't cache if payload data is empty const payloadData = ssrContext.payload?.data - if (!payloadData || Object.keys(payloadData).length === 0) return + const isPlaceholderValue = (value: unknown) => + Array.isArray(value) + && value.length === 1 + && typeof value[0] === 'object' + && value[0] !== null + && Object.keys(value[0] as Record<string, unknown>).length === 0 + + const isEmptyPayload = + !payloadData + || Object.keys(payloadData).length === 0 + || Object.values(payloadData).every((value) => value == null || isPlaceholderValue(value)) + + if (isEmptyPayload) return