diff --git a/packages/plugin-vue/__tests__/ssr-then-client-cache.spec.ts b/packages/plugin-vue/__tests__/ssr-then-client-cache.spec.ts
new file mode 100644
index 00000000..a13e9ef3
--- /dev/null
+++ b/packages/plugin-vue/__tests__/ssr-then-client-cache.spec.ts
@@ -0,0 +1,99 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { ResolvedOptions } from '../src/index'
+import { resolveCompiler } from '../src/compiler'
+import { transformMain } from '../src/main'
+import { resolveScript } from '../src/script'
+import { getDescriptor } from '../src/utils/descriptorCache'
+
+const compiler = resolveCompiler(process.cwd())
+
+function createOptions(): ResolvedOptions {
+ return {
+ root: '/root',
+ isProduction: false,
+ sourceMap: false,
+ cssDevSourcemap: false,
+ compiler,
+ } as ResolvedOptions
+}
+
+function createPluginContext() {
+ return {
+ warn: vi.fn(),
+ error: vi.fn((error: unknown) => {
+ throw error
+ }),
+ } as any
+}
+
+describe('ssr-then-client descriptor cache', () => {
+ // This simulates the flow that poisons client transforms in Vitest when a
+ // workspace mixes ssr (jsdom) and browser projects:
+ // 1. Main SSR transform of Component.vue populates the descriptor cache
+ // and runs `compileScript` with `templateOptions.ssr = true`, mutating
+ // the cached descriptor with ssr-specific state.
+ // 2. A subsequent sub-block transform (triggered by the client pass, e.g.
+ // `Component.vue?vue&type=template`) looks the descriptor up via
+ // `getDescriptor` — without the fix this returned the same mutated
+ // descriptor, so `resolveScript(desc, ssr=false)` then ran
+ // `compileScript` on a descriptor already tainted with ssr state and
+ // emitted a setup whose `__returned__` dropped template-only imports.
+ it('keeps ssr and client descriptor state separate', async () => {
+ const filename = '/root/Component.vue'
+ const source = [
+ '',
+ '',
+ ].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 87f081e7..8bf732b9 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 692e7158..cfc54d60 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