Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions packages/plugin-vue/__tests__/ssr-then-client-cache.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = [
'<script setup>',
"import Child from './Child.vue'",
'</script>',
'<template><Child /></template>',
].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/)
})
})
13 changes: 9 additions & 4 deletions packages/plugin-vue/src/handleHotUpdate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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}`)
}
Expand Down
18 changes: 16 additions & 2 deletions packages/plugin-vue/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,14 @@ export default function vuePlugin(rawOptions: Options = {}): Plugin<Api> {
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 <script> + <script setup> merge via compileScript()
Expand Down Expand Up @@ -481,7 +488,14 @@ export default function vuePlugin(rawOptions: Options = {}): Plugin<Api> {
const descriptor: ExtendedSFCDescriptor = query.src
? getSrcDescriptor(filename, query) ||
getTempSrcDescriptor(filename, query)
: getDescriptor(filename, options.value)!
: getDescriptor(
filename,
options.value,
true,
false,
undefined,
ssr,
)!

if (query.src) {
this.addWatchFile(filename)
Expand Down
13 changes: 11 additions & 2 deletions packages/plugin-vue/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,18 @@ export async function transformMain(
const { devServer, isProduction, devToolsEnabled } = options

const prevDescriptor = getPrevDescriptor(filename)
const { descriptor, errors } = createDescriptor(filename, code, options)
const { descriptor, errors } = createDescriptor(
filename,
code,
options,
false,
ssr,
)

if (fs.existsSync(filename)) {
// populate descriptor cache for HMR if it's not set yet
// populate descriptor cache for HMR if it's not set yet.
// HMR is client-only, so we always store the HMR descriptor under the
// client key regardless of the current transform's ssr flag.
getDescriptor(
filename,
options,
Expand All @@ -54,6 +62,7 @@ export async function transformMain(
// post-transform code, so we populate the descriptor with post-transform
// code here as well.
filename.endsWith('.vue') ? undefined : code,
false,
)
}

Expand Down
14 changes: 9 additions & 5 deletions packages/plugin-vue/src/script.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { SFCDescriptor, SFCScriptBlock } from 'vue/compiler-sfc'
import { resolveTemplateCompilerOptions } from './template'
import { cache as descriptorCache } from './utils/descriptorCache'
import { peekCachedDescriptor } from './utils/descriptorCache'
import type { ResolvedOptions } from './index'

// ssr and non ssr builds would output different script content
Expand All @@ -10,10 +10,14 @@ let ssrCache = new WeakMap<SFCDescriptor, SFCScriptBlock | null>()
export const typeDepToSFCMap = new Map<string, Set<string>>()

export function invalidateScript(filename: string): void {
const desc = descriptorCache.get(filename)
if (desc) {
clientCache.delete(desc)
ssrCache.delete(desc)
// Descriptors are keyed by `(filename, ssr)`; clear both variants so a
// subsequent transform recompiles the script.
for (const ssr of [false, true]) {
const desc = peekCachedDescriptor(filename, ssr)
if (desc) {
clientCache.delete(desc)
ssrCache.delete(desc)
}
}
}

Expand Down
73 changes: 66 additions & 7 deletions packages/plugin-vue/src/utils/descriptorCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,26 @@ export interface SFCParseResult {
errors: (CompilerError | SyntaxError)[]
}

// Descriptor caches are keyed by `(filename, ssr)` so that SSR and client
// transforms operate on distinct descriptor objects. `compileScript` mutates
// the descriptor with ssr-specific state, so sharing one descriptor between
// an ssr pass and a subsequent client pass produces incorrect output
// (e.g. `<script setup>` imports that are only referenced from the template
// get dropped from `__returned__`).
export const cache = new Map<string, SFCDescriptor>()
// we use a separate descriptor cache for HMR purposes.
// The main cached descriptors are parsed from SFCs that may have been
// transformed by other plugins, e.g. vue-macros;
// The HMR cached descriptors are based on the raw, pre-transform SFCs.
// HMR is client-only, but we still namespace the key for consistency.
export const hmrCache = new Map<string, SFCDescriptor>()
// `prevCache` is consulted for HMR diffing, which is client-only.
const prevCache = new Map<string, SFCDescriptor | undefined>()

function getCacheKey(filename: string, ssr: boolean): string {
return `${filename}\0${ssr ? 'ssr' : 'client'}`
}

export function createDescriptor(
filename: string,
source: string,
Expand All @@ -31,12 +43,22 @@ export function createDescriptor(
features,
}: ResolvedOptions,
hmr = false,
ssr = false,
): SFCParseResult {
const { descriptor, errors } = compiler.parse(source, {
const parseResult = compiler.parse(source, {
filename,
sourceMap,
templateParseOptions: template?.compilerOptions,
})
// `compiler.parse` is backed by an internal LRU cache keyed by the source
// (and other parse options). Two parses of the same SFC therefore return
// the **same** descriptor object. Because `compileScript` mutates the
// descriptor (and its script/scriptSetup blocks) with ssr-specific compiled
// state, reusing that shared object across ssr and client transforms would
// poison the second transform. Clone the descriptor so each cache entry
// owns an independent object that `compileScript` is free to mutate.
const descriptor = cloneDescriptor(parseResult.descriptor)
const { errors } = parseResult

// ensure the path is normalized in a way that is consistent inside
// project (relative to root) and on different systems.
Expand All @@ -58,18 +80,23 @@ export function createDescriptor(
descriptor.id = getHash(normalizedPath + (isProduction ? source : ''))
}

;(hmr ? hmrCache : cache).set(filename, descriptor)
;(hmr ? hmrCache : cache).set(getCacheKey(filename, ssr), descriptor)
return { descriptor, errors }
}

export function getPrevDescriptor(filename: string): SFCDescriptor | undefined {
return prevCache.get(filename)
}

export function invalidateDescriptor(filename: string, hmr = false): void {
export function invalidateDescriptor(
filename: string,
hmr = false,
ssr = false,
): void {
const _cache = hmr ? hmrCache : cache
const prev = _cache.get(filename)
_cache.delete(filename)
const key = getCacheKey(filename, ssr)
const prev = _cache.get(key)
_cache.delete(key)
if (prev) {
prevCache.set(filename, prev)
}
Expand All @@ -85,17 +112,20 @@ export function getDescriptor(
createIfNotFound = true,
hmr = false,
code?: string,
ssr = false,
): SFCDescriptor | undefined {
const _cache = hmr ? hmrCache : cache
if (_cache.has(filename)) {
return _cache.get(filename)!
const key = getCacheKey(filename, ssr)
if (_cache.has(key)) {
return _cache.get(key)!
}
if (createIfNotFound) {
const { descriptor, errors } = createDescriptor(
filename,
code ?? fs.readFileSync(filename, 'utf-8'),
options,
hmr,
ssr,
)
if (errors.length && !hmr) {
throw errors[0]
Expand All @@ -104,6 +134,21 @@ export function getDescriptor(
}
}

export function setCachedDescriptor(
filename: string,
descriptor: SFCDescriptor,
ssr = false,
): void {
cache.set(getCacheKey(filename, ssr), descriptor)
}

export function peekCachedDescriptor(
filename: string,
ssr = false,
): SFCDescriptor | undefined {
return cache.get(getCacheKey(filename, ssr))
}

export function getSrcDescriptor(
filename: string,
query: VueQuery,
Expand Down Expand Up @@ -139,6 +184,9 @@ export function setSrcDescriptor(
entry: SFCDescriptor,
scoped?: boolean,
): void {
// `?src=` descriptors are only consumed by the style transform (via
// `getSrcDescriptor`), which never calls `compileScript`. They are safe to
// key by filename alone.
if (scoped) {
// if multiple Vue files use the same src file, they will be overwritten
// should use other key
Expand All @@ -151,3 +199,14 @@ export function setSrcDescriptor(
function getHash(text: string): string {
return crypto.hash('sha256', text, 'hex').substring(0, 8)
}

function cloneDescriptor(descriptor: SFCDescriptor): SFCDescriptor {
return {
...descriptor,
script: descriptor.script ? { ...descriptor.script } : null,
scriptSetup: descriptor.scriptSetup ? { ...descriptor.scriptSetup } : null,
template: descriptor.template ? { ...descriptor.template } : null,
styles: descriptor.styles.map((s) => ({ ...s })),
customBlocks: descriptor.customBlocks.map((b) => ({ ...b })),
}
}