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