Skip to content

perf: cache rendered payloads#1643

Merged
danielroe merged 13 commits intomainfrom
perf/payload-cache
Feb 25, 2026
Merged

perf: cache rendered payloads#1643
danielroe merged 13 commits intomainfrom
perf/payload-cache

Conversation

@danielroe
Copy link
Member

🔗 Linked issue

resolves #1194
resolves #1534
resolves #1558

🧭 Context

📚 Description

this aims to implement a payload cache so we don't have to render 2x - once for HTML and once for JSON.

@vercel
Copy link

vercel bot commented Feb 25, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
npmx.dev Ready Ready Preview, Comment Feb 25, 2026 4:35pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Feb 25, 2026 4:35pm
npmx-lunaria Ignored Ignored Feb 25, 2026 4:35pm

Request Review

@codecov
Copy link

codecov bot commented Feb 25, 2026

Codecov Report

❌ Patch coverage is 8.33333% with 33 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
app/pages/package/[[org]]/[name].vue 0.00% 24 Missing and 5 partials ⚠️
app/plugins/fix.client.ts 50.00% 3 Missing ⚠️
app/composables/npm/useResolvedVersion.ts 0.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 25, 2026

📝 Walkthrough

Walkthrough

Adds runtime payload caching and hydration-aware handling for ISR-enabled routes. Introduces a server Nitro plugin that serves and caches serialized _payload.json responses, a server plugin that serializes SSR payloads onto the request event for later reuse, a client plugin to initialise payload data and trigger a refetch after suspense hydration, a new Nitro storage entry for payload-cache, changes to package page rendering and useResolvedVersion to expose status and handle SSR/SPA hydration states, adjusts ISR fallback payload shape, and adds devalue for payload serialization.

Possibly related PRs

  • npmx-dev/npmx.dev PR 1604 — Modifies the same composable and package page, changing resolvedVersion/status handling and SSR 404/hydration logic.
  • npmx-dev/npmx.dev PR 878 — Touches the same client hydration plugin (app/plugins/fix.client.ts) to initialise payload data and manage hydration refresh.
  • npmx-dev/npmx.dev PR 933 — Adjusts version-resolution and package-fetch call sites, aligning useResolvedVersion/usePackage usage with this change.
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The PR description clearly relates to the changeset by explaining the payload caching implementation to avoid double rendering.
Linked Issues check ✅ Passed The changes comprehensively address all three linked issues through payload caching, hydration fixes, and improved version resolution handling.
Out of Scope Changes check ✅ Passed All code changes are directly aligned with the stated objectives of implementing payload caching and resolving the linked issues.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch perf/payload-cache

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (4)
app/pages/package/[[org]]/[name].vue (2)

256-272: The document.querySelector('article') selector is fragile.

Line 270 relies on finding the first <article> element in the DOM. If the page layout changes (e.g., another <article> is added above the package content, or a parent component wraps content in <article>), this would capture the wrong HTML. Consider using a more specific selector, such as a data attribute or an ID.

Suggested improvement
-    ? (document.querySelector('article')?.innerHTML ?? null)
+    ? (document.getElementById('package-article')?.innerHTML ?? null)

And add id="package-article" to the <article> element on line 745:

-    <article v-else-if="status === 'success' && pkg" :class="$style.packagePage">
+    <article v-else-if="status === 'success' && pkg" id="package-article" :class="$style.packagePage">

737-743: Rendering captured server HTML via v-html — acceptable here but worth a note.

The serverRenderedHtml originates from the server's own rendered DOM captured before hydration, so it's not a user-controlled XSS vector. However, a brief inline comment explaining this provenance would help future maintainers understand why raw HTML injection is safe in this specific case.

server/plugins/payload-cache.ts (2)

120-130: response.body as string — unchecked type assertion.

Line 124 asserts response.body is a string. In Nitro's render pipeline, payload JSON responses should indeed be strings, but if a custom middleware or hook modifies the body to a different type (e.g., Buffer, ReadableStream), this would silently store the wrong representation.

A runtime guard would be more defensive:

Suggested guard
     if (isPayloadRequest) {
       // This was a _payload.json render — cache the response body directly
       const routePath = getRouteFromPayloadUrl(ctx.event.path)
+      if (typeof response.body !== 'string') return
       cachePayload(ctx.event, routePath, {
-        body: response.body as string,
+        body: response.body,
         statusCode: response.statusCode ?? 200,

159-167: Dev log at line 171 fires before the async write completes.

The console.log('[payload-cache] CACHED: ...') on line 171 executes synchronously, before the storage.setItem promise (inside waitUntil) resolves. If the write fails, the log is misleading. Consider moving the success log inside the .then() or accepting the slight inaccuracy for dev-only logging.


ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 26d967e and c6164f8.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (9)
  • app/composables/npm/useResolvedVersion.ts
  • app/pages/package/[[org]]/[name].vue
  • app/plugins/fix.client.ts
  • app/plugins/payload-cache.server.ts
  • modules/cache.ts
  • modules/isr-fallback.ts
  • nuxt.config.ts
  • package.json
  • server/plugins/payload-cache.ts

Comment on lines +27 to +30
// Don't cache if payload data is empty
const payloadData = ssrContext.payload?.data
if (!payloadData || Object.keys(payloadData).length === 0) return

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3


ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c6164f8 and a9b656a.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (2)
  • app/pages/package/[[org]]/[name].vue
  • package.json
🚧 Files skipped from review as they are similar to previous changes (1)
  • package.json

Comment on lines +230 to +242
watch(
[resolvedStatus, resolvedVersion],
([status, version]) => {
if ((!version && status === 'success') || status === 'error') {
showError({
statusCode: 404,
statusMessage: $t('package.not_found'),
message: $t('package.not_found_message'),
})
}
},
{ immediate: true },
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

watch with immediate: true may call showError during SSR.

On the server, the throw createError guard at lines 218–228 only fires when resolvedStatus is already 'success' or 'error'. If resolvedStatus is still 'pending' at that point, the guard is skipped and the watch is registered. With immediate: true, it fires synchronously during setup — on the server context as well as the client. If, by the time the immediate callback runs, resolvedStatus is already 'error' (e.g., if useResolvedVersion resolves synchronously to an error on the server), showError is called from the server context instead of throw createError, which produces a different error-response shape (soft error page vs. HTTP 404).

Consider guarding the watch so it only runs on the client:

🛡️ Proposed fix
-watch(
-  [resolvedStatus, resolvedVersion],
-  ([status, version]) => {
-    if ((!version && status === 'success') || status === 'error') {
-      showError({
-        statusCode: 404,
-        statusMessage: $t('package.not_found'),
-        message: $t('package.not_found_message'),
-      })
-    }
-  },
-  { immediate: true },
-)
+if (import.meta.client) {
+  watch(
+    [resolvedStatus, resolvedVersion],
+    ([status, version]) => {
+      if ((!version && status === 'success') || status === 'error') {
+        showError({
+          statusCode: 404,
+          statusMessage: $t('package.not_found'),
+          message: $t('package.not_found_message'),
+        })
+      }
+    },
+    { immediate: true },
+  )
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
watch(
[resolvedStatus, resolvedVersion],
([status, version]) => {
if ((!version && status === 'success') || status === 'error') {
showError({
statusCode: 404,
statusMessage: $t('package.not_found'),
message: $t('package.not_found_message'),
})
}
},
{ immediate: true },
)
if (import.meta.client) {
watch(
[resolvedStatus, resolvedVersion],
([status, version]) => {
if ((!version && status === 'success') || status === 'error') {
showError({
statusCode: 404,
statusMessage: $t('package.not_found'),
message: $t('package.not_found_message'),
})
}
},
{ immediate: true },
)
}

Comment on lines 743 to 747
<article
v-else-if="isHydratingWithServerContent && serverRenderedHtml"
:class="$style.packagePage"
v-html="serverRenderedHtml"
/>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

v-html binding missing the eslint-disable comment used elsewhere in the file.

Line 1374 uses <!-- eslint-disable vue/no-v-html -- HTML is sanitized server-side --> before the v-html binding on the README, establishing a project convention for documenting why raw HTML is trusted. The new v-html on the hydration snapshot article at line 746 doesn't follow this pattern. Even though the source is our own server-rendered markup, the missing annotation leaves the intent undocumented and will produce a lint warning.

💅 Proposed fix
+    <!-- eslint-disable-next-line vue/no-v-html -- snapshot of server-rendered HTML, not user-supplied content -->
     <article
       v-else-if="isHydratingWithServerContent && serverRenderedHtml"
       :class="$style.packagePage"
       v-html="serverRenderedHtml"
     />

@danielroe danielroe merged commit 40d1964 into main Feb 25, 2026
21 checks passed
@danielroe danielroe deleted the perf/payload-cache branch February 25, 2026 16:39
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
app/pages/package/[[org]]/[name].vue (1)

218-227: Extract the repeated 404 payload into one helper.

The not-found payload is duplicated in two places, which makes future edits easy to drift.

♻️ Suggested refactor
+function packageNotFoundError() {
+  return {
+    statusCode: 404,
+    statusMessage: $t('package.not_found'),
+    message: $t('package.not_found_message'),
+  }
+}
+
 if (
   import.meta.server &&
   !resolvedVersion.value &&
   ['success', 'error'].includes(resolvedStatus.value)
 ) {
-  throw createError({
-    statusCode: 404,
-    statusMessage: $t('package.not_found'),
-    message: $t('package.not_found_message'),
-  })
+  throw createError(packageNotFoundError())
 }
 
 watch(
   [resolvedStatus, resolvedVersion],
   ([status, version]) => {
     if ((!version && status === 'success') || status === 'error') {
-      showError({
-        statusCode: 404,
-        statusMessage: $t('package.not_found'),
-        message: $t('package.not_found_message'),
-      })
+      showError(packageNotFoundError())
     }
   },
   { immediate: true },
 )

Also applies to: 233-238


ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a9b656a and 4ff1fe6.

📒 Files selected for processing (2)
  • app/pages/package/[[org]]/[name].vue
  • server/plugins/payload-cache.ts

Comment on lines +265 to +276
const isSpaFallback = shallowRef(hasEmptyPayload && !nuxtApp.payload.path)
const isHydratingWithServerContent = shallowRef(
hasEmptyPayload && nuxtApp.payload.path === route.path,
)
// When we have server-rendered content but no payload data, capture the server
// DOM before Vue's hydration replaces it. This lets us show the server-rendered
// HTML as a static snapshot while data refetches, avoiding any visual flash.
const serverRenderedHtml = shallowRef<string | null>(
isHydratingWithServerContent.value
? (document.getElementById('package-article')?.innerHTML ?? null)
: null,
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add a hard fallback when snapshot capture is unavailable.

Line 737 suppresses the skeleton while isHydratingWithServerContent is true, but Line 746 also requires serverRenderedHtml. If Line 274 cannot find #package-article, the page can render a blank main area until async data resolves.

💡 Suggested fix
 const serverRenderedHtml = shallowRef<string | null>(
   isHydratingWithServerContent.value
     ? (document.getElementById('package-article')?.innerHTML ?? null)
     : null,
 )
+
+if (isHydratingWithServerContent.value && !serverRenderedHtml.value) {
+  isHydratingWithServerContent.value = false
+  isSpaFallback.value = true
+}

Also applies to: 736-750

Comment on lines +86 to +88
const age = (Date.now() - cached.cachedAt) / 1000
if (age > PAYLOAD_CACHE_STALE_TTL) return

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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

Comment on lines +120 to +132
if (isPayloadRequest) {
// 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, {
body: response.body,
statusCode: response.statusCode ?? 200,
headers: {
'content-type': 'application/json;charset=utf-8',
'x-powered-by': 'Nuxt',
},
})
} else if (isHtmlResponse && isISRRoute(ctx.event)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Apply the same cacheability gate to payload-response writes.

The _payload.json branch caches every successful payload response, but the HTML branch only caches when isISRRoute(ctx.event) is true. This inconsistency can cache routes that were not intended to be cache-backed.

🔒 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (isPayloadRequest) {
// 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, {
body: response.body,
statusCode: response.statusCode ?? 200,
headers: {
'content-type': 'application/json;charset=utf-8',
'x-powered-by': 'Nuxt',
},
})
} else if (isHtmlResponse && isISRRoute(ctx.event)) {
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, {
body: response.body,
statusCode: response.statusCode ?? 200,
headers: {
'content-type': 'application/json;charset=utf-8',
'x-powered-by': 'Nuxt',
},
})
} else if (isHtmlResponse && isISRRoute(ctx.event)) {

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hydration mismatch on package pages Linking to specific version of package returns 404 Repeated reloadings shortly shows error page

2 participants