-
-
Notifications
You must be signed in to change notification settings - Fork 279
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 all commits
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 |
|---|---|---|
|
|
@@ -210,21 +210,79 @@ const { data: skillsData } = useLazyFetch<SkillsListResponse>( | |
| const { data: packageAnalysis } = usePackageAnalysis(packageName, requestedVersion) | ||
| const { data: moduleReplacement } = useModuleReplacement(packageName) | ||
|
|
||
| const { data: resolvedVersion } = await useResolvedVersion(packageName, requestedVersion) | ||
| const { data: resolvedVersion, status: resolvedStatus } = await useResolvedVersion( | ||
| packageName, | ||
| requestedVersion, | ||
| ) | ||
|
|
||
| if (resolvedVersion.value === null) { | ||
| 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'), | ||
| }) | ||
| } | ||
|
|
||
| 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 }, | ||
| ) | ||
|
|
||
| const { | ||
| data: pkg, | ||
| status, | ||
| error, | ||
| } = usePackage(packageName, () => resolvedVersion.value ?? requestedVersion.value) | ||
|
|
||
| // Detect two hydration scenarios where the external _payload.json is missing: | ||
| // | ||
| // 1. SPA fallback (200.html): No real content was server-rendered. | ||
| // → Show skeleton while data fetches on the client. | ||
| // | ||
| // 2. SSR-rendered HTML with missing payload: Content was rendered but the external _payload.json | ||
| // returned an ISR fallback. | ||
| // → Preserve the server-rendered DOM, don't flash to skeleton. | ||
| const nuxtApp = useNuxtApp() | ||
| const route = useRoute() | ||
| const hasEmptyPayload = | ||
| import.meta.client && | ||
| nuxtApp.isHydrating && | ||
| nuxtApp.payload.serverRendered && | ||
| !Object.keys(nuxtApp.payload.data ?? {}).length | ||
| 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, | ||
| ) | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
Comment on lines
+265
to
+276
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. Add a hard fallback when snapshot capture is unavailable. Line 737 suppresses the skeleton while 💡 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 |
||
|
|
||
| if (isSpaFallback.value || isHydratingWithServerContent.value) { | ||
| nuxtApp.hooks.hookOnce('app:suspense:resolve', () => { | ||
| isSpaFallback.value = false | ||
| isHydratingWithServerContent.value = false | ||
| serverRenderedHtml.value = null | ||
| }) | ||
| } | ||
|
|
||
| const displayVersion = computed(() => pkg.value?.requestedVersion ?? null) | ||
| const versionSecurityMetadata = computed<PackageVersionInfo[]>(() => { | ||
| if (!pkg.value) return [] | ||
|
|
@@ -672,9 +730,30 @@ const showSkeleton = shallowRef(false) | |
| </ButtonBase> | ||
| </DevOnly> | ||
| <main class="container flex-1 w-full py-8"> | ||
| <PackageSkeleton v-if="showSkeleton || status === 'pending'" /> | ||
|
|
||
| <article v-else-if="status === 'success' && pkg" :class="$style.packagePage"> | ||
| <!-- Scenario 1: SPA fallback — show skeleton (no real content to preserve) --> | ||
| <!-- Scenario 2: SSR with missing payload — preserve server DOM, skip skeleton --> | ||
| <PackageSkeleton | ||
| v-if=" | ||
| isSpaFallback || (!isHydratingWithServerContent && (showSkeleton || status === 'pending')) | ||
| " | ||
| /> | ||
|
|
||
| <!-- During hydration without payload, show captured server HTML as a static snapshot. | ||
| This avoids a visual flash: the user sees the server content while data refetches. | ||
| v-html is safe here: the content originates from the server's own SSR output, | ||
| captured from the DOM before hydration — it is not user-controlled input. --> | ||
| <article | ||
| v-else-if="isHydratingWithServerContent && serverRenderedHtml" | ||
| id="package-article" | ||
| :class="$style.packagePage" | ||
| v-html="serverRenderedHtml" | ||
| /> | ||
|
Comment on lines
745
to
750
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.
Line 1374 uses 💅 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"
/> |
||
|
|
||
| <article | ||
| v-else-if="status === 'success' && pkg" | ||
| id="package-article" | ||
| :class="$style.packagePage" | ||
| > | ||
| <!-- Package header --> | ||
| <header | ||
| class="sticky top-14 z-1 bg-[--bg] py-2 border-border" | ||
|
|
||
| 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 | ||
|
|
||
|
Comment on lines
+27
to
+30
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. 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 |
||
| 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.
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.
watchwithimmediate: truemay callshowErrorduring SSR.On the server, the
throw createErrorguard at lines 218–228 only fires whenresolvedStatusis already'success'or'error'. IfresolvedStatusis still'pending'at that point, the guard is skipped and the watch is registered. Withimmediate: true, it fires synchronously during setup — on the server context as well as the client. If, by the time the immediate callback runs,resolvedStatusis already'error'(e.g., ifuseResolvedVersionresolves synchronously to an error on the server),showErroris called from the server context instead ofthrow 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
📝 Committable suggestion