Skip to content

Commit d59c3c0

Browse files
authored
feat(plugin-vue): add forced vapor mode option (#766)
1 parent 28d6889 commit d59c3c0

6 files changed

Lines changed: 296 additions & 7 deletions

File tree

packages/plugin-vue/__tests__/template-only-vapor.spec.ts

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { SFCDescriptor } from 'vue/compiler-sfc'
33
import type { ResolvedOptions } from '../src/index'
44
import { resolveCompiler } from '../src/compiler'
55
import { transformMain } from '../src/main'
6+
import { clearScriptCache, resolveScript } from '../src/script'
67
import { transformTemplateAsModule } from '../src/template'
78
import { createDescriptor } from '../src/utils/descriptorCache'
89

@@ -18,6 +19,15 @@ function createOptions(): ResolvedOptions {
1819
} as ResolvedOptions
1920
}
2021

22+
function createForcedVaporOptions(): ResolvedOptions {
23+
return {
24+
...createOptions(),
25+
features: {
26+
vapor: true,
27+
},
28+
}
29+
}
30+
2131
function parseDescriptor(
2232
filename: string,
2333
source: string,
@@ -126,3 +136,221 @@ describe.todo('template-only vapor __multiRoot', () => {
126136
expect(result?.code).not.toContain('multiRoot as _sfc_multiRoot')
127137
})
128138
})
139+
140+
// TODO: remove todo in v3.6
141+
describe.todo('features.vapor', () => {
142+
it('forces a template-only Vue SFC to compile in vapor mode', async () => {
143+
const filename = '/root/Forced.vue'
144+
const source = '<template><div /><div /></template>'
145+
const options = createForcedVaporOptions()
146+
147+
const result = await transformMain(
148+
source,
149+
filename,
150+
options,
151+
createPluginContext(),
152+
false,
153+
false,
154+
)
155+
156+
expect(result?.code).toContain('const _sfc_main = { __vapor: true }')
157+
expect(result?.code).toContain('_sfc_main.__multiRoot = true')
158+
})
159+
160+
it('forces external template modules to compile in vapor mode', async () => {
161+
const filename = '/root/ForcedExternal.vue'
162+
const source = '<template lang="pug">div\ndiv</template>'
163+
const options = createForcedVaporOptions()
164+
const descriptor = parseDescriptor(filename, source, options)
165+
166+
const mainResult = await transformMain(
167+
source,
168+
filename,
169+
options,
170+
createPluginContext(),
171+
false,
172+
false,
173+
)
174+
const templateResult = await transformTemplateAsModule(
175+
descriptor.template!.content,
176+
filename,
177+
descriptor,
178+
options,
179+
createPluginContext(),
180+
false,
181+
false,
182+
)
183+
184+
expect(mainResult?.code).toContain(
185+
'import { render as _sfc_render, multiRoot as _sfc_multiRoot }',
186+
)
187+
expect(mainResult?.code).toContain('_sfc_main.__multiRoot = _sfc_multiRoot')
188+
expect(templateResult.code).toContain('export const multiRoot = true')
189+
})
190+
191+
it('passes forced vapor mode to compileScript', () => {
192+
const filename = '/root/ForcedScript.vue'
193+
const options = createForcedVaporOptions()
194+
const descriptor = parseDescriptor(
195+
filename,
196+
'<script setup>const msg = "hi"</script><template>{{ msg }}</template>',
197+
options,
198+
)
199+
const compileScript = vi.fn(() => ({
200+
...descriptor.scriptSetup!,
201+
content: 'const _sfc_main = {}',
202+
}))
203+
204+
clearScriptCache()
205+
resolveScript(
206+
descriptor,
207+
{
208+
...options,
209+
compiler: {
210+
...compiler,
211+
compileScript,
212+
},
213+
},
214+
false,
215+
false,
216+
)
217+
218+
expect(compileScript).toHaveBeenCalledWith(
219+
descriptor,
220+
expect.objectContaining({
221+
vapor: true,
222+
templateOptions: expect.objectContaining({
223+
vapor: true,
224+
}),
225+
}),
226+
)
227+
})
228+
229+
it('does not force normal script SFCs into vapor mode', async () => {
230+
const filename = '/root/NormalScript.vue'
231+
const source = `
232+
<script>
233+
export default {
234+
props: {
235+
href: String
236+
}
237+
}
238+
</script>
239+
<template><a :href="href"><slot /></a></template>
240+
`
241+
const options = createForcedVaporOptions()
242+
const compileScript = vi.fn(compiler.compileScript)
243+
const compileTemplate = vi.fn(compiler.compileTemplate)
244+
245+
const result = await transformMain(
246+
source,
247+
filename,
248+
{
249+
...options,
250+
compiler: {
251+
...compiler,
252+
compileScript,
253+
compileTemplate,
254+
},
255+
},
256+
createPluginContext(),
257+
false,
258+
false,
259+
)
260+
261+
expect(result?.code).not.toContain('__vapor: true')
262+
expect(result?.code).not.toContain('template as _template')
263+
expect(compileScript).toHaveBeenCalledWith(
264+
expect.anything(),
265+
expect.objectContaining({
266+
vapor: false,
267+
templateOptions: expect.objectContaining({
268+
vapor: false,
269+
}),
270+
}),
271+
)
272+
expect(compileTemplate).toHaveBeenCalledWith(
273+
expect.objectContaining({
274+
vapor: false,
275+
}),
276+
)
277+
})
278+
279+
it('forces SFCs with both normal script and script setup into vapor mode', async () => {
280+
const filename = '/root/ScriptAndSetup.vue'
281+
const source = `
282+
<script>
283+
export default {
284+
props: {
285+
href: String
286+
}
287+
}
288+
</script>
289+
<script setup>
290+
const msg = 'hi'
291+
</script>
292+
<template><a :href="href">{{ msg }}</a></template>
293+
`
294+
const options = createForcedVaporOptions()
295+
const compileScript = vi.fn(compiler.compileScript)
296+
297+
await transformMain(
298+
source,
299+
filename,
300+
{
301+
...options,
302+
compiler: {
303+
...compiler,
304+
compileScript,
305+
},
306+
},
307+
createPluginContext(),
308+
false,
309+
false,
310+
)
311+
312+
expect(compileScript).toHaveBeenCalledWith(
313+
expect.anything(),
314+
expect.objectContaining({
315+
vapor: true,
316+
templateOptions: expect.objectContaining({
317+
vapor: true,
318+
}),
319+
}),
320+
)
321+
})
322+
323+
it('continues to force VitePress markdown SFCs into vapor mode', async () => {
324+
const filename = '/root/index.md'
325+
const source = `
326+
<script >
327+
export const __pageData = JSON.parse("{\\"title\\":\\"Hello\\",\\"description\\":\\"\\",\\"frontmatter\\":{},\\"headers\\":[],\\"relativePath\\":\\"index.md\\",\\"filePath\\":\\"index.md\\"}")
328+
export default {name:"index.md",__vapor:true}
329+
</script>
330+
<template><div><h1 id="hello" tabindex="-1">Hello</h1></div></template>
331+
`
332+
const options = createForcedVaporOptions()
333+
const compileTemplate = vi.fn(compiler.compileTemplate)
334+
335+
await transformMain(
336+
source,
337+
filename,
338+
{
339+
...options,
340+
compiler: {
341+
...compiler,
342+
compileTemplate,
343+
},
344+
},
345+
createPluginContext(),
346+
false,
347+
false,
348+
)
349+
350+
expect(compileTemplate).toHaveBeenCalledWith(
351+
expect.objectContaining({
352+
vapor: true,
353+
}),
354+
)
355+
})
356+
})

packages/plugin-vue/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,14 @@ export interface Options {
122122
* - **default:** /\.ce\.vue$/
123123
*/
124124
customElement?: boolean | string | RegExp | (string | RegExp)[]
125+
/**
126+
* Force all <script setup> Vue SFC (`.vue`) files to compile in Vapor mode.
127+
* When enabled, this acts as a plugin-level fallback for SFCs without the
128+
* per-file `vapor` marker.
129+
* - Available in Vue 3.6 and later.
130+
* - **default:** `false`
131+
*/
132+
vapor?: boolean
125133
/**
126134
* Set to `false` to disable Options API support and allow related code in
127135
* Vue core to be dropped via dead-code elimination in production builds,

packages/plugin-vue/src/main.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { transformTemplateInMain } from './template'
2424
import { isEqualBlock, isOnlyTemplateChanged } from './handleHotUpdate'
2525
import { createRollupError } from './utils/error'
2626
import { EXPORT_HELPER_ID } from './helper'
27+
import { isVaporMode } from './utils/vapor'
2728
import type { ResolvedOptions } from './index'
2829

2930
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
@@ -67,9 +68,10 @@ export async function transformMain(
6768
// feature information
6869
const attachedProps: [string, string][] = []
6970
const hasScoped = descriptor.styles.some((s) => s.scoped)
70-
// @ts-expect-error TODO remove when 3.6 is out
7171
const isTemplateOnlyVapor =
72-
!descriptor.script && !descriptor.scriptSetup && descriptor.vapor
72+
!descriptor.script &&
73+
!descriptor.scriptSetup &&
74+
isVaporMode(descriptor, options)
7375

7476
// script
7577
const { code: scriptCode, map: scriptMap } = await genScriptCode(
@@ -345,9 +347,10 @@ async function genTemplateCode(
345347
}> {
346348
const template = descriptor.template!
347349
const hasScoped = descriptor.styles.some((style) => style.scoped)
348-
// @ts-expect-error TODO remove when 3.6 is out
349350
const needsMultiRoot =
350-
!descriptor.script && !descriptor.scriptSetup && descriptor.vapor
351+
!descriptor.script &&
352+
!descriptor.scriptSetup &&
353+
isVaporMode(descriptor, options)
351354

352355
// If the template is not using pre-processor AND is not using external src,
353356
// compile and inline it directly in the main module. When served in vite this
@@ -404,8 +407,7 @@ async function genScriptCode(
404407
code: string
405408
map: RawSourceMap | undefined
406409
}> {
407-
// @ts-expect-error TODO remove when 3.6 is out
408-
const vaporFlag = descriptor.vapor ? '__vapor: true' : ''
410+
const vaporFlag = isVaporMode(descriptor, options) ? '__vapor: true' : ''
409411
let scriptCode = `const ${scriptIdentifier} = { ${vaporFlag} }`
410412
let map: RawSourceMap | undefined
411413

packages/plugin-vue/src/script.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { SFCDescriptor, SFCScriptBlock } from 'vue/compiler-sfc'
22
import { resolveTemplateCompilerOptions } from './template'
33
import { cache as descriptorCache } from './utils/descriptorCache'
4+
import { isVaporMode } from './utils/vapor'
45
import type { ResolvedOptions } from './index'
56

67
// ssr and non ssr builds would output different script content
@@ -85,6 +86,8 @@ export function resolveScript(
8586
? scriptIdentifier
8687
: undefined,
8788
customElement,
89+
// @ts-expect-error TODO remove when 3.6 is out
90+
vapor: isVaporMode(descriptor, options),
8891
propsDestructure:
8992
options.features?.propsDestructure ?? options.script?.propsDestructure,
9093
})

packages/plugin-vue/src/template.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
import type { Rollup } from 'vite'
1010
import { getResolvedScript, resolveScript } from './script'
1111
import { createRollupError } from './utils/error'
12+
import { isVaporMode } from './utils/vapor'
1213
import type { ResolvedOptions } from './index'
1314

1415
export async function transformTemplateAsModule(
@@ -193,7 +194,8 @@ export function resolveTemplateCompilerOptions(
193194
return {
194195
...options.template,
195196
// @ts-expect-error TODO remove when 3.6 is out
196-
vapor: descriptor.vapor,
197+
198+
vapor: isVaporMode(descriptor, options),
197199
id,
198200
ast: canReuseAST(options.compiler.version)
199201
? descriptor.template?.ast
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { SFCDescriptor } from 'vue/compiler-sfc'
2+
3+
interface VaporModeOptions {
4+
features?: {
5+
vapor?: boolean
6+
}
7+
}
8+
9+
/**
10+
* Resolve the effective Vapor mode for an SFC handled by this plugin.
11+
*
12+
* `descriptor.vapor` is the per-file opt-in marker. `features.vapor` is the
13+
* plugin-level force switch for SFCs that can be forced into Vapor mode.
14+
*/
15+
export function isVaporMode(
16+
descriptor: SFCDescriptor,
17+
options: VaporModeOptions,
18+
): boolean {
19+
// @ts-expect-error TODO remove when 3.6 is out
20+
if (descriptor.vapor) {
21+
return true
22+
}
23+
if (options.features?.vapor) {
24+
return canForceVaporMode(descriptor)
25+
}
26+
return false
27+
}
28+
29+
/**
30+
* `features.vapor` can force template-only SFCs, `<script setup>` SFCs, and
31+
* non-`.vue` transformed SFCs into Vapor mode. It cannot force `.vue` SFCs
32+
* with only a normal `<script>` because Vapor SFC support requires
33+
* `<script setup>`.
34+
*/
35+
function canForceVaporMode(descriptor: SFCDescriptor): boolean {
36+
if (descriptor.filename.endsWith('.vue')) {
37+
if (descriptor.scriptSetup) {
38+
return true
39+
}
40+
if (descriptor.script) {
41+
return false
42+
}
43+
}
44+
45+
return true
46+
}

0 commit comments

Comments
 (0)