diff --git a/.github/contributing.md b/.github/contributing.md
index 2554582b887..681d26e698d 100644
--- a/.github/contributing.md
+++ b/.github/contributing.md
@@ -290,27 +290,39 @@ This is made possible via several configurations:
```mermaid
flowchart LR
+ vue["vue"]
compiler-sfc["@vue/compiler-sfc"]
compiler-dom["@vue/compiler-dom"]
+ compiler-vapor["@vue/compiler-vapor"]
compiler-core["@vue/compiler-core"]
- vue["vue"]
runtime-dom["@vue/runtime-dom"]
+ runtime-vapor["@vue/runtime-vapor"]
runtime-core["@vue/runtime-core"]
reactivity["@vue/reactivity"]
subgraph "Runtime Packages"
runtime-dom --> runtime-core
+ runtime-vapor --> runtime-core
runtime-core --> reactivity
end
subgraph "Compiler Packages"
compiler-sfc --> compiler-core
compiler-sfc --> compiler-dom
+ compiler-sfc --> compiler-vapor
compiler-dom --> compiler-core
+ compiler-vapor --> compiler-core
end
+ vue --> compiler-sfc
vue ---> compiler-dom
vue --> runtime-dom
+ vue --> compiler-vapor
+ vue --> runtime-vapor
+
+ %% Highlight class
+ classDef highlight stroke:#35eb9a,stroke-width:3px;
+ class compiler-vapor,runtime-vapor highlight;
```
There are some rules to follow when importing across package boundaries:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c8c217f62c4..6b69e4727e4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -9,6 +9,7 @@ on:
branches:
- main
- minor
+ - vapor
jobs:
test:
@@ -16,7 +17,7 @@ jobs:
uses: ./.github/workflows/test.yml
continuous-release:
- if: github.repository == 'vuejs/core'
+ if: github.repository == 'vuejs/core' && github.ref_name != 'vapor'
runs-on: ubuntu-latest
steps:
- name: Checkout
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 25c2556091c..53c6a467924 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -80,6 +80,32 @@ jobs:
- name: verify treeshaking
run: node scripts/verify-treeshaking.js
+ e2e-vapor:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup cache for Chromium binary
+ uses: actions/cache@v4
+ with:
+ path: ~/.cache/puppeteer
+ key: chromium-${{ hashFiles('pnpm-lock.yaml') }}
+
+ - name: Install pnpm
+ uses: pnpm/action-setup@v4.0.0
+
+ - name: Install Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version-file: '.node-version'
+ cache: 'pnpm'
+
+ - run: pnpm install
+ - run: node node_modules/puppeteer/install.mjs
+
+ - name: Run e2e tests
+ run: pnpm run test-e2e-vapor
+
lint-and-test-dts:
runs-on: ubuntu-latest
env:
diff --git a/.gitignore b/.gitignore
index 9dd21f59bf6..973c062daf7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,4 @@ TODOs.md
dts-build/packages
*.tsbuildinfo
*.tgz
+packages-private/benchmark/reference
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 302428290b9..7907859bb86 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -14,5 +14,6 @@
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
- "editor.formatOnSave": true
+ "editor.formatOnSave": true,
+ "vitest.disableWorkspaceWarning": true
}
diff --git a/eslint.config.js b/eslint.config.js
index b752b2e19f1..1f5128ec72f 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -106,7 +106,7 @@ export default tseslint.config(
// Packages targeting DOM
{
- files: ['packages/{vue,vue-compat,runtime-dom}/**'],
+ files: ['packages/{vue,vue-compat,runtime-dom,runtime-vapor}/**'],
rules: {
'no-restricted-globals': ['error', ...NodeGlobals],
},
@@ -126,6 +126,7 @@ export default tseslint.config(
files: [
'packages-private/template-explorer/**',
'packages-private/sfc-playground/**',
+ 'packages-private/local-playground/**',
],
rules: {
'no-restricted-globals': ['error', ...NodeGlobals],
@@ -152,6 +153,8 @@ export default tseslint.config(
'./*.{js,ts}',
'packages/*/*.js',
'packages/vue/*/*.js',
+ 'packages-private/benchmark/*',
+ 'packages-private/e2e-utils/*',
],
rules: {
'no-restricted-globals': 'off',
diff --git a/package.json b/package.json
index 3542fe215de..266d47d065f 100644
--- a/package.json
+++ b/package.json
@@ -9,16 +9,18 @@
"build-dts": "tsc -p tsconfig.build.json --noCheck && rollup -c rollup.dts.config.js",
"clean": "rimraf --glob packages/*/dist temp .eslintcache",
"size": "run-s \"size-*\" && node scripts/usage-size.js",
- "size-global": "node scripts/build.js vue runtime-dom -f global -p --size",
+ "size-global": "node scripts/build.js vue runtime-dom compiler-dom -f global -p --size",
"size-esm-runtime": "node scripts/build.js vue -f esm-bundler-runtime",
- "size-esm": "node scripts/build.js runtime-dom runtime-core reactivity shared -f esm-bundler",
+ "size-esm": "node scripts/build.js runtime-shared runtime-dom runtime-core reactivity shared runtime-vapor -f esm-bundler",
"check": "tsc --incremental --noEmit",
"lint": "eslint --cache .",
"format": "prettier --write --cache .",
"format-check": "prettier --check --cache .",
"test": "vitest",
- "test-unit": "vitest --project unit",
+ "test-unit": "vitest --project unit --project unit-jsdom",
"test-e2e": "node scripts/build.js vue -f global -d && vitest --project e2e",
+ "test-e2e-vapor": "pnpm run prepare-e2e-vapor && vitest --project e2e-vapor",
+ "prepare-e2e-vapor": "node scripts/build.js -f cjs+esm-bundler+esm-bundler-runtime && pnpm run -C packages-private/vapor-e2e-test build",
"test-dts": "run-s build-dts test-dts-only",
"test-dts-only": "tsc -p packages-private/dts-built-test/tsconfig.json && tsc -p ./packages-private/dts-test/tsconfig.test.json",
"test-coverage": "vitest run --project unit --coverage",
@@ -29,19 +31,17 @@
"release": "node scripts/release.js",
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s",
"dev-esm": "node scripts/dev.js -if esm-bundler-runtime",
- "dev-compiler": "run-p \"dev template-explorer\" serve",
- "dev-sfc": "run-s dev-sfc-prepare dev-sfc-run",
- "dev-sfc-prepare": "node scripts/pre-dev-sfc.js || npm run build-all-cjs",
- "dev-sfc-serve": "vite packages-private/sfc-playground --host",
- "dev-sfc-run": "run-p \"dev compiler-sfc -f esm-browser\" \"dev vue -if esm-bundler-runtime\" \"dev vue -ipf esm-browser-runtime\" \"dev server-renderer -if esm-bundler\" dev-sfc-serve",
+ "dev-prepare-cjs": "node scripts/prepare-cjs.js || node scripts/build.js -f cjs",
+ "dev-compiler": "run-p \"dev template-explorer\" serve open",
+ "dev-sfc": "run-s dev-prepare-cjs dev-sfc-run",
+ "dev-sfc-serve": "vite packages-private/sfc-playground",
+ "dev-sfc-run": "run-p \"dev compiler-sfc -f esm-browser\" \"dev vue -if esm-browser-vapor\" \"dev vue -ipf esm-browser-vapor\" \"dev server-renderer -if esm-bundler\" dev-sfc-serve",
+ "dev-vapor": "pnpm -C packages-private/local-playground run dev",
"serve": "serve",
"open": "open http://localhost:3000/packages-private/template-explorer/local.html",
- "build-sfc-playground": "run-s build-all-cjs build-runtime-esm build-browser-esm build-ssr-esm build-sfc-playground-self",
- "build-all-cjs": "node scripts/build.js vue runtime compiler reactivity shared -af cjs",
- "build-runtime-esm": "node scripts/build.js runtime reactivity shared -af esm-bundler && node scripts/build.js vue -f esm-bundler-runtime && node scripts/build.js vue -f esm-browser-runtime",
- "build-browser-esm": "node scripts/build.js runtime reactivity shared -af esm-bundler && node scripts/build.js vue -f esm-bundler && node scripts/build.js vue -f esm-browser",
- "build-ssr-esm": "node scripts/build.js compiler-sfc server-renderer -f esm-browser",
- "build-sfc-playground-self": "cd packages-private/sfc-playground && npm run build",
+ "build-sfc-playground": "run-s build-sfc-deps build-sfc-playground-self",
+ "build-sfc-deps": "node scripts/build.js -f ~global+global-runtime",
+ "build-sfc-playground-self": "pnpm run -C packages-private/sfc-playground build",
"preinstall": "npx only-allow pnpm",
"postinstall": "simple-git-hooks"
},
@@ -74,6 +74,7 @@
"@types/node": "^22.14.1",
"@types/semver": "^7.7.0",
"@types/serve-handler": "^6.1.4",
+ "@vitest/ui": "^3.0.2",
"@vitest/coverage-v8": "^3.1.3",
"@vitest/eslint-plugin": "^1.1.44",
"@vue/consolidate": "1.0.0",
diff --git a/packages-private/benchmark/.gitignore b/packages-private/benchmark/.gitignore
new file mode 100644
index 00000000000..484ab7e5c61
--- /dev/null
+++ b/packages-private/benchmark/.gitignore
@@ -0,0 +1 @@
+results/*
diff --git a/packages-private/benchmark/client/App.vue b/packages-private/benchmark/client/App.vue
new file mode 100644
index 00000000000..c85deea53ea
--- /dev/null
+++ b/packages-private/benchmark/client/App.vue
@@ -0,0 +1,136 @@
+
+
+
+ Vue.js (VDOM) Benchmark
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages-private/benchmark/client/AppVapor.vue b/packages-private/benchmark/client/AppVapor.vue
new file mode 100644
index 00000000000..0fd284da3f4
--- /dev/null
+++ b/packages-private/benchmark/client/AppVapor.vue
@@ -0,0 +1,136 @@
+
+
+
+ Vue.js (Vapor) Benchmark
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages-private/benchmark/client/data.ts b/packages-private/benchmark/client/data.ts
new file mode 100644
index 00000000000..ea5de1451d8
--- /dev/null
+++ b/packages-private/benchmark/client/data.ts
@@ -0,0 +1,78 @@
+import { shallowRef } from 'vue'
+
+let ID = 1
+
+function _random(max: number) {
+ return Math.round(Math.random() * 1000) % max
+}
+
+export function buildData(count = 1000) {
+ const adjectives = [
+ 'pretty',
+ 'large',
+ 'big',
+ 'small',
+ 'tall',
+ 'short',
+ 'long',
+ 'handsome',
+ 'plain',
+ 'quaint',
+ 'clean',
+ 'elegant',
+ 'easy',
+ 'angry',
+ 'crazy',
+ 'helpful',
+ 'mushy',
+ 'odd',
+ 'unsightly',
+ 'adorable',
+ 'important',
+ 'inexpensive',
+ 'cheap',
+ 'expensive',
+ 'fancy',
+ ]
+ const colours = [
+ 'red',
+ 'yellow',
+ 'blue',
+ 'green',
+ 'pink',
+ 'brown',
+ 'purple',
+ 'brown',
+ 'white',
+ 'black',
+ 'orange',
+ ]
+ const nouns = [
+ 'table',
+ 'chair',
+ 'house',
+ 'bbq',
+ 'desk',
+ 'car',
+ 'pony',
+ 'cookie',
+ 'sandwich',
+ 'burger',
+ 'pizza',
+ 'mouse',
+ 'keyboard',
+ ]
+ const data = []
+ for (let i = 0; i < count; i++)
+ data.push({
+ id: ID++,
+ label: shallowRef(
+ adjectives[_random(adjectives.length)] +
+ ' ' +
+ colours[_random(colours.length)] +
+ ' ' +
+ nouns[_random(nouns.length)],
+ ),
+ })
+ return data
+}
diff --git a/packages-private/benchmark/client/index.html b/packages-private/benchmark/client/index.html
new file mode 100644
index 00000000000..c3ca4c53590
--- /dev/null
+++ b/packages-private/benchmark/client/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+ Vue Vapor Benchmark
+
+
+
+
+
+
+
diff --git a/packages-private/benchmark/client/index.ts b/packages-private/benchmark/client/index.ts
new file mode 100644
index 00000000000..a12f727a101
--- /dev/null
+++ b/packages-private/benchmark/client/index.ts
@@ -0,0 +1,5 @@
+if (import.meta.env.IS_VAPOR) {
+ import('./vapor')
+} else {
+ import('./vdom')
+}
diff --git a/packages-private/benchmark/client/profiling.ts b/packages-private/benchmark/client/profiling.ts
new file mode 100644
index 00000000000..ee4f38b6090
--- /dev/null
+++ b/packages-private/benchmark/client/profiling.ts
@@ -0,0 +1,94 @@
+/* eslint-disable no-console */
+/* eslint-disable no-restricted-syntax */
+/* eslint-disable no-restricted-globals */
+
+import { nextTick } from 'vue'
+
+declare global {
+ var doProfile: boolean
+ var reactivity: boolean
+ var recordTime: boolean
+ var times: Record
+}
+
+globalThis.recordTime = true
+globalThis.doProfile = false
+globalThis.reactivity = false
+
+export const defer = () => new Promise(r => requestIdleCallback(r))
+
+const times: Record = (globalThis.times = {})
+
+export function wrap(
+ id: string,
+ fn: (...args: any[]) => any,
+): (...args: any[]) => Promise {
+ return async (...args) => {
+ if (!globalThis.recordTime) {
+ return fn(...args)
+ }
+
+ document.body.classList.remove('done')
+
+ const { doProfile } = globalThis
+ await nextTick()
+
+ doProfile && console.profile(id)
+ const start = performance.now()
+ fn(...args)
+
+ await nextTick()
+ let time: number
+ if (globalThis.reactivity) {
+ time = performance.measure(
+ 'flushJobs-measure',
+ 'flushJobs-start',
+ 'flushJobs-end',
+ ).duration
+ performance.clearMarks()
+ performance.clearMeasures()
+ } else {
+ time = performance.now() - start
+ }
+ const prevTimes = times[id] || (times[id] = [])
+ prevTimes.push(time)
+
+ const { min, max, median, mean, std } = compute(prevTimes)
+ const msg =
+ `${id}: min: ${min} / ` +
+ `max: ${max} / ` +
+ `median: ${median}ms / ` +
+ `mean: ${mean}ms / ` +
+ `time: ${time.toFixed(2)}ms / ` +
+ `std: ${std} ` +
+ `over ${prevTimes.length} runs`
+ doProfile && console.profileEnd(id)
+ console.log(msg)
+ const timeEl = document.getElementById('time')!
+ timeEl.textContent = msg
+
+ document.body.classList.add('done')
+ }
+}
+
+function compute(array: number[]) {
+ const n = array.length
+ const max = Math.max(...array)
+ const min = Math.min(...array)
+ const mean = array.reduce((a, b) => a + b) / n
+ const std = Math.sqrt(
+ array.map(x => Math.pow(x - mean, 2)).reduce((a, b) => a + b) / n,
+ )
+ const median = array.slice().sort((a, b) => a - b)[Math.floor(n / 2)]
+ return {
+ max: round(max),
+ min: round(min),
+ mean: round(mean),
+ std: round(std),
+ median: round(median),
+ }
+}
+
+function round(n: number) {
+ return +n.toFixed(2)
+}
diff --git a/packages-private/benchmark/client/vapor.ts b/packages-private/benchmark/client/vapor.ts
new file mode 100644
index 00000000000..2574da9dea1
--- /dev/null
+++ b/packages-private/benchmark/client/vapor.ts
@@ -0,0 +1,4 @@
+import { createVaporApp } from 'vue'
+import App from './AppVapor.vue'
+
+createVaporApp(App as any).mount('#app')
diff --git a/packages-private/benchmark/client/vdom.ts b/packages-private/benchmark/client/vdom.ts
new file mode 100644
index 00000000000..01433bca2ac
--- /dev/null
+++ b/packages-private/benchmark/client/vdom.ts
@@ -0,0 +1,4 @@
+import { createApp } from 'vue'
+import App from './App.vue'
+
+createApp(App).mount('#app')
diff --git a/packages-private/benchmark/index.js b/packages-private/benchmark/index.js
new file mode 100644
index 00000000000..3af47eaf499
--- /dev/null
+++ b/packages-private/benchmark/index.js
@@ -0,0 +1,393 @@
+// @ts-check
+import path from 'node:path'
+import { parseArgs } from 'node:util'
+import { mkdir, rm, writeFile } from 'node:fs/promises'
+import Vue from '@vitejs/plugin-vue'
+import { build } from 'vite'
+import connect from 'connect'
+import sirv from 'sirv'
+import { launch } from 'puppeteer'
+import colors from 'picocolors'
+import { exec, getSha } from '../../scripts/utils.js'
+import process from 'node:process'
+import readline from 'node:readline'
+
+// Thanks to https://github.com/krausest/js-framework-benchmark (Apache-2.0 license)
+const {
+ values: {
+ skipLib,
+ skipApp,
+ skipBench,
+ vdom,
+ noVapor,
+ port: portStr,
+ count: countStr,
+ warmupCount: warmupCountStr,
+ noHeadless,
+ noMinify,
+ reference,
+ },
+} = parseArgs({
+ allowNegative: true,
+ allowPositionals: true,
+ options: {
+ skipLib: {
+ type: 'boolean',
+ short: 'l',
+ },
+ skipApp: {
+ type: 'boolean',
+ short: 'a',
+ },
+ skipBench: {
+ type: 'boolean',
+ short: 'b',
+ },
+ noVapor: {
+ type: 'boolean',
+ },
+ vdom: {
+ type: 'boolean',
+ short: 'v',
+ },
+ port: {
+ type: 'string',
+ short: 'p',
+ default: '8193',
+ },
+ count: {
+ type: 'string',
+ short: 'c',
+ default: '30',
+ },
+ warmupCount: {
+ type: 'string',
+ short: 'w',
+ default: '5',
+ },
+ noHeadless: {
+ type: 'boolean',
+ },
+ noMinify: {
+ type: 'boolean',
+ },
+ reference: {
+ type: 'boolean',
+ short: 'r',
+ },
+ },
+})
+
+const port = +(/** @type {string}*/ (portStr))
+const count = +(/** @type {string}*/ (countStr))
+const warmupCount = +(/** @type {string}*/ (warmupCountStr))
+const sha = await getSha(true)
+
+if (!skipLib && !reference) {
+ await buildLib()
+}
+if (!skipApp && !reference) {
+ await rm('client/dist', { recursive: true }).catch(() => {})
+ vdom && (await buildApp(false))
+ !noVapor && (await buildApp(true))
+}
+const server = startServer()
+
+if (!skipBench) {
+ await benchmark()
+ server.close()
+}
+
+async function buildLib() {
+ console.info(colors.blue('Building lib...'))
+
+ /** @type {import('node:child_process').SpawnOptions} */
+ const options = {
+ cwd: path.resolve(import.meta.dirname, '../..'),
+ stdio: 'inherit',
+ env: { ...process.env, BENCHMARK: 'true' },
+ }
+ const [{ ok }, { ok: ok2 }, { ok: ok3 }] = await Promise.all([
+ exec(
+ 'pnpm',
+ `run --silent build shared compiler-core compiler-dom -pf cjs`.split(' '),
+ options,
+ ),
+ exec(
+ 'pnpm',
+ 'run --silent build compiler-sfc compiler-ssr compiler-vapor -f cjs'.split(
+ ' ',
+ ),
+ options,
+ ),
+ exec(
+ 'pnpm',
+ `run --silent build shared reactivity runtime-core runtime-dom runtime-vapor vue -f esm-bundler+esm-bundler-runtime`.split(
+ ' ',
+ ),
+ options,
+ ),
+ ])
+
+ if (!ok || !ok2 || !ok3) {
+ console.error('Failed to build')
+ process.exit(1)
+ }
+}
+
+/** @param {boolean} isVapor */
+async function buildApp(isVapor) {
+ console.info(
+ colors.blue(`\nBuilding ${isVapor ? 'Vapor' : 'Virtual DOM'} app...\n`),
+ )
+
+ process.env.NODE_ENV = 'production'
+
+ const CompilerSFC = await import(
+ '../../packages/compiler-sfc/dist/compiler-sfc.cjs.js'
+ )
+
+ const runtimePath = path.resolve(
+ import.meta.dirname,
+ '../../packages/vue/dist/vue.runtime.esm-bundler.js',
+ )
+
+ const mode = isVapor ? 'vapor' : 'vdom'
+ await build({
+ root: './client',
+ base: `/${mode}`,
+ define: {
+ 'import.meta.env.IS_VAPOR': String(isVapor),
+ },
+ build: {
+ minify: !noMinify,
+ outDir: path.resolve('./client/dist', mode),
+ rollupOptions: {
+ onwarn(log, handler) {
+ if (log.code === 'INVALID_ANNOTATION') return
+ handler(log)
+ },
+ },
+ },
+ resolve: {
+ alias: {
+ vue: runtimePath,
+ },
+ },
+ clearScreen: false,
+ plugins: [
+ Vue({
+ compiler: CompilerSFC,
+ }),
+ ],
+ })
+}
+
+function startServer() {
+ const server = connect()
+ .use(sirv(reference ? './reference' : './client/dist', { dev: true }))
+ .listen(port)
+ printPort()
+ process.on('SIGTERM', () => server.close())
+ return server
+}
+
+async function benchmark() {
+ console.info(colors.blue(`\nStarting benchmark...`))
+
+ const browser = await initBrowser()
+
+ await mkdir('results', { recursive: true }).catch(() => {})
+ if (!noVapor) {
+ await doBench(browser, true)
+ }
+ if (vdom) {
+ await doBench(browser, false)
+ }
+
+ await browser.close()
+}
+
+/**
+ * @param {boolean} isVapor
+ */
+function getURL(isVapor) {
+ return `http://localhost:${port}/${reference ? '' : isVapor ? 'vapor' : 'vdom'}/`
+}
+
+/**
+ *
+ * @param {import('puppeteer').Browser} browser
+ * @param {boolean} isVapor
+ */
+async function doBench(browser, isVapor) {
+ const mode = reference ? `reference` : isVapor ? 'vapor' : 'vdom'
+ console.info('\n\nmode:', mode)
+
+ const page = await browser.newPage()
+ page.emulateCPUThrottling(4)
+ await page.goto(getURL(isVapor), {
+ waitUntil: 'networkidle0',
+ })
+
+ await forceGC()
+ const t = performance.now()
+
+ console.log('warmup run')
+ await eachRun(() => withoutRecord(benchOnce), warmupCount)
+
+ console.log('benchmark run')
+ await eachRun(benchOnce, count)
+
+ console.info(
+ 'Total time:',
+ colors.cyan(((performance.now() - t) / 1000).toFixed(2)),
+ 's',
+ )
+ const times = await getTimes()
+ const result =
+ /** @type {Record} */
+ Object.fromEntries(Object.entries(times).map(([k, v]) => [k, compute(v)]))
+
+ console.table(result)
+ await writeFile(
+ `results/benchmark-${sha}-${mode}.json`,
+ JSON.stringify(result, undefined, 2),
+ )
+ await page.close()
+ return result
+
+ async function benchOnce() {
+ await clickButton('run') // test: create rows
+ await clickButton('update') // partial update
+ await clickButton('swaprows') // swap rows
+ await select() // test: select row, remove row
+ await clickButton('clear') // clear rows
+
+ await withoutRecord(() => clickButton('run'))
+ await clickButton('add') // append rows to large table
+
+ await withoutRecord(() => clickButton('clear'))
+ await clickButton('runlots') // create many rows
+ await withoutRecord(() => clickButton('clear'))
+
+ // TODO replace all rows
+ }
+
+ function getTimes() {
+ return page.evaluate(() => /** @type {any} */ (globalThis).times)
+ }
+
+ async function forceGC() {
+ await page.evaluate(
+ `window.gc({type:'major',execution:'sync',flavor:'last-resort'})`,
+ )
+ }
+
+ /** @param {() => any} fn */
+ async function withoutRecord(fn) {
+ const currentRecordTime = await page.evaluate(() => globalThis.recordTime)
+ await page.evaluate(() => (globalThis.recordTime = false))
+ await fn()
+ await page.evaluate(
+ currentRecordTime => (globalThis.recordTime = currentRecordTime),
+ currentRecordTime,
+ )
+ }
+
+ /** @param {string} id */
+ async function clickButton(id) {
+ await page.click(`#${id}`)
+ await wait()
+ }
+
+ async function select() {
+ for (let i = 1; i <= 10; i++) {
+ await page.click(`tbody > tr:nth-child(2) > td:nth-child(2) > a`)
+ await page.waitForSelector(`tbody > tr:nth-child(2).danger`)
+ await page.click(`tbody > tr:nth-child(2) > td:nth-child(3) > a`)
+ await wait()
+ }
+ }
+
+ async function wait() {
+ await page.waitForSelector('.done')
+ }
+}
+
+/**
+ * @param {Function} bench
+ * @param {number} count
+ */
+async function eachRun(bench, count) {
+ for (let i = 0; i < count; i++) {
+ readline.cursorTo(process.stdout, 0)
+ readline.clearLine(process.stdout, 0)
+ process.stdout.write(`${i + 1}/${count}`)
+ await bench()
+ }
+ if (count === 0) {
+ process.stdout.write('0/0 (skip)')
+ }
+ process.stdout.write('\n')
+}
+
+async function initBrowser() {
+ const disableFeatures = [
+ 'Translate', // avoid translation popups
+ 'PrivacySandboxSettings4', // avoid privacy popup
+ 'IPH_SidePanelGenericMenuFeature', // bookmark popup see https://github.com/krausest/js-framework-benchmark/issues/1688
+ ]
+
+ const args = [
+ '--js-flags=--expose-gc', // needed for gc() function
+ '--no-default-browser-check',
+ '--disable-sync',
+ '--no-first-run',
+ '--ash-no-nudges',
+ '--disable-extensions',
+ `--disable-features=${disableFeatures.join(',')}`,
+ ]
+
+ const headless = !noHeadless
+ console.info('headless:', headless)
+ const browser = await launch({
+ headless: headless,
+ args,
+ })
+ console.log('browser version:', colors.blue(await browser.version()))
+
+ return browser
+}
+
+/** @param {number[]} array */
+function compute(array) {
+ const n = array.length
+ const max = Math.max(...array)
+ const min = Math.min(...array)
+ const mean = array.reduce((a, b) => a + b) / n
+ const std = Math.sqrt(
+ array.map(x => Math.pow(x - mean, 2)).reduce((a, b) => a + b) / n,
+ )
+ const median = array.slice().sort((a, b) => a - b)[Math.floor(n / 2)]
+ return {
+ max: round(max),
+ min: round(min),
+ mean: round(mean),
+ std: round(std),
+ median: round(median),
+ }
+}
+
+/** @param {number} n */
+function round(n) {
+ return +n.toFixed(2)
+}
+
+function printPort() {
+ const vaporLink = !noVapor
+ ? `\n${reference ? `Reference` : `Vapor`}: ${colors.blue(getURL(true))}`
+ : ''
+ const vdomLink = vdom ? `\nvDom: ${colors.blue(getURL(false))}` : ''
+ console.info(`\n\nServer started at`, vaporLink, vdomLink)
+}
diff --git a/packages-private/benchmark/package.json b/packages-private/benchmark/package.json
new file mode 100644
index 00000000000..e6eb08e9539
--- /dev/null
+++ b/packages-private/benchmark/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "benchmark",
+ "version": "0.0.0",
+ "author": "三咲智子 Kevin Deng ",
+ "license": "MIT",
+ "type": "module",
+ "scripts": {
+ "dev": "pnpm start --noMinify --skipBench --vdom",
+ "start": "node index.js"
+ },
+ "dependencies": {
+ "@vitejs/plugin-vue": "catalog:",
+ "connect": "^3.7.0",
+ "sirv": "^2.0.4",
+ "vite": "catalog:"
+ },
+ "devDependencies": {
+ "@types/connect": "^3.4.38"
+ }
+}
diff --git a/packages-private/benchmark/tsconfig.json b/packages-private/benchmark/tsconfig.json
new file mode 100644
index 00000000000..4a32149504f
--- /dev/null
+++ b/packages-private/benchmark/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "esnext",
+ "lib": ["es2022", "dom"],
+ "allowJs": true,
+ "moduleDetection": "force",
+ "module": "preserve",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "types": ["node", "vite/client"],
+ "strict": true,
+ "noUnusedLocals": true,
+ "declaration": true,
+ "esModuleInterop": true,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "paths": {
+ "vue": ["../packages/vue/src/runtime-with-vapor.ts"],
+ "@vue/*": ["../packages/*/src"]
+ }
+ },
+ "include": ["**/*"]
+}
diff --git a/packages-private/local-playground/index.html b/packages-private/local-playground/index.html
new file mode 100644
index 00000000000..a01ba96dc0b
--- /dev/null
+++ b/packages-private/local-playground/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Vue Vapor
+
+
+
+
+
+
diff --git a/packages-private/local-playground/package.json b/packages-private/local-playground/package.json
new file mode 100644
index 00000000000..37c9818a706
--- /dev/null
+++ b/packages-private/local-playground/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "playground",
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "node ./setup/vite.js",
+ "build": "vite build -c vite.prod.config.ts",
+ "prepreview": "cd ../ && pnpm run build runtime-vapor -f esm-bundler",
+ "preview": "pnpm run build && vite preview -c vite.prod.config.ts"
+ },
+ "dependencies": {
+ "@vueuse/core": "^11.1.0",
+ "vue": "workspace:*"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "catalog:",
+ "@vue/compiler-sfc": "workspace:*",
+ "vite": "catalog:",
+ "vite-hyper-config": "^0.4.0",
+ "vite-plugin-inspect": "^0.8.7"
+ }
+}
diff --git a/packages-private/local-playground/setup/dev.js b/packages-private/local-playground/setup/dev.js
new file mode 100644
index 00000000000..72a7ed4a835
--- /dev/null
+++ b/packages-private/local-playground/setup/dev.js
@@ -0,0 +1,66 @@
+// @ts-check
+import path from 'node:path'
+
+const resolve = (/** @type {string} */ p) =>
+ path.resolve(import.meta.dirname, '../../../packages', p)
+
+/**
+ * @param {Object} [env]
+ * @param {boolean} [env.browser]
+ * @returns {import('vite').Plugin}
+ */
+export function DevPlugin({ browser = false } = {}) {
+ return {
+ name: 'dev-plugin',
+ config() {
+ return {
+ resolve: {
+ alias: {
+ vue: resolve('vue/src/runtime-with-vapor.ts'),
+
+ '@vue/runtime-core': resolve('runtime-core/src'),
+ '@vue/runtime-dom': resolve('runtime-dom/src'),
+ '@vue/runtime-vapor': resolve('runtime-vapor/src'),
+
+ '@vue/compiler-core': resolve('compiler-core/src'),
+ '@vue/compiler-dom': resolve('compiler-dom/src'),
+ '@vue/compiler-vapor': resolve('compiler-vapor/src'),
+
+ '@vue/compiler-sfc': resolve('compiler-sfc/src'),
+ '@vue/compiler-ssr': resolve('compiler-ssr/src'),
+
+ '@vue/reactivity': resolve('reactivity/src'),
+ '@vue/shared': resolve('shared/src'),
+ '@vue/runtime-shared': resolve('runtime-shared/src'),
+ },
+ },
+ define: {
+ __COMMIT__: `"__COMMIT__"`,
+ __VERSION__: `"0.0.0"`,
+ __DEV__: `true`,
+ // this is only used during Vue's internal tests
+ __TEST__: `false`,
+ // If the build is expected to run directly in the browser (global / esm builds)
+ __BROWSER__: String(browser),
+ __GLOBAL__: String(false),
+ __ESM_BUNDLER__: String(true),
+ __ESM_BROWSER__: String(false),
+ // is targeting Node (SSR)?
+ __NODE_JS__: String(false),
+ // need SSR-specific branches?
+ __SSR__: String(false),
+ __BENCHMARK__: 'false',
+
+ // 2.x compat build
+ __COMPAT__: String(false),
+
+ // feature flags
+ __FEATURE_SUSPENSE__: `true`,
+ __FEATURE_OPTIONS_API__: `true`,
+ __FEATURE_PROD_DEVTOOLS__: `false`,
+ __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__: `false`,
+ },
+ }
+ },
+ }
+}
diff --git a/packages-private/local-playground/setup/vite.js b/packages-private/local-playground/setup/vite.js
new file mode 100644
index 00000000000..e6b9a927206
--- /dev/null
+++ b/packages-private/local-playground/setup/vite.js
@@ -0,0 +1,14 @@
+// @ts-check
+
+import { startVite } from 'vite-hyper-config'
+import { DevPlugin } from './dev.js'
+
+startVite(
+ undefined,
+ { plugins: [DevPlugin()] },
+ {
+ deps: {
+ inline: ['@vitejs/plugin-vue'],
+ },
+ },
+)
diff --git a/packages-private/local-playground/src/.gitignore b/packages-private/local-playground/src/.gitignore
new file mode 100644
index 00000000000..2e1b9cfb780
--- /dev/null
+++ b/packages-private/local-playground/src/.gitignore
@@ -0,0 +1,5 @@
+*
+!.gitignore
+!App.vue
+!main.ts
+!style.css
diff --git a/packages-private/local-playground/src/App.vue b/packages-private/local-playground/src/App.vue
new file mode 100644
index 00000000000..b6124e39a35
--- /dev/null
+++ b/packages-private/local-playground/src/App.vue
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+ slot props: {{ foo }}
+ component prop: {{ msg }}
+
+
+ A test slot
+
+
diff --git a/packages-private/local-playground/src/main.ts b/packages-private/local-playground/src/main.ts
new file mode 100644
index 00000000000..9d682d9ffb6
--- /dev/null
+++ b/packages-private/local-playground/src/main.ts
@@ -0,0 +1 @@
+import './_entry'
diff --git a/packages-private/local-playground/src/style.css b/packages-private/local-playground/src/style.css
new file mode 100644
index 00000000000..832e61618b0
--- /dev/null
+++ b/packages-private/local-playground/src/style.css
@@ -0,0 +1,6 @@
+.red {
+ color: red;
+}
+.green {
+ color: green;
+}
diff --git a/packages-private/local-playground/tsconfig.json b/packages-private/local-playground/tsconfig.json
new file mode 100644
index 00000000000..8ed98192084
--- /dev/null
+++ b/packages-private/local-playground/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "isolatedDeclarations": false,
+ "allowJs": true
+ },
+ "include": ["./**/*", "../../packages/*/src"]
+}
diff --git a/packages-private/local-playground/vite.config.ts b/packages-private/local-playground/vite.config.ts
new file mode 100644
index 00000000000..8b4b1a423f7
--- /dev/null
+++ b/packages-private/local-playground/vite.config.ts
@@ -0,0 +1,19 @@
+import { defineConfig } from 'vite'
+import Inspect from 'vite-plugin-inspect'
+import { DevPlugin } from './setup/dev'
+import Vue from '@vitejs/plugin-vue'
+import * as CompilerSFC from '@vue/compiler-sfc'
+
+export default defineConfig({
+ clearScreen: false,
+ plugins: [
+ Vue({
+ compiler: CompilerSFC,
+ }),
+ DevPlugin(),
+ Inspect(),
+ ],
+ optimizeDeps: {
+ exclude: ['@vueuse/core'],
+ },
+})
diff --git a/packages-private/local-playground/vite.prod.config.ts b/packages-private/local-playground/vite.prod.config.ts
new file mode 100644
index 00000000000..6bafb7a772a
--- /dev/null
+++ b/packages-private/local-playground/vite.prod.config.ts
@@ -0,0 +1,26 @@
+import { defineConfig } from 'vite'
+import Vue from '@vitejs/plugin-vue'
+import * as CompilerSFC from '@vue/compiler-sfc'
+
+export default defineConfig({
+ build: {
+ modulePreload: false,
+ target: 'esnext',
+ minify: 'terser',
+ terserOptions: {
+ format: { comments: false },
+ compress: {
+ pure_getters: true,
+ },
+ },
+ },
+ clearScreen: false,
+ plugins: [
+ Vue({
+ compiler: CompilerSFC,
+ features: {
+ optionsAPI: false,
+ },
+ }),
+ ],
+})
diff --git a/packages-private/sfc-playground/src/App.vue b/packages-private/sfc-playground/src/App.vue
index 740d770260c..92fe8e7b984 100644
--- a/packages-private/sfc-playground/src/App.vue
+++ b/packages-private/sfc-playground/src/App.vue
@@ -1,6 +1,12 @@
+
+
+ {{ msg }}
+
diff --git a/packages-private/sfc-playground/vite.config.ts b/packages-private/sfc-playground/vite.config.ts
index 2e77f1970a7..c1a40fd1ca9 100644
--- a/packages-private/sfc-playground/vite.config.ts
+++ b/packages-private/sfc-playground/vite.config.ts
@@ -53,6 +53,8 @@ function copyVuePlugin(): Plugin {
copyFile(`vue/dist/vue.esm-browser.prod.js`)
copyFile(`vue/dist/vue.runtime.esm-browser.js`)
copyFile(`vue/dist/vue.runtime.esm-browser.prod.js`)
+ copyFile(`vue/dist/vue.runtime-with-vapor.esm-browser.js`)
+ copyFile(`vue/dist/vue.runtime-with-vapor.esm-browser.prod.js`)
copyFile(`server-renderer/dist/server-renderer.esm-browser.js`)
},
}
diff --git a/packages-private/template-explorer/package.json b/packages-private/template-explorer/package.json
index 08da34b173e..c546f4a298d 100644
--- a/packages-private/template-explorer/package.json
+++ b/packages-private/template-explorer/package.json
@@ -11,6 +11,7 @@
"enableNonBrowserBranches": true
},
"dependencies": {
+ "@vue/compiler-vapor": "workspace:^",
"monaco-editor": "^0.52.2",
"source-map-js": "^1.2.1"
}
diff --git a/packages-private/template-explorer/src/index.ts b/packages-private/template-explorer/src/index.ts
index 988712d623c..96619b5a311 100644
--- a/packages-private/template-explorer/src/index.ts
+++ b/packages-private/template-explorer/src/index.ts
@@ -1,15 +1,18 @@
import type * as m from 'monaco-editor'
+import type { CompilerError } from '@vue/compiler-dom'
+import { compile } from '@vue/compiler-dom'
import {
- type CompilerError,
type CompilerOptions,
- compile,
-} from '@vue/compiler-dom'
-import { compile as ssrCompile } from '@vue/compiler-ssr'
+ compile as vaporCompile,
+} from '@vue/compiler-vapor'
+// import { compile as ssrCompile } from '@vue/compiler-ssr'
+
import {
compilerOptions,
defaultOptions,
initOptions,
ssrMode,
+ vaporMode,
} from './options'
import { toRaw, watchEffect } from '@vue/runtime-dom'
import { SourceMapConsumer } from 'source-map-js'
@@ -77,10 +80,16 @@ window.init = () => {
console.clear()
try {
const errors: CompilerError[] = []
- const compileFn = ssrMode.value ? ssrCompile : compile
+ const compileFn = /* ssrMode.value ? ssrCompile : */ (
+ vaporMode.value ? vaporCompile : compile
+ ) as typeof vaporCompile
const start = performance.now()
const { code, ast, map } = compileFn(source, {
...compilerOptions,
+ prefixIdentifiers:
+ compilerOptions.prefixIdentifiers ||
+ compilerOptions.mode === 'module' ||
+ compilerOptions.ssr,
filename: 'ExampleTemplate.vue',
sourceMap: true,
onError: err => {
diff --git a/packages-private/template-explorer/src/options.ts b/packages-private/template-explorer/src/options.ts
index e3cc6173a8a..341e885c083 100644
--- a/packages-private/template-explorer/src/options.ts
+++ b/packages-private/template-explorer/src/options.ts
@@ -1,8 +1,9 @@
import { createApp, h, reactive, ref } from 'vue'
-import type { CompilerOptions } from '@vue/compiler-dom'
+import type { CompilerOptions } from '@vue/compiler-vapor'
import { BindingTypes } from '@vue/compiler-core'
export const ssrMode = ref(false)
+export const vaporMode = ref(true)
export const defaultOptions: CompilerOptions = {
mode: 'module',
@@ -39,11 +40,11 @@ const App = {
compilerOptions.prefixIdentifiers || compilerOptions.mode === 'module'
return [
- h('h1', `Vue 3 Template Explorer`),
+ h('h1', `Vue Template Explorer`),
h(
'a',
{
- href: `https://github.com/vuejs/core/tree/${__COMMIT__}`,
+ href: `https://github.com/vuejs/vue/tree/${__COMMIT__}`,
target: `_blank`,
},
`@${__COMMIT__}`,
@@ -222,6 +223,18 @@ const App = {
}),
h('label', { for: 'compat' }, 'v2 compat mode'),
]),
+
+ h('li', [
+ h('input', {
+ type: 'checkbox',
+ id: 'vapor',
+ checked: vaporMode.value,
+ onChange(e: Event) {
+ vaporMode.value = (e.target as HTMLInputElement).checked
+ },
+ }),
+ h('label', { for: 'vapor' }, 'vapor'),
+ ]),
]),
]),
]
diff --git a/packages-private/tsconfig.json b/packages-private/tsconfig.json
index 1c287a7500c..37a38f53fc6 100644
--- a/packages-private/tsconfig.json
+++ b/packages-private/tsconfig.json
@@ -3,5 +3,5 @@
"compilerOptions": {
"isolatedDeclarations": false
},
- "include": ["."]
+ "include": [".", "../packages/vue/__tests__/e2e/e2eUtils.ts"]
}
diff --git a/packages-private/vapor-e2e-test/__tests__/todomvc.spec.ts b/packages-private/vapor-e2e-test/__tests__/todomvc.spec.ts
new file mode 100644
index 00000000000..3de8392e5e2
--- /dev/null
+++ b/packages-private/vapor-e2e-test/__tests__/todomvc.spec.ts
@@ -0,0 +1,195 @@
+import path from 'node:path'
+import {
+ E2E_TIMEOUT,
+ setupPuppeteer,
+} from '../../../packages/vue/__tests__/e2e/e2eUtils'
+import connect from 'connect'
+import sirv from 'sirv'
+
+describe('e2e: todomvc', () => {
+ const {
+ page,
+ click,
+ isVisible,
+ count,
+ text,
+ value,
+ isChecked,
+ isFocused,
+ classList,
+ enterValue,
+ clearValue,
+ timeout,
+ } = setupPuppeteer()
+
+ let server: any
+ const port = '8194'
+ beforeAll(() => {
+ server = connect()
+ .use(sirv(path.resolve(import.meta.dirname, '../dist')))
+ .listen(port)
+ process.on('SIGTERM', () => server && server.close())
+ })
+
+ afterAll(() => {
+ server.close()
+ })
+
+ async function removeItemAt(n: number) {
+ const item = (await page().$('.todo:nth-child(' + n + ')'))!
+ const itemBBox = (await item.boundingBox())!
+ await page().mouse.move(itemBBox.x + 10, itemBBox.y + 10)
+ await click('.todo:nth-child(' + n + ') .destroy')
+ }
+
+ test(
+ 'vapor',
+ async () => {
+ const baseUrl = `http://localhost:${port}/todomvc/`
+ await page().goto(baseUrl)
+
+ expect(await isVisible('.main')).toBe(false)
+ expect(await isVisible('.footer')).toBe(false)
+ expect(await count('.filters .selected')).toBe(1)
+ expect(await text('.filters .selected')).toBe('All')
+ expect(await count('.todo')).toBe(0)
+
+ await enterValue('.new-todo', 'test')
+ expect(await count('.todo')).toBe(1)
+ expect(await isVisible('.todo .edit')).toBe(false)
+ expect(await text('.todo label')).toBe('test')
+ expect(await text('.todo-count strong')).toBe('1')
+ expect(await isChecked('.todo .toggle')).toBe(false)
+ expect(await isVisible('.main')).toBe(true)
+ expect(await isVisible('.footer')).toBe(true)
+ expect(await isVisible('.clear-completed')).toBe(false)
+ expect(await value('.new-todo')).toBe('')
+
+ await enterValue('.new-todo', 'test2')
+ expect(await count('.todo')).toBe(2)
+ expect(await text('.todo:nth-child(2) label')).toBe('test2')
+ expect(await text('.todo-count strong')).toBe('2')
+
+ // toggle
+ await click('.todo .toggle')
+ expect(await count('.todo.completed')).toBe(1)
+ expect(await classList('.todo:nth-child(1)')).toContain('completed')
+ expect(await text('.todo-count strong')).toBe('1')
+ expect(await isVisible('.clear-completed')).toBe(true)
+
+ await enterValue('.new-todo', 'test3')
+ expect(await count('.todo')).toBe(3)
+ expect(await text('.todo:nth-child(3) label')).toBe('test3')
+ expect(await text('.todo-count strong')).toBe('2')
+
+ await enterValue('.new-todo', 'test4')
+ await enterValue('.new-todo', 'test5')
+ expect(await count('.todo')).toBe(5)
+ expect(await text('.todo-count strong')).toBe('4')
+
+ // toggle more
+ await click('.todo:nth-child(4) .toggle')
+ await click('.todo:nth-child(5) .toggle')
+ expect(await count('.todo.completed')).toBe(3)
+ expect(await text('.todo-count strong')).toBe('2')
+
+ // remove
+ await removeItemAt(1)
+ expect(await count('.todo')).toBe(4)
+ expect(await count('.todo.completed')).toBe(2)
+ expect(await text('.todo-count strong')).toBe('2')
+ await removeItemAt(2)
+ expect(await count('.todo')).toBe(3)
+ expect(await count('.todo.completed')).toBe(2)
+ expect(await text('.todo-count strong')).toBe('1')
+
+ // remove all
+ await click('.clear-completed')
+ expect(await count('.todo')).toBe(1)
+ expect(await text('.todo label')).toBe('test2')
+ expect(await count('.todo.completed')).toBe(0)
+ expect(await text('.todo-count strong')).toBe('1')
+ expect(await isVisible('.clear-completed')).toBe(false)
+
+ // prepare to test filters
+ await enterValue('.new-todo', 'test')
+ await enterValue('.new-todo', 'test')
+ await click('.todo:nth-child(2) .toggle')
+ await click('.todo:nth-child(3) .toggle')
+
+ // active filter
+ await click('.filters li:nth-child(2) a')
+ await timeout(1)
+ expect(await count('.todo')).toBe(1)
+ expect(await count('.todo.completed')).toBe(0)
+ // add item with filter active
+ await enterValue('.new-todo', 'test')
+ expect(await count('.todo')).toBe(2)
+
+ // completed filter
+ await click('.filters li:nth-child(3) a')
+ await timeout(1)
+ expect(await count('.todo')).toBe(2)
+ expect(await count('.todo.completed')).toBe(2)
+
+ // filter on page load
+ await page().goto(`${baseUrl}#active`)
+ expect(await count('.todo')).toBe(2)
+ expect(await count('.todo.completed')).toBe(0)
+ expect(await text('.todo-count strong')).toBe('2')
+
+ // completed on page load
+ await page().goto(`${baseUrl}#completed`)
+ expect(await count('.todo')).toBe(2)
+ expect(await count('.todo.completed')).toBe(2)
+ expect(await text('.todo-count strong')).toBe('2')
+
+ // toggling with filter active
+ await click('.todo .toggle')
+ expect(await count('.todo')).toBe(1)
+ await click('.filters li:nth-child(2) a')
+ await timeout(1)
+ expect(await count('.todo')).toBe(3)
+ await click('.todo .toggle')
+ expect(await count('.todo')).toBe(2)
+
+ // editing triggered by blur
+ await click('.filters li:nth-child(1) a')
+ await timeout(1)
+ await click('.todo:nth-child(1) label', { clickCount: 2 })
+ expect(await count('.todo.editing')).toBe(1)
+ expect(await isFocused('.todo:nth-child(1) .edit')).toBe(true)
+ await clearValue('.todo:nth-child(1) .edit')
+ await page().type('.todo:nth-child(1) .edit', 'edited!')
+ await click('.new-todo') // blur
+ expect(await count('.todo.editing')).toBe(0)
+ expect(await text('.todo:nth-child(1) label')).toBe('edited!')
+
+ // editing triggered by enter
+ await click('.todo label', { clickCount: 2 })
+ await enterValue('.todo:nth-child(1) .edit', 'edited again!')
+ expect(await count('.todo.editing')).toBe(0)
+ expect(await text('.todo:nth-child(1) label')).toBe('edited again!')
+
+ // cancel
+ await click('.todo label', { clickCount: 2 })
+ await clearValue('.todo:nth-child(1) .edit')
+ await page().type('.todo:nth-child(1) .edit', 'edited!')
+ await page().keyboard.press('Escape')
+ expect(await count('.todo.editing')).toBe(0)
+ expect(await text('.todo:nth-child(1) label')).toBe('edited again!')
+
+ // empty value should remove
+ await click('.todo label', { clickCount: 2 })
+ await enterValue('.todo:nth-child(1) .edit', ' ')
+ expect(await count('.todo')).toBe(3)
+
+ // toggle all
+ await click('.toggle-all+label')
+ expect(await count('.todo.completed')).toBe(3)
+ await click('.toggle-all+label')
+ expect(await count('.todo:not(.completed)')).toBe(3)
+ },
+ E2E_TIMEOUT,
+ )
+})
diff --git a/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts b/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts
new file mode 100644
index 00000000000..360f48085a1
--- /dev/null
+++ b/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts
@@ -0,0 +1,84 @@
+import path from 'node:path'
+import {
+ E2E_TIMEOUT,
+ setupPuppeteer,
+} from '../../../packages/vue/__tests__/e2e/e2eUtils'
+import connect from 'connect'
+import sirv from 'sirv'
+
+describe('vdom / vapor interop', () => {
+ const { page, click, text, enterValue } = setupPuppeteer()
+
+ let server: any
+ const port = '8193'
+ beforeAll(() => {
+ server = connect()
+ .use(sirv(path.resolve(import.meta.dirname, '../dist')))
+ .listen(port)
+ process.on('SIGTERM', () => server && server.close())
+ })
+
+ afterAll(() => {
+ server.close()
+ })
+
+ test(
+ 'should work',
+ async () => {
+ const baseUrl = `http://localhost:${port}/interop/`
+ await page().goto(baseUrl)
+
+ expect(await text('.vapor > h2')).toContain('Vapor component in VDOM')
+
+ expect(await text('.vapor-prop')).toContain('hello')
+
+ const t = await text('.vdom-slot-in-vapor-default')
+ expect(t).toContain('slot prop: slot prop')
+ expect(t).toContain('component prop: hello')
+
+ await click('.change-vdom-slot-in-vapor-prop')
+ expect(await text('.vdom-slot-in-vapor-default')).toContain(
+ 'slot prop: changed',
+ )
+
+ expect(await text('.vdom-slot-in-vapor-test')).toContain('A test slot')
+
+ await click('.toggle-vdom-slot-in-vapor')
+ expect(await text('.vdom-slot-in-vapor-test')).toContain(
+ 'fallback content',
+ )
+
+ await click('.toggle-vdom-slot-in-vapor')
+ expect(await text('.vdom-slot-in-vapor-test')).toContain('A test slot')
+
+ expect(await text('.vdom > h2')).toContain('VDOM component in Vapor')
+
+ expect(await text('.vdom-prop')).toContain('hello')
+
+ const tt = await text('.vapor-slot-in-vdom-default')
+ expect(tt).toContain('slot prop: slot prop')
+ expect(tt).toContain('component prop: hello')
+
+ await click('.change-vapor-slot-in-vdom-prop')
+ expect(await text('.vapor-slot-in-vdom-default')).toContain(
+ 'slot prop: changed',
+ )
+
+ expect(await text('.vapor-slot-in-vdom-test')).toContain('fallback')
+
+ await click('.toggle-vapor-slot-in-vdom-default')
+ expect(await text('.vapor-slot-in-vdom-default')).toContain(
+ 'default slot fallback',
+ )
+
+ await click('.toggle-vapor-slot-in-vdom-default')
+
+ await enterValue('input', 'bye')
+ expect(await text('.vapor-prop')).toContain('bye')
+ expect(await text('.vdom-slot-in-vapor-default')).toContain('bye')
+ expect(await text('.vdom-prop')).toContain('bye')
+ expect(await text('.vapor-slot-in-vdom-default')).toContain('bye')
+ },
+ E2E_TIMEOUT,
+ )
+})
diff --git a/packages-private/vapor-e2e-test/index.html b/packages-private/vapor-e2e-test/index.html
new file mode 100644
index 00000000000..7dc205e5ab0
--- /dev/null
+++ b/packages-private/vapor-e2e-test/index.html
@@ -0,0 +1,2 @@
+VDOM / Vapor interop
+Vapor TodoMVC
diff --git a/packages-private/vapor-e2e-test/interop/App.vue b/packages-private/vapor-e2e-test/interop/App.vue
new file mode 100644
index 00000000000..772a6989dd7
--- /dev/null
+++ b/packages-private/vapor-e2e-test/interop/App.vue
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+ slot prop: {{ foo }}
+ component prop: {{ msg }}
+
+
+ A test slot
+
+
diff --git a/packages-private/vapor-e2e-test/interop/VaporComp.vue b/packages-private/vapor-e2e-test/interop/VaporComp.vue
new file mode 100644
index 00000000000..88a60c782c0
--- /dev/null
+++ b/packages-private/vapor-e2e-test/interop/VaporComp.vue
@@ -0,0 +1,50 @@
+
+
+
+
+
This is a Vapor component in VDOM
+
props.msg: {{ msg }}
+
+
+
+
+
vdom slots in vapor component
+
+
+ #default:
+
+
+ #test: fallback content
+
+
+
+
+
+
+ slot prop: {{ foo }}
+ component prop: {{ msg }}
+
+
+
+
diff --git a/packages-private/vapor-e2e-test/interop/VdomComp.vue b/packages-private/vapor-e2e-test/interop/VdomComp.vue
new file mode 100644
index 00000000000..30ec1b2eeb5
--- /dev/null
+++ b/packages-private/vapor-e2e-test/interop/VdomComp.vue
@@ -0,0 +1,28 @@
+
+
+
+
+
This is a VDOM component in Vapor
+
props.msg: {{ msg }}
+
+
vapor slots in vdom
+
+
+ #default: default slot fallback
+
+
+ #test fallback
+
+
+
+
diff --git a/packages-private/vapor-e2e-test/interop/index.html b/packages-private/vapor-e2e-test/interop/index.html
new file mode 100644
index 00000000000..79052a023ba
--- /dev/null
+++ b/packages-private/vapor-e2e-test/interop/index.html
@@ -0,0 +1,2 @@
+
+
diff --git a/packages-private/vapor-e2e-test/interop/main.ts b/packages-private/vapor-e2e-test/interop/main.ts
new file mode 100644
index 00000000000..d5d6d7dcf8c
--- /dev/null
+++ b/packages-private/vapor-e2e-test/interop/main.ts
@@ -0,0 +1,4 @@
+import { createApp, vaporInteropPlugin } from 'vue'
+import App from './App.vue'
+
+createApp(App).use(vaporInteropPlugin).mount('#app')
diff --git a/packages-private/vapor-e2e-test/package.json b/packages-private/vapor-e2e-test/package.json
new file mode 100644
index 00000000000..66ea0457ec9
--- /dev/null
+++ b/packages-private/vapor-e2e-test/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "vapor-e2e-test",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build"
+ },
+ "devDependencies": {
+ "@types/connect": "^3.4.38",
+ "@vitejs/plugin-vue": "catalog:",
+ "connect": "^3.7.0",
+ "sirv": "^2.0.4",
+ "vite": "catalog:",
+ "vue": "workspace:*"
+ }
+}
diff --git a/packages-private/vapor-e2e-test/todomvc/App.vue b/packages-private/vapor-e2e-test/todomvc/App.vue
new file mode 100644
index 00000000000..910ada51093
--- /dev/null
+++ b/packages-private/vapor-e2e-test/todomvc/App.vue
@@ -0,0 +1,228 @@
+
+
+
+
+
diff --git a/packages-private/vapor-e2e-test/todomvc/index.html b/packages-private/vapor-e2e-test/todomvc/index.html
new file mode 100644
index 00000000000..79052a023ba
--- /dev/null
+++ b/packages-private/vapor-e2e-test/todomvc/index.html
@@ -0,0 +1,2 @@
+
+
diff --git a/packages-private/vapor-e2e-test/todomvc/main.ts b/packages-private/vapor-e2e-test/todomvc/main.ts
new file mode 100644
index 00000000000..42497ab518d
--- /dev/null
+++ b/packages-private/vapor-e2e-test/todomvc/main.ts
@@ -0,0 +1,5 @@
+import { createVaporApp } from 'vue'
+import App from './App.vue'
+import 'todomvc-app-css/index.css'
+
+createVaporApp(App).mount('#app')
diff --git a/packages-private/vapor-e2e-test/vite.config.ts b/packages-private/vapor-e2e-test/vite.config.ts
new file mode 100644
index 00000000000..1e29a4dbd13
--- /dev/null
+++ b/packages-private/vapor-e2e-test/vite.config.ts
@@ -0,0 +1,20 @@
+import { defineConfig } from 'vite'
+import Vue from '@vitejs/plugin-vue'
+import * as CompilerSFC from 'vue/compiler-sfc'
+import { resolve } from 'node:path'
+
+export default defineConfig({
+ plugins: [
+ Vue({
+ compiler: CompilerSFC,
+ }),
+ ],
+ build: {
+ rollupOptions: {
+ input: {
+ interop: resolve(import.meta.dirname, 'interop/index.html'),
+ todomvc: resolve(import.meta.dirname, 'todomvc/index.html'),
+ },
+ },
+ },
+})
diff --git a/packages/compiler-core/src/ast.ts b/packages/compiler-core/src/ast.ts
index 2d6df9d9010..bae13372a98 100644
--- a/packages/compiler-core/src/ast.ts
+++ b/packages/compiler-core/src/ast.ts
@@ -86,6 +86,13 @@ export interface Position {
column: number
}
+export type AllNode =
+ | ParentNode
+ | ExpressionNode
+ | TemplateChildNode
+ | AttributeNode
+ | DirectiveNode
+
export type ParentNode = RootNode | ElementNode | IfBranchNode | ForNode
export type ExpressionNode = SimpleExpressionNode | CompoundExpressionNode
diff --git a/packages/compiler-core/src/babelUtils.ts b/packages/compiler-core/src/babelUtils.ts
index 52fabeea896..6ede6bd0386 100644
--- a/packages/compiler-core/src/babelUtils.ts
+++ b/packages/compiler-core/src/babelUtils.ts
@@ -12,6 +12,7 @@ import type {
Program,
} from '@babel/types'
import { walk } from 'estree-walker'
+import { type BindingMetadata, BindingTypes } from './options'
/**
* Return value indicates whether the AST walked can be a constant
@@ -308,8 +309,8 @@ export const isFunctionType = (node: Node): node is Function => {
return /Function(?:Expression|Declaration)$|Method$/.test(node.type)
}
-export const isStaticProperty = (node: Node): node is ObjectProperty =>
- node &&
+export const isStaticProperty = (node?: Node): node is ObjectProperty =>
+ !!node &&
(node.type === 'ObjectProperty' || node.type === 'ObjectMethod') &&
!node.computed
@@ -510,3 +511,77 @@ export function unwrapTSNode(node: Node): Node {
return node
}
}
+
+export function isStaticNode(node: Node): boolean {
+ node = unwrapTSNode(node)
+
+ switch (node.type) {
+ case 'UnaryExpression': // void 0, !true
+ return isStaticNode(node.argument)
+
+ case 'LogicalExpression': // 1 > 2
+ case 'BinaryExpression': // 1 + 2
+ return isStaticNode(node.left) && isStaticNode(node.right)
+
+ case 'ConditionalExpression': {
+ // 1 ? 2 : 3
+ return (
+ isStaticNode(node.test) &&
+ isStaticNode(node.consequent) &&
+ isStaticNode(node.alternate)
+ )
+ }
+
+ case 'SequenceExpression': // (1, 2)
+ case 'TemplateLiteral': // `foo${1}`
+ return node.expressions.every(expr => isStaticNode(expr))
+
+ case 'ParenthesizedExpression': // (1)
+ return isStaticNode(node.expression)
+
+ case 'StringLiteral':
+ case 'NumericLiteral':
+ case 'BooleanLiteral':
+ case 'NullLiteral':
+ case 'BigIntLiteral':
+ return true
+ }
+ return false
+}
+
+export function isConstantNode(node: Node, bindings: BindingMetadata): boolean {
+ if (isStaticNode(node)) return true
+
+ node = unwrapTSNode(node)
+ switch (node.type) {
+ case 'Identifier':
+ const type = bindings[node.name]
+ return type === BindingTypes.LITERAL_CONST
+ case 'RegExpLiteral':
+ return true
+ case 'ObjectExpression':
+ return node.properties.every(prop => {
+ // { bar() {} } object methods are not considered static nodes
+ if (prop.type === 'ObjectMethod') return false
+ // { ...{ foo: 1 } }
+ if (prop.type === 'SpreadElement')
+ return isConstantNode(prop.argument, bindings)
+ // { foo: 1 }
+ return (
+ (!prop.computed || isConstantNode(prop.key, bindings)) &&
+ isConstantNode(prop.value, bindings)
+ )
+ })
+ case 'ArrayExpression':
+ return node.elements.every(element => {
+ // [1, , 3]
+ if (element === null) return true
+ // [1, ...[2, 3]]
+ if (element.type === 'SpreadElement')
+ return isConstantNode(element.argument, bindings)
+ // [1, 2]
+ return isConstantNode(element, bindings)
+ })
+ }
+ return false
+}
diff --git a/packages/compiler-core/src/codegen.ts b/packages/compiler-core/src/codegen.ts
index 70116cfb61a..26d0bbef23d 100644
--- a/packages/compiler-core/src/codegen.ts
+++ b/packages/compiler-core/src/codegen.ts
@@ -105,22 +105,38 @@ const aliasHelper = (s: symbol) => `${helperNameMap[s]}: _${helperNameMap[s]}`
type CodegenNode = TemplateChildNode | JSChildNode | SSRCodegenNode
-export interface CodegenResult {
+export interface BaseCodegenResult {
code: string
preamble: string
- ast: RootNode
+ ast: unknown
map?: RawSourceMap
+ helpers?: Set | Set
+}
+
+export interface CodegenResult extends BaseCodegenResult {
+ ast: RootNode
+ helpers: Set
}
-enum NewlineType {
+export enum NewlineType {
+ /** Start with `\n` */
Start = 0,
+ /** Ends with `\n` */
End = -1,
+ /** No `\n` included */
None = -2,
+ /** Don't know, calc it */
Unknown = -3,
}
export interface CodegenContext
- extends Omit, 'bindingMetadata' | 'inline'> {
+ extends Omit<
+ Required,
+ | 'bindingMetadata'
+ | 'inline'
+ | 'vaporRuntimeModuleName'
+ | 'expressionPlugins'
+ > {
source: string
code: string
line: number
@@ -398,6 +414,7 @@ export function generate(
code: context.code,
preamble: isSetupInlined ? preambleContext.code : ``,
map: context.map ? context.map.toJSON() : undefined,
+ helpers: ast.helpers,
}
}
diff --git a/packages/compiler-core/src/index.ts b/packages/compiler-core/src/index.ts
index 29e5f681300..e54b0c3a498 100644
--- a/packages/compiler-core/src/index.ts
+++ b/packages/compiler-core/src/index.ts
@@ -17,21 +17,26 @@ export {
createTransformContext,
traverseNode,
createStructuralDirectiveTransform,
+ getSelfName,
type NodeTransform,
type StructuralDirectiveTransform,
type DirectiveTransform,
} from './transform'
export {
generate,
+ NewlineType,
type CodegenContext,
type CodegenResult,
type CodegenSourceMapGenerator,
type RawSourceMap,
+ type BaseCodegenResult,
} from './codegen'
export {
ErrorCodes,
errorMessages,
createCompilerError,
+ defaultOnError,
+ defaultOnWarn,
type CoreCompilerError,
type CompilerError,
} from './errors'
@@ -52,6 +57,7 @@ export {
transformExpression,
processExpression,
stringifyExpression,
+ isLiteralWhitelisted,
} from './transforms/transformExpression'
export {
buildSlots,
@@ -75,4 +81,5 @@ export {
checkCompatEnabled,
warnDeprecation,
CompilerDeprecationTypes,
+ type CompilerCompatOptions,
} from './compat/compatConfig'
diff --git a/packages/compiler-core/src/options.ts b/packages/compiler-core/src/options.ts
index 1de865f42eb..9983071609e 100644
--- a/packages/compiler-core/src/options.ts
+++ b/packages/compiler-core/src/options.ts
@@ -174,6 +174,12 @@ interface SharedTransformCodegenOptions {
* @default mode === 'module'
*/
prefixIdentifiers?: boolean
+ /**
+ * A list of parser plugins to enable for `@babel/parser`, which is used to
+ * parse expressions in bindings and interpolations.
+ * https://babeljs.io/docs/en/next/babel-parser#plugins
+ */
+ expressionPlugins?: ParserPlugin[]
/**
* Control whether generate SSR-optimized render functions instead.
* The resulting function must be attached to the component via the
@@ -272,12 +278,6 @@ export interface TransformOptions
* @default false
*/
cacheHandlers?: boolean
- /**
- * A list of parser plugins to enable for `@babel/parser`, which is used to
- * parse expressions in bindings and interpolations.
- * https://babeljs.io/docs/en/next/babel-parser#plugins
- */
- expressionPlugins?: ParserPlugin[]
/**
* SFC scoped styles ID
*/
diff --git a/packages/compiler-core/src/transform.ts b/packages/compiler-core/src/transform.ts
index aeb96cc2b4a..7d35ec9f700 100644
--- a/packages/compiler-core/src/transform.ts
+++ b/packages/compiler-core/src/transform.ts
@@ -123,6 +123,11 @@ export interface TransformContext
filters?: Set
}
+export function getSelfName(filename: string): string | null {
+ const nameMatch = filename.replace(/\?.*$/, '').match(/([^/\\]+)\.\w+$/)
+ return nameMatch ? capitalize(camelize(nameMatch[1])) : null
+}
+
export function createTransformContext(
root: RootNode,
{
@@ -150,11 +155,10 @@ export function createTransformContext(
compatConfig,
}: TransformOptions,
): TransformContext {
- const nameMatch = filename.replace(/\?.*$/, '').match(/([^/\\]+)\.\w+$/)
const context: TransformContext = {
// options
filename,
- selfName: nameMatch && capitalize(camelize(nameMatch[1])),
+ selfName: getSelfName(filename),
prefixIdentifiers,
hoistStatic,
hmr,
diff --git a/packages/compiler-core/src/transforms/transformExpression.ts b/packages/compiler-core/src/transforms/transformExpression.ts
index 9ae8897e674..9012c2701f7 100644
--- a/packages/compiler-core/src/transforms/transformExpression.ts
+++ b/packages/compiler-core/src/transforms/transformExpression.ts
@@ -44,7 +44,8 @@ import { parseExpression } from '@babel/parser'
import { IS_REF, UNREF } from '../runtimeHelpers'
import { BindingTypes } from '../options'
-const isLiteralWhitelisted = /*@__PURE__*/ makeMap('true,false,null,this')
+export const isLiteralWhitelisted: (key: string) => boolean =
+ /*@__PURE__*/ makeMap('true,false,null,this')
export const transformExpression: NodeTransform = (node, context) => {
if (node.type === NodeTypes.INTERPOLATION) {
diff --git a/packages/compiler-core/src/utils.ts b/packages/compiler-core/src/utils.ts
index b49d70bb2fb..b90a7018c8b 100644
--- a/packages/compiler-core/src/utils.ts
+++ b/packages/compiler-core/src/utils.ts
@@ -160,7 +160,7 @@ export const isMemberExpressionBrowser = (exp: ExpressionNode): boolean => {
export const isMemberExpressionNode: (
exp: ExpressionNode,
- context: TransformContext,
+ context: Pick,
) => boolean = __BROWSER__
? (NOOP as any)
: (exp, context) => {
@@ -185,7 +185,7 @@ export const isMemberExpressionNode: (
export const isMemberExpression: (
exp: ExpressionNode,
- context: TransformContext,
+ context: Pick,
) => boolean = __BROWSER__ ? isMemberExpressionBrowser : isMemberExpressionNode
const fnExpRE =
@@ -196,7 +196,7 @@ export const isFnExpressionBrowser: (exp: ExpressionNode) => boolean = exp =>
export const isFnExpressionNode: (
exp: ExpressionNode,
- context: TransformContext,
+ context: Pick,
) => boolean = __BROWSER__
? (NOOP as any)
: (exp, context) => {
@@ -227,7 +227,7 @@ export const isFnExpressionNode: (
export const isFnExpression: (
exp: ExpressionNode,
- context: TransformContext,
+ context: Pick,
) => boolean = __BROWSER__ ? isFnExpressionBrowser : isFnExpressionNode
export function advancePositionWithClone(
@@ -279,6 +279,7 @@ export function assert(condition: boolean, msg?: string): void {
}
}
+/** find directive */
export function findDir(
node: ElementNode,
name: string | RegExp,
diff --git a/packages/compiler-dom/src/errors.ts b/packages/compiler-dom/src/errors.ts
index b47624840ab..15641e531af 100644
--- a/packages/compiler-dom/src/errors.ts
+++ b/packages/compiler-dom/src/errors.ts
@@ -48,7 +48,7 @@ if (__TEST__) {
}
}
-export const DOMErrorMessages: { [code: number]: string } = {
+export const DOMErrorMessages: Record = {
[DOMErrorCodes.X_V_HTML_NO_EXPRESSION]: `v-html is missing expression.`,
[DOMErrorCodes.X_V_HTML_WITH_CHILDREN]: `v-html will override element children.`,
[DOMErrorCodes.X_V_TEXT_NO_EXPRESSION]: `v-text is missing expression.`,
@@ -60,4 +60,7 @@ export const DOMErrorMessages: { [code: number]: string } = {
[DOMErrorCodes.X_V_SHOW_NO_EXPRESSION]: `v-show is missing expression.`,
[DOMErrorCodes.X_TRANSITION_INVALID_CHILDREN]: ` expects exactly one child element or component.`,
[DOMErrorCodes.X_IGNORED_SIDE_EFFECT_TAG]: `Tags with side effect (
+ {{ foo }}
`)
expect(bindings).toStrictEqual({
__propsAliases: {
@@ -173,6 +174,7 @@ describe('sfc reactive props destructure', () => {
"foo:bar": { type: String, required: true, default: 'foo-bar' },
"onUpdate:modelValue": { type: Function, required: true }
},`)
+ expect(content).toMatch(`__props.foo`)
assertCode(content)
})
diff --git a/packages/compiler-sfc/__tests__/parse.spec.ts b/packages/compiler-sfc/__tests__/parse.spec.ts
index 265655e47ef..82b8cf98f11 100644
--- a/packages/compiler-sfc/__tests__/parse.spec.ts
+++ b/packages/compiler-sfc/__tests__/parse.spec.ts
@@ -381,6 +381,17 @@ h1 { color: red }
})
})
+ describe('vapor mode', () => {
+ test('on empty script', () => {
+ const { descriptor } = parse(``)
+ expect(descriptor.vapor).toBe(true)
+ })
+ test('on template', () => {
+ const { descriptor } = parse(``)
+ expect(descriptor.vapor).toBe(true)
+ })
+ })
+
describe('warnings', () => {
function assertWarning(errors: Error[], msg: string) {
expect(errors.some(e => e.message.match(msg))).toBe(true)
diff --git a/packages/compiler-sfc/package.json b/packages/compiler-sfc/package.json
index ebae648d12e..262f6f40d20 100644
--- a/packages/compiler-sfc/package.json
+++ b/packages/compiler-sfc/package.json
@@ -46,6 +46,7 @@
"@vue/compiler-core": "workspace:*",
"@vue/compiler-dom": "workspace:*",
"@vue/compiler-ssr": "workspace:*",
+ "@vue/compiler-vapor": "workspace:*",
"@vue/shared": "workspace:*",
"estree-walker": "catalog:",
"magic-string": "catalog:",
diff --git a/packages/compiler-sfc/src/compileScript.ts b/packages/compiler-sfc/src/compileScript.ts
index 36bb2cfd2df..eb3b2d119c9 100644
--- a/packages/compiler-sfc/src/compileScript.ts
+++ b/packages/compiler-sfc/src/compileScript.ts
@@ -2,6 +2,7 @@ import {
BindingTypes,
UNREF,
isFunctionType,
+ isStaticNode,
unwrapTSNode,
walkIdentifiers,
} from '@vue/compiler-dom'
@@ -125,6 +126,10 @@ export interface SFCScriptCompileOptions {
* Transform Vue SFCs into custom elements.
*/
customElement?: boolean | ((filename: string) => boolean)
+ /**
+ * Force to use of Vapor mode.
+ */
+ vapor?: boolean
}
export interface ImportBinding {
@@ -169,6 +174,8 @@ export function compileScript(
const scopeId = options.id ? options.id.replace(/^data-v-/, '') : ''
const scriptLang = script && script.lang
const scriptSetupLang = scriptSetup && scriptSetup.lang
+ const vapor = sfc.vapor || options.vapor
+ const ssr = options.templateOptions?.ssr
if (!scriptSetup) {
if (!script) {
@@ -743,7 +750,7 @@ export function compileScript(
if (
sfc.cssVars.length &&
// no need to do this when targeting SSR
- !options.templateOptions?.ssr
+ !ssr
) {
ctx.helperImports.add(CSS_VARS_HELPER)
ctx.helperImports.add('unref')
@@ -853,12 +860,12 @@ export function compileScript(
} else {
// inline mode
if (sfc.template && !sfc.template.src) {
- if (options.templateOptions && options.templateOptions.ssr) {
+ if (ssr) {
hasInlinedSsrRenderFn = true
}
// inline render function mode - we are going to compile the template and
// inline it right here
- const { code, ast, preamble, tips, errors } = compileTemplate({
+ const { code, preamble, tips, errors, helpers } = compileTemplate({
filename,
ast: sfc.template.ast,
source: sfc.template.content,
@@ -868,6 +875,7 @@ export function compileScript(
scoped: sfc.styles.some(s => s.scoped),
isProd: options.isProd,
ssrCssVars: sfc.cssVars,
+ vapor,
compilerOptions: {
...(options.templateOptions &&
options.templateOptions.compilerOptions),
@@ -903,7 +911,7 @@ export function compileScript(
// avoid duplicated unref import
// as this may get injected by the render function preamble OR the
// css vars codegen
- if (ast && ast.helpers.has(UNREF)) {
+ if (helpers && helpers.has(UNREF)) {
ctx.helperImports.delete('unref')
}
returned = code
@@ -923,7 +931,11 @@ export function compileScript(
`\n}\n\n`,
)
} else {
- ctx.s.appendRight(endOffset, `\nreturn ${returned}\n}\n\n`)
+ ctx.s.appendRight(
+ endOffset,
+ // vapor mode generates its own return when inlined
+ `\n${vapor && !ssr ? `` : `return `}${returned}\n}\n\n`,
+ )
}
// 10. finalize default export
@@ -972,13 +984,17 @@ export function compileScript(
ctx.s.prependLeft(
startOffset,
`\n${genDefaultAs} /*@__PURE__*/${ctx.helper(
- `defineComponent`,
+ vapor && !ssr ? `defineVaporComponent` : `defineComponent`,
)}({${def}${runtimeOptions}\n ${
hasAwait ? `async ` : ``
}setup(${args}) {\n${exposeCall}`,
)
ctx.s.appendRight(endOffset, `})`)
} else {
+ // in TS, defineVaporComponent adds the option already
+ if (vapor) {
+ runtimeOptions += `\n __vapor: true,`
+ }
if (defaultExport || definedOptions) {
// without TS, can't rely on rest spread, so we use Object.assign
// export default Object.assign(__default__, { ... })
@@ -1247,40 +1263,3 @@ function canNeverBeRef(node: Node, userReactiveImport?: string): boolean {
return false
}
}
-
-function isStaticNode(node: Node): boolean {
- node = unwrapTSNode(node)
-
- switch (node.type) {
- case 'UnaryExpression': // void 0, !true
- return isStaticNode(node.argument)
-
- case 'LogicalExpression': // 1 > 2
- case 'BinaryExpression': // 1 + 2
- return isStaticNode(node.left) && isStaticNode(node.right)
-
- case 'ConditionalExpression': {
- // 1 ? 2 : 3
- return (
- isStaticNode(node.test) &&
- isStaticNode(node.consequent) &&
- isStaticNode(node.alternate)
- )
- }
-
- case 'SequenceExpression': // (1, 2)
- case 'TemplateLiteral': // `foo${1}`
- return node.expressions.every(expr => isStaticNode(expr))
-
- case 'ParenthesizedExpression': // (1)
- return isStaticNode(node.expression)
-
- case 'StringLiteral':
- case 'NumericLiteral':
- case 'BooleanLiteral':
- case 'NullLiteral':
- case 'BigIntLiteral':
- return true
- }
- return false
-}
diff --git a/packages/compiler-sfc/src/compileTemplate.ts b/packages/compiler-sfc/src/compileTemplate.ts
index b043cf813d7..29d1853d2d6 100644
--- a/packages/compiler-sfc/src/compileTemplate.ts
+++ b/packages/compiler-sfc/src/compileTemplate.ts
@@ -1,5 +1,5 @@
import {
- type CodegenResult,
+ type BaseCodegenResult,
type CompilerError,
type CompilerOptions,
type ElementNode,
@@ -24,24 +24,29 @@ import {
} from './template/transformSrcset'
import { generateCodeFrame, isObject } from '@vue/shared'
import * as CompilerDOM from '@vue/compiler-dom'
+import * as CompilerVapor from '@vue/compiler-vapor'
import * as CompilerSSR from '@vue/compiler-ssr'
import consolidate from '@vue/consolidate'
import { warnOnce } from './warn'
import { genCssVarsFromList } from './style/cssVars'
export interface TemplateCompiler {
- compile(source: string | RootNode, options: CompilerOptions): CodegenResult
+ compile(
+ source: string | RootNode,
+ options: CompilerOptions,
+ ): BaseCodegenResult
parse(template: string, options: ParserOptions): RootNode
}
export interface SFCTemplateCompileResults {
code: string
- ast?: RootNode
+ ast?: unknown
preamble?: string
source: string
tips: string[]
errors: (string | CompilerError)[]
map?: RawSourceMap
+ helpers?: Set
}
export interface SFCTemplateCompileOptions {
@@ -52,6 +57,7 @@ export interface SFCTemplateCompileOptions {
scoped?: boolean
slotted?: boolean
isProd?: boolean
+ vapor?: boolean
ssr?: boolean
ssrCssVars?: string[]
inMap?: RawSourceMap
@@ -168,6 +174,7 @@ function doCompileTemplate({
source,
ast: inAST,
ssr = false,
+ vapor = false,
ssrCssVars,
isProd = false,
compiler,
@@ -202,7 +209,11 @@ function doCompileTemplate({
const shortId = id.replace(/^data-v-/, '')
const longId = `data-v-${shortId}`
- const defaultCompiler = ssr ? (CompilerSSR as TemplateCompiler) : CompilerDOM
+ const defaultCompiler = ssr
+ ? (CompilerSSR as TemplateCompiler)
+ : vapor
+ ? (CompilerVapor as TemplateCompiler)
+ : CompilerDOM
compiler = compiler || defaultCompiler
if (compiler !== defaultCompiler) {
@@ -227,25 +238,30 @@ function doCompileTemplate({
inAST = createRoot(template.children, inAST.source)
}
- let { code, ast, preamble, map } = compiler.compile(inAST || source, {
- mode: 'module',
- prefixIdentifiers: true,
- hoistStatic: true,
- cacheHandlers: true,
- ssrCssVars:
- ssr && ssrCssVars && ssrCssVars.length
- ? genCssVarsFromList(ssrCssVars, shortId, isProd, true)
- : '',
- scopeId: scoped ? longId : undefined,
- slotted,
- sourceMap: true,
- ...compilerOptions,
- hmr: !isProd,
- nodeTransforms: nodeTransforms.concat(compilerOptions.nodeTransforms || []),
- filename,
- onError: e => errors.push(e),
- onWarn: w => warnings.push(w),
- })
+ let { code, ast, preamble, map, helpers } = compiler.compile(
+ inAST || source,
+ {
+ mode: 'module',
+ prefixIdentifiers: true,
+ hoistStatic: true,
+ cacheHandlers: true,
+ ssrCssVars:
+ ssr && ssrCssVars && ssrCssVars.length
+ ? genCssVarsFromList(ssrCssVars, shortId, isProd, true)
+ : '',
+ scopeId: scoped ? longId : undefined,
+ slotted,
+ sourceMap: true,
+ ...compilerOptions,
+ hmr: !isProd,
+ nodeTransforms: nodeTransforms.concat(
+ compilerOptions.nodeTransforms || [],
+ ),
+ filename,
+ onError: e => errors.push(e),
+ onWarn: w => warnings.push(w),
+ },
+ )
// inMap should be the map produced by ./parse.ts which is a simple line-only
// mapping. If it is present, we need to adjust the final map and errors to
@@ -271,7 +287,16 @@ function doCompileTemplate({
return msg
})
- return { code, ast, preamble, source, errors, tips, map }
+ return {
+ code,
+ ast,
+ preamble,
+ source,
+ errors,
+ tips,
+ map,
+ helpers,
+ }
}
function mapLines(oldMap: RawSourceMap, newMap: RawSourceMap): RawSourceMap {
diff --git a/packages/compiler-sfc/src/parse.ts b/packages/compiler-sfc/src/parse.ts
index c8be865508f..98b08a20815 100644
--- a/packages/compiler-sfc/src/parse.ts
+++ b/packages/compiler-sfc/src/parse.ts
@@ -84,6 +84,8 @@ export interface SFCDescriptor {
*/
slotted: boolean
+ vapor: boolean
+
/**
* compare with an existing descriptor to determine whether HMR should perform
* a reload vs. re-render.
@@ -137,6 +139,7 @@ export function parse(
customBlocks: [],
cssVars: [],
slotted: false,
+ vapor: false,
shouldForceReload: prevImports => hmrShouldReload(prevImports, descriptor),
}
@@ -159,8 +162,9 @@ export function parse(
ignoreEmpty &&
node.tag !== 'template' &&
isEmpty(node) &&
- !hasSrc(node)
+ !hasAttr(node, 'src')
) {
+ descriptor.vapor ||= hasAttr(node, 'vapor')
return
}
switch (node.tag) {
@@ -171,6 +175,7 @@ export function parse(
source,
false,
) as SFCTemplateBlock)
+ descriptor.vapor ||= !!templateBlock.attrs.vapor
if (!templateBlock.attrs.src) {
templateBlock.ast = createRoot(node.children, source)
@@ -195,7 +200,8 @@ export function parse(
break
case 'script':
const scriptBlock = createBlock(node, source, pad) as SFCScriptBlock
- const isSetup = !!scriptBlock.attrs.setup
+ descriptor.vapor ||= !!scriptBlock.attrs.vapor
+ const isSetup = !!(scriptBlock.attrs.setup || scriptBlock.attrs.vapor)
if (isSetup && !descriptor.scriptSetup) {
descriptor.scriptSetup = scriptBlock
break
@@ -404,13 +410,8 @@ function padContent(
}
}
-function hasSrc(node: ElementNode) {
- return node.props.some(p => {
- if (p.type !== NodeTypes.ATTRIBUTE) {
- return false
- }
- return p.name === 'src'
- })
+function hasAttr(node: ElementNode, name: string) {
+ return node.props.some(p => p.type === NodeTypes.ATTRIBUTE && p.name === name)
}
/**
diff --git a/packages/compiler-sfc/src/script/defineProps.ts b/packages/compiler-sfc/src/script/defineProps.ts
index 9a4880a1a54..ac5226168e4 100644
--- a/packages/compiler-sfc/src/script/defineProps.ts
+++ b/packages/compiler-sfc/src/script/defineProps.ts
@@ -79,6 +79,15 @@ export function processDefineProps(
)
}
ctx.propsTypeDecl = node.typeParameters.params[0]
+ // register bindings
+ const { props } = resolveTypeElements(ctx, ctx.propsTypeDecl)
+ if (props) {
+ for (const key in props) {
+ if (!(key in ctx.bindingMetadata)) {
+ ctx.bindingMetadata[key] = BindingTypes.PROPS
+ }
+ }
+ }
}
// handle props destructure
@@ -190,10 +199,6 @@ export function extractRuntimeProps(
for (const prop of props) {
propStrings.push(genRuntimePropFromType(ctx, prop, hasStaticDefaults))
- // register bindings
- if ('bindingMetadata' in ctx && !(prop.key in ctx.bindingMetadata)) {
- ctx.bindingMetadata[prop.key] = BindingTypes.PROPS
- }
}
let propsDecls = `{
diff --git a/packages/compiler-ssr/__tests__/ssrComponent.spec.ts b/packages/compiler-ssr/__tests__/ssrComponent.spec.ts
index 2fde4560ec4..fb2fff86574 100644
--- a/packages/compiler-ssr/__tests__/ssrComponent.spec.ts
+++ b/packages/compiler-ssr/__tests__/ssrComponent.spec.ts
@@ -39,6 +39,7 @@ describe('ssr: components', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) {
_ssrRenderVNode(_push, _createVNode(_resolveDynamicComponent("foo"), _mergeProps({ prop: "b" }, _attrs), null), _parent)
+ _push(\`\`)
}"
`)
@@ -49,6 +50,7 @@ describe('ssr: components', () => {
return function ssrRender(_ctx, _push, _parent, _attrs) {
_ssrRenderVNode(_push, _createVNode(_resolveDynamicComponent(_ctx.foo), _mergeProps({ prop: "b" }, _attrs), null), _parent)
+ _push(\`\`)
}"
`)
})
@@ -244,7 +246,8 @@ describe('ssr: components', () => {
_ssrRenderList(list, (i) => {
_push(\`\`)
})
- _push(\`\`)
+ _push(\`\`)
+ _push(\`\`)
} else {
_push(\`\`)
}
@@ -267,7 +270,8 @@ describe('ssr: components', () => {
_ssrRenderList(_ctx.list, (i) => {
_push(\`\`)
})
- _push(\`\`)
+ _push(\`\`)
+ _push(\`\`)
} else {
_push(\`\`)
}
@@ -361,6 +365,7 @@ describe('ssr: components', () => {
_push(\`\`)
if (false) {
_push(\`\`)
+ _push(\`\`)
} else {
_push(\`\`)
}
diff --git a/packages/compiler-ssr/__tests__/ssrElement.spec.ts b/packages/compiler-ssr/__tests__/ssrElement.spec.ts
index f1d509acfb0..d344405f3ed 100644
--- a/packages/compiler-ssr/__tests__/ssrElement.spec.ts
+++ b/packages/compiler-ssr/__tests__/ssrElement.spec.ts
@@ -396,4 +396,50 @@ describe('ssr: element', () => {
`)
})
})
+
+ describe('dynamic anchor', () => {
+ test('two consecutive components', () => {
+ expect(
+ getCompiledString(`
+
+ `),
+ ).toMatchInlineSnapshot(`
+ "\`\`)
+ _push(_ssrRenderComponent(_component_Comp1, null, null, _parent))
+ _push(\`\`)
+ _push(_ssrRenderComponent(_component_Comp2, null, null, _parent))
+ _push(\`
\`"
+ `)
+ })
+
+ test('multiple consecutive components', () => {
+ expect(
+ getCompiledString(`
+
+ `),
+ ).toMatchInlineSnapshot(`
+ "\`\`)
+ _push(_ssrRenderComponent(_component_Comp1, null, null, _parent))
+ _push(\`\`)
+ _push(_ssrRenderComponent(_component_Comp2, null, null, _parent))
+ _push(\`\`)
+ _push(_ssrRenderComponent(_component_Comp3, null, null, _parent))
+ _push(\`\`)
+ _push(_ssrRenderComponent(_component_Comp4, null, null, _parent))
+ _push(\`
\`"
+ `)
+ })
+ })
})
diff --git a/packages/compiler-ssr/__tests__/ssrFallthroughAttrs.spec.ts b/packages/compiler-ssr/__tests__/ssrFallthroughAttrs.spec.ts
index 7b3d1962c3e..712c09d0946 100644
--- a/packages/compiler-ssr/__tests__/ssrFallthroughAttrs.spec.ts
+++ b/packages/compiler-ssr/__tests__/ssrFallthroughAttrs.spec.ts
@@ -29,6 +29,7 @@ describe('ssr: attrs fallthrough', () => {
_push(\`\`)
if (true) {
_push(\`\`)
+ _push(\`\`)
} else {
_push(\`\`)
}
diff --git a/packages/compiler-ssr/__tests__/ssrInjectCssVars.spec.ts b/packages/compiler-ssr/__tests__/ssrInjectCssVars.spec.ts
index 9e70dac0bdc..0666e8949cc 100644
--- a/packages/compiler-ssr/__tests__/ssrInjectCssVars.spec.ts
+++ b/packages/compiler-ssr/__tests__/ssrInjectCssVars.spec.ts
@@ -70,6 +70,7 @@ describe('ssr: inject