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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + 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 @@ + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 ( + `) 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