Skip to content

fix(plugin-vue): isolate ssr and client descriptor state (fix #764)#765

Open
seanogdev wants to merge 1 commit into
vitejs:mainfrom
seanogdev:fix/descriptor-cache-ssr-poisoning
Open

fix(plugin-vue): isolate ssr and client descriptor state (fix #764)#765
seanogdev wants to merge 1 commit into
vitejs:mainfrom
seanogdev:fix/descriptor-cache-ssr-poisoning

Conversation

@seanogdev
Copy link
Copy Markdown

@seanogdev seanogdev commented Apr 13, 2026

Description

Fixes the descriptor-cache poisoning described in #764.

packages/plugin-vue/src/utils/descriptorCache.ts keys its descriptor cache by filename only, and vue/compiler-sfc's parseCache returns the same SFCDescriptor object for the same source. compileScript mutates that descriptor with ssr-specific compiled state, so an ssr transform followed by a client transform of the same SFC produces a <script setup> whose __returned__ omits template-only imports. The rendered component then throws $setup.<Component> is not a function.

This PR:

  1. Keys the plugin's descriptor caches (cache, hmrCache) by (filename, ssr) via a composite string key. ssr is threaded through createDescriptor, getDescriptor, invalidateDescriptor, and the load/transform sub-block handlers (which already had ssr in scope from opt.ssr). prevCache (HMR diffing) stays filename-keyed because HMR is client-only.
  2. Clones the descriptor returned from compiler.parse (shallow spread plus shallow clones of script, scriptSetup, template, styles, customBlocks). Step (1) alone is insufficient because parseCache returns the same descriptor for the same source regardless of our cache key — the clone gives each cache entry an object compileScript can safely mutate.
  3. Updates invalidateScript and handleHotUpdate to invalidate both ssr/client entries (new peekCachedDescriptor / setCachedDescriptor helpers).
  4. Leaves ?src= descriptors (setSrcDescriptor / getSrcDescriptor) filename-keyed — they're only consumed by the style transform, never by compileScript.

Adds a regression test __tests__/ssr-then-client-cache.spec.ts that:

  • Simulates the ssr-then-client flow via transformMain + resolveScript
  • Fails without the descriptor clone (verified locally by stripping just the clone and re-running)
  • Passes with the full fix

Additional context

Symptom was first reported as vitest-dev/vitest#9855 because Vitest's related command surfaces it via cross-project SSR dependency analysis, but the bug is plugin-vue's — any ssr→client transform order on the same SFC reproduces it.

Verified downstream by re-running the teamwork.com monorepo's pnpm test related $files (the command that was failing on our branch) against a linked build of this branch: 3 previously-failing storybook tests now pass, 120/120 total — with no changes to the consuming component (the <script setup> const workaround was reverted before the run).


What is the purpose of this pull request?

  • Bug fix
  • New Feature
  • Documentation update
  • Other

Before submitting the PR, please make sure you do the following

  • Read the Contributing Guidelines.
  • Read the Pull Request Guidelines and follow the PR Title Convention.
  • Check that there isn't already a PR that solves the problem the same way to avoid creating a duplicate.
  • Provide a description in this PR that addresses what the PR is solving, or reference the issue that it solves (e.g. fixes #764).
  • Ideally, include relevant tests that fail without this PR but pass with it.

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, `<script setup>` imports that were only
referenced from the template got dropped from `__returned__`, causing
runtime `$setup.<Component> is not a function` errors.

Two fixes are needed because there are two layers of sharing:

1. Key our own descriptor cache by `(filename, ssr)` so the main and
   sub-block transforms look up distinct entries for each mode. The
   `prevCache` (HMR diffing only) stays filename-keyed.

2. Clone the descriptor returned from `compiler.parse`. `parse` is
   backed by an internal LRU cache keyed by source, so even with
   distinct cache entries both keys pointed at the same object.
   Cloning the descriptor and its script/scriptSetup/template/style
   blocks gives each cached entry an object `compileScript` is free
   to mutate.

Adds a regression test covering the exact Vitest flow from
vitest-dev/vitest#9855.
@seanogdev seanogdev changed the title fix(plugin-vue): isolate ssr and client descriptor state fix(plugin-vue): isolate ssr and client descriptor state (fix #764) Apr 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant