From efedc86f7a2ed1910be43d9607a2dd93a1a7e388 Mon Sep 17 00:00:00 2001
From: Sean O'Grady <1761115+seanogdev@users.noreply.github.com>
Date: Mon, 13 Apr 2026 10:56:01 +0100
Subject: [PATCH] fix(plugin-vue): isolate ssr and client descriptor state
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The descriptor cache was keyed by filename only. `compileScript`
mutates the descriptor (and its script/scriptSetup blocks) with
ssr-specific compiled state, so sharing the same descriptor object
between an ssr pass and a subsequent client pass poisoned the client
output — most visibly, `',
+ '',
+ ].join('\n')
+ const options = createOptions()
+
+ // Step 1: main SSR transform caches and mutates an ssr descriptor.
+ await transformMain(
+ source,
+ filename,
+ options,
+ createPluginContext(),
+ /* ssr */ true,
+ /* customElement */ false,
+ )
+
+ // Step 2: client flow resolves the cached descriptor — before the fix this
+ // returned the SSR-poisoned descriptor, shared between both modes.
+ const clientDescriptor = getDescriptor(
+ filename,
+ options,
+ /* createIfNotFound */ true,
+ /* hmr */ false,
+ source,
+ /* ssr */ false,
+ )!
+ const ssrDescriptor = getDescriptor(
+ filename,
+ options,
+ /* createIfNotFound */ false,
+ /* hmr */ false,
+ undefined,
+ /* ssr */ true,
+ )
+
+ expect(ssrDescriptor).toBeDefined()
+ // The descriptors must not be the same object, otherwise a subsequent
+ // `resolveScript` call runs `compileScript` on an ssr-tainted descriptor.
+ expect(clientDescriptor).not.toBe(ssrDescriptor)
+
+ // Step 3: resolving the client script on the client descriptor must
+ // produce output that retains the template-only import binding.
+ const clientScript = resolveScript(
+ clientDescriptor,
+ options,
+ /* ssr */ false,
+ /* customElement */ false,
+ )!
+ expect(clientScript).toBeTruthy()
+ expect(clientScript.bindings).toMatchObject({ Child: expect.any(String) })
+ // The compiled script keeps `Child` as a setup-referenceable binding so
+ // the render function can resolve it.
+ expect(clientScript.content).toMatch(/\bChild\b/)
+ })
+})
diff --git a/packages/plugin-vue/src/handleHotUpdate.ts b/packages/plugin-vue/src/handleHotUpdate.ts
index 87f081e71..8bf732b91 100644
--- a/packages/plugin-vue/src/handleHotUpdate.ts
+++ b/packages/plugin-vue/src/handleHotUpdate.ts
@@ -6,10 +6,10 @@ import { isCSSRequest } from 'vite'
import type * as t from '@babel/types'
import {
- cache,
createDescriptor,
getDescriptor,
invalidateDescriptor,
+ setCachedDescriptor,
} from './utils/descriptorCache'
import {
getResolvedScript,
@@ -159,8 +159,11 @@ export async function handleHotUpdate(
if (updateType.length) {
if (file.endsWith('.vue')) {
// invalidate the descriptor cache so that the next transform will
- // re-analyze the file and pick up the changes.
- invalidateDescriptor(file)
+ // re-analyze the file and pick up the changes. Clear both the client
+ // and ssr-keyed entries so a subsequent transform (in either mode)
+ // picks up the new source.
+ invalidateDescriptor(file, false, false)
+ invalidateDescriptor(file, false, true)
} else {
// https://github.com/vuejs/vitepress/issues/3129
// For non-vue files, e.g. .md files in VitePress, invalidating the
@@ -169,7 +172,9 @@ export async function handleHotUpdate(
// To fix that we need to provide the descriptor we parsed here in the
// main cache. This assumes no other plugin is applying pre-transform to
// the file type - not impossible, but should be extremely unlikely.
- cache.set(file, descriptor)
+ // HMR is client-only, so we store the fresh descriptor under the
+ // client key.
+ setCachedDescriptor(file, descriptor, false)
}
debug(`[vue:update(${updateType.join('&')})] ${file}`)
}
diff --git a/packages/plugin-vue/src/index.ts b/packages/plugin-vue/src/index.ts
index 692e71582..cfc54d60c 100644
--- a/packages/plugin-vue/src/index.ts
+++ b/packages/plugin-vue/src/index.ts
@@ -433,7 +433,14 @@ export default function vuePlugin(rawOptions: Options = {}): Plugin {
if (query.src) {
return fs.readFileSync(filename, 'utf-8')
}
- const descriptor = getDescriptor(filename, options.value)!
+ const descriptor = getDescriptor(
+ filename,
+ options.value,
+ true,
+ false,
+ undefined,
+ ssr,
+ )!
let block: SFCBlock | null | undefined
if (query.type === 'script') {
// handle