@@ -210,21 +210,79 @@ const { data: skillsData } = useLazyFetch<SkillsListResponse>(
210210const { data : packageAnalysis } = usePackageAnalysis (packageName , requestedVersion )
211211const { 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+
223244const {
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+
228286const displayVersion = computed (() => pkg .value ?.requestedVersion ?? null )
229287const 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"
0 commit comments