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