CHAPTER 01

Chapter 1: Macro Cognition: The Engineering Design Philosophy of the Core Repository

Upstream: vuejs/core · Commit @4ab865a8 · Progress: Chapter 1 of 14

Before we begin tracing any single line of reactivity or virtual DOM implementation, we must first understand the engineering substrate upon which this code depends for its existence. Opening the Vue core repository, the first thing that catches the eye is not the framework's core logic, butpackage.jsonandpnpm-workspace.yamland other engineering configuration files—they contain no runtime functionality whatsoever, yet they determine whether the entire framework can be correctly built, tested, and released. This chapter aims to answer precisely this preliminary question: what exactly is the core repository. It is not@vue/runtime-corethat npm package, but rather the engineering substrate that hostsruntime-core、reactivity、compiler-sfcand more than a dozen publicly released packages, plussfc-playground、template-explorerand other private experimental packages. Understanding how this substrate is organized is the prerequisite for all subsequent chapters (build, types, release, size budget). This chapter will unfold along three main threads: the dual-directory structure of the workspace, the unified constraints of root-level TypeScript and Rollup, and the decoupling philosophy between the "source repository" and "release artifacts."

I. Dual-Directory Structure: Physical Isolation Between packages and packages-private

Intuitive Model

Imagine the core repository as an R&D building.packages/is the official product line, where what is produced must be branded and sold to the market;packages-private/is the internal laboratory, where samples are used only for debugging and demonstration and are never shipped externally. Both share the same utilities (dependencies, build tools), but the access control system (release process) treats them differently.

Without this layer of physical isolation, an internal debugging playground package could easily be mistakenly published to npm—this is not a hypothetical, but a classic monorepo incident.

Data Structure and Memory Layout

The workspace boundary is defined bypnpm-workspace.yaml. It has only three effective declarations:

📎 pnpm-workspace.yaml:1-3

yaml
packages:
  - 'packages/*'
  - 'packages-private/*'

These two globs tell pnpm:packages/andpackages-private/each subdirectory under is an independent package. pnpm will create symbolic links for them, so that@vue/runtime-corewhen referencing@vue/reactivitypoints directly to the local source directory, rather than downloading from the registry.

Immediately following is thecatalog:section, which is pnpm'sdependency version catalogmechanism:

📎 pnpm-workspace.yaml:5-13

yaml
catalog:
  '@babel/parser': ^7.29.8
  '@babel/types': ^7.29.8
  'entities': '^7.0.1'
  'estree-walker': ^2.0.2
  'magic-string': ^0.30.21
  'source-map-js': ^1.2.1
  'vite': ^8.3.0
  '@vitejs/plugin-vue': ^6.0.9

Rootpackage.jsoncorresponds to"@babel/parser": "catalog:" 📎 package.json:65-65。catalog:is a placeholder, which pnpm replaces with the version declared in the catalog section during installation. The benefit of doing this is:@babel/parserthe version of is maintained in onlypnpm-workspace.yamlone place, and all packages referencing it automatically align, eliminating version drift where "Package A uses 7.28, Package B uses 7.29."

Scenario-Driven Walkthrough: What Happens After apnpm installOnce

Suppose you executepnpm installin the repository root. Plug into this scenario and trace step by step:

Step 1: preinstall gate.pnpm triggers the rootpackage.json'spreinstallscript before installation:

📎 package.json:45-45

json
"preinstall": "npx only-allow pnpm"
[Design Inference and Architecture Trade-offs]

only-allow pnpmchecks whether the current package manager is pnpm, and if not, directly errors out and exits. The existence of this script means: installing the core repository with npm or yarn will fail. Why must pnpm be locked in? Because the core repository relies on pnpm's workspace symlinks and catalog mechanism, npm's workspaces do not supportcatalog:syntax, and yarn's PnP mode changes module resolution paths, causingcreateRequirebehavior in build scripts to be inconsistent.

Step 2: Resolve workspace.pnpm readspnpm-workspace.yaml, scanspackages/*andpackages-private/*, and for each directory containingpackage.jsoncreates a package record.

Step 3: Apply catalog replacement.Rootpackage.jsonall incatalog:The placeholders are replaced with the actual versions from the catalog section, then installed uniformly.

Step 4: postinstall hook.Triggered after installation completes:

📎 package.json:46-46

json
"postinstall": "simple-git-hooks"

simple-git-hooksRead the rootpackage.jsonin thesimple-git-hooksfield, write the Git hook to.git/hooks/:

📎 package.json:48-51

json
"simple-git-hooks": {
  "pre-commit": "pnpm lint-staged && pnpm check",
  "commit-msg": "node scripts/verify-commit.js"
}

pre-commitThe hook runs lint-staged and type checking before every commit,commit-msgThe hook validates commit message format (Vue uses conventional commits). Note thepreinstallandpostinstallsymmetry: the former guards the gate (only allows pnpm), the latter sets up defenses (installs Git hooks).

Design thinking and pitfalls

[Design inference and architectural trade-offs]

Why use two globs instead of onepackages*/?Explicitly listing two directories makes the semantics of "public" and "private" visible at the configuration level. Any new developer readingpnpm-workspace.yamlimmediately knows the repository has two types of packages. If written aspackages*/, this semantics is hidden.

allowBuildsand supply chain security.Note this configuration:

📎 pnpm-workspace.yaml:15-21

yaml
allowBuilds:
  '@parcel/watcher': true
  '@swc/core': true
  'esbuild': true
  'puppeteer': true
  'simple-git-hooks': true
  'unrs-resolver': true

pnpm by default forbids dependency packages from executing install scripts (postinstall), because this is a common entry point for supply chain attacks.allowBuildsis a whitelist: only the listed packages are allowed to run build scripts.@swc/core、esbuildneeds to download platform-specific native binaries,puppeteerneeds to download Chromium,simple-git-hooksneeds to write Git hooks—these are all legitimate build-time behaviors, so they are explicitly allowed.

minimumReleaseAge: 1440The deeper meaning of.This line of configuration requires that newly published dependency versions must be "at least 24 hours old" (1440 minutes) before they are allowed to be installed:

📎 pnpm-workspace.yaml:33-33

yaml
minimumReleaseAge: 1440
[Design inference and architectural trade-offs]

This is a cooldown mechanism to defend against npm supply chain poisoning. After an attacker hijacks a package and publishes a malicious version, it is usually discovered and taken down within hours. Setting a 24-hour cooldown allows the core repository to avoid this window. AndminimumReleaseAgeExcludeallows exceptions for specific security patches:

📎 pnpm-workspace.yaml:36-38

yaml
minimumReleaseAgeExclude:
  # Renovate security update: vitest@4.1.11
  - vitest@4.1.11

The comment explicitly states that this is a security update triggered by Renovate and needs to take effect immediately, so the cooldown is exempted.

---

Part Two: Root-level tsconfig: uniformly constraining the type boundaries of all subpackages

Intuitive model

If each subpackage maintains its own tsconfig, there will be cracks like "package A usesstrict: false, package B usesstrict: true". The root-level tsconfig is theconstitution: it defines the type rules that all subpackages must jointly obey, and subpackages can only append on top of it, not violate it.

Data structures and memory layout

The roottsconfig.json'scompilerOptionsis the foundation of the entire repository's type system. Pick out a few key fields:

📎 tsconfig.json:5-29

json
"target": "es2016",
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"noUnusedLocals": true,
"isolatedModules": true,
"isolatedDeclarations": true,
"composite": true,
"paths": {
  "@vue/compat": ["./packages/vue-compat/src"],
  "@vue/*": ["./packages/*/src"],
  "vue": ["./packages/vue/src"]
}

Field-by-field interpretation:

  • target: es2016: output syntax downgraded to ES2016. This echoes the esbuildtargetin the Rollup configuration (isServerRenderer || isCJSBuild ? 'es2019' : 'es2016' 📎 rollup.config.js:337-337)。
  • moduleResolution: bundler: uses bundler-style module resolution, allowing omitted extensions and supporting theexportsfield.
  • strict: true: enables all strict checks, includingstrictNullChecks、noImplicitAny, etc.
  • noUnusedLocals: true: unused local variables directly error. This rule has practical significance in conjunction with Tree-shaking—unused variables are often a signal of dead code.
  • isolatedModules: true: requires each file to be independently transpilable. This is a prerequisite for tools like esbuild/swc that "transpile file by file without cross-file type analysis."
  • isolatedDeclarations: true: requires all exports to explicitly annotate types. This rule directly serves the.d.tsgeneration pipeline—only explicit annotations allowtscto quickly generate declaration files without full type inference.
  • composite: true: enables the incremental build metadata required for project references.

pathsThe field is the workspace'stype-layer mirror:@vue/*mapped to./packages/*/src, allowing TypeScript to resolve directly to source code at compile time, rather than the symlinks innode_modules. This complements pnpm's runtime symlinks—runtime relies on pnpm, compile time relies on paths.

Scenario-driven Walkthrough: onepnpm checktype check

checkThe script istsc --incremental --noEmit 📎 package.json:15-15. Substituting into this scenario:

Step 1: Read the include scope.tsconfig'sincludedetermines which files participate in checking:

📎 tsconfig.json:31-39

json
"include": [
  "packages/global.d.ts",
  "packages/*/src",
  "packages/*/__tests__",
  "packages/vue/jsx-runtime",
  "packages/runtime-dom/types/jsx.d.ts",
  "scripts/*",
  "rollup.*.js"
]

Note thatscripts/*androllup.*.jsare also within the checking scope. This means the build scripts themselves are also subject to type constraints—rollup.config.jsat the top of// @ts-check 📎 rollup.config.js:1-1combined with JSDoc type annotations allows this pure JS file to also be checked bytsc.

Step 2: Apply exclude exclusions.

📎 tsconfig.json:40-40

json
"exclude": ["packages-private/sfc-playground/src/vue-dev-proxy*"]
[Design inference and architectural trade-offs]

sfc-playgroundThevue-dev-proxyfiles in are excluded. Why? Such files are usually dynamically generated proxy code at runtime, whose type shapes are unstable, and including them in checks would create noise.

Step 3: Incremental checking. --incrementalletstsccache the previous check results to.tsbuildinfo, and only rechecks changed files.--noEmitmeans check only without output—type checking and artifact generation are two independent pipelines.

Design thinking and pitfalls

isolatedDeclarationsThe cost and benefit of.After enabling this rule, any export must explicitly annotate the return type, for exampleexport function foo(): numberinstead ofexport function foo() { return 1 }. This increases writing cost, but in exchange for a substantial increase in.d.tsgeneration speed—tscdeclaration files can be produced without cross-file inference. This echoes thebuild-dtsin thetsc -p tsconfig.build.json --noCheckscript--noCheck: since types are already explicitly annotated, checking can even be skipped when generating declaration files.

typesGlobal injection of the field.

📎 tsconfig.json:21-21

json
"types": ["vitest/globals", "puppeteer", "node"]

These three type packages are globally injected, meaning test files can directly usedescribe、it、expectwithout importing, and e2e tests can directly use the types ofpuppeteer. This is a trade-off between convenience and pollution—the more global types there are, the greater the risk of naming conflicts, but the better the writing experience for test code.

---

3. Rollup Configuration: A Unified Factory from buildOptions to Multi-Format Artifacts

Intuitive Model

The Rollup configuration is the core repository'sfinal assembly plant. It doesn't care what a specific package does; it only cares about "which formats this package needs to produce, where the entry file for each format is, and which dependencies should be externalized." Thepackage.jsonin each sub-package'sbuildOptionsfield is the shipping manifest attached to the package, and the assembly plant works according to the manifest.

Data Structures and Memory Layout

The entry point of the configuration file establishes the "build by package" model:

📎 rollup.config.js:32-44

js
if (!process.env.TARGET) {
  throw new Error('TARGET package must be specified via --environment flag.')
}
...
const privatePackages = fs.readdirSync('packages-private')
const pkgBase = privatePackages.includes(process.env.TARGET)
  ? `packages-private`
  : `packages`
const packagesDir = path.resolve(__dirname, pkgBase)
const packageDir = path.resolve(packagesDir, process.env.TARGET)
...
const pkg = require(resolve(`package.json`))
const packageOptions = pkg.buildOptions || {}
const name = packageOptions.filename || path.basename(packageDir)

Key design points:TARGETThe environment variable specifies which package to build. The configuration usesfs.readdirSync('packages-private')to determine whether the package belongs to a public or private directory, thereby decidingpkgBase. This is aruntime directory probe—there's no need to maintain a list of "which packages are private"; the directory structure itself is the truth.

buildOptionsis a custom field in the sub-package'spackage.json,packageOptions.filenamedetermines the artifact filename prefix,packageOptions.formatsdetermines the default build format.

The mapping from format to artifact is defined byoutputConfigs:

📎 rollup.config.js:58-88

js
const outputConfigs = {
  'esm-bundler': { file: resolve(`dist/${name}.esm-bundler.js`), format: 'es' },
  'esm-browser': { file: resolve(`dist/${name}.esm-browser.js`), format: 'es' },
  cjs:           { file: resolve(`dist/${name}.cjs.js`),         format: 'cjs' },
  global:        { file: resolve(`dist/${name}.global.js`),      format: 'iife' },
  'esm-bundler-runtime': { file: resolve(`dist/${name}.runtime.esm-bundler.js`), format: 'es' },
  'esm-browser-runtime': { file: resolve(`dist/${name}.runtime.esm-browser.js`), format: 'es' },
  'global-runtime':      { file: resolve(`dist/${name}.runtime.global.js`),      format: 'iife' },
}

Seven formats covering three consumption scenarios:esm-bundlerfor consumption by bundlers like Vite/webpack,esm-browserfor native browser ESM consumption,globalfor<script>tag consumption. Those with the-runtimesuffix are "runtime-only" builds, open only to the mainvuepackage.

Scenario-Driven Walkthrough: The Complete Decision Flow of a Singlepnpm build vue

Let's walk through the scenario of executingnode scripts/build.js vue.TARGET=vue, tracing the decisions insidecreateConfig:

Step 1: Determine the format list.

📎 rollup.config.js:91-92

js
const defaultFormats = ['esm-bundler', 'cjs']
const inlineFormats = process.env.FORMATS && process.env.FORMATS.split(',')
const packageFormats = inlineFormats || packageOptions.formats || defaultFormats
const packageConfigs = process.env.PROD_ONLY
  ? []
  : packageFormats.map(format => createConfig(format, outputConfigs[format]))

Priority: command lineFORMATS> sub-packagebuildOptions.formats> default['esm-bundler', 'cjs']。PROD_ONLYIf the environment variable is true, skip non-production builds and keep only the subsequently appended.prod.jsconfiguration.

Step 2: Compute build flags. createConfigInternally derives a set of boolean flags from the format string:

📎 rollup.config.js:131-142

js
const isProductionBuild = process.env.__DEV__ === 'false' || /\.prod\.js$/.test(output.file)
const isBundlerESMBuild = /esm-bundler/.test(format)
const isBrowserESMBuild = /esm-browser/.test(format)
const isServerRenderer = name === 'server-renderer'
const isCJSBuild = format === 'cjs'
const isGlobalBuild = /global/.test(format)
const isCompatPackage = pkg.name === '@vue/compat'
const isCompatBuild = !!packageOptions.compat
const isBrowserBuild =
  (isGlobalBuild || isBrowserESMBuild || isBundlerESMBuild) &&
  !packageOptions.enableNonBrowserBranches

These flags are thesingle source of truthfor all subsequent decisions: entry file selection, define replacement, external determination, plugin assembly—all depend on them.

Step 3: Select the entry file.

📎 rollup.config.js:159-168

js
let entryFile = /runtime$/.test(format) ? `src/runtime.ts` : `src/index.ts`

if (isCompatPackage && (isBrowserESMBuild || isBundlerESMBuild)) {
  entryFile = /runtime$/.test(format)
    ? `src/esm-runtime.ts`
    : `src/esm-index.ts`
}

The default entry issrc/index.ts, and runtime-only builds usesrc/runtime.ts. The compat package (@vue/compat, i.e., the Vue 2 compatibility build) needs to provide both default and named exports, which causes Rollup to error on non-ESM targets, so a separateesm-index.ts / esm-runtime.tsentry is used for the ESM build.

Step 4: Generate the define replacement table. resolveDefineReplaces compile-time constants like__DEV__、__BROWSER__in the source code with literals:

📎 rollup.config.js:170-201

js
const replacements = {
  __COMMIT__: `"${process.env.COMMIT}"`,
  __VERSION__: `"${masterVersion}"`,
  __TEST__: `false`,
  __BROWSER__: String(isBrowserBuild),
  __GLOBAL__: String(isGlobalBuild),
  __ESM_BUNDLER__: String(isBundlerESMBuild),
  __ESM_BROWSER__: String(isBrowserESMBuild),
  __CJS__: String(isCJSBuild),
  __SSR__: String(!isGlobalBuild),
  __COMPAT__: String(isCompatBuild),
  __FEATURE_SUSPENSE__: `true`,
  __FEATURE_OPTIONS_API__: isBundlerESMBuild ? `__VUE_OPTIONS_API__` : `true`,
  __FEATURE_PROD_DEVTOOLS__: isBundlerESMBuild ? `__VUE_PROD_DEVTOOLS__` : `false`,
  __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__: isBundlerESMBuild ? `__VUE_PROD_HYDRATION_MISMATCH_DETAILS__` : `false`,
}

There's an elegant layering here:feature flags are not hardcoded in the esm-bundler build, but kept as identifiers like__VUE_OPTIONS_API__, left for the end user's bundler to replace. This way users can disable Options API support viadefine: { __VUE_OPTIONS_API__: false }, thereby tree-shaking the related code. In global/esm-browser builds, however, these flags are hardcoded totrue/false, because artifacts consumed directly by the browser have no bundler involved.

Step 5: Allow environment variable overrides.

📎 rollup.config.js:208-216

js
// allow inline overrides like
//__RUNTIME_COMPILE__=true pnpm build runtime-core
Object.keys(replacements).forEach(key => {
  if (key in process.env) {
    const value = process.env[key]
    assert(typeof value === 'string')
    replacements[key] = value
  }
})

Any define key can be overridden by an environment variable of the same name. The example given in the comments is__RUNTIME_COMPILE__=true pnpm build runtime-core—used for debugging specific compilation branches.

Step 6: Assemble the plugin chain.

📎 rollup.config.js:324-342

js
plugins: [
  json({ namedExports: false }),
  alias({ entries }),
  enumPlugin,
  ...resolveReplace(),
  esbuild({
    tsconfig: path.resolve(__dirname, 'tsconfig.json'),
    sourceMap: output.sourcemap,
    minify: false,
    target: isServerRenderer || isCJSBuild ? 'es2019' : 'es2016',
    define: resolveDefine(),
  }),
  ...resolveNodePlugins(),
  ...plugins,
],

Plugin order matters:jsonhandles JSON imports first,aliasmaps@vue/*to source paths,enumPlugindoes enum inlining,replacedoes string replacement,esbuilddoes TS transpilation. Note thatesbuild'stsconfigpoints to the root tsconfig—all sub-packages share the same type configuration, which is precisely the build-time manifestation of the "constitution" discussed in Section 2.

Step 7: Append production builds.IfNODE_ENV=production:

📎 rollup.config.js:97-114

js
if (process.env.NODE_ENV === 'production') {
  packageFormats.forEach(format => {
    if (packageOptions.prod === false) {
      return
    }
    if (format === 'cjs') {
      packageConfigs.push(createProductionConfig(format))
    }
    if (/^(global|esm-browser)(-runtime)?/.test(format)) {
      packageConfigs.push(createMinifiedConfig(format))
    }
  })
}

The CJS format appends a.prod.jsversion (replacing with__DEV__=false), and the global and esm-browser formats append a minified version (minified with swc).packageOptions.prod === falsePackages with

can opt out of this mechanism.

mermaid
flowchart TD
    start["node scripts/build.js vue"] --> check_target{"process.env.TARGET 存在?"}
    check_target -->|否| throw_err["throw Error: TARGET must be specified"]
    check_target -->|是| detect_dir{"TARGET 在 packages-private 中?"}
    detect_dir -->|是| base_priv["pkgBase = packages-private"]
    detect_dir -->|否| base_pub["pkgBase = packages"]
    base_priv --> read_pkg["require(package.json) 读取 buildOptions"]
    base_pub --> read_pkg
    read_pkg --> resolve_formats{"FORMATS 环境变量?"}
    resolve_formats -->|有| use_inline["使用命令行格式"]
    resolve_formats -->|无| check_buildopts{"buildOptions.formats?"}
    check_buildopts -->|有| use_pkg["使用包声明格式"]
    check_buildopts -->|无| use_default["使用默认 esm-bundler,cjs"]
    use_inline --> create_cfg["createConfig(format, output)"]
    use_pkg --> create_cfg
    use_default --> create_cfg
    create_cfg --> check_output{"output 配置存在?"}
    check_output -->|否| exit_err["console.log invalid format; process.exit(1)"]
    check_output -->|是| pick_entry{"格式含 runtime?"}
    pick_entry -->|是| entry_rt["entryFile = src/runtime.ts"]
    pick_entry -->|否| entry_idx["entryFile = src/index.ts"]
    entry_rt --> build_flags["计算 isBundlerESMBuild/isCJSBuild 等标志"]
    entry_idx --> build_flags
    build_flags --> prod_check{"NODE_ENV == production?"}
    prod_check -->|是| add_prod["追加 .prod.js 与 minified 配置"]
    prod_check -->|否| done["导出 packageConfigs"]
    add_prod --> done

Copy

externalDesign Reflections and Pitfalls resolveExternal's three-branch strategy.

📎 rollup.config.js:257-283

js
function resolveExternal() {
  const treeShakenDeps = ['source-map-js', '@babel/parser', 'estree-walker', 'entities/decode']

  if (isGlobalBuild || isBrowserESMBuild || isCompatPackage) {
    if (!packageOptions.enableNonBrowserBranches) {
      return treeShakenDeps
    }
  } else {
    return [
      ...Object.keys(pkg.dependencies || {}),
      ...Object.keys(pkg.peerDependencies || {}),
      ...['path', 'url', 'stream'],
      ...treeShakenDeps,
    ]
  }
}

CopytreeShakenDepsBrowser builds (global/esm-browser) inline all dependencies, listing onlydependenciesas external to suppress warnings—these dependencies are never actually referenced in the browser branch and will be removed by tree-shaking. Node/esm-bundler builds externalize allpeerDependenciesand

onwarn, letting consumers manage dependency versions themselves.

📎 rollup.config.js:344-348

js
onwarn: (msg, warn) => {
  if (msg.code !== 'CIRCULAR_DEPENDENCY') {
    warn(msg)
  }
},

Copyruntime-coreCircular dependency warnings are silenced. Vue'sreactivityand

treeshake.moduleSideEffects: falsehave a legitimate circular reference (the reactivity system needs to reference the component instance type), and these cycles are safe at runtime, so they are filtered.

📎 rollup.config.js:355-355

js
treeshake: {
  moduleSideEffects: false,
},

CopyThis tells Rollup: all modules have no side effects, so unreferenced imports can be safely removed. This is anaggressive assumption

—if a module executes side-effect code at the top level (such as registering a global variable), it might be incorrectly removed. Vue's source code guarantees by convention that all modules are pure, so this optimization can be enabled.pure_gettersswc-minify's

📎 rollup.config.js:373-388

js
async renderChunk(contents, _, { format }) {
  const { code } = await minifySwc(contents, {
    module: format === 'es',
    format: { comments: false },
    compress: { ecma: 2016, pure_getters: true },
    safari10: true,
    mangle: true,
  })
  return { code: banner + code, map: null }
}

pure_getters: trueCopyobj.fooTells the minifier that "property access has no side effects," so unused getter calls can be safely removed. This is dangerous for Vue's reactive code—track()) rather than implicit getter side effects, so it is safe.map: nullindicates that no sourcemap is generated after compression—production artifacts do not need debugging mappings.

---

Design thinking: why the source repository and release artifacts must be decoupled

Returning to the core proposition of this chapter. The engineering design of the core repository has a main thread running throughout:The responsibility of the source repository is "production," and the responsibility of release artifacts is "consumption." The two are decoupled through the build pipeline.。

This is specifically reflected in three aspects:

First, source code is not published directly. package.jsonTheprivate: true 📎 package.json:2-2indicates that the root package is never published. In each subpackage'spackage.jsonthemain/module/exportsfield points todist/artifacts under, rather thansrc/. When users installvuewhat they get is the built.jsand.d.ts, while the source code remains in the repository.

Second, the artifact format is determined by the consumption scenario.The seven formats are not listed arbitrarily, but correspond to seven real consumption paths: Vite users getesm-bundler, CDN users getglobal, Node SSR users getcjs. The format selection logic is centralized inrollup.config.jsone place, and subpackages only need to declare inbuildOptions.formatswhich ones are needed.

Third, types and implementation are separated. build-dtsThe scripttsc -p tsconfig.build.json --noCheck && rollup -c rollup.dts.config.js 📎 package.json:9-9indicates that.d.tsgeneration is an independent pipeline.isolatedDeclarations: trueallows declaration file generation to skip type checking (--noCheck), because the types have already been explicitly annotated.

[Design inference and architectural trade-offs]

The deeper motivation for this decoupling is:The way source code is organized serves developers, and the way artifacts are organized serves consumers; the optimal solutions for the two are different. Source code needs a clear directory structure, complete type information, and debuggable sourcemaps; artifacts need minimal size, the correct module format, and a stable API surface. Forcing the two to be unified (for example, directly publishing TS source code) would harm the experience on both ends at the same time.

---

Chapter summary

This chapter established a macro-level understanding of the core repository from three dimensions:

1. Dual-directory structure:packages/andpackages-private/physical isolation, combined with pnpm workspace symlinks and the catalog version directory, achieves a clear boundary between "public packages" and "private packages."preinstallgatekeeping,allowBuildswhitelist,minimumReleaseAgeand cooldown period together form the supply chain security defense line.

2. Root-level tsconfig: as the type constitution for all subpackages, it implements compile-time workspace resolution throughpathsmapping, and supports incremental builds and fast declaration file generation throughisolatedDeclarationsandcomposite.

3. Rollup unified factory: with theTARGETenvironment variable as the entry point, it reads subpackage metadata throughbuildOptions, and uses a set of boolean flags to drive entry selection, define replacement, external determination, and plugin assembly, ultimately producing artifacts in seven formats.

The core philosophy isthe decoupling of the source repository and release artifacts: the repository is responsible for production, artifacts are responsible for consumption, and the build pipeline is the only bridge between the two.

---

Chapter transition

This chapter answered "what the core repository is." But the static structure of the repository is only the stage; the real drama happens during the execution of a build request:scripts/build.jshow command-line arguments are parsed, how the Rollup API is called, and how build failures and concurrency are handled. The next chapter will trace the end-to-end journey of a build request from input to artifact, transforming the static understanding established in this chapter into a dynamic execution view.

Chapter reflection and self-test

Q1: If inpnpm-workspace.yamltheminimumReleaseAge: 1440were changed to0, what risk would be introduced in dependency upgrade scenarios? Why isminimumReleaseAgeExcludenecessary?

Reference analysis:

minimumReleaseAge: 1440 📎 pnpm-workspace.yaml:33-33requires that newly published dependency versions must be at least 24 hours old before they are allowed to be installed. If changed to0, then any just-published version can be pulled in immediately.

Risk scenario: an attacker hijacks a transitive dependency (for example, a patch version of@babel/parser) and publishes a version containing a malicious postinstall script. During the 24-hour cooldown period, the community usually discovers the problem and removes that version; if the cooldown period is 0, the core repository's CI may automatically upgrade and execute the malicious script within the attack window.

minimumReleaseAgeExclude 📎 pnpm-workspace.yaml:36-38exists because the cooldown mechanism conflicts with the urgency of security patches. Thevitest@4.1.11in the comment is a security update detected by Renovate—such updates need to take effect immediately, and waiting 24 hours would instead extend the exposure window. Therefore, an explicit exemption list is needed to let security updates bypass the cooldown period. This reflects the security design principle of "conservative by default, explicit for exceptions."

Q2: rollup.config.jsInresolveDefinethe handling of__FEATURE_OPTIONS_API__isisBundlerESMBuild ? '__VUE_OPTIONS_API__' : 'true'. If it were incorrectly changed to return'true'for all formats, what impact would that have on end users?

Reference analysis:

📎 rollup.config.js:192-194

js
__FEATURE_OPTIONS_API__: isBundlerESMBuild
  ? `__VUE_OPTIONS_API__`
  : `true`,

In the esm-bundler build,__FEATURE_OPTIONS_API__is preserved as the identifier__VUE_OPTIONS_API__and left for the end user's bundler to replace. Users can setdefine: { __VUE_OPTIONS_API__: false }in their own build configuration, thereby allowing Tree-shaking to remove all Options API-related code (data、methods、computedhandling logic for options such as), significantly reducing artifact size.

If changed to return'true'for all formats, then the Options API code in the esm-bundler artifact would be hard-coded and retained, the user'sdefineconfiguration would become ineffective, and Tree-shaking would be impossible. For a project that only uses the Composition API, this would add several KB to the artifact size for no reason.

The key insight of this design is:the final form of the esm-bundler artifact is determined by the user's bundler, so feature flags must be deferred until the user's build time for resolution. In contrast, global/esm-browser artifacts run directly in the browser, with no bundler involved, so they must be hard-coded.

Q3: rollup.config.jsofresolveExternal, the browser build only returnstreeShakenDepsas external, while the Node build returns alldependencies. Suppose one day someone adds a new runtime dependencyruntime-coretofoo-lib, but forgets to updateresolveExternal's logic. What happens in the browser build?

Reference Analysis:

📎 rollup.config.js:257-283

The browser build (isGlobalBuild || isBrowserESMBuild) only returns!packageOptions.enableNonBrowserBrancheswhentreeShakenDeps(source-map-js、@babel/parser、estree-walker、entities/decode). This meansfoo-libis not in the external list,

At this point, we have seen from a macro level the overall design philosophy of the core repository as an engineering mothership: the dual-directory workspace structure defines the boundary between public packages and private experimental packages, the root-level TypeScript and Rollup configurations provide unified constraints, and the decoupling of the source repository from published artifacts makes multi-format output possible. These insights pave the way for deeper exploration of specific engineering pipelines later. In the next chapter, we will shift our view from static structure to dynamic flow, starting fromnode scripts/build.js vueas the starting point, tracing the end-to-end journey of a complete build request from command-line argument parsing, target package location, Rollup configuration generation, to artifact writing to disk, and seeing how build.js parses flags such as formats/devOnly/release through parseArgs, how it dynamically requires the target package's package.json and reads buildOptions, and ultimately drives rollup.config.js to produce multi-format artifacts such as esm-bundler, cjs, and global.

Turn Any Codebase into a Book You Can Actually Understand

Enjoyed this chapter? Turn your private codebase into an architecture book

Local-first Tauri 2 + Rust architecture. 100% offline security, zero code uploaded. Dual-pane reading with immutable commit line anchors.

⚡ Tauri 2 · Rust Native Core · 100% Offline & Private · Tested on 1M+ LOC

CHAPTER 02

Chapter 2: Main Trunk Lifecycle: The End-to-End Journey of a Build Request

Upstream: vuejs/core · Commit @4ab865a8 · Progress: Chapter 2 of 14

In the previous chapter, we clarified the positioning of the core repository as an engineering mothership, and how the pnpm workspace and root-level configuration uniformly constrain all subpackages. Now, we go deep into the core of the build system and trace how a single command drives the entire build process.node scripts/build.js vueIt appears simple, but it is the only entry point for all artifacts—esm-bundler, cjs, global. Understanding how it translates user intent into executable build tasks is a key step in mastering Vue's build mechanism.

Rollup Configuration Generation: From Environment Variables to Multi-Format Artifacts

build.jsAfter starting Rollup throughexec, control shifts torollup.config.js. This file is the "brain" of the build system—it reads environment variables and dynamically generates an array of Rollup configuration objects.

Environment Variable Validation and Package Location

📎 rollup.config.js:27-29

IfTARGETis not set, throw an error directly. This is defensive programming: the Rollup configuration may be called directly (such asrollup -c), in which case there is nobuild.jsinjecting environment variables, so it must fail fast.

📎 rollup.config.js:32-44

Here the private package determination logic frombuild.jsis repeated—becauserollup.config.jsis an independent process and cannot sharebuild.js's in-memory state.resolveThe function resolves a relative path to an absolute path under the package directory,pkgis the target package'spackage.jsoncontent,packageOptionsis thebuildOptionsfield within it,nameis the artifact filename prefix (preferbuildOptions.filename, otherwise use the directory name).

Format mapping table:outputConfigs

📎 rollup.config.js:58-88

This table defines the mapping from 7 formats to output configurations. Key observations:

  • esm-bundler、esm-browser、esm-bundler-runtime、esm-browser-runtimeare allformat: 'es', the only difference is the filename.
  • cjsisformat: 'cjs'。
  • globalandglobal-runtimeisformat: 'iife'(immediately invoked function expression), suitable for direct inclusion via<script>tags.
  • runtimeFormats with thevuesuffix are only meaningful for the main

package—they do not include the compiler and are smaller in size.

📎 rollup.config.js:91-92

Format Selection: Three Levels of PriorityFORMATSFormat selection follows three levels of priority: command-linebuildOptions.formatsenvironment variable > package's['esm-bundler', 'cjs']。PROD_ONLY> default

The environment variable controls whether to skip the base configuration—if only building the production version, the base configuration array is empty, and only the production configuration is pushed afterward.

📎 rollup.config.js:97-114

Production Configuration Append LogicNODE_ENV === 'production'When

  • , for each format:packageOptions.prod === falseIf
  • , skip (the package does not need a production version).cjsIf it iscreateProductionConfig, append.prod.js—generate the
  • file./^(global|esm-browser)(-runtime)?/If it matchescreateMinifiedConfig, append
—generate the minified version.

〔Design Inference and Architectural Trade-offs〕cjsWhy doescreateProductionConfiguseglobal/esm-browserwhilecreateMinifiedConfiguses

createConfig? Because CJS is for Node, and the Node environment does not need minification (users will handle it themselves), but it does need to distinguish dev/prod branches; whereas artifacts directly included by the browser must be minified to reduce size. This difference is reflected in the implementations of the two factory functions.

createConfig: The Core of Configuration Generation

📎 rollup.config.js:125-142

is the largest function; it receives the format and output configuration and returns the complete Rollup configuration object.

  • isProductionBuildIt begins with the calculation of a series of boolean flags:__DEV__: determined by the.prod.jsenvironment variable or whether the filename contains
  • isBundlerESMBuild、isBrowserESMBuild、isCJSBuild、isGlobalBuild.
  • isServerRenderer: matched by a regular expression on the format name.server-renderer。
  • isCompatPackage、isCompatBuild: whether the package name is
  • isBrowserBuild: related to the Vue 2 compatibility build.

: global build or browser ESM build, and the non-browser branch is not enabled.resolveDefine、resolveReplace、resolveExternalThese flags are used repeatedly in the subsequent

📎 rollup.config.js:144-157

and are the core basis for configuration differentiation.exportsBasic output configuration settings: banner copyright header,automode (compat packages usenamed, the rest useesModule), CJS build enablesexternalLiveBindings: falseinterop, sourcemap is controlled by environment variables,reexportProtoFromExternal: falseandoutput.nameare compatibility settings for Rollup 4. The global build additionally setswindow, that is, the variable name mounted on

.

📎 rollup.config.js:159-168

Entry File Selectionsrc/index.tsThe default entry isruntime, but formats with thesrc/runtime.tsThe ESM build of the compat package needs to export both default and named, so a separateesm-index.ts / esm-runtime.tsentry is used.

Macro definitions:resolveDefine

📎 rollup.config.js:170-218

resolveDefineReturns a replacement table that replaces__COMMIT__、__VERSION__、__BROWSER__and other macros in the source code with literals. These macros are used for conditional compilation in the source code—for example,if (__DEV__) { ... }will be replaced withif (false) { ... }in production builds, and then removed by Tree-shaking.

Key design:__FEATURE_OPTIONS_API__、__FEATURE_PROD_DEVTOOLS__Feature flags such asesm-bundlerare kept as__VUE_OPTIONS_API__identifiers in the build, allowing end users to override them through bundler configuration; in other builds they are directly hardcoded astrueorfalse。

📎 rollup.config.js:203-206

Non-esm-bundlerbuilds hardcode__DEV__because their dev/prod branches are already determined at build time.

📎 rollup.config.js:210-216

The last step allows environment variables to override any macro definition, supporting__RUNTIME_COMPILE__=true pnpm build runtime-coreinline overrides like this.

Replacement plugin:resolveReplace

📎 rollup.config.js:222-255

resolveReplaceHandles replacements outsideresolveDefinethat esbuild cannot handle:

  • MergeenumDefines(enum inline definitions frominlineEnums).
  • In production browser builds, add/*@__PURE__*/annotations to error creation functions to help Tree-shaking.
  • esm-bundlerIn the__DEV__build, replace!!(process.env.NODE_ENV !== 'production')with
  • and let the bundler decide.process.envIn the browser ESM build, replace

with an empty object to avoid browser errors.resolveExternal

📎 rollup.config.js:257-283

External dependencies:treeShakenDepsThis is the core of the thought question at the end of the previous chapter. The browser build only returnsdependenciesas external—although these dependencies are imported, they are not actually executed in the browser branch, and are listed here only to suppress Rollup warnings. Node/ESM-bundler builds externalize allpeerDependenciesandpath、url、streamas well as Node built-in modules such as

Final configuration object

📎 rollup.config.js:319-352

The returned configuration object contains:

  • input: absolute path to the entry file.
  • external: list of external dependencies.
  • plugins: plugin array, in the order json → alias → enumPlugin → replace → esbuild → nodePlugins.
  • output: output configuration.
  • onwarn: filter outCIRCULAR_DEPENDENCYwarnings (circular dependencies exist in Vue source code, but they are harmless at runtime).
  • treeshake.moduleSideEffects: false: tell Rollup that all modules have no side effects, enabling aggressive Tree-shaking.

The following figure shows the data flow from environment variables to the final configuration:

mermaid
flowchart LR
    env["process.env<br/>TARGET, FORMATS, NODE_ENV"] --> pkg_load["require(package.json)"]
    pkg_load --> pkg_opts["packageOptions<br/>= pkg.buildOptions"]
    env --> fmt_sel["packageFormats<br/>= FORMATS || buildOptions.formats || default"]
    fmt_sel --> cfg_map["outputConfigs[format]"]
    pkg_opts --> create_cfg["createConfig(format, output)"]
    cfg_map --> create_cfg
    create_cfg --> define["resolveDefine()<br/>__DEV__, __BROWSER__ ..."]
    create_cfg --> replace["resolveReplace()<br/>enumDefines, __DEV__"]
    create_cfg --> external["resolveExternal()<br/>treeShakenDeps / deps"]
    create_cfg --> node_plugins["resolveNodePlugins()<br/>commonJS, nodeResolve"]
    define --> rollup_cfg["RollupOptions<br/>{ input, external, plugins, output }"]
    replace --> rollup_cfg
    external --> rollup_cfg
    node_plugins --> rollup_cfg
    rollup_cfg --> rollup_run["Rollup 执行构建"]
    rollup_run --> dist["dist/*.js 产物落盘"]

Artifact writing and size checking

execProcess management for

build.jsStarts the Rollup subprocess throughexec:

📎 scripts/utils.js:64-114

execwrapsspawnand returns a Promise. Key design:

  • stdiodefaults to['ignore', 'pipe', 'pipe']—stdin is ignored, stdout/stderr are captured through pipes.
  • shell: process.platform === 'win32'—on Windows, a shell is required to correctly parse the command.
  • Collect output through thestderrChunksandstdoutChunksarrays, concatenating in theexitevent.
  • Resolve when the exit code is 0; otherwise reject with the stderr content.
[Design inference and architectural trade-offs]

Note thatbuild.jscallsexecwith{ stdio: 'inherit' }, which overrides the default pipe configuration and lets Rollup output pass through directly to the terminal. This is the correct behavior for a build tool—users need to see build progress in real time.

Size checking:checkAllSizes

📎 scripts/build.js:206-215

Size checking has two skip conditions:devOnlyis true, or a format is specified but does not includeglobal. Because size checking only targets global build artifacts—those are the files directly imported by end users, and size is most sensitive there.

📎 scripts/build.js:222-228

checkSizeCheck two files:${target}.global.prod.jsand${target}.runtime.global.prod.js(the latter is checked only when no format is specified orglobal-runtimeis specified).

📎 scripts/build.js:235-264

checkFileSizeRead the file, usegzipSyncandbrotliCompressSyncto calculate the compressed size, and useprettyBytesto format the output. IfwriteSizeis true, write the result totemp/size/${fileName}.json—this is the data source for size budget checks in CI.

Type declaration build

📎 scripts/build.js:94-108

IfbuildTypesis true, callpnpm run build-dts, and pass the target list through--environment TARGETS:.... This ensures type declarations are generated only for the packages actually being built.

Design thinking and production pitfalls

Why use--environmentinstead of passing parameters directly?Rollup's--environmentis the only way to pass parameters that can be read in the config file throughprocess.env. Passing--configparameters directly requires parsingprocess.argv, whereas--environmentprovides structured key-value parsing.

fuzzyMatchTargetRegex trap in target.match(partialTarget)InpartialTargetis user input. If the user inputsruntime-core,-it is a literal in the regex, so there is no problem; but if the input isruntime.core,.it will match any character and may match unexpected targets. This is the inherent risk of fuzzy matching, but Vue package names do not contain regex special characters, so it will not actually trigger.

Resource contention in concurrent builds. runParallelusescpus().lengthas the concurrency limit, but each Rollup process itself also starts workers. In low-core CI containers, this may cause out-of-memory. In production, if OOM occurs, it can be mitigated by--max-old-space-sizeor by reducing the concurrency count.

scanEnumsCache lifecycle of removeCacheis called infinally, but ifscanEnumsitself throws,removeCachewill not be assigned, and the call infinallywill fail. In fact, the function returned byscanEnumsis already determined beforetry, so this risk does not exist—but this is a timing detail that needs to be confirmed when reading.

resolveExternalRisk of omission inThe thought question in the previous chapter already pointed out: if a new dependency is added toruntime-corebutresolveExternalis forgotten to be updated, the browser build will bundle that dependency in (because it is not in the external list), causing size bloat. This is the inherent cost of the "whitelist external" strategy.

Chapter summary

The complete journey of onenode scripts/build.js vue:

1. parseArgsparses the command line,commitis obtained synchronously.

2. run()CallscanEnumsto generate the enum cache, parse the target (fuzzyMatchTargetorallTargets)。

3. buildAllthroughrunParallelconcurrently schedulebuild。

4. buildlocate the package directory, readpackage.json, filter private packages, cleandist, assemble--environmentparameters, callexecStart Rollup.

5. rollup.config.jsRead environment variables, throughcreateConfiggenerate the configuration array,resolveDefine/resolveReplace/resolveExternaland handle macros, replacements, and external dependencies separately.

6. Rollup executes the build, and the artifacts are written to disk atdist/。

7. checkAllSizesCalculate gzip/brotli sizes, optionally write totemp/size/。

8. If--withTypes, callbuild-dtsto generate type declarations.

Chapter Review and Self-Test

Q1: Inbuild.js'sbuildfunction,if (!formats && fs.existsSync(...))this condition determines whether to delete thedistdirectory. If the!formatscondition is removed (i.e., deletedistregardless of whether the format is specified),pnpm build-all-cjsin a script like

Reference Analysis:

📎 scripts/build.js:172-175

pnpm build-all-cjscorresponds tonode scripts/build.js vue runtime compiler reactivity shared -af cjs(see📎 package.json:40). It specifies-f cjs, soformatsis'cjs',!formatsis false, and the current logic will not deletedist。

If!formatsis removed, every build will deletedist. Butbuild-all-cjsonly builds thecjsformat, so after deletiondistonly contains thecjsartifacts, and previously builtesm-bundler、globaland other formats are all lost. More seriously,build-runtime-esm、build-browser-esmand other scripts will execute in sequence (see📎 package.json:39'sbuild-sfc-playgroundscript), and each script will delete the artifacts of the previous script, resulting in the finaldistcontaining only the format of the last script. This would break the SFC Playground build—it requires artifacts in multiple formats to exist simultaneously.

Q2: runParallelInif (maxConcurrency <= source.length)what is the purpose of thetargets.length === 1condition? If it is removed, what happens when building a single package (

)?:

📎 scripts/build.js:131-151

Reference AnalysismaxConcurrency > source.lengthThis condition controls whether concurrency throttling is enabled. Whenexecuting, throttling is not needed—all tasks can start simultaneously. If this condition is removed, even if there is only one task, it will create theawait Promise.race(executing)。

array and executeexecutingFor a single task,e,Promise.racethere is only one Promise inexecuting.splice(executing.indexOf(e), 1)that will wait for it to complete. This will not cause an error, but it introduces an unnecessary Promise chain and microtask scheduling overhead. More importantly,

still works correctly in the single-task scenario, so there is no functional difference, only a slight performance loss.maxConcurrencyThe real risk is: ifcpus().lengthis 0 (theoretically impossible, becauseexecuting.length >= 0is at least 1),Promise.race([])is always true,cpus().lengthwill hang forever. But

Q3: resolveExternalguarantees that this boundary will not be triggered.treeShakenDepsIn

, the browser build returns:

📎 rollup.config.js:257-283

treeShakenDepsas external, but these dependencies are not actually executed in the browser branch. What happens if they are removed from the external list (i.e., let Rollup try to bundle them)?source-map-js、@babel/parser、estree-walker、entities/decodeReference Analysiscompiler-sfcincludes__BROWSER__. These are dependencies of packages such as

, and are conditionally compiled out in the browser build through thetreeshake.moduleSideEffects: false(📎 rollup.config.js:355-355macro.if (!__BROWSER__)If removed from external, Rollup will try to resolve and bundle these dependencies. Because__BROWSER__), and the import statements of these dependencies are located in thetruebranch, esbuild's define will replace

withonwarn, causing the branch to be marked as dead code. Rollup's Tree-shaking will remove these imports, and the final artifact will not contain the code of these dependencies.

But the problem is: Rollup needs to resolve modules before Tree-shaking. If these dependencies are not installed (for example, in a minimal CI environment), Rollup will report a "cannot resolve module" error. Listing them as external is a defensive measure—even if the dependency does not exist, Rollup will not try to resolve it, and will only issue a warning (whilescripts/dev.jswill filter out warnings for non-circular dependencies).

Turn Any Codebase into a Book You Can Actually Understand

Enjoyed this chapter? Turn your private codebase into an architecture book

Local-first Tauri 2 + Rust architecture. 100% offline security, zero code uploaded. Dual-pane reading with immutable commit line anchors.

⚡ Tauri 2 · Rust Native Core · 100% Offline & Private · Tested on 1M+ LOC

CHAPTER 03

Back to top ↑

Upstream: vuejs/core · Commit @4ab865a8 · Progress: Chapter 3 of 14

Book progress: Chapter 3 / 14scripts/dev.jsVerification status: FACT line numbers are truly anchoredscripts/pre-dev-sfc.jsIn the previous chapter, we traced the complete pipeline of production builds from argument parsing to multi-format artifact output, a pipeline that pursues completeness and standardization of artifacts. But the core demand of development mode is only one thing: change one line of code, and immediately see the effect in the browser. The production build pipeline of "parse arguments → generate config → full bundle → write to disk" often takes tens of seconds and cannot satisfy this demand at all. The Vue core repository maintains an independent development-time pipeline for this:

uses esbuild's watch mode for incremental builds,

and precompiles the SFC compiler before the main build. This chapter breaks down the collaboration mechanism between the two.

3.1 dev.js: An Incremental Builder That Trades Speed with esbuild📎 scripts/dev.js:3-5

Intuitive Model

Production builds are like "formal typesetting and printing at a printing factory"—quality first, slower is okay; development builds are like "pencil sketches on scratch paper"—not seeking refinement, only seeking immediate appearance. Vue chooses esbuild instead of Rollup to draw this sketch, and the reason is written in the comments at the beginning of the file: Rollup artifacts are smaller and Tree-shaking is better, but esbuild is much faster.

Without this script, developers would have to run a full production build for every change, and the feedback loop would degrade from milliseconds to minutes, completely losing the hot update experience.parseArgsArgument Parsing and Format InferenceformatThe script entry uses Node's built-inglobal)、prodto parse three options:false)、inline(defaultfalse)。📎 scripts/dev.js:18-40positional arguments are collected astargets, if empty then defaults to['vue']。📎 scripts/dev.js:42-53

〔Design Inference and Architectural Trade-offs〕

There is an easily overlooked detail here:rawFormatandformatare two separate assignments.parseArgs'sdefault: 'global'already guarantees thatrawFormathas a value, but the script still writesconst format = rawFormat || 'global'as a fallback.📎 scripts/dev.js:42This is a defensive pattern, avoidingparseArgsbehavior changes or downstreamformat.startsWiththrowing errors when an empty string is explicitly passed in.

formatThe mapping to esbuild output format is a three-way branch: starting withglobalmaps toiife, equal tocjsmaps tocjs, everything else defaults toesm。📎 scripts/dev.js:42-53The artifact filename suffix is handled separately by the-runtimesuffix:global-runtimebecomesruntime.global, the rest remain unchanged.📎 scripts/dev.js:42-53

Target Package Location and Output Path

The script first reads thepackages-privatedirectory listing, used to determine whether the target package is a public or private package.📎 scripts/dev.js:56For each target, decide whether the package base path ispackagesorpackages-private, thenrequireitspackage.jsonto getversionandbuildOptions。📎 scripts/dev.js:58-63

There is a special case for output filenames:vue-compattargets are renamed tovue, avoiding artifacts namedvue-compat.global.js。📎 scripts/dev.js:64-69The final path looks likepackages/vue/dist/vue.global.js,prodWhen true, insertprod.segment.

external resolution: avoiding bundling dependencies into artifacts

externalThe array determines which modules are not bundled. The logic is split into two layers:

First layer, wheninlineis not enabled and the format iscjsor containsesm-bundler, add all keys ofdependencies、peerDependenciesto external, and hardcodepath、url、streamthree Node built-in modules.📎 scripts/dev.js:76-88The comment explicitly states these three are for@vue/compiler-sfcandserver-renderer.

Second layer, for thecompiler-sfctarget, additionally resolve@vue/consolidate'sdevDependencies, and externalize them along withfs、vm、cryptoetc.📎 scripts/dev.js:90-112The code also hardcodesreact-dom/server、teacup/lib/express、arc-templates/dist/es5、then-pug、then-jadeand other template engine paths—these are template engines supported by consolidate, which are optional dependencies and cannot be force-installed.

〔Design Inference and Architectural Trade-offs〕

This logic is highly duplicated withrollup.config.js, and the source comments acknowledge this (TODO this logic is largely duplicated from rollup.config.js). The reason no shared function was extracted is that dev and prod external strategies have subtle differences (dev externalizes more aggressively to speed up builds), and forcing unification would instead increase coupling.

Plugins and define injection

The plugin array defaults to only onelog-rebuild, which prints the relative path of build artifacts in theonEndhook.📎 scripts/dev.js:115-124This is the only feedback signal for developers to perceive "changes have taken effect".

〔Design Inference and Architectural Trade-offs〕

The second plugin is conditional: when the format is notcjsand the package'sbuildOptions.enableNonBrowserBranchesis true, mountpolyfillNode()。📎 scripts/dev.js:126-128Packages likecompiler-sfcstill go through the Node branch in browser builds, requiring polyfills for Node built-in modules to run in the browser environment.

defineThe block is the most information-dense part of this chapter.📎 scripts/dev.js:141-159It replaces all__XXX__macros in the source code with literals:

  • __COMMIT__is fixed to"dev",__VERSION__takes the package version;
  • __DEV__is determined by theprodflag,__TEST__is alwaysfalse;
  • __BROWSER__The derivation of is the most subtle:format !== 'cjs' && !pkg.buildOptions?.enableNonBrowserBranches。📎 scripts/dev.js:146-148That is, only "non-cjs and the package does not support non-browser branches" is marked as browser environment;
  • __SSR__isformat !== 'global', i.e., global builds do not enable the SSR branch;
  • __COMPAT__is determined by whether the target isvue-compat;
  • Three feature flags (__FEATURE_SUSPENSE__、__FEATURE_OPTIONS_API__、__FEATURE_PROD_DEVTOOLS__、__FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) are all hardcoded in dev mode.

These macros correspond one-to-one with thevitest.config.tsindefineblock.📎 vitest.config.ts:6-21The test environment sets__TEST__totrue、__DEV__and setstrue, and the difference from dev builds is precisely the distinction between "test vs development" runtime states.

watch mode startup

The last step isesbuild.context(...).then(ctx => ctx.watch())。📎 scripts/dev.js:130-161 contextcreating a build context but not executing immediately,watch()actually starts file watching. After that, esbuild internally maintains a dependency graph, and any change to a depended-upon file triggers incremental rebuild, with the rebuild completion callbackonEndprinting logs.

mermaid
flowchart TD
    start["parseArgs 解析 format/prod/inline"] --> targets{"positionals 为空?"}
    targets -->|是| def["targets = ['vue']"]
    targets -->|否| use["targets = positionals"]
    def --> loop["遍历每个 target"]
    use --> loop
    loop --> priv{"target 在 packages-private?"}
    priv -->|是| pbase["pkgBase = packages-private"]
    priv -->|否| pub["pkgBase = packages"]
    pbase --> req["require package.json"]
    pub --> req
    req --> ext{"inline 开启?"}
    ext -->|是| noext["external = []"]
    ext -->|否| fmt{"format 是 cjs 或 esm-bundler?"}
    fmt -->|是| deps["加入 dependencies/peerDependencies + path/url/stream"]
    fmt -->|否| sfc{"target == compiler-sfc?"}
    deps --> sfc
    sfc -->|是| cons["加入 consolidate devDeps + fs/vm/crypto"]
    sfc -->|否| noext
    cons --> ctx["esbuild.context 创建上下文"]
    noext --> ctx
    ctx --> watch["ctx.watch() 启动监听"]
    watch --> onend["onEnd 打印 built: 相对路径"]

3.2 pre-dev-sfc.js: A pre-compilation sentinel to break circular dependencies

Intuitive model

Imagine a "chicken-and-egg" dilemma:compiler-sfc's source code importscompiler-core, whilecompiler-corein development mode needscompiler-sfcto process.vuefiles. If both rely on esbuild watch for real-time compilation, whoever compiles first gets stuck.pre-dev-sfc.js's role is to "hatch the egg first, then raise the chicken"—before the main build starts, ensure the CJS artifacts of these packages already exist.

Checklist and short-circuit logic

The script maintains a fixed checklist:compiler-sfc、compiler-core、compiler-dom、compiler-ssr、shared。📎 scripts/pre-dev-sfc.js:4-10For each package, check whetherpackages/${pkg}/dist/${pkg}.cjs.jsexists.📎 scripts/pre-dev-sfc.js:4-23

As long as one is missing,allFilesPresentis set tofalseand immediatelybreak, without checking the remaining packages.📎 scripts/pre-dev-sfc.js:20-21Finally, ifallFilesPresentis false,process.exit(1)exits with a non-zero code.📎 scripts/pre-dev-sfc.js:25-27

Semantics of exit codes

This script itself does not perform any compilation; it only does "existence assertions".exit(1)is a signal for upper-level callers (usually the npm script's&&chain or CI scripts): artifacts are incomplete, a full build needs to be run first. If all exist, it exits normally (exit code 0), and the main build continues.

mermaid
flowchart TD
    start["遍历 packagesToCheck 清单"] --> check{"dist/pkg.cjs.js 存在?"}
    check -->|是| next{"还有下一个包?"}
    next -->|是| check
    next -->|否| ok["allFilesPresent 保持 true"]
    check -->|否| fail["allFilesPresent = false 并 break"]
    ok --> exit0["正常退出 退出码 0"]
    fail --> exit1["process.exit(1) 退出码 1"]

3.3 aliases.js and vitest.config.ts: The other half of the development-time pipeline

scripts/dev.jssolves "how to quickly generate artifacts", but during development there is another path: running tests.scripts/aliases.jsprovides shared path aliases for vitest and rollup.📎 scripts/aliases.js:7-7

Alias generation logic

resolveEntryForPkgmaps package names topackages/${p}/src/index.ts。📎 scripts/aliases.js:7-7The base entries hardcode four special mappings:vue、vue/compiler-sfc、vue/server-renderer、@vue/compat。📎 scripts/aliases.js:16-21

Then iterate throughpackagesall subdirectories under the directory, skippingvueitself, skippingnonSrcPackages(sfc-playground、template-explorer、dts-test), skipping existing keys, and only if it is a directory, add it to the@vue/${dir}mapping.📎 scripts/aliases.js:23-35

〔Design Inference and Architectural Trade-offs〕

This strategy of "hardcoded special items + dynamic scanning of general items" is to allow new packages to be added without manually modifying the alias file—as long as the directory name follows the convention, vitest can automatically resolve it.nonSrcPackagesThe exclusion list is because these three packages have nosrc/index.tsentry point, and forcing a mapping would cause parsing to fail.

Vitest's define and alias consumption

vitest.config.tsdirectly importentriesasresolve.alias。📎 vitest.config.ts:3📎 vitest.config.ts:22-24itsdefineblocks contrast with the macro injection in dev.js: the test environment__DEV__: true、__TEST__: true、__BROWSER__: false、__CJS__: true。📎 vitest.config.ts:6-21

tests are split into five projects:unit、unit-gc、unit-jsdom、e2e、e2e-browser。📎 vitest.config.ts:51-118among themunit-gcusespool: 'forks'and passes--expose-gcspecifically to run SSR tests that require manually triggering GC.📎 vitest.config.ts:65-76 e2e-browserenables Playwright's Chromium instance to run Transition-related tests.📎 vitest.config.ts:99-117

mermaid
sequenceDiagram
    participant Dev as 开发者
    participant NPM as npm script
    participant Pre as pre-dev-sfc.js
    participant DevJS as dev.js
    participant ESB as esbuild context
    participant FS as 文件系统

    Dev->>NPM: 启动开发
    NPM->>Pre: 检查 SFC 产物
    Pre->>FS: existsSync(dist/*.cjs.js)
    alt 产物缺失
        FS-->>Pre: false
        Pre-->>NPM: exit(1)
        NPM-->>Dev: 提示先跑完整构建
    else 产物齐全
        FS-->>Pre: true
        Pre-->>NPM: exit(0)
        NPM->>DevJS: 启动 dev.js
        DevJS->>ESB: context(...).watch()
        ESB->>FS: 监听源码变化
        Dev->>FS: 修改 src/index.ts
        FS-->>ESB: 文件变更事件
        ESB->>ESB: 增量重建
        ESB-->>Dev: onEnd 打印 built: 路径
    end

Design considerations

Why use esbuild for dev and Rollup for prod?This is not an arbitrary technical choice, but rather because the constraints of the two scenarios differ. In development, bundle size is not sensitive, while feedback latency is extremely sensitive; in production, the opposite is true. esbuild is written in Go and is highly parallelized, making cold starts and incremental builds an order of magnitude faster, but its tree-shaking and code-splitting capabilities are weaker than Rollup's.📎 scripts/dev.js:3-5Using two sets of tools to serve two scenarios separately is a pragmatic engineering trade-off.

[Design inference and architectural trade-offs]

Why does pre-dev-sfc only check and not compile?If it triggered compilation itself, it would reintroduce the circular dependency—it needs to compilecompiler-sfc, and the compilation process itself may depend oncompiler-sfc's output. So it can only perform an "assertion," exposing the fact of "missing output" to the upper layer, which then decides whether to run a full build or report an error and exit. This is a kind of "sentinel pattern": it does not solve the problem, it only reports it.

Is the duplication in the external list technical debt?The external logic in dev.js and rollup.config.js is duplicated, and the source comments acknowledge this.📎 scripts/dev.js:73However, the external sets of the two are not completely identical—dev externalizes more aggressively for speed. Forcibly extracting a shared function would require introducing a parameterized difference switch, which would instead make both pieces of logic harder to read. This is a typical trade-off of "duplication over the wrong abstraction."

Chapter summary

This chapter breaks down the three pieces of the Vue core development-mode pipeline:

1. scripts/dev.js: use esbuild'scontext().watch()to implement incremental builds, throughparseArgsparse formats and flags, dynamicallyrequiretarget packagepackage.jsonlocate the output path, inject__DEV__、__BROWSER__and other macros to control conditional compilation, and uselog-rebuildplugin to print feedback after each rebuild.

2. scripts/pre-dev-sfc.js: before the main build, check whether the CJS outputs of the five core packages exist; if missing, short-circuit with exit code 1 to avoid build deadlock caused by circular dependencies.

3. scripts/aliases.js + vitest.config.ts: provide shared path aliases for the test pipeline, with hardcoded special entries plus dynamic scanning of general entries, combined with multi-project configuration to cover five test scenarios: unit, GC, jsdom, e2e, and browser e2e.

Chapter review and self-test

Q1: If you removescripts/pre-dev-sfc.jsfrombreak(that is, check all packages before deciding to exit), in what scenarios would the developer experience worsen? Why did the source author choose to "short-circuit upon finding the first missing one"?

Reference analysis:

📎 scripts/pre-dev-sfc.js:4-23

breakis located inif (!fs.existsSync(...))branch, and once a package output is found to be missing, it immediately breaks out of the loop.

If you removebreak, the script will continue checking the remaining packages, and ultimatelyallFilesPresentis stillfalse, the exit code is still 1,functionally equivalent. But the difference lies in:

1. Performance: the fiveexistsSynccalls themselves are fast, but if the manifest expands to dozens of packages, short-circuiting can save a large number of unnecessary stat system calls.

2. Semantics: short-circuiting expresses "if even one is missing, the whole is incomplete"—this is a Boolean assertion, and there is no need to know exactly how many are missing. Continuing to check produces no additional information.

3. Developer experience: what actually worsens is the "error message." The current script does not print which package is missing; the developer only sees exit code 1. If you removebreakand add logging, it could instead tell the developer "compiler-core and shared are missing"—but this requires extra code. The author chose the simplest implementation, leaving the diagnosis of "which one is missing" to the upper-level build script's error reporting.

Sobreak's core motivation is "assertion semantics + performance," not experience optimization.

Q2: scripts/dev.jsIn__BROWSER__the derivation offormat !== 'cjs' && !pkg.buildOptions?.enableNonBrowserBranchesisbuildOptions.enableNonBrowserBranches. Suppose some package'strueis-f global, and the developer uses__BROWSER__to build; at this timefalseistrue. What consequences would this cause? What if it were mistakenly changed to

?:

📎 scripts/dev.js:146-148

Reference analysisformat = 'global'WhenenableNonBrowserBranches = trueand

  • format !== 'cjs':true
  • !pkg.buildOptions?.enableNonBrowserBranchesisfalse
  • is__BROWSER__ = false

overallif (__BROWSER__)This means that allif (false)branches in the source code are replaced by esbuild's define with

, browser-specific code is removed by tree-shaking, and non-browser branches (Node-specific logic) are retained.Consequencefs、path: the global build output is supposed to run in the browser, but it includes Node-specific branches. If these branches referenceenableNonBrowserBranchesand other Node built-in modules, the browser will report "module undefined" when loading. This is exactly why packages for whichcompiler-sfcis true (such aspolyfillNode()) are usually not used for global builds, or require📎 scripts/dev.js:126-128

plugin as a fallback.true:__BROWSER__ = trueIf mistakenly changed tocompiler-sfc, the browser branch is retained and the Node branch is removed. For

Q3: scripts/aliases.js, a package that must run SFC compilation in a Node environment, this would cause core functionality (reading files, calling Node APIs) to be tree-shaken away, and the output would report "function undefined" when run in Node.packagesInnonSrcPackages(sfc-playground、template-explorer、dts-test, when dynamically scanning thepackagesdirectory, it skipssrc/index.ts, and has not been added tononSrcPackages, what happens? At which stage will vitest throw an error at runtime?

Reference analysis:

📎 scripts/aliases.js:23-35

The dynamic scanning logic is: for each directory, ifdir !== 'vue', not innonSrcPackages, the key does not exist, and it is a directory, then add it toentries['@vue/${dir}'] = resolveEntryForPkg(dir)。

resolveEntryForPkgreturns the path ofpackages/${p}/src/index.ts.📎 scripts/aliases.js:7-7Note that itdoes not check whether the file exists, it only concatenates paths.

Consequence: the alias will be registered, but it points to a nonexistent file. When vitest resolves an import, if some test file imports this package, Vite's resolve plugin will try to load that path and report "cannot resolve module" or "file does not exist."

Error stage: not whenaliases.jsexecutes (it only does string concatenation), but after vitest starts, the first time that import is resolved. If no test imports this package, no error will occur—the alias just sits in theentriesobject.

Workaround: add such packages withoutsrc/index.tstononSrcPackages, or ensure the new package has a standard entry point. This is also whynonSrcPackagesneeds to be maintained manually—it is the exception list to "convention over configuration."

The boundaries of the collaboration among the three are very clear:pre-dev-sfcmanages "whether the artifact is ready,"dev.jsmanages "how the artifact is quickly updated,"aliasesmanages "how tests resolve source code." The development-time pipeline solves the speed problem, but there is another, more hidden type of optimization during the build phase—transformations that are completed before the code is executed by the browser. The next chapter will enter compile-time magic and look at how enum inlining and the Tree-shaking verification mechanism replace TypeScript enums with literals during the build phase, and ensure that the promise of on-demand imports is not broken.

Turn Any Codebase into a Book You Can Actually Understand

Enjoyed this chapter? Turn your private codebase into an architecture book

Local-first Tauri 2 + Rust architecture. 100% offline security, zero code uploaded. Dual-pane reading with immutable commit line anchors.

⚡ Tauri 2 · Rust Native Core · 100% Offline & Private · Tested on 1M+ LOC

CHAPTER 04

Chapter 4: Compile-Time Magic: Enum Inlining and Tree-shaking Verification Mechanism

Upstream: vuejs/core · Commit @4ab865a8 · Progress: Chapter 4 of 14

In the previous chapter, we saw how the development-time pipeline uses file watching and incremental builds to trade for the speed of "change one line and it takes effect immediately." But beyond speed, Vue has another more hidden constraint: the size of the published artifact must be controllable. One of the enemies of this constraint is TypeScript's enum—at runtime it is a real object and will break Tree-shaking. This chapter enters the compile phase to see how scripts/inline-enums.js "dissolves" enums into literals before the code is executed by the browser; then see how scripts/verify-treeshaking.js, after the build, uses artifact strings to reverse-verify that the promise of "on-demand imports" has not been quietly broken.

4.1 Enum Inlining: Dissolving Runtime Objects into Literals

Intuitive model

Imagine you write a recipe in which "a pinch of salt" appears repeatedly. If every time you cook you have to flip to the appendix to look up "a pinch = 3 grams," it is both slow and takes up space. What enum inlining does is, before printing, directly replace every "a pinch of salt" in the book with "3 grams of salt," and then tear out that appendix page. For the reader (the runtime), the result is exactly the same, but the book is thinner.

If it did not exist, what disaster would the system face? An ordinary TypeScriptenumcompiles into a real object literal and has a bidirectional mapping (Enum[Enum.A] === 'A'). This object isa module-level declaration with side effects, and Rollup cannot prove that it is unused, so it can only keep it—even if you import only one member, the entire enum object together with the reverse mapping will be packed into the artifact.📎 scripts/inline-enums.js:3-9The comments inconst enumsay it very directly: they once used

, but because of issue #1228 switched to a normal enum, and therefore used this script to "manually recover the zero-cost benefit of const enum."

Data structures and memory layout📎 scripts/inline-enums.js:33-36

  • EnumMember:{ name, value }The core of the script is three type definitions; understanding them means understanding the entire data flow.
  • EnumDeclaration:{ id, range: [start, end], members }。range, the name of a single enum member and the evaluated literal.isthe source byte offsetexport enum X { ... }, pointing to the start and end positions of the entire
  • EnumData:{ declarations, defines }。declarationsdeclaration in the file—this is the anchor for the subsequent precise replacement by MagicString.definesIndexed by file path, recording the replacement ranges of all enum declarations in that file; is a flat mapping whose key is the literal after ` 形式的字符串,值是 ${enumName}.${memberName}

JSON.stringify`.definesThere is a key design here:the key of。📎 scripts/inline-enums.js:98-103does not include the file pathErrorCodes. The comments explain the reason—@vue/compiler-corecan exist simultaneously in@vue/runtime-coreandErrorCodes.__EXTEND_POINT__, so enums with the same name are allowed to exist across files; but the samefullKey in definesis not allowed to repeat in two enums with the same name, otherwisename conflictis hit and

is thrown directly. This is a constraint of "globally unique by member name," not "globally unique by enum name."temp/enum.json。📎 scripts/inline-enums.js:33-36The cache is stored inscanEnums()Why is persistence to disk needed? Becauseis called only once at the build entry, while Rollup will start。📎 scripts/inline-enums.js:39-41independent processesinlineEnums()for each package and each format. The comments point out: the data must be shared across concurrent Rollup processes, so it must be serialized to disk and read back by each process's

.

Step-by-Step: From grep to literal replacementexport enumStep 1: grep out all files containing📎 scripts/inline-enums.js:51-61.spawnSync('git', ['grep', 'export enum'])usespath:line:content, and the output looks like:, then split out the first segment (the file path) bySet, and usegit grepinstead of traversing the file system—it naturally only scans files tracked by Git, automatically excludingnode_modulesand build artifacts.

Step 2: Babel parses and collects enum information.📎 scripts/inline-enums.js:64-70For each file, use@babel/parserwith thetypescriptplugin,sourceType: 'module'parse into an AST, then only traverseast.program.bodytop-level nodes.📎 scripts/inline-enums.js:74-79Only recognizeExportNamedDeclarationnodes wheredeclaration.type === 'TSEnumDeclaration'—that is,non-exported enums will not be processed.。

For each enum declaration, the script evaluates members one by one. Member evaluation has three paths:

1. Literal initialization:StringLiteralorNumericLiteraldirectly takeinit.value。📎 scripts/inline-enums.js:114-119

2. Binary expression: such as1 << 2. RecursivelyresolveValueprocess the left and right operands; operands can be literals orMemberExpression(i.e., referencing previously defined enum members).📎 scripts/inline-enums.js:121-151The key is in theMemberExpressionbranch: it usescontent.slice(node.start, node.end)to extract the expression string fromthe original source text(such asErrorCodes.FOO), then look updefines. If not found, throwunhandled enum initialization expression。📎 scripts/inline-enums.js:132-141This explains whydefinesmust be a global flat map—when referencing across enums, the referenced member may come from another file, but the key only recognizes枚举名.成员名。

3. Unary expression: such as-1, concatenate into a-1string and then useevaluateto evaluate.📎 scripts/inline-enums.js:152-163

The evaluation itself usesnew Function('return ' + exp)()。📎 scripts/inline-enums.js:39-41This is acontrolled eval: the input comes from already-parsed AST fragments in the source code, not arbitrary user input, so the safety boundary is controllable.

Step 3: Handle members without initializers (auto-increment semantics).📎 scripts/inline-enums.js:171-183If a member has noinitializer: the first member defaults to0; for subsequent members, iflastInitializedis a number then++; if it is a string then throwwrong enum initialization sequence—because string enum members do not allow implicit auto-increment. This is exactly the semantics of TypeScript enums.

Step 4: Write cache and return a cleanup function.📎 scripts/inline-enums.js:200-213 scanEnums()Return a closure; calling itrmSyncdeletes the cache file.build.jsUse it intry/finally.📎 scripts/build.js:81-112This ensures that even if an error is thrown midway through the build, the cache will be cleaned up and will not pollute the next build.

Step 5: Rollup transform phase replacement. inlineEnums()Read back the cache and construct a Rollup plugin.📎 scripts/inline-enums.js:219-234Intransform(code, id), ifidhitsenumData.declarations, use MagicString to replace[start, end]this declaration with an object literal.📎 scripts/inline-enums.js:242-274

The replaced form isexport const X = { ... }. Note that itdoes not simply delete the enum, but rewrites it into an object literal, and additionally generates reverse mappings for numeric members:JSON.stringify(value.toString()) + ': ' + JSON.stringify(name)。📎 scripts/inline-enums.js:257-270The comment references the reverse-mappings rule in the official TypeScript documentation: string enum members do not generate reverse mappings, numeric members do. This ensures that the runtime behavior after replacement is exactly the same as the original enum.

What truly eliminates runtime overhead is thatdefinesis handed to@rollup/plugin-replace。📎 rollup.config.js:222-223all references toX.Member'sreferencesare directly replaced with literals in the replacement plugin, so if that rewritten object literal is unused, it can be tree-shaken away.

The following flowchart depicts the complete decision path from grep to replacement:

mermaid
flowchart TD
    grep["spawnSync git grep 'export enum'"] --> files["去重得到文件列表"]
    files --> parse["@babel/parser 解析 AST"]
    parse --> check{"顶层节点是<br/>ExportNamedDeclaration<br/>且 declaration 为 TSEnumDeclaration?"}
    check -->|否| skip["跳过该节点"]
    check -->|是| dup{"enumIds 已含该 id?"}
    dup -->|是| err1["throw 不支持声明合并"]
    dup -->|否| member["遍历 members 求值"]
    member --> init{"有 initializer?"}
    init -->|有| eval["字面量/二元/一元求值"]
    init -->|无| auto["lastInitialized 自增或默认 0"]
    eval --> conflict{"fullKey 已在 defines?"}
    auto --> conflict
    conflict -->|是| err2["throw name conflict"]
    conflict -->|否| save["saveValue 写入 members 与 defines"]
    save --> cache["writeFileSync temp/enum.json"]
    cache --> transform["Rollup transform: MagicString 重写声明"]
    transform --> replace["plugin-replace 用 defines 替换引用"]

Design considerations and pitfalls

Why use MagicString instead of regenerating the entire file?Becauses.update(start, end, ...)only replaces the enum declaration segment, leaving all other source bytes completely untouched,s.generateMap()and can still generate precise sourcemaps.📎 scripts/inline-enums.js:277-281If Babel were used to reprint the entire AST, the original formatting and comments would be lost, and sourcemap quality would degrade.

rangeWhy is itnode.start/node.endrather thandeclaration.start?📎 scripts/inline-enums.js:189-193assertsnode.start(i.e.,ExportNamedDeclarationnode), and the replacement range coversexport enum X {...}the entire segment, including theexportkeyword. The replacement text starts withexport const, which connects exactly.

Pitfalls:definesThe global uniqueness constraint ofIf two different files each have aErrorCodes, and both define__EXTEND_POINT__, the build will fail directly.📎 scripts/inline-enums.js:101-103This is not a bug, but a deliberate design—becausedefinesis a global replacement table and cannot distinguish file origins. In production, when adding new enum members, if the name conflicts with an existing enum member, it will blow up here.

Pitfall:new FunctionThe evaluation timing ofBinary expression evaluation occurs during thescanEnumsphase, at which pointdefinesmay not yet contain the referenced member (if the reference order is reversed).📎 scripts/inline-enums.js:136-140will throwunhandled enum initialization expression. This requires that references to enum members must follow the source order of "define first, reference later."

4.2 Tree-shaking verification: using artifact strings to prove the promise in reverse

Intuitive model

Enum inlining is an "ahead-of-time optimization," but does the optimization actually take effect? If some helper is accidentally retained due to improper coding style, the bundle size will quietly inflate, and the developer will be completely unaware.verify-treeshaking.jsIt is that "post-mortem quality inspector": it builds the artifact, then examines the artifact like an autopsy to check whether things thatshould not appear do appear. Without it, Vue's on-demand import promise might silently break after some refactor, only to be discovered when users complain that the package got bigger.

Data structures and check items

This script has no complex data structures; the core is aerrorsarray and threeincludeschecks.📎 scripts/verify-treeshaking.js:6-6It first builds theglobal-runtimeformat, then reads the dev and prod artifacts separately.

The three check items correspond to three types of "Tree-shaking failures":

1. The dev artifact contains__spreadValues。📎 scripts/verify-treeshaking.js:13-19This is the helper generated by esbuild for{ ...obj }object spread syntax. If it appears, it means object spread was used in the runtime code, whereas Vue's convention is to use theextendhelper instead to avoid extra code.

2. The prod artifact containsVue warn。📎 scripts/verify-treeshaking.js:26-31This indicates there is awarn()call not wrapped by the__DEV__condition, causing warning code to leak into the production bundle.

3. The prod artifact contains the DOM tag configuration list。📎 scripts/verify-treeshaking.js:33-42such ashtml,body,base、svg,animate,animateMotion、annotation,annotation-xml,maction. These areisHTMLTag()Data inside helpers like these should only exist in the compiler and be shaken out by the runtime. If it appears in runtime artifacts, it means the runtime path mistakenly used a compiler-only helper.

Step-by-Step: Verification Process

📎 scripts/verify-treeshaking.js:5-5Firstexec('pnpm', ['build', 'vue', '-f', 'global-runtime']), only buildvuepackage'sglobal-runtimeformat—this is the minimal runtime artifact, best suited for exposing leaks. After the build completes, read both files synchronously, check eachincludesone by one, and on a hit push a message with an explanation intoerrors. Finally, iferrors.lengthis non-zero, throw an aggregated error.📎 scripts/verify-treeshaking.js:44-48

mermaid
flowchart TD
    build["exec pnpm build vue -f global-runtime"] --> readDev["读取 vue.runtime.global.js"]
    readDev --> c1{"dev 含 __spreadValues?"}
    c1 -->|是| e1["push: 应改用 extend helper"]
    c1 -->|否| readProd["读取 vue.runtime.global.prod.js"]
    e1 --> readProd
    readProd --> c2{"prod 含 'Vue warn'?"}
    c2 -->|是| e2["push: warn 未被 __DEV__ 包裹"]
    c2 -->|否| c3{"prod 含 DOM tag 配置?"}
    e2 --> c3
    c3 -->|是| e3["push: 编译器 helper 泄漏到运行时"]
    c3 -->|否| done{"errors 为空?"}
    e3 --> done
    done -->|是| pass["验证通过"]
    done -->|否| fail["throw 聚合错误"]

Design Thinking and Pitfalls

[Design Inference and Architectural Trade-offs]

Why use stringincludesinstead of AST analysis?Because this is a "sentinel check" rather than "precise analysis." It does not pursue completeness; it only sets up low-cost alarms for three types of regressions that have actually occurred historically. String matching has zero dependencies, zero parsing overhead, and remains effective on minified artifacts—AST analysis actually becomes harder after minify.

[Design Inference and Architectural Trade-offs]

Why only verifyglobal-runtime?This format inlines all dependencies (externalis empty), making it the artifact most sensitive to size and most easily polluted by mistake. If it is clean, other formats are usually clean too. At the same time, it builds quickly, making it suitable for frequent runs in CI.

[Design Inference and Architectural Trade-offs]

Pitfall: The check items are a "blacklist" and will become ineffective as the code evolves.If one dayisHTMLTag's data structure changes,html,body,basethis string no longer appears, and the check becomes useless. This requires maintainers to update the sentinel strings here in sync when changing related helpers. This is the inherent cost of blacklist-style verification.

4.3 Collaboration with Rollup: Plugin Order and define Injection

Enum inlining does not run in isolation; it is embedded in Rollup's plugin pipeline. Only by understanding its position in the pipeline can you understand whydefinesshould be handed toreplacerather thanesbuild。

📎 rollup.config.js:47-50callinginlineEnums()at the top level of the config module, destructuring out[enumPlugin, enumDefines]. Note that this isexecuted when each Rollup process starts, reading the cache written byscanEnums.

The order of the plugin array is:json → alias → enumPlugin → ...resolveReplace() → esbuild。📎 rollup.config.js:324-339 enumPluginis placed beforereplace, meaning enum declaration rewriting happens first, and thenreplaceusesdefinesto replace references. Andesbuildis placed last, responsible for TS transpilation.

Whydefinesgoes throughreplaceinstead ofesbuild'sdefine?📎 rollup.config.js:220-221comment gives the answer: esbuild's define is "a bit strict, only allowing literal JSON or identifiers." But enum member names likeErrorCodes.__EXTEND_POINT__are dotted member expressions, and esbuild's define cannot directly handle such keys. So@rollup/plugin-replacemust be used, as it supports replacement of arbitrary string keys.📎 rollup.config.js:250-251and setspreventAssignment: true, avoiding replacing the left-hand side of assignment statements as well.

resolveReplace()Inconst replacements = { ...enumDefines }is the first step.📎 rollup.config.js:222-223Only afterward are production-environment/*@__PURE__*/annotations,__DEV__and other replacements layered on. This order ensures that enum literal replacement always takes effect.

Design Thinking

The essence of enum inlining is "trading build-time complexity for runtime size."It fully reproduces TypeScript's type system semantics (enum evaluation, auto-increment, reverse mapping) at build time—scanEnumsthe evaluation logic in is almost a subset of the TS compiler's enum evaluation.📎 scripts/inline-enums.js:110-183This brings maintenance cost: if TS adds new enum syntax (such as more complex constant expressions), this must keep up, otherwise it throwsunhandledan error. But the benefit is clear: zero enum objects at runtime, and Tree-shaking can be thorough.

[Design Inference and Architectural Trade-offs]

The verification script and the inline script are a pair of "promise and fulfillment."The inline script promises "enums do not take up runtime size," and the verification script checks "other code has not secretly taken up size either." Together they guard Vue's size budget. This paired design of "optimization + verification" is a typical pattern in engineering large frontend libraries: any optimization needs an automated check to prevent regression.

Cross-process caching is a necessity for concurrent builds. scanEnumsThe pattern of single execution andinlineEnumsmultiple reads📎 scripts/inline-enums.js:39-41solves the problem of "one scan, N processes consuming." Without caching, every Rollup process would have to grep + parse again, wasting a large amount of IO and CPU.

Chapter Summary

Chapter Review and Self-Test

Q1: If you deletescanEnumsinsaveValue'sif (fullKey in defines)conflict check, in what scenarios would it cause errors in the build artifacts?

Reference Analysis:

definesis a global flat mapping, with keys as枚举名.成员名, not including file paths.📎 scripts/inline-enums.js:98-103After deleting the conflict check, if two different files each have an enum with the same name and define a member with the same name (such as@vue/compiler-coreand@vue/runtime-coreboth havingErrorCodes.__EXTEND_POINT__), the later writer will overwrite the earlier writer.

Consequences:defines['ErrorCodes.__EXTEND_POINT__']only one value remains, andplugin-replacecannot distinguish the file source during replacement, so it will replaceallfiles'ErrorCodes.__EXTEND_POINT__with the same value.📎 rollup.config.js:222-223As a result, one package's enum member value is silently tampered with, causing incorrect runtime behavior that is extremely hard to troubleshoot—because the source code looks completely correct.

This is exactly why the comment emphasizes "same-name enums across files are allowed, but same-name members are not."📎 scripts/inline-enums.js:98-100The conflict check is the gatekeeper preventing the global replacement table from being polluted.

Q2: If you swap the order ofrollup.config.jsinenumPlugin's plugin array with...resolveReplace(), what will happen?

Reference Analysis:

The current order isenumPluginfirst,replacelater.📎 rollup.config.js:331-332Rollup'stransformhook executes in plugin array order.

If swapped,replacewill run first, and at this point the enum declaration is still in its originalexport enum X { ... }form.replaceusesdefinesto replaceX.Memberreferences—but at this point the references are still there, so the replacement can take effect. The problem occurs whenenumPluginsubsequently runs: it usess.update(start, end, ...)to rewrite the declaration section.📎 scripts/inline-enums.js:250-273Butreplacehas already modifiedcode, andenumPluginreceivescodeisreplaceThe output's byte offsets have already been compared withscanEnumsrecorded inrange(based on the original source code)no longer correspond。

Consequence: MagicString will cut at the wrong offsets, and the output's syntax will be corrupted. This reveals an implicit contract of the plugin pipeline:Transformations based on source offsets must be executed firstso that subsequent transformations can safely continue on its output.

Q3: verify-treeshaking.jsonly checks three string sentinels. If some refactor changesisHTMLTaginternal data from'html,body,base'to array form['html','body','base']what would the verification script do? What design flaw does this expose?

Reference analysis:

The verification script usesprodBuild.includes('html,body,base')to check.📎 scripts/verify-treeshaking.js:33-37If the data is changed to an array, the comma-joined string will no longer appear in the minified output,includesreturnsfalseand the checksilently passes— even ifisHTMLTagreally leaked into the runtime output.

This exposes the inherent flaw of blacklist-style string verification:sentinel strings are coupled to the source implementation; once the implementation changes, the verification becomes invalid. It cannot detect "unknown leaks"; it can only detect "known leaks whose string form has not changed."

[Design inference and architectural trade-offs]

Improvement direction: You could instead check for more stable identifiers (such as the function nameisHTMLTag), or use lint rules at the source level to prohibit runtime imports of compiler helpers, rather than relying on output strings. But under the current cost constraints, string sentinels are a "good enough and cheap" compromise.

Enum inlining solves "how to eliminate runtime overhead at build time," and the verification script solves "how to confirm that the optimization has not been broken." But build outputs include more than JS; there is another type of artifact that also requires pipeline processing — type declaration files. The next chapter will enter the type artifact pipeline and see how Vue generates a release-grade type package from source.d.tsand howdts-testuses type contract tests to guard the type shape of the public API.

This chapter dismantled two key scripts in the compilation phase. inline-enums.js uses git grep to locate enums, Babel to parse the AST, new Function to evaluate members, and MagicString to precisely rewrite declarations, ultimately turning enum references into literals through the defines global replacement table, allowing the enum object to be tree-shaken away. verify-treeshaking.js then uses string sentinels to check the build output, ensuring that three known types of Tree-shaking leaks do not regress. One is responsible for "optimization," and the other for "verifying that the optimization has not been broken," together safeguarding Vue's size commitment. Next, we will shift from the compilation phase to the generation pipeline of type artifacts, and see how Vue ensures strict consistency between source types and published types.

Turn Any Codebase into a Book You Can Actually Understand

Enjoyed this chapter? Turn your private codebase into an architecture book

Local-first Tauri 2 + Rust architecture. 100% offline security, zero code uploaded. Dual-pane reading with immutable commit line anchors.

⚡ Tauri 2 · Rust Native Core · 100% Offline & Private · Tested on 1M+ LOC

CHAPTER 05

Chapter 5: Type Artifact Pipeline: From Source .d.ts to Release-Grade Type Package

Upstream: vuejs/core · Commit @4ab865a8 · Progress: Chapter 5 of 14

In the previous chapter, we dismantledinline-enums.jsandverify-treeshaking.js: one is responsible for replacing enum references with literals so that the enum object can be tree-shaken away, and the other is responsible for using string sentinels after the build to confirm that three known leaks have not regressed. Together they safeguard Vue's runtime size commitment. But build outputs are not only JS. When usersimport { ref } from 'vue'the type hints shown by the editor,tsctype checking of user code, all depend on another type of artifact —.d.tsdeclaration files. If the JS output is wrong, it errors at runtime; if the type output is wrong, it errors on the user side at compile time, or worse: the types silently drift, user code can compile, but the type shape does not match the real runtime behavior. This chapter traces how Vue aggregates source types scattered across each subpackagesrcinto a release-grade type package, and usesdts-built-testto perform type smoke tests on the real build output.

5.1 Two-stage type pipeline: tsc produces output, rollup aggregates

Intuitive model

Imagine a printing pipeline: in the first stage, each subpackage lays out its own manuscript (.tssource) into single-page proofs (.d.ts); in the second stage, dozens of proofs are bound into a book in directory order (release-grade.d.ts), with unified headers and footers (export declarations).

Without this pipeline, Vue would have to manually maintain a release type file, and any source change would require synchronized manual edits — a breeding ground for type drift. Vue's approach is:Type artifacts are generated entirely from source, never handwritten。

Stage one: tsconfig.build.json defines the output scope

tsconfig.build.jsonis the first-stage configuration of this pipeline. It inherits the roottsconfig.jsonand only covers build-related options.

📎 tsconfig.build.json:3-9

Key options broken down one by one:

  • declaration: true: have tsc generate a corresponding.d.ts。
  • emitDeclarationOnly: true:for each source file, outputting only types, not JS. JS is handled by Rollup; here tsc is purely a type extractor.
  • stripInternal: true: any declaration marked@internalis removed from.d.ts. This is Vue's first gate for controlling the public API surface — even if internal implementation details areexport, as long as they are marked@internalthey will not leak into the published types.
  • composite: false: disable the incremental build mode of project references. Vue does not need cross-package incrementality here; turning it off avoids the extra state brought by.tsbuildinfo.

includeThe list precisely defines which directories participate in output:

📎 tsconfig.build.json:10-23

Note that hereonly 12 directories are listed, not the entirepackages/。packages-private/、packages/dts-test/、packages/sfc-playground/etc. are not included. This means: the types of private packages and test packageswill neverEnter the release artifacts. This is a physical isolation—not by convention, but by configuration.

[Design Inference and Architectural Trade-offs]

Why use a whitelist instead of a blacklist? Because adding new sub-packages in a monorepo is the norm. If using aexcludeblacklist, when a new private package is added and someone forgets to add it to exclude, its types will silently leak into the release artifacts. A whitelist is the opposite: new packages by default do not participate in the build and must be explicitly added, conforming to the "secure by default" principle.

After executingtsc -p tsconfig.build.json --noCheck, the artifacts land intemp/packages/<pkg>/src/*.d.ts. Note--noCheck: skip type checking, only emit. Type checking is handled by a separatetsc --noEmit, and the build phase does not repeat the check, saving time.

Phase Two: rollup.dts.config.js aggregation

Phase two is driven byrollup.dts.config.js. Its entry point first performs a pre-check:

📎 rollup.dts.config.js:15-22

Iftemp/packagesdoes not exist, it means phase one did not run, and the script directlyprocess.exit(1)and prompts to runtscfirst. This is the pipeline'sordering contract: the rollup phase strongly depends on the tsc phase's artifacts, and neither can be missing.

Next, it reads all sub-package directories and supports theTARGETSenvironment variable for subset builds:

📎 rollup.dts.config.js:15-22

TARGETSThe mechanism allows rebuilding types for only a few packages, significantly shortening the feedback loop during development and debugging.

The core istargetPackages.map(...)generating a Rollup config for each package:

📎 rollup.dts.config.js:23-42

Field-by-field breakdown:

  • input: ./temp/packages/${pkg}/src/index.d.ts: the entry is the type file produced in phase one, not the source code.ts。
  • output.file: packages/${pkg}/dist/${pkg}.d.ts: artifacts land in each package's owndistdirectory, with filenames matching package names (e.g.,vue.d.ts)。
  • format: 'es': type files uniformly use ES module format.
  • plugins: [dts(), patchTypes(pkg), ...(pkg === 'vue' ? [copyMts()] : [])]: three plugins, the first two apply to all packages,copyMtsonly applies to thevuepackage.

onwarnThe hook is worth discussing separately:

📎 rollup.dts.config.js:23-42

During the dts rollup process, all non-relative-path imports are externalized by default. This causes Rollup to reportUNRESOLVED_IMPORTwarnings. But this isexpected behavior—theimport { X } from 'some-pkg'in type files should remain as external references and should not be bundled in. So the script directlyreturnswallows warnings for "unresolved imports with non-relative paths," and only passes unresolved imports with relative paths to the defaultwarn。

[Design Inference and Architectural Trade-offs]

There is a subtlety here:!warning.exporter?.startsWith('.')checks whether the exporter starts with.. If a relative-path import is unresolved, it means phase one's artifacts are missing—a real problem that must be flagged. This distinction minimizes warning noise while not letting real errors slip through.

Pipeline Overview

mermaid
flowchart TD
    src["packages/*/src/*.ts<br/>源码类型"] --> tsc{"tsc -p tsconfig.build.json<br/>--noCheck"}
    tsc -->|"include 白名单命中"| temp["temp/packages/*/src/*.d.ts<br/>单包校样"]
    tsc -->|"不在 include 列表"| skip["不产出<br/>私有包/测试包被隔离"]
    temp --> check{"temp/packages 存在?"}
    check -->|"否"| exit["process.exit(1)<br/>提示先跑 tsc"]
    check -->|"是"| rollup["rollup-plugin-dts<br/>聚合为单文件"]
    rollup --> patch["patchTypes(pkg)<br/>内联导出 + 追加 types/"]
    patch --> vue{"pkg === 'vue'?"}
    vue -->|"是"| mts["copyMts()<br/>写 vue.d.mts"]
    vue -->|"否"| done["packages/pkg/dist/pkg.d.ts"]
    mts --> done

This diagram anchors the two-phase control flow:tsc's whitelist determines who can enter the pipeline,rollup'scheckdetermines whether it can continue,patchTypesis a mandatory step,copyMtsis thevuepackage-specific branch.

5.2 patchTypes: Rewriting aggregated artifacts into release-grade shape

Intuitive Model

rollup-plugin-dtsAfter merging dozens of.d.tsinto a single file, the resulting shape is "declare a bunch of types first, then export them all through one giantexport { A, B, C, ... }." This is unfriendly for human reading, and for some toolchains (such as VitePress'sdefineComponentcall) it can also trigger the error "inferred type cannot be named without a reference."

patchTypesis thispost-processing shaping step: change "centralized export" to "inline export in place," then append package-specific type augmentations.

Data Structures: Two Sets and Three Passes

patchTypesreturns a Rollup plugin, with the core logic in therenderChunkhook. It maintains two collections:

📎 rollup.dts.config.js:87-88

  • isExported: records alltype names that were originally exported(fromexport { ... }declarations).
  • shouldRemoveExport: records alltype names that need to be removed from the big export block(because they have already been inlined and exported).

The processing flow is divided into three passes (pass 0 / pass 1 / pass 2), a typical "collect first, rewrite next, clean up last" pattern.

Step-by-Step Walkthrough

Pass 0: Collect all exported type names.

📎 rollup.dts.config.js:90-100

Traverse the AST top-level nodes; for anyExportNamedDeclarationthatdoes not have a source(i.e., is not aexport ... from '...'re-export), add the specifier's local name toisExported。

Pass 1: Add theexportprefix in place for declaration nodes.

📎 rollup.dts.config.js:102-125

Traverse top-level nodes, and forVariableDeclaration、TSTypeAliasDeclaration、TSInterfaceDeclaration、TSDeclareFunction、TSEnumDeclaration、ClassDeclarationsix kinds of declarations, callprocessDeclaration。

processDeclaration's logic:

📎 rollup.dts.config.js:70-85

Three steps:

1. If there is noid, return directly (e.g., anonymous declarations).

2. If the name starts with_, skip it—this is theconvention: types with an underscore prefix are internal helper types and are not exported.

3. Add the name toshouldRemoveExport; if the name is inisExported(i.e., it was originally exported), then at the declaration's start positionprependLeftaexport string.

Note that theVariableDeclarationbranch has an extra assertion:

📎 rollup.dts.config.js:104-115

If adeclare constdeclares multiple declarators (e.g.,declare const a, b), throw an error directly. BecauseprocessDeclarationonly handlesdeclarations[0], multiple declarators would cause missed processing. Here,fail fastis chosen rather than silent error, reflecting defensive programming.

Pass 2: Remove inlined types from the big export block.

📎 rollup.dts.config.js:127-171

TraverseExportNamedDeclaration, and for each specifier:

  • If its local name is inshouldRemoveExport, andexported === local(excluding theexport { Foo as Bar }renaming case), remove that specifier.
  • When removing, use MagicString for precise deletion: if there are more specifiers after it, delete up to the start of the next specifier; if it is the last one, delete up to the end of the previous one or its own start.
  • If all specifiers of the entire export block are removed, delete the entireExportNamedDeclarationnode.

Final step: Append package-specific types.

📎 rollup.dts.config.js:172-183

code = s.toString()After obtaining the rewritten code, check whether thepackages/${pkg}/typesdirectory exists. If it exists, read the contents of all files in the directory, concatenate them with newlines, and append them to the end of the code.

[Design Inference and Architectural Trade-offs]

Thistypes/directory isa manually maintained type augmentationentry point, used to hold types that cannot be automatically generated from source code (such as JSX global augmentations, macro type declarations). It is merged in the same file as automatically generated types, but the sources are clearly separated—automatically generated ones on top, manually augmented ones below.

Why must exports be inlined?

The comment gives the direct reason:

📎 rollup.dts.config.js:45-51

The original text says: change all types to inline exports and remove them from the large export block, otherwise in VitePress'sdefineComponentcall, it will report "the inferred type cannot be named without a reference".

[Design Inference and Architectural Trade-offs]

The essence of this error is: when TypeScript generates types, if a type can only be named by "referencing an export from another module" and that reference is not visible on the consumer side, it will report an error. A centralized export block separates the type name from the declaration location, exacerbating this problem. Inline exports make each type visible at its declaration site, eliminating this indirection layer.

copyMts: providing types for Node ESM/CJS dual mode

copyMtsThe plugin only takes effect for thevuepackage:

📎 rollup.dts.config.js:196-204

In thewriteBundlehook, it writes the contents ofvue.d.tsas-is tovue.d.mts。

The comment explains the reason:

📎 rollup.dts.config.js:188-192

According to TypeScript 4.7'spackage.jsonexports specification, to correctly provide types for both Node ESM and CJS,there must be two independent declaration files. So during the build, copyvue.d.tsasvue.d.mts。

[Design Inference and Architectural Trade-offs]

Why copy rather than regenerate? Because the type shapes of ESM and CJS are completely identical; the only differences are the file extension andpackage.json'sexportsmapping. Copying is the cheapest solution, avoiding running rollup again.

5.3 dts-built-test: type smoke testing on real artifacts

Intuitive model

The previous two sections ensured that type artifacts can be generated and have the correct shape. But "can be generated" does not equal "generated correctly." IfpatchTypeshas a bug in one of its traversal passes and accidentally deletes an export, the artifact can still be generated, but usersimportwill discover missing types.

dts-built-testisa type smoke test run on real build artifacts: it does not test source types, but ratherimportthe publishedvuepackage, verifying that key type shapes have not regressed.

Data structure: a minimal type assertion

The core of the entire test package is just one file:

📎 packages-private/dts-built-test/src/index.ts:3-6

Line-by-line interpretation:

  • L1: fromvueimportdefineComponent. Note that what is imported here is thepackage name, not a relative path—it consumespackages/vue/dist/vue.d.tsthis real artifact.
  • L3-6: define a component_CustomPropsNotErased, with empty props and empty setup.
  • L8: comment// #8376, pointing to a specific issue.
  • L9-12: exportCustomPropsNotErased, with type_CustomPropsNotErasedintersected with{ foo: string }.

What this test verifies is:defineComponent's return type, after being intersected with{ foo: string },foothe property will not be erased。

[Design Inference and Architectural Trade-offs]

Background speculation for issue #8376:defineComponent's return type may undergo some conditional type or mapped type processing, causing extra properties in the intersection type to be "erased." This test locks down this behavior with a minimal reproduction; once it regresses, it will error during the type-checking phase.

Package configuration: workspace dependency points to the real artifact

📎 packages-private/dts-built-test/package.json:1-11

Key fields:

  • private: true: not published to npm.
  • types: dist/index.d.ts: type entry points to the build artifact.
  • dependenciesInworkspace:*three@vue/shared、@vue/reactivity、vue。
dependencies:

[Design Inference and Architectural Trade-offs]@vue/sharedWhy depend on@vue/reactivityandvue? Becausetypes's types may reference the types of these two packages. In workspace mode, pnpm will symlink these dependencies to local packages, and the local packages'distfields point to the artifacts under their respective. In this way, the entire test chain consumesbuild artifacts

, not source code.

dts-built-testHow the test runssrc/index.tsitself has no test script; itstscis the test case. The way to run it is: in CI, executetscto type-check this package. If the type shape regresses,

errors, and CI fails.

[Design Inference and Architectural Trade-offs]The cleverness of this design is that it encodes the "type contract" ascompilable codetsc. No additional assertion library is needed, no runtime is needed,

itself is the test runner. If the types are correct, it compiles; if the types are wrong, compilation fails.

Division of labor with dts-testdts-built-testNote that this chapter'sdts-testand the next chapter's

  • dts-built-testare two different things:(this chapter): consumesbuild artifacts
  • dts-test, verifying release-level type shapes.(next chapter): consumessource types
, verifying API surface contracts.

[Design Inference and Architectural Trade-offs]patchTypesWhy are two layers needed? Because source types and artifact types may be inconsistent.stripInternal's AST rewriting,types/'s removal,dts-built-testdirectory appending, may all introduce artifact-level bugs even when source types are correct.

specifically guards this last mile.

mermaid
sequenceDiagram
    participant CI as CI 脚本
    participant TSC as tsc (tsconfig.build.json)
    participant Rollup as rollup.dts.config.js
    participant Patch as patchTypes(pkg)
    participant Dist as packages/vue/dist
    participant BuiltTest as dts-built-test

    CI->>TSC: tsc -p tsconfig.build.json --noCheck
    TSC->>TSC: include 白名单过滤
    TSC-->>Rollup: temp/packages/*/src/*.d.ts
    Rollup->>Rollup: existsSync('temp/packages') 校验
    Rollup->>Rollup: rollup-plugin-dts 聚合
    Rollup->>Patch: renderChunk(code, chunk)
    Patch->>Patch: pass0 收集 isExported
    Patch->>Patch: pass1 prependLeft('export ')
    Patch->>Patch: pass2 移除大导出块 specifier
    Patch->>Patch: 追加 packages/vue/types/*
    Patch-->>Rollup: 改写后 code
    Rollup->>Dist: 写 vue.d.ts
    Rollup->>Dist: copyMts 写 vue.d.mts
    CI->>BuiltTest: tsc 类型检查
    BuiltTest->>Dist: import { defineComponent } from 'vue'
    Dist-->>BuiltTest: 类型形状
    BuiltTest-->>CI: 编译通过 / 报错

copypatchTypesThis sequence diagram anchors cross-module collaboration: CI drives the two stages of tsc and Rollup,dts-built-test's three traversal passes are the core processing,

consumes the artifact at the end for verification.

Design thinking, error recovery, and production pitfalls

patchTypesWhy use MagicString instead of string replacement?code.replace(...)uses MagicString throughout for precise rewriting, rather than

1. . There are two reasons:Precise positioningstart/end: AST nodes carry their own

2. offsets, and MagicString operates by offset, so it will not accidentally affect identifiers with the same name.MagicString can generate mappings, allowing the rewritten type files to still be traced back to the source code. Although the sourcemap use of type files is limited, maintaining consistency is good practice.

Fail fast vs. silently tolerate

patchTypesUsed in multiple placesassert:

📎 rollup.dts.config.js:74-74

📎 rollup.dts.config.js:107-108

📎 rollup.dts.config.js:147-148

These assertions throw immediately when encountering unexpected AST shapes. CompareonwarnwhereUNRESOLVED_IMPORTis silently swallowed—Expected noise is swallowed, unexpected shapes fail fastThis is the correct posture for a build script: better for the build to fail than to produce type files with the wrong shape.

Production pitfalls:_Prefix convention

processDeclarationSkip_Types beginning with:

📎 rollup.dts.config.js:76-78

This means that any exported type in the source code beginning with_will not be inlined and exported. If a type should be public but is skipped because its name begins with_users will encounter a "type does not exist" error.

[Design inference and architectural trade-offs]

The approach to troubleshooting this kind of problem: first check whether the type is still in the large export block in the build outputvue.d.tsthen check whether the type name in the source code begins with_This is an implicit coupling between naming conventions and tool behavior, and it is easy to fall into this pitfall.

Production pitfall: multiple declarator assertion

📎 rollup.dts.config.js:106-115

If a certain.d.tscontainsdeclare const a, bthe build throws an error directly. This is rare in handwritten types, but it will be triggered if a type file generated by some tool uses this form. The error message will print the problematic code snippet for easier localization.

Chapter summary

This chapter traced the complete pipeline of Vue type artifacts:

1. First stage (tsc):tsconfig.build.jsonUseincludewhitelist to precisely define the output scope,emitDeclarationOnlyoutput only types,stripInternalremove internal declarations. The artifacts land intemp/packages/。

2. Second stage (rollup):rollup.dts.config.jsUserollup-plugin-dtsto aggregate the types of each package,patchTypesrewrite centralized exports into inline exports through three AST traversal passes, and appendtypes/manual enhancements for the directory.copyMtsForvuepackage additionally generate.d.mts。

3. Verification stage (dts-built-test)Perform type smoke tests on real build artifacts, using compilable code to lock down key type shapes and prevent type drift.

Chapter review and self-test

Q1: Iftsconfig.build.json'sincludewhitelist is changed to["packages"](that is, including the entire packages directory), what will happen? In what scenarios would this cause published type pollution?

Reference analysis:

includeAfter changing from 12 precise directories to["packages"]all subpackages (includingpackages-privateallpackages/*outside of it) will participate in tsc output.📎 tsconfig.build.json:10-23

Consequence chain:

1. temp/packages/There will be many additional packages under.d.ts。

2. rollup.dts.config.js'sreaddirSync('temp/packages')will read these extra packages.📎 rollup.dts.config.js:15-22

3. targetPackagesBy default equals all packages, so it will generate for each packagepackages/<pkg>/dist/<pkg>.d.ts。📎 rollup.dts.config.js:15-22

Pollution scenario: if a package should not be published (such as an internal tool package), its type artifacts will appear underdistIf that package'spackage.jsondoes not haveprivate: truethe publish script may publish it to npm as well, causing internal types to leak.

This is exactly the value of the whitelist design: newly added packages do not participate by default and must be explicitly added, conforming to secure defaults.

Q2: patchTypesIn pass 1 ofprocessDeclarationdirectly_types beginning withreturnIf the type of a public API happens to begin with_(such as_InternalTypebeing accidentally exported), what will users see? How should it be troubleshooted?

Reference analysis:

processDeclarationWhen encountering_it returns directly at the beginning, neither addingshouldRemoveExportnor prependingexport 。📎 rollup.dts.config.js:76-78

Consequences:

1. The type will not receive inlineexport。

2. It also will not be removed from the large export block (because it is not inshouldRemoveExport).

3. So itis still in the large export blockand can theoretically still be imported.

But the problem is: theexport { _InternalType }in the large export block references the declaration location. If that declaration is removed for some reason (such asstripInternal), the export block will reference a nonexistent name, causingtscto report an error.

Troubleshooting approach:

1. Check whether the type in the build outputvue.d.tsneither hasexportat the declaration site nor is referenced in the large export block.

2. Check whether the type name in the source code begins with_.

3. If it is confirmed to be a naming issue, rename it to remove the underscore prefix.

This exposes the implicit coupling between naming conventions and tool behavior:_The prefix is intended to mean "internal," but the tool treats it as "not exported," and the two semantics are not fully consistent.

Q3: dts-built-test'ssrc/index.tsuses the intersection typetypeof _CustomPropsNotErased & { foo: string }to verifyfoois not erased. If the intersection type is changed toOmit<typeof _CustomPropsNotErased, never> & { foo: string }can the test still catch the regression of #8376? Why?

Reference analysis:

Omit<T, never>will create a new mapped type, which willrecomputeall properties of T. If the bug in #8376 is "extra properties in the intersection type are erased," then:

  • Original formT & { foo: string }: direct intersection,foois part of the intersection type. IfdefineComponent's return type handling logic erases extra properties in the intersection,foowill be lost.
  • OmitForm:Omitfirst mapTthen intersect with{ foo: string }.OmitThe mapping process may change the type structure so that the bug's trigger condition no longer holds—even if the bug exists, the test may still pass.

📎 packages-private/dts-built-test/src/index.ts:9-12

Therefore, the test case'sminimalityis crucial: it must precisely reproduce the bug's trigger path. Any additional type transformation (such asOmit、Pick) may mask the bug. This is also why the test uses the most plain intersection type rather than a more "elegant" form.

[Design inference and architectural trade-offs]

Improvement direction: multiple forms can be kept at the same time to cover different type transformation paths and improve regression capture rate. But this increases maintenance cost and requires trade-offs.

The type pipeline solves "how to generate publish-level types from source code,"dts-built-testand solves "how to verify the artifact type shape." But the type contract is not limited to "whether the shape is correct"; it also includes "whether the API surface matches expectations"—which types should be exported, which should not, and whether generic constraints are precise. The next chapter will enterdts-test, see how Vue uses type contract tests to guard the public API surface.

Together, the three form a closed loop of "generate → shape → verify," ensuring that source types and published types are strictly consistent. However, the type package itself being correct does not mean the type shape of the public API is locked down. In the next chapter, we will dive intopackages-private/dts-test, and see how more than 20.test-d.tsfiles useexpectTypeand other tools to turn "types as API contracts" into regression-testable automated tests.

Turn Any Codebase into a Book You Can Actually Understand

Enjoyed this chapter? Turn your private codebase into an architecture book

Local-first Tauri 2 + Rust architecture. 100% offline security, zero code uploaded. Dual-pane reading with immutable commit line anchors.

⚡ Tauri 2 · Rust Native Core · 100% Offline & Private · Tested on 1M+ LOC

CHAPTER 06

Chapter 6: Type Contract Testing: How dts-test Guards the API Surface

Upstream: vuejs/core · Commit @4ab865a8 · Progress: Chapter 6 of 14

In the previous chapter, we traced the generation pipeline for type declarations and saw how Vue uses build configuration and smoke tests to ensure that "source types" and "published types" are strictly consistent. But type contracts are not just about whether the shape is correct; more critically, they are about whether the API surface matches expectations—which types should be exported, which should not, and whether generic constraints are precise. This chapter enterspackages-private/dts-test, and see how Vue uses more than 20.test-d.tsfiles to turn "types as API contracts" into regression-testable automated tests.

The cognitive model of type contract testing: turning a "specification" into an "executable contract"

dts-testThe files in the directory have a counterintuitive characteristic: theyproduce almost no runtime behavior at all. OpendefineComponent.test-d.tsx, and you will see a large number ofdefineComponent({...})calls, but they are never actually executed when the tests run—these files are onlytsc/vue-tsctype-checked,noEmit: trueensuring that no JS is produced.

📎 packages-private/dts-test/tsconfig.test.json:1-11

This configuration is the "runtime environment" of the entire contract system:noEmitdisables emit output,jsx: preserveleaves TSX syntax to be parsed by the type system,strictturns on all strict checks,moduleResolution: bundlermatches modern bundler semantics,liband also brings inesnextanddom。Without this configuration,.test-d.tsxthe JSX in would be treated as runtime JSX, and type assertions would lose their meaning。

[Design inference and architectural trade-offs]

Making type tests a separatepackages-privatesubpackage rather than stuffing them intopackages/vue's__tests__has three motivations: first, the dependencies of type tests arevue'spublish-level types(vue/jsx、vue's.d.ts), rather than internal source modules, and physical isolation can force the use of public entry points; second,tscchecking type tests takes far longer than runtime unit tests, and a separate directory makes it easier for CI to schedule them independently; third,.test-d.tsxfiles will not be mistakenly executed by Vitest's runtime collector.

Everyday analogy: ordinary unit tests are like "powering on the machine and running it once to see whether it smokes," while type contract tests are like "checking the terms one by one before signing a contract"—there is no actual transaction, only confirmation that "the amount payable by Party A" is written as "RMB" rather than "USD." If the contract terms are wrong, it does not matter how smoothly the machine runs.

utils.d.tsprovides all the tools for this "contract checking":

📎 packages-private/dts-test/utils.d.ts:7-21

There are only four key tools:expectType<T>(value: T)asserts thatvalueis exactly of typeT;expectAssignable<T, T2 extends T>asserts thatT2is assignable toT;IsUnion<T>determines whetherTis a union type;IsAny<T>determines whetherTisany. Note L5'simport 'vue/jsx'—it registers the global JSX namespace so that<MyComponent />in TSX can be recognized by the type system asJSX.Element。

📎 packages-private/dts-test/utils.d.ts:7-21

IsUnion's implementation is worth a closer look:T extends any ? (U extends T ? false : true) : neveruses distributive conditional types; ifTis a union type, each member is evaluated independently, and finallyextends falsedetermines whether all branches returnfalse. This isan existence proof at the type level—used to lock down contracts such as "props.jjjmust be a union type rather than being merged into a single signature."

Scenario-driven Walkthrough:defineComponentThe full chain of props type inference in

defineComponent.test-d.tsxhas 2260 lines and is the core of the contract system. Let us put ourselves in a concrete scenario:The user writesdefineComponent({ props: {...}, setup(props) {...} }), and Vue's type system needs to infer frompropsruntime declaration the precise type of thesetupparameter inprops. This chain is the most complex part of Vue's type system.Step 1: Construct the "expected type" as the contract baseline

The test file first defines the

interface, explicitly hard-coding the type that each props declaration style should inferExpectedPropsThis interface is the written version of the "contract terms." Note several subtle types::

📎 packages-private/dts-test/defineComponent.test-d.tsx:21-53

(optional props witha?: number | undefined(has default, so non-optional),undefined)、aa: numberexplicitly declared),aaa: number | null(PropType<number | null>but the type containsaaaa: number | undefined(required: true as const). These differences are not written arbitrarily; each corresponds to a specific branch in theundefineddeclaration.propsStep 2: "Feed" various declaration styles into

ThisdefineComponent

📎 packages-private/dts-test/defineComponent.test-d.tsx:57-158

object ispropsan exhaustive matrix of declaration styles, covering all ways of writing Vue props:— constructor shorthand, inferred as

  • a: Number— has default, inferred as non-optionalnumber | undefined
  • aa: { type: Number as PropType<number | undefined>, default: 1 }preventsnumber
  • aaaa: { type: Number, required: true as const } —— as constfrom being widened totrue, preserving the literal typebooleanmakes the property non-void
  • b: { type: String, required: true as true } —— required: true— no
  • bb: { default: 'hello' }, inferring the type solely from defaulttype— explicit type cast
  • cc: Array as PropType<string[]>— array syntax, inferred as
  • l: [Date]— multi-type array, inferred asDate | undefined
  • ll: [Date, Number]— same as aboveDate | number | undefined
  • lll: [String, Number][Design inference and architectural trade-offs]
(L70) and

required: true as const(L75) coexisting is a trace of historical evolution: early on,required: true as truewas used, and later it was discovered thatas trueis more general (it can simultaneously lock down other literals in the object), but the old form is retained to verify backward compatibility. This is the typical value of contract testing—as constit simultaneously locks down "the new form works" and "the old form does not regress"Step 3: Assert in the three positions。

setup / render / thisThis is the most ingenious design of contract testing:

the same props type must infer correctly in three different consumption positionsperforms。

📎 packages-private/dts-test/defineComponent.test-d.tsx:160-217

setup(props)on each prop. Note the special handling at L168-170:expectType<ExpectedProps['x']>(props.x)。注意 L168-170 的特殊处理:

📎 packages-private/dts-test/defineComponent.test-d.tsx:168-170

// @ts-expect-error should included 'undefined'combined withexpectType<number>(props.aaaa)——deliberately write an assertion that will throw an error, using@ts-expect-errorto swallow the error. This verifies thatprops.aaaa's typeis not number(otherwise this line would not throw an error,@ts-expect-errorbut would instead fail because "there is no error to swallow"). This is the "reverse assertion" technique of type testing.

📎 packages-private/dts-test/defineComponent.test-d.tsx:204-205

// @ts-expect-error props should be readonlycombined withprops.a = 1— verifies that props are readonly insetup. If some refactor accidentally makes props mutable, this line no longer throws an error,@ts-expect-errorwill fail.

render()In, assertions are made through two paths,this.$propsandthis.x:

📎 packages-private/dts-test/defineComponent.test-d.tsx:221-279

L252-276 verifies that "declared props must also be exposed onthis", and L278-279 verifies thatthis.a = 1throws an error (thisprops on are also readonly). L281-287 verifies the unwrapping of the setup return value:this.cisnumber(ref(1)is unwrapped),this.d.e.valueisstring(nested refs preserve.value)、this.f.gisGT(reactivebranded types in are not unwrapped).

Step 4: Type validation on the TSX consumer side

The final link in the type contract is "how users use this component". In TSX,<MyComponent />'s props validation is an independent type path:

📎 packages-private/dts-test/defineComponent.test-d.tsx:296-322

Here it verifies that<MyComponent>accepts all declared props, as well asclass/style/key/ref/ref_forthese built-in attributes. Then comesreverse validation:

📎 packages-private/dts-test/defineComponent.test-d.tsx:337-345

// @ts-expect-error missing required propsverifies that missing required props throws an error;wrong prop typesverifies that type mismatch throws an error; L342 verifies thatggg="baz"throws an error (gggonly accepts'foo' | 'bar')。

The entire chain can be summarized with a data flow diagram:

mermaid
flowchart LR
    A["props 声明对象<br/>L57-158"] --> B["defineComponent<br/>泛型推导"]
    B --> C["ExtractPropTypes<br/>运行时声明 → 类型"]
    C --> D["setup(props)<br/>L162-217"]
    C --> E["render() this.$props<br/>L221-279"]
    C --> F["TSX 消费端<br/>L296-345"]
    D --> G["expectType 断言<br/>契约锁定"]
    E --> G
    F --> G
    G --> H{"全部通过?"}
    H -->|是| I["类型契约成立"]
    H -->|否| J["tsc 报错<br/>CI 阻断合并"]

The key to this diagram is:the samepropsdeclaration must simultaneously satisfy the type expectations of three consumption positions. Any inference deviation in any one place will causetscto throw an error.

Boundaries and backdoors:__typeProps、__typeEmitsand conditional type contracts

defineComponentThere is a fundamental limitation in type inference:runtime props declarations cannot express "conditional types". For example, "whencolor='white',appearancemust be'outline'" cannot be written with runtime object syntax. Vue provides__typePropsand other "type backdoors" for this.

__typeProps: the type escape hatch for conditional props

📎 packages-private/dts-test/defineComponent.test-d.tsx:1803-1836

ConditionalPropsis a union type: eithercolorandappearanceare both optional, orcolor: 'white'andappearance: 'outline'. Tests verify:

  • L1823-1824:<Comp color="white" />throws an error — providingcolor: 'white'alone does not satisfy either branch
  • L1825-1826:<Comp color="white" appearance="normal" />throws an error —appearancemust be'outline'
  • L1827:<Comp color="white" appearance="outline" />passes
[Design inference and architectural trade-offs]

__typePropsThe design motivation of is "to let the type system express constraints that runtime cannot express". It does not participate in runtime props parsing; it is purely a type-level override. The cost is that users need to manually maintain consistency between types and runtime declarations — this is also why it is called a "backdoor" rather than a formal API.

__typeEmits: equivalence of the two emits syntaxes

__typeEmitssupports two syntaxes, and the testslock down both at the same time:

📎 packages-private/dts-test/defineComponent.test-d.tsx:1838-1885

Object syntax{ change: [id: number], update: [value: string] }uses named tuples to express parameters. Tests verify thatthis.$props.onChange?.(123)passes andonChange?.('123')throws an error.

📎 packages-private/dts-test/defineComponent.test-d.tsx:1887-1934

Call signature syntax{ (e: 'change', id: number): void; (e: 'update', value: string): void }uses overloads to express it.The test bodies for the two syntaxes are almost line-by-line identical— this is intentional: the contract requires both forms to producecompletely equivalenttype behavior.

[Design inference and architectural trade-offs]

Why keep both syntaxes? Object syntax is closer todefineEmits's style, while call signature syntax is closer to traditional TS event types. Vue needs to support both and guarantee consistent behavior. The "line-by-line mirror" structure of the tests is the strongest proof of equivalence.

__typeRefsand__typeEl: cross-component references and host node types

📎 packages-private/dts-test/defineComponent.test-d.tsx:1936-1952

__typeRefslets parent components know precisely the type of a child component ref.Parentdeclares__typeRefs: { child: ComponentInstance<typeof Child> }, sorefs.child.$refs.foocan be inferred asnumber。

📎 packages-private/dts-test/defineComponent.test-d.tsx:1963-1977

__typeElis more subtle. The test comments at L1963-1977 point out the design intent:The host nodes of custom renderers (TUI, canvas, native) are not DOMElement, soTypeElcannot be constrained toElement. The tests use theCustomElementinterface to verify that$elcan accept any host type.

[Design inference and architectural trade-offs]

This is the type-level guarantee for Vue 3's support of custom renderers. IfTypeElwere hard-constrained toElement,@vue/runtime-test, users of non-DOM renderers like this would not be able to correctly infer$eltypes. What the contract tests guard here is "renderer agnosticism".

Mutually exclusive constraints between generic components and runtime props

function syntax w/ runtime propsThe section locks down an important rule:Generic components cannot coexist with object runtime props。

📎 packages-private/dts-test/defineComponent.test-d.tsx:1501-1545

The comment at L1501generics aren't supported with object runtime propsis a contract declaration. L1525-1535 verifies that generic setup + object props throws an error; L1538-1539 verifies that<Comp3<string>>throws an error. Array props, however, allow generics (L1464-1499).

[Design inference and architectural trade-offs]

The root cause of this constraint is the order of type inference: object props requireExtractPropTypesto determine the type first, while generics can only be determined at instantiation time, so the two conflict. Array props do not participate in type extraction, so there is no conflict. Contract tests solidify this "type system limitation" into regressable assertions.

Design thinking, error recovery, and production pitfalls

@ts-expect-errorThe double-edged sword of

@ts-expect-erroris the core tool of type contract testing, but it has a fatal trap:when the code below it no longer throws an error,@ts-expect-erroritself will throw an error. This seems like protection, but in reality it requires test authors to precisely control "where the error occurs".

📎 packages-private/dts-test/defineComponent.test-d.tsx:1354-1362

Look at this snippet:// @ts-expect-error missing propis placed on<Comp msg={123} />'sprevious line, but the entire expression is wrapped inexpectType<JSX.Element>(...). If@ts-expect-error's position shifts by one line, or the error actually occurs in theexpectTypecall rather than in the JSX, the test will fail.

[Design inference and architectural trade-offs]

Production pitfall: when TypeScript version upgrades cause slight adjustments in error locations, a large number of@ts-expect-errormay fail collectively. Vue's strategy isto place@ts-expect-errortightly against the asserted code, and to lock the TypeScript version in CI. Any TS upgrade requires revalidating all type tests.

IsAnyandIsUnion: Type-level "proof of existence"

📎 packages-private/dts-test/defineComponent.test-d.tsx:1991-1993

expectType<IsAny<typeof props.foo>>(false)Verifyprops.foois notany. This isreverse contract: not only requires the type to be correct, but also requires the type to "not degrade intoany」。anyis a black hole in the type system; anyanywill make subsequent assertions meaningless.

📎 packages-private/dts-test/defineComponent.test-d.tsx:195-196

expectType<IsUnion<typeof props.jjj>>(true)Verifyjjjis a union type.jjjDeclared as((arg1: string) => string) | ((arg1: string, arg2: string) => string), if the type system merges it into a single signature,IsUnionwill returnfalse, and the test fails.

[Design inference and architectural trade-offs]

These two tools guard "type precision" rather than "type correctness." A type that degrades intoanyor whose union is merged "looks usable" in most usage scenarios, but loses IDE hints and compile-time checks. Contract tests must lock down this precision.

Implicit contract of declaration order

📎 packages-private/dts-test/defineComponent.test-d.tsx:1784-1801

This comment is extremely critical:code generated by tsc / vue-tsc, make sure this continues to work so we don't accidentally change the args order of DefineComponent。DefineComponenthas 13 generic parameters, and the order ispublic contract——vue-tscThe generated component type depends on this order. The test usesdeclare const MyButton: DefineComponent<...>to explicitly write out all 13 parameters, locking the order.

[Design inference and architectural trade-offs]

This is the most easily overlooked contract: the order of generic parameters is not an "implementation detail," but the "ABI of generated code." Any PR that adjusts the order will makevue-tscgenerated.d.tsincompatible with the runtime type. Contract tests here act as an "ABI compatibility guard."

Cross-file contract:componentInstance.test-d.tsxsupplement to

componentInstance.test-d.tsxis only 154 lines, but coversComponentInstanceall input forms of the utility type:

📎 packages-private/dts-test/componentInstance.test-d.tsx:10-40

ComponentInstance<typeof CompSetup>extract the instance type from thedefineComponentresult;ComponentInstance<typeof CompFunctional>extract from a functional component;ComponentInstance<typeof CompFunction>extract from a bare function. All three must infer theComponentPublicInstancebase class.

📎 packages-private/dts-test/componentInstance.test-d.tsx:71-116

Even more extreme is the "bare object withoutdefineComponentwrapper":CompObjectSetup、CompObjectData、CompObjectNoPropsall three forms must be correctly extracted byComponentInstance. L113-114 is especially counterintuitive:CompObjectNoPropshas nopropsdeclaration, butcompObjectNoProps.testis still inferred asstring | undefined—this is the fallback provided by theComponentPublicInstancebase class.

📎 packages-private/dts-test/componentInstance.test-d.tsx:143-147

The#12751test at L141 locks down a boundary:__typeEmitsthe declared'update:visible'event should be exposed on the instance ascomp['onUpdate:visible'](a string key with a colon), and$propshas type{ 'onUpdate:visible'?: (value?: boolean) => any }. L152-153 verifies thatcomp['$props']['$props']reports an error—preventing recursive self-reference of the type.

Chapter summary

dts-testThe directory uses more than 20.test-d.tsfiles to turn "types are API contracts" into regression-ready automated tests. The core mechanism has three layers:

1. Tool layer:expectType、expectAssignable、IsUnion、IsAnyprovides type assertion primitives,@ts-expect-errorprovides reverse assertion capability.

2. Contract layer:ExpectedPropsThe interface explicitly hard-codes "what type should be inferred,"propsthe declaration matrix exhaustively enumerates all writing styles, and the three consumption positions (setup/render/TSX) cross-validate.

3. Backdoor layer:__typeProps、__typeEmits、__typeRefs、__typeElprovides an escape hatch for type constraints that cannot be expressed at runtime, while locking down the equivalence of the two emits syntaxes.

Chapter reflection and self-test

Q1: If you removedefineComponent.test-d.tsxthe@ts-expect-errorat L168-170 and keep onlyexpectType<number>(props.aaaa), what happens? Why would this test "silently fail"?

Reference analysis:

props.aaaais declared as{ type: Number as PropType<number | undefined>, required: true as const }, and its inferred type isnumber | undefined(becausePropType<number | undefined>explicitly includesundefined)。

expectType<number>(props.aaaa)requiresprops.aaaato be exactlynumber. Since the actual type isnumber | undefined, this lineitself will report an error。@ts-expect-errorIts role is to "expect an error here and swallow it."

If you remove@ts-expect-error, this line will directly report an error and the test will fail—which looks "stricter." But the problem is:if some refactor makesprops.aaaaactually becomenumber(bug fix or behavior change), this line no longer reports an error, and after removing@ts-expect-errorthe test will pass—at that point the test cannot distinguish between "the type is correct" and "the type is wrong but happens not to report an error."

Keeping@ts-expect-errorisbidirectional locking: it requires both "the current type isnumber | undefined" (by having@ts-expect-errorswallowexpectType<number>'s error), and "the type cannot benumber" (if it becomesnumber,@ts-expect-error, it will fail because there is no error to swallow). This is the core technique of type contract testing—using "expected error" to lock down "the type must contain a certain component"。

📎 packages-private/dts-test/defineComponent.test-d.tsx:168-170

Q2: __typePropsThe backdoor test (L1803-1836) verifies the constraints of the conditional union type. If you changeConditionalPropsfrom a union type to{ color?: 'normal' | 'primary' | 'secondary' | 'white'; appearance?: 'normal' | 'outline' | 'text' }(that is, flatten all options), how will the test fail? What design constraint of__typePropsdoes this illustrate?

Reference analysis:

The flattened type allows anycolorandappearancecombination, includingcolor: 'white' + appearance: 'normal'. But test L1825-1826 explicitly requires this combination toreport an error:

code
// @ts-expect-error
;<Comp color="white" appearance="normal" />

If the type is flattened, this line no longer reports an error, and@ts-expect-errorfails because "there is no error to swallow." At the same time, the<Comp color="white" />at L1823-1824 will also change from "reporting an error" to "passing," likewise causing@ts-expect-errorto fail.

This shows that the design constraint of__typePropsis:it must preserve the "branch mutual exclusivity" semantics of the union type。__typePropsIt is not a simple "type override," but "using the type system to express conditional constraints that runtime props cannot express." If during implementationPropsis subjected to a mapping transformation such asPrettifyorOmit, it may break the discriminability of the union branches and cause the constraints to fail.

[Design inference and architectural trade-offs]

This is also why__typePropsthe test cases use the most plainCommonProps & ConditionalPropsintersection rather than a more "elegant" mapped type—any additional type transformation may mask bugs.

Q3: DefineComponentThe order of the 13 generic parameters ofVNodeProps & AllowedComponentProps & ComponentCustomPropsis explicitly locked by L1784-1801. If some refactor swaps the 9th parameter (Readonly<ExtractPropTypes<{}>>) with the 10th parameter (

), which downstream parts will be affected? Why must contract tests lock down this order?:

DefineComponentReference analysisvue-tscThe generic parameter order of<script setup>is the "ABI" whendefineProps / defineEmits,vue-tscgenerates the component type. When the user writesCreateComponentPublicInstance<...>in, it generates atype similar to L1999-2116, where the

position

1. vue-tscof the generic parameters determines the meaning of each type parameter..d.tsIf the 9th and 10th parameters are swapped:DefineComponentthe generatedVNodeProps & AllowedComponentProps & ComponentCustomPropswill fill parameters in the old order, butReadonly<ExtractPropTypes<{}>>interprets them in the new order—All props types of the user component are misaligned。

2. L1786-1800'sdeclare const MyButton: DefineComponent<...>will directly throw an error—because{}andVNodeProps & ...are incompatible.

3. L1999-2116'sErrorMessagetype (simulatingvue-tscgenerated result) will also throw an error.

The value of contract tests locking down order lies in:It elevates "generic parameter order" from an "implementation detail" to a "public contract". Any PR that adjusts the order will immediately fail L1786-1800, preventing incompatible changes from entering a release.

📎 packages-private/dts-test/defineComponent.test-d.tsx:1784-1801

[Design inference and architectural trade-offs]

This is the most easily underestimated value of type contract tests: what they guard is not "whether the types are correct," but "the interface stability of the type system." Generic parameter order,@ts-expect-error's position,IsAny's return value, are all components of the "type ABI."

Type contract tests solve "whether the API surface meets expectations." But types are only half of Vue engineering—the other half is "how users verify these APIs' behavior in real time in the browser." The next chapter will enter SFC Playground to see how Vue packages the compiler, runtime, and type system into an in-browser real-time debugging environment, letting users see compilation output and runtime results the moment they change code.

What contract tests guard is not only "whether the types are correct," but also "whether the types are precise" (IsAny/IsUnion), "whether generic parameter order is stable" (DefineComponent13 parameters), "renderer agnosticism" (__typeElnot constrained toElement). Once these constraints are broken, user-side IDE hints,vue-tscgenerated types will drift. And the stability of type contracts must ultimately serve developers' daily debugging experience—in the next chapter we will walk intopackages-private/sfc-playground, to see how a pure frontend Playground completes the closed loop of SFC compilation and real-time preview within the browser.

Turn Any Codebase into a Book You Can Actually Understand

Enjoyed this chapter? Turn your private codebase into an architecture book

Local-first Tauri 2 + Rust architecture. 100% offline security, zero code uploaded. Dual-pane reading with immutable commit line anchors.

⚡ Tauri 2 · Rust Native Core · 100% Offline & Private · Tested on 1M+ LOC

CHAPTER 07

Chapter 7: SFC Playground: The In-Browser Real-Time Compilation and Debugging Subsystem

Upstream: vuejs/core · Commit @4ab865a8 · Progress: Chapter 7 of 14

In the previous chapter we used more than 20.test-d.tsfiles to nail "types as API contracts" into CI. But type contracts only answer "what the API surface looks like"; they cannot answer "what this SFC actually compiles into" or "whether rendering results are consistent in SSR mode." To answer the latter two questions, the Vue team needed a sandbox that could run the full compilation pipeline in the browser—this ispackages-private/sfc-playground. It is fundamentally different from the public packages underpackages/:package.jsonin"private": trueand"version": "0.0.0" 📎 packages-private/sfc-playground/package.json:2-4, meaning it is never published to npm and is only an official debugging tool. Among its dependencies,vuepoints toworkspace:* 📎 packages-private/sfc-playground/package.json:19, that is, the local source build artifact rather than the stable version on npm—this naturally makes the Playground a "living demo of the current commit." This chapter focuses on three questions: how the entry initializes, how the Header drives state switching, and how build-time constants are injected.

1. The minimalism of the entry: the initialization contract of main.ts and ReplStore

Intuitive model

main.tshas only 9 lines, like a "power-on self-test script": before the Vue app mounts, it first puts a global configuration ontowindow, telling Vue DevTools "which app is selected by default." Without this step, when DevTools opens it will face multiple app instances (the Playground itself + the code running in the user's REPL) and cannot automatically focus, degrading the debugging experience to manual switching.

Data structures and global side effects

main.tsThe core ofcreateAppis notwindow, but the polluting write to

📎 packages-private/sfc-playground/src/main.ts:4-7

ts
// @ts-expect-error Custom window property
window.VUE_DEVTOOLS_CONFIG = {
  defaultSelectedAppId: 'repl',
}

There are two engineering details worth noting here:

[Design inference and architectural trade-offs]

1. @ts-expect-errorrather than@ts-ignore:window's standard typeWindow & typeof globalThisdoes not have theVUE_DEVTOOLS_CONFIGfield. Using@ts-expect-errormeans "I know this will error here, and I require it to error"—if in the future some@types/*adds this field,@ts-expect-errorwill inversely error due to "not producing an error," thereby reminding the author to remove that annotation. This is in the same vein as the type contract testing approach from the previous chapter:Use the type system to guard intent, not to conceal problems。

[Design inference and architectural trade-offs]

2. defaultSelectedAppId: 'repl''s string convention: this'repl'must exactly match the id used when@vue/replinternally creates the app. It is a cross-package literal contract with no type constraint protection—once@vue/replchanges the id, the Playground's DevTools default selection will silently fail.

Step-by-Step: From HTML to Mounting

The execution flow is extremely short, but every step has implicit constraints:

1. The browser loadsindex.html, which contains<div id="app">(not provided in this material, butmount('#app')can be inferred from it).

2. Module graph resolution:main.tsat the top ofimport App from './App.vue' 📎 packages-private/sfc-playground/src/main.ts:2triggers@vitejs/plugin-vue's SFC compilation.

[Design inference and architectural trade-offs]

3. Key order:window.VUE_DEVTOOLS_CONFIGmust be written beforecreateApp(App).mount('#app') 📎 packages-private/sfc-playground/src/main.ts:9. Because DevTools' hook is registered insidecreateApp, writing the configuration later than mount will not affect the initial selection.

4. mount('#app')triggersApp.vue's setup, thereby creatingReplStore(inApp.vue, not included in this material).

mermaid
flowchart TD
    load["浏览器加载 index.html"] --> parse["解析 main.ts 模块图"]
    parse --> sfc["@vitejs/plugin-vue 编译 App.vue"]
    sfc --> setcfg["写入 window.VUE_DEVTOOLS_CONFIG"]
    setcfg --> check{"VUE_DEVTOOLS_CONFIG 已设置?"}
    check -->|是| mount["createApp(App).mount('#app')"]
    check -->|否| devtools["DevTools 无法默认选中 repl"]
    mount --> appsetup["App.vue setup 创建 ReplStore"]
    appsetup --> ready["Playground 就绪"]
    devtools --> mount

Design thinking and pitfalls

main.tsThe minimalism ofis deliberate:App.vuepush all complexity down intoReplStoreThe entry point only handles two things: "global side-effect injection + mounting." No business logic should appear here. This is a trade-off of Playground being a "debugging tool" rather than a "product"—it doesn't need SSR compatibility, multiple entry points, or lazy loading.

[Design Inference and Architectural Trade-offs]

Production pitfalls:window.VUE_DEVTOOLS_CONFIGisglobal singleton. If Playground is embedded in another page that also uses DevTools (such as an iframe scenario), the later writer will overwrite the former. Since Playground is typically deployed independently, this risk is accepted.

---

II. Header.vue: computed derived state and emit unidirectional data flow

Intuitive model

Header.vueis Playground's "control panel"—version selection, PROD/DEV toggle, SSR toggle, theme toggle, share, download. It itselfdoes not hold any business state, all state comes fromprops.storeand boolean props, all changes are reported to the parent component throughemit. Without this "dumb component + event bubbling" constraint, Header would become a disaster zone of scattered state, and the side effects of version switching and SSR toggling would be impossible to manage centrally.

Data structure and field analysis

Header's props definition is the key to understanding its responsibilities:

📎 packages-private/sfc-playground/src/Header.vue:13-19

ts
const props = defineProps<{
  store: ReplStore
  prod: boolean
  ssr: boolean
  autoSave: boolean
  theme: 'dark' | 'light'
}>()

The five props fall into two categories:

  • store: ReplStore: the unique state container reference, from@vue/repl. Header reads through itstore.loading、store.vueVersion、store.typescriptVersion, and directly writesstore.vueVersion。
  • four boolean/literal props:prod、ssr、autoSave、theme. They arecontrolled state, Header is read-only and does not write, changes mustemit。

corresponding emit list📎 packages-private/sfc-playground/src/Header.vue:20-28:

ts
const emit = defineEmits([
  'toggle-theme',
  'toggle-ssr',
  'toggle-prod',
  'toggle-autosave',
  'reload-page',
])

Notetoggle-themealthough internally bytoggleDark()emit, buttoggle-ssr/toggle-prod/toggle-autosaveis directly in the template$emitof📎 packages-private/sfc-playground/src/Header.vue:102-118. This mixing is a common style in Vue 3<script setup>:use function emit when side effects are needed, use template when pure forwarding$emit。

Step-by-Step: Version display and switching

Scenario: User opens Playground, Header needs to display the current Vue version.

Step 1: computed derived display text

📎 packages-private/sfc-playground/src/Header.vue:30-37

ts
const vueVersion = computed(() => {
  if (store.loading) {
    return 'loading...'
  }
  return store.vueVersion || `@${__COMMIT__}`
})

There are three levels of priority here:loadingstate →'loading...'; user explicitly selected a version →store.vueVersion; otherwise →@${__COMMIT__}(current commit short hash).__COMMIT__is a build-time injected constant, detailed in the next section.

Step 2: VersionSelect two-way binding

📎 packages-private/sfc-playground/src/Header.vue:88-88

html
<VersionSelect
  :model-value="vueVersion"
  @update:model-value="setVueVersion"
  pkg="vue"
  label="Vue Version"
>

Note heredoes not usev-model, but explicitly splits into:model-value + @update:model-value. The reason is thatvueVersionis computed (read-only), cannot be directly two-way bound; must write throughsetVueVersionthis setter functionstore.vueVersion:

📎 packages-private/sfc-playground/src/Header.vue:39-41

ts
async function setVueVersion(v: string) {
  store.vueVersion = v
}

function resetVueVersion() {
  store.vueVersion = null
}
[Design Inference and Architectural Trade-offs]

setVueVersiondeclared asasyncbut internally has noawait—is this legacy or intentional? Presumably to align withVersionSelect's async loading semantics (switching versions triggers remote loading), maintaining interface consistency.

Step 3: TypeScript version comparison

📎 packages-private/sfc-playground/src/Header.vue:76-80

html
<VersionSelect
  v-model="store.typescriptVersion"
  pkg="typescript"
  label="TypeScript Version"
/>

The TypeScript version usesv-model, becausestore.typescriptVersionis a writable normal property, no computed wrapper needed.The same component uses two binding methods in the same template, which is a direct manifestation of "controlled vs uncontrolled."

Theme switching: combination of side effects and emit

📎 packages-private/sfc-playground/src/Header.vue:58-66

ts
function toggleDark() {
  const cls = document.documentElement.classList
  cls.toggle('dark')
  localStorage.setItem(
    'vue-sfc-playground-prefer-dark',
    String(cls.contains('dark')),
  )
  emit('toggle-theme', cls.contains('dark'))
}

This function does three things: manipulate DOM class, persist to localStorage, emit to notify parent component.Note it does not directly modifyprops.theme—because props are read-only, the parent component only updates after receivingtoggle-theme, which then drives the template'sthemetext:title📎 packages-private/sfc-playground/src/Header.vue:123。

[Design Inference and Architectural Trade-offs]

There is a subtle design here:DOM class manipulation and Vue reactive state are two independent paths。document.documentElement.classList.toggle('dark')directly modifies DOM, whilethemeprop is updated through Vue. If the two are out of sync (e.g., parent component refuses to update), the UI will show inconsistency where "class has switched but title text hasn't changed." In practice, the parent component always accepts the emit, so the problem doesn't manifest.

Hidden logic: copyLink's metaKey branch

📎 packages-private/sfc-playground/src/Header.vue:47-56

ts
async function copyLink(e: MouseEvent) {
  if (e.metaKey) {
    resetVueVersion()
    // hidden logic for going to local debug from play.vuejs.org
    window.location.href = 'http://localhost:5173/' + window.location.hash
    return
  }
  await navigator.clipboard.writeText(location.href)
  alert('Sharable URL has been copied to clipboard.')
}

This is adeveloper backdoor: holding Cmd onplay.vuejs.organd clicking the share button will navigate tolocalhost:5173(local dev server), and carry the current URL hash over. The hash encodes the complete REPL state (source code, version, options), so local debugging can reproduce online issues. The comment// hidden logic for going to local debug from play.vuejs.org 📎 packages-private/sfc-playground/src/Header.vue:47-56explicitly marks this as an intentionally hidden feature.

[Design Inference and Architectural Trade-offs]

resetVueVersion()is called before navigation, settingstore.vueVersiontonull, ensuring local debugging uses the current commit rather than the online selected version.

mermaid
flowchart TD
    click["用户点击 Share 按钮"] --> meta{"e.metaKey 按下?"}
    meta -->|是| reset["resetVueVersion() 置 null"]
    reset --> jump["跳转 localhost:5173 + hash"]
    jump --> local["本地 dev server 复现"]
    meta -->|否| copy["navigator.clipboard.writeText(location.href)"]
    copy --> check{"写入成功?"}
    check -->|是| alert["alert 提示已复制"]
    check -->|否| fail["静默失败 (无 catch)"]

Design thinking and pitfalls

[Design Inference and Architectural Trade-offs]

Pitfall 1:navigator.clipboard's permissions and secure context。copyLinkhas no try/catch📎 packages-private/sfc-playground/src/Header.vue:47-56. On non-HTTPS or when the user denies clipboard permission,writeTextwill reject, causing an uncaught Promise rejection. Playground is deployed on HTTPS, the risk is accepted, but this is a typical "production environment trap."

[Design Inference and Architectural Trade-offs]

Pitfall 2:toggleDark's localStorage key hardcoded。'vue-sfc-playground-prefer-dark'is a string literal, no constant extraction. If the key needs to be changed in the future, a global search is required.

Pitfall 3:currentCommitandvueVersioncomparison. In the template:class="{ active: vueVersion === \@${currentCommit}\ }" 📎 packages-private/sfc-playground/src/Header.vue:88-88Compare using string concatenation. If__COMMIT__injection fails (becomesundefined), here it becomes'@undefined', never matching. The reliability of build-time constant injection directly determines UI correctness—this is exactly the topic of the next section.

---

III. Build-time Constant Injection: The Dual Responsibilities of __COMMIT__ and copyVuePlugin

Intuitive Model

vite.config.tsis the Playground's "assembly workshop": at build time it executesgit rev-parseto get the commit hash, and throughdefineturns it into a global constant__COMMIT__; at the same time, through a custom plugin, it copies the ESM browser artifacts underpackages/vue/dist/to the Playground's output directory. Without this step, the Playground cannot load "the Vue runtime of the current commit" in the browser—it can only rely on the stable version on npm, losing the meaning of a "live demo."

Data Structures and Build-time Constants

📎 packages-private/sfc-playground/vite.config.ts:7-9

ts
const commit = spawnSync('git', ['rev-parse', '--short=7', 'HEAD'])
  .stdout.toString()
  .trim()

spawnSyncsynchronously executes the git command,--short=7taking the 7-character short hash. Synchronous execution is deliberate:the config file needs the value ofcommitat module load time, and async would disrupt Vite's config resolution timing.

📎 packages-private/sfc-playground/vite.config.ts:23-26

ts
define: {
  __COMMIT__: JSON.stringify(commit),
  __VUE_PROD_DEVTOOLS__: JSON.stringify(true),
},

defineis Vite'stext replacementmechanism: all__COMMIT__in the source code are replaced withJSON.stringify(commit)'s result (i.e., a quoted string literal).JSON.stringifyis necessary—if you writecommitdirectly, after replacement it becomes the bare identifierabc1234, treated as a variable name rather than a string.

[Design Inference and Architectural Trade-offs]

__VUE_PROD_DEVTOOLS__: trueis another key constant: it makes Vue'sproduction buildalso retain DevTools support. By default, production builds strip the DevTools hook to reduce size, but the Playground needs to debug user code, so it is forcibly enabled.

Step-by-Step: copyVuePlugin's Artifact Transfer

📎 packages-private/sfc-playground/vite.config.ts:32-63

ts
function copyVuePlugin(): Plugin {
  return {
    name: 'copy-vue',
    generateBundle() {
      const copyFile = (file: string) => {
        const filePath = path.resolve(
          import.meta.dirname,
          '../../packages',
          file,
        )
        const basename = path.basename(file)
        if (!fs.existsSync(filePath)) {
          throw new Error(
            `${basename} not built. ` +
              `Run "nr build vue -f esm-browser" first.`,
          )
        }
        this.emitFile({
          type: 'asset',
          fileName: basename,
          source: fs.readFileSync(filePath, 'utf-8'),
        })
      }

      copyFile(`vue/dist/vue.esm-browser.js`)
      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(`server-renderer/dist/server-renderer.esm-browser.js`)
    },
  }
}

Key points analyzed one by one:

1. generateBundlehook: executes after Rollup generates the bundle and before writing to disk. At this point you canemitFilestuff extra files into the output.

2. import.meta.dirname: the ESM version of__dirnameprovided by Node 20.11+. The path../../packagesgoes up frompackages-private/sfc-playground/to the repository root, then intopackages/。

3. Existence check + explicit error: ifvue.esm-browser.jsdoes not exist, throw an error with repair instructionsRun "nr build vue -f esm-browser" first.. This is a model ofdeveloper experience—the error message directly tells you how to fix it.

4. Five artifacts:vue's full build/runtime build × dev/prod, plusserver-renderer. These five files are exactly the candidate set that the Playground dynamically imports in the browser, corresponding to the version switching and SSR toggle in the Header.

[Design Inference and Architectural Trade-offs]

Why these five?The full build (with compiler) is used for "runtime compilation" scenarios; the runtime build is used for "precompilation" scenarios; dev/prod correspond to the Header's PROD/DEV toggle; server-renderer corresponds to the SSR toggle. These five files constitute the Playground's "Vue runtime matrix."

The Complete Data Flow of Version Switching

Connecting the Header'ssetVueVersionwith copyVuePlugin's artifacts:

mermaid
flowchart LR
    user["用户选择版本"] --> setver["setVueVersion(v)"]
    setver --> store["store.vueVersion = v"]
    store --> repl["@vue/repl 内部"]
    repl --> fetch{"版本来源?"}
    fetch -->|"@commit"| local["加载本地 vue.esm-browser.js"]
    fetch -->|"3.4.0"| cdn["从 CDN 加载"]
    local --> compile["浏览器内编译 SFC"]
    cdn --> compile
    compile --> preview["实时预览"]

Note the special value@${__COMMIT__}: it corresponds to the local artifacts copied by copyVuePlugin, not the CDN. This is why the Playground must copy Vue's browser build artifacts in—the "This Commit" option needs local files。

Design Reflections and Pitfalls

[Design Inference and Architectural Trade-offs]

Pitfall 1:spawnSync's failure handling. If the current directory is not a git repository (e.g., extracted from a tarball),spawnSyncreturns a non-zero exit code,stdoutis empty,commitbecomes an empty string. At this point__COMMIT__is replaced with"", and in the Header@${currentCommit}becomes'@'. There is no explicit error handling.

[Design Inference and Architectural Trade-offs]

Pitfall 2:optimizeDeps.exclude: ['@vue/repl'] 📎 packages-private/sfc-playground/vite.config.ts:27-29. Vite by default pre-bundles dependencies to speed up cold start, but@vue/replis excluded. The reason is that@vue/replinternally uses dynamic import and workers, and pre-bundling would break these mechanisms. This is a common "pre-bundling vs. dynamic loading conflict" problem in the Vite ecosystem.

[Design Inference and Architectural Trade-offs]

Pitfall 3:script.fsconfiguration 📎 packages-private/sfc-playground/vite.config.ts:13-19。@vitejs/plugin-vue'sscript.fsoption allows SFC's<script>block to read files throughfs. Herefs.existsSyncandfs.readFileSyncare passed in to support parsing ofimportstatements in SFCs (e.g.,import x from './foo'needs to check whether a file exists).This is the key to the Playground being able to simulate complete module resolution in the browser—it injects Node's fs capability into the compiler's resolution phase.

---

Design Reflections: The Playground's Architectural Trade-offs

Looking at the three subsections together, the Playground's architecture follows a clear principle:Separate "state" from "side effects," and separate "build time" from "runtime"。

  • main.tsonly performs global side-effect injection and does not touch business state.
  • Header.vueis a pure presentational component, with state flowing in through props and out through emit.
  • vite.config.tssolidifies the build-time information "current commit" into a constant, read-only at runtime.
[Design Inference and Architectural Trade-offs]

This separation brings a direct benefit:the Playground can be embedded into any Vue application(e.g., an inline example in a documentation site), as long as you providestoreand four boolean props.

The cost isState is scattered:storeIn@vue/repl, the boolean state is in the parent component, the DOM class is ondocument.documentElement, and there is another copy in localStorage. Four places of state need to be manually synchronized, and any one out of sync will cause UI inconsistency.

[Design inference and architectural trade-offs]

Another trade-off isgiving up SSR compatibility。main.tsdirectly accessingwindow,Header.vue'stoggleDarkdirectly accessingdocument. Playground is a pure CSR application and does not need to consider server-side rendering.

---

Chapter summary

This chapter analyzedpackages-private/sfc-playground's three core files:

1. main.ts: a 9-line entry point, whose core is the injection order ofwindow.VUE_DEVTOOLS_CONFIG—it must come beforemount.

2. Header.vue: derivescomputedthroughvueVersion, and reports all state changes throughemit.copyLink'smetaKeybranch is a hidden local debugging backdoor.

3. vite.config.ts:spawnSyncgets the commit hash,defineinjects__COMMIT__,copyVuePluginto copy the five Vue browser build artifacts into the Playground output directory.

The main thread running through all three isthe boundary between build-time constants and runtime state:__COMMIT__is a read-only build-time fact,store.vueVersionis a mutable runtime choice, and Header'svueVersioncomputed unifies the two into a single display string.

Chapter review and self-test

Q1: If the assignment ofmain.tsinwindow.VUE_DEVTOOLS_CONFIGis moved to aftercreateApp(App).mount('#app'), what will happen? Why?

Reference analysis:window.VUE_DEVTOOLS_CONFIGis the configuration read by Vue DevTools when registering the hook insidecreateApp. It will immediately register📎 packages-private/sfc-playground/src/main.ts:4-9。createApp, and at this point DevTools will read__VUE_DEVTOOLS_GLOBAL_HOOK__to decide which app is selected by default. If the assignment happens later thandefaultSelectedAppId, DevTools has already completed the first app selection, the configuration will not take effect, and the user needs to manually switch to themountapp in DevTools. More subtly: becausereplalso creates an app internally, a late assignment may cause DevTools to select Playground itself by default instead of the user's REPL, so debugging user code requires manual switching. This reflects the importance of "global side-effect injection order" in debugging tools.@vue/repl's

Q2: Header.vuesimultaneously operates on the DOM class, localStorage, and emit, but does not directly modifytoggleDark(). If the parent component receives theprops.themeevent and refuses to update thetoggle-themeprop, what UI inconsistency will occur? How can it be located at the source-code level?themeReference analysis

In:toggleDark(),📎 packages-private/sfc-playground/src/Header.vue:58-66is called directly, which immediately changes thedocument.documentElement.classList.toggle('dark')class on the DOM and triggers the CSS variable switch (seedark's📎 packages-private/sfc-playground/src/Header.vue:186-186rule). But the.dark navtext in the template:titledepends on📎 packages-private/sfc-playground/src/Header.vue:123. If the parent component does not update it, the title will remain at the old value. Location method: check in the browser DevTools whether the class ofprops.themecontradicts the button's title attribute. The root cause is that "DOM side effects" and "Vue reactive state" follow two independent paths, with no single source of truth.<html>In

Q3: copyVuePlugin, perform agenerateBundlecheck on each file, and throw an error with repair instructions when it is missing. If this check is removed andfs.existsSyncis called directly, what will happen in a CI environment (without building vue first)? How will the error message mislead developers?fs.readFileSyncReference analysis

: after removing the check,will throwfs.readFileSync. This error only tells the developer "the file does not exist," but does not tell the developer "you need to runENOENT: no such file or directory, open '.../packages/vue/dist/vue.esm-browser.js' 📎 packages-private/sfc-playground/vite.config.ts:32-63first." In a CI environment, developers may mistakenly think it is a path configuration error, a permissions issue, or an uninitialized git submodule, wasting a lot of time troubleshooting. The original code'snr build vue -f esm-browserbinds the "symptom" with the "repair action," which is a key detail of developer experience design. This also explains why the Playground build script must have a clear dependency order with the Vue core build script.throw new Error(\${basename} not built. Run "nr build vue -f esm-browser" first.\)The next chapter will enter

---

and see how Vue visualizes the compiler's intermediate products (AST, transformation results, code generation), allowing developers to observe step by step every transformation from template to render function. Unlike Playground's "end-to-end black box," Template Explorer is a "white-box probe."packages-private/template-explorerAt this point, we have seen clearly how SFC Playground moves the compilation pipeline into the browser: entry initialization, Header state switching, and build-time constant injection together form a sandbox that can be debugged in real time. But Playground's perspective is always "the compilation and execution of the entire SFC," and it does not directly answer "what transformation the compiler actually performs on a given template expression." The next chapter will enter Template Explorer and see how it lays out the compilation results of

and@vue/compiler-domline by line, using SourceMapConsumer to establish a mapping between source code and output, thereby turning the compiler's internal behavior into an observable and inferable probe.@vue/compiler-ssr← Previous chapter: Chapter 6

Turn Any Codebase into a Book You Can Actually Understand

Enjoyed this chapter? Turn your private codebase into an architecture book

Local-first Tauri 2 + Rust architecture. 100% offline security, zero code uploaded. Dual-pane reading with immutable commit line anchors.

⚡ Tauri 2 · Rust Native Core · 100% Offline & Private · Tested on 1M+ LOC

CHAPTER 08

Project: vuejs/core

Upstream: vuejs/core · Commit @4ab865a8 · Progress: Chapter 8 of 14

In the previous chapter, we saw how SFC Playground encapsulates the entire chain of "input SFC → in-browser compilation → real-time preview" into a black box: developers see the final rendered result but cannot see what the compiler does in between. When a custom directive is written in the template, or when hoistStatic is enabled and the output suddenly contains a bunch of _hoisted_1 variables, Playground cannot answer "why the compiler generates it this way." Template Explorer's positioning is exactly the opposite: it lays out the compilation output of @vue/compiler-dom and @vue/compiler-ssr, the AST, error markers, and the position mapping from source code to output. Its core is not "running," but "observing." This chapter unfolds around three files: index.ts is responsible for compilation invocation and bidirectional SourceMap mapping, options.ts uses reactive to manage dozens of CompilerOptions and drive the UI, and theme.ts customizes the Monaco editor theme.

1. Compilation invocation and bidirectional SourceMap mapping: index.ts

Intuitive model

Template Explorer'sindex.tsis like a "bidirectional translation machine": the template is input on the left, and the render function is output on the right. But it has one more capability than a translation machine—when you place the cursor on a certain line on the left, the corresponding output on the right is highlighted; conversely, when you place the cursor on the right, the corresponding template on the left is highlighted. Without SourceMap mapping, this tool would degenerate into two side-by-side text boxes, and developers could only compare by eye, unable to establish the causal chain of "which line of the template → which line of the output."

Data structures and memory layout

index.tsThere are no complex Structs in it, but there are several key module-level state variables that determine the behavior of the entire tool:

lastSuccessfulCodeandlastSuccessfulMapare the cache of the compilation result📎 packages-private/template-explorer/src/index.ts:74-75. The former is a string, and the latter isSourceMapConsumer | undefined. Note thatlastSuccessfulMapis initiallyundefined, and is assigned only when compilation succeeds andmapexists📎 packages-private/template-explorer/src/index.ts:99-100. Thisundefinedstate is the guard condition for all subsequent cursor mapping logic—if compilation fails, the mapping feature automatically fails silently instead of throwing an exception.

PersistedStateThe interface defines the state shape persisted to localStorage and the URL hash📎 packages-private/template-explorer/src/index.ts:26-30:src(template source code),ssr(whether SSR mode is enabled),options(compiler options). There is a key design here:options's type is the completeCompilerOptions, but during actual persistence only "items different from the default values" are saved, and this pruning logic is completed inreCompile.

sharedEditorOptionsare the construction options shared by the two editors📎 packages-private/template-explorer/src/index.ts:26-30:fontSize: 14、scrollBeyondLastLine: false、renderWhitespace: 'selection'、minimap.enabled: false. The minimap is disabled because templates and output are usually only a few dozen lines, and the minimap instead takes up horizontal space.

Step-by-Step Walkthrough

Scenario: the user opens the page, inputs<div>{{ msg }}</div>, and then moves the cursor.

Step 1: Initialization and state restoration. window.initis the global entry point📎 packages-private/template-explorer/src/index.ts:41. It first registers and activates the custom theme📎 packages-private/template-explorer/src/index.ts:44-45, and then tries to restore state from the URL hash or localStorage📎 packages-private/template-explorer/src/index.ts:49-56. Note the decoding order here: firstatobthenescape, and thendecodeURIComponent. If hash parsing fails, it falls back tolocalStorage.getItem('state'), and then falls back to{}. If the entire JSON.parse fails, it clears localStorage and prints a warning📎 packages-private/template-explorer/src/index.ts:57-64。

After restoring state, there is a detail that is easy to overlook:delete persistedState.options?.nodeTransforms 📎 packages-private/template-explorer/src/index.ts:69. The comment explains the reason—functions cannot be serialized, so during persistencenodeTransformsis lost, and if an empty object remains during restoration, it will cause abnormal compiler behavior. This is the classic trap of "persisting non-serializable fields."

Step 2: Compilation corecompileCode。This is the heart of the entire tool📎 packages-private/template-explorer/src/index.ts:76-106. It firstconsole.clear(), and then selectsssrMode.valueaccording tossrCompileorcompile 📎 packages-private/template-explorer/src/index.ts:80. Note the call parameters ofcompileFn: spreadcompilerOptions, forcefilename: 'ExampleTemplate.vue'、sourceMap: true, and inject theonErrorcallback to collect errors📎 packages-private/template-explorer/src/index.ts:82-89。

There is a design decision here:filenameis hardcoded as'ExampleTemplate.vue'. This value must exactly matchgeneratedPositionForin the subsequent📎 packages-private/template-explorer/src/index.ts:189call, otherwise the SourceMap query will return empty results. This is an implicit contract—the strings in the two places must be consistent, but no type system guarantees it.

After compilation is complete, errors are converted to Monaco's marker format and set on the editor📎 packages-private/template-explorer/src/index.ts:91-95。formatErrorconvertsCompilerError'slocto Monaco'sstartLineNumber/startColumn/endLineNumber/endColumn 📎 packages-private/template-explorer/src/index.ts:108-119. Noteerrors.filter(e => e.loc)—only errors with position information are marked; errors withoutloc(such as global configuration errors) are only output to the console.

Step 3: Establishment of the SourceMap.After successful compilation,lastSuccessfulMap = new SourceMapConsumer(map!) 📎 packages-private/template-explorer/src/index.ts:99, and thencomputeColumnSpans() 📎 packages-private/template-explorer/src/index.ts:100。computeColumnSpansis called. It is a key API ofsource-map-js: it precomputes the column span of each mapping segment, making thegeneratedPositionForfield returned bylastColumnavailable. Without this step, reverse mapping can only locate the starting column and cannot highlight the entire token range.

Step 4: Bidirectional cursor mapping.When the user is in thesource editorand moves the cursor,editor.onDidChangeCursorPosition 📎 packages-private/template-explorer/src/index.ts:184is triggered. After a 100ms debounce, the callback callslastSuccessfulMap.generatedPositionFor({ source: 'ExampleTemplate.vue', line, column: column - 1 }) 📎 packages-private/template-explorer/src/index.ts:188-192. Notecolumn - 1: Monaco's column numbers start from 1, while SourceMap's column numbers start from 0. If the returnedposhaslineandcolumn, create a decorator on the output editor to highlight the corresponding range📎 packages-private/template-explorer/src/index.ts:194-206, and scroll to that position📎 packages-private/template-explorer/src/index.ts:207-210。

Reverse mapping is inoutput.onDidChangeCursorPosition📎 packages-private/template-explorer/src/index.ts:223. It callsoriginalPositionFor 📎 packages-private/template-explorer/src/index.ts:227-230, but with an extra guard: ignorepos.line === 1 && pos.column === 0's "mock location"📎 packages-private/template-explorer/src/index.ts:231-237. This guard is crucial—some code generated by the compiler (such asimportstatements or helper functions) has no corresponding template position, and SourceMap will return{ line: 1, column: 0 }as a placeholder. If not ignored, placing the cursor on these lines will incorrectly highlight the first line of the template.

Step 5: State persistence. reCompilenot only triggers compilation, but is also responsible for writing the current state to localStorage and the URL hash📎 packages-private/template-explorer/src/index.ts:121-146. During persistence there is a trimming logic: iterate overcompilerOptions, and only save items that are "not objects and not equal to the default value"📎 packages-private/template-explorer/src/index.ts:125-133. This explains whybindingMetadataoptions of this object type are not persisted—it is too complex, and the default value is already sufficient for demonstration.

mermaid
flowchart TD
    init["window.init()"] --> restore{"hash 或 localStorage 有状态?"}
    restore -->|是| parse["JSON.parse 成功?"]
    restore -->|否| useDefault["使用默认模板"]
    parse -->|成功| delNodeTrans["delete nodeTransforms"]
    parse -->|失败| clearLS["localStorage.clear() + 警告"]
    delNodeTrans --> createEditor["monaco.editor.create(source)"]
    clearLS --> createEditor
    useDefault --> createEditor
    createEditor --> initOpt["initOptions()"]
    initOpt --> watch["watchEffect(reCompile)"]
    watch --> compileCode["compileCode(source)"]
    compileCode --> chooseFn{"ssrMode.value?"}
    chooseFn -->|true| ssr["ssrCompile(source, opts)"]
    chooseFn -->|false| dom["compile(source, opts)"]
    ssr --> hasMap{"map 存在?"}
    dom --> hasMap
    hasMap -->|是| newSMC["new SourceMapConsumer(map)"]
    hasMap -->|否| skipMap["lastSuccessfulMap 保持 undefined"]
    newSMC --> computeSpan["computeColumnSpans()"]
    computeSpan --> setOutput["output.setValue(code)"]
    skipMap --> setOutput
    compileCode -->|抛异常| catchErr["lastSuccessfulCode = ERROR 注释"]
    catchErr --> setOutput

Design thinking and production pitfalls

Why usesource-map-jsinstead ofsource-map? source-mapis Mozilla's original library, which is large and depends on WASM (newer versions).source-map-jsis a pure JS implementation, small in size, and suitable for browser environments. As a pure frontend tool, Template Explorer choosingsource-map-jsis reasonable📎 packages-private/template-explorer/package.json:15。

debounce delay choice.The source code editor's debounce defaults to 300ms📎 packages-private/template-explorer/src/index.ts:271, while the cursor movement debounce is 100ms📎 packages-private/template-explorer/src/index.ts:215. This difference is intentional: compilation is a heavy operation, and 300ms avoids frequent triggering; cursor movement is a light operation, and 100ms ensures responsiveness. But 100ms can still cause highlight flicker when moving the cursor quickly—this is an acceptable tradeoff.

window.init's global mounting.Note thatwindow.initandwindow.monacoare both mounted on the global📎 packages-private/template-explorer/src/index.ts:19-23. This is because the Monaco editor is asynchronously loaded via the CDN'sloader.js, and after loading completes it callswindow.init. This "global callback" pattern is Monaco's standard usage in non-modular environments, but it is incompatible with modern ESM build approaches.

---

II. Reactive-driven options panel: options.ts

Intuitive model

options.tsis like a "console panel": there are more than a dozen switches and radio buttons on it, each corresponding to a compiler behavior. Toggle any switch, and the compiled output on the right changes immediately. Without this module, developers could only modify thecompilecall parameters in the source code and recompile, and could not compare the effects of different options in real time.

Data structures and memory layout

options.ts's core consists of three exports:

ssrModeis aref(false) 📎 packages-private/template-explorer/src/options.ts:5. It is independent ofcompilerOptions, because SSR mode switches the compile function itself (compile vs ssrCompile), not the compile options.

defaultOptionsis a completeCompilerOptionsobject📎 packages-private/template-explorer/src/options.ts:5-27. It defines the default values for all options, includingmode: 'module'、prefixIdentifiers: false、hoistStatic: false、cacheHandlers: false、scopeId: null、inline: false、ssrCssVars: '{ color }'、compatConfig: { MODE: 3 }、whitespace: 'condense', as well as abindingMetadata 📎 packages-private/template-explorer/src/options.ts:18-26。

compilerOptionscontaining 7 binding typesreactive(Object.assign({}, defaultOptions)) 📎 packages-private/template-explorer/src/options.ts:29-31isObject.assign({}, ...). Note thatreactive(defaultOptions)is used here for a shallow copy—if you directlycompilerOptions, modifyingdefaultOptionswill pollutereCompile, causing the "compare with default value" logic in

Step-by-Step Walkthrough

to fail.

Scenario: the user clicks the "hoistStatic" checkbox. AppStep 1: UI rendering.setupThe component's📎 packages-private/template-explorer/src/options.ts:33-35returns a render functionssrMode.value、compilerOptions.mode、compilerOptions.prefixIdentifiers. This render function reads reactive state such as📎 packages-private/template-explorer/src/options.ts:36-39, so when these states change, the entire UI re-renders.

Step 2: The checkbox's checked binding. hoistStaticThe checkbox'scheckedattribute iscompilerOptions.hoistStatic && !isSSR 📎 packages-private/template-explorer/src/options.ts:150. There is a logic here: in SSR mode,hoistStaticis forced to display as unchecked, because SSR compilation does not support static hoisting. At the same time,disabled: isSSR 📎 packages-private/template-explorer/src/options.ts:151ensures that the user cannot toggle it in SSR mode.

Step 3: onChange handling.When the user clicks the checkbox,onChangetriggers📎 packages-private/template-explorer/src/options.ts:152-156, directly assigninge.target.checkedtocompilerOptions.hoistStatic. SincecompilerOptionsisreactive, this assignment triggers dependency tracking, which in turn triggerswatchEffect(reCompile) 📎 packages-private/template-explorer/src/index.ts:266, and finally recompiles.

Step 4: Linkage between options.Note thatcacheHandlers'scheckedisusePrefix && compilerOptions.cacheHandlers && !isSSR 📎 packages-private/template-explorer/src/options.ts:166,disabledis!usePrefix || isSSR 📎 packages-private/template-explorer/src/options.ts:167. This means thatcacheHandlersdepends onprefixIdentifiersormode === 'module'. This linkage relationship is manifested in the UI as: whenprefixIdentifiersis not enabled and the mode isfunction,cacheHandlersthe checkbox is disabled.

scopeId's linkage is more complex:disabled: !isModule 📎 packages-private/template-explorer/src/options.ts:182,checked: isModule && compilerOptions.scopeId 📎 packages-private/template-explorer/src/options.ts:183. scopeId can only be set in module mode, and on change, ifisModuleis false, it will be forcibly set tonull 📎 packages-private/template-explorer/src/options.ts:184-189。

Step 5: Mounting. initOptionscallscreateApp(App).mount(document.getElementById('header')!) 📎 packages-private/template-explorer/src/options.ts:232-234. Note that here it usesvuefrom thecreateApppackage@vue/runtime-dom, rather thanoptions.ts—becausevueis application-layer code and can directly depend on the full

mermaid
flowchart LR
    subgraph reactive_state["reactive 状态层"]
        ssrMode["ssrMode: Ref<boolean>"]
        compilerOptions["compilerOptions: reactive(CompilerOptions)"]
    end
    subgraph ui_layer["UI 渲染层 (options.ts)"]
        modeRadio["mode 单选"]
        wsRadio["whitespace 单选"]
        ssrCheck["SSR 复选框"]
        prefixCheck["prefixIdentifiers 复选框"]
        hoistCheck["hoistStatic 复选框"]
        cacheCheck["cacheHandlers 复选框"]
        scopeCheck["scopeId 复选框"]
        inlineCheck["inline 复选框"]
        compatCheck["compatConfig 复选框"]
    end
    subgraph compile_layer["编译层 (index.ts)"]
        watchEffect["watchEffect(reCompile)"]
        compileCode["compileCode()"]
    end
    ssrMode -->|"checked/disabled"| ssrCheck
    ssrMode -->|"isSSR 守卫"| hoistCheck
    ssrMode -->|"isSSR 守卫"| cacheCheck
    compilerOptions -->|"mode"| modeRadio
    compilerOptions -->|"whitespace"| wsRadio
    compilerOptions -->|"prefixIdentifiers"| prefixCheck
    compilerOptions -->|"hoistStatic"| hoistCheck
    compilerOptions -->|"cacheHandlers"| cacheCheck
    compilerOptions -->|"scopeId"| scopeCheck
    compilerOptions -->|"inline"| inlineCheck
    compilerOptions -->|"compatConfig.MODE"| compatCheck
    modeRadio -->|"onChange 赋值"| compilerOptions
    wsRadio -->|"onChange 赋值"| compilerOptions
    ssrCheck -->|"onChange 赋值"| ssrMode
    prefixCheck -->|"onChange 赋值"| compilerOptions
    hoistCheck -->|"onChange 赋值"| compilerOptions
    cacheCheck -->|"onChange 赋值"| compilerOptions
    scopeCheck -->|"onChange 赋值"| compilerOptions
    inlineCheck -->|"onChange 赋值"| compilerOptions
    compatCheck -->|"onChange 赋值"| compilerOptions
    compilerOptions -->|"依赖追踪"| watchEffect
    ssrMode -->|"依赖追踪"| watchEffect
    watchEffect --> compileCode

Copy

Design thinking and production pitfallsreactiveWhy useref? compilerOptionsinstead ofreactiveis an object containing more than a dozen fields, and usingcompilerOptions.hoistStatic = trueallows directcompilerOptions.value.hoistStatic = true, without needingreactive. This is more concise in UI code. But the cost ofcompilerOptions.xxxis that destructuring loses reactivity—there is no destructuring in the source code, and everything is accessed through

bindingMetadata, which is the correct usage.'s default value design.📎 packages-private/template-explorer/src/options.ts:18-26The default value contains 7 bindingsSETUP_CONST、SETUP_REF、SETUP_LET、SETUP_MAYBE_REF、PROPS, coveringprefixIdentifiersfive types. This is to allow developers, after opening$setup, to immediately see the impact of different binding types on the wayprefixIdentifiersis accessed in the output. Without this default value,

compatConfig's effect would be very monotonous. compilerOptions.compatConfig!.MODE = 2 📎 packages-private/template-explorer/src/options.ts:216-220's nested reactivity.reactiveThis kind of nested assignment is reactive underreactive, becausecompatConfigrecursively proxies nested objects. But note thatCompatConfig | undefined's type is!, so acompatConfigassertion is used. If the default value does not contain

ssrMode, this will crash at runtime.compilerOptionsSeparation of responsibilities between ssrModeandref,compilerOptions.reactiveisssriscompilerOptions. Why not putssrintoCompilerOptions? Because

---

is not a field of

—it determines which compile function to use, not the parameters passed to the compile function. This separation of "control-flow state" and "configuration state" is a clear design.

theme.tsLike giving the editor "a new skin": it defines the color and font style for each syntax token. Without this module, Monaco will use the defaultvs-darktheme. Although it works, HTML tags, expressions, and directives in Vue templates will lack visual distinction, making it difficult for developers to quickly locate key parts.

Data Structures and Memory Layout

theme.tsExport an object that conforms to the MonacoIStandaloneThemeDatainterface📎 packages-private/template-explorer/src/theme.ts:1-244. It has three top-level fields:

base: 'vs-dark'Specifies the base theme📎 packages-private/template-explorer/src/theme.ts:2,inherit: trueRepresents rules that inherit from the base theme📎 packages-private/template-explorer/src/theme.ts:3. This means only the differences need to be defined, and undefined tokens will fall back tovs-dark。

rulesis an array, where each element containstoken(Monaco's token name) andforeground/background/fontStyle 📎 packages-private/template-explorer/src/theme.ts:4-235. This array has more than 50 entries, covering token types such as number, comment, keyword, string, variable, entity.name.tag, etc.

colorsDefines the colors of the editor UI📎 packages-private/template-explorer/src/theme.ts:236-243:editor.foreground、editor.background、editor.selectionBackground、editor.lineHighlightBackground、editorCursor.foreground、editorWhitespace.foreground。

Step-by-Step Walkthrough

Scenario: Register the theme when the page loads.

Step 1: Define the theme. monaco.editor.defineTheme('my-theme', theme) 📎 packages-private/template-explorer/src/index.ts:44. This call registerstheme.ts's exported object into Monaco's theme registry, with the key name'my-theme'。

Step 2: Activate the theme. monaco.editor.setTheme('my-theme') 📎 packages-private/template-explorer/src/index.ts:45. This line of code must be called afterdefineTheme, otherwise it will throw a "theme is not defined" error.

Step 3: Token matching.When Monaco renders template code, it uses the HTML language service to tokenize the code, and then looks up rules inrulesby token name. For example,<div>indivwill be marked asentity.name.tag, matched toforeground: 'cc6666' 📎 packages-private/template-explorer/src/theme.ts:41-44, and displayed in red.

Design Considerations and Production Pitfalls

Why useinherit: true?If not inherited, all token colors would need to be defined, including those that do not appear in templates (such asmarkup.heading、meta.diff). Inheritance allows the theme file to focus only on tokens that actually appear in templates and JS output.

Hierarchical matching of token names.Monaco's token matching is prefix-based:entity.name.tagwill matchentity.name.tag.html、entity.name.tag.css, etc. The source code defines bothentity.name.tag 📎 packages-private/template-explorer/src/theme.ts:41-44andentity.name.tag.css 📎 packages-private/template-explorer/src/theme.ts:169-172, and the latter overrides the former in CSS-specific scenarios.

colorsThe division of labor betweenrulesand rulescontrols the color of code text,colorscontrols the color of the editor UI (background, cursor, selected line). The two are independent but need to be visually coordinated. In the source code,editor.background: '#1D1F21'is close to the default background ofbase: 'vs-dark', in order to maintain visual consistency.

---

Design Considerations: Engineering Trade-offs of a Visual Probe

The core difference between Template Explorer and SFC Playground lies in the "granularity of observation." Playground observes "whether the entire SFC can run after compilation," while Template Explorer observes "what a single template expression is compiled into." This difference determines the technical choices of the two tools:

The introduction of SourceMapConsumer is inevitable.Without it, developers can only compare source code and output by eye, and cannot establish a precise "line X -> line Y" mapping. However, the SourceMapConsumer API is asynchronous (newer versions return a Promise), while the source code uses the synchronous versionsource-map-js, in order to simplify the calling logic.

reactiveManaging options is a natural choice in the Vue ecosystem.If native DOM events were used to manually manage state synchronization for a dozen options, the amount of code would double.reactive's dependency tracking automates the chain from "option change -> recompilation,"watchEffect(reCompile)and a single line of code completes the subscription.

Monaco's global loading mode is historical baggage. window.monacoThe global mounting approach ofwindow.initand

---

originates from Monaco's AMD loader design. In modern ESM builds, this seems out of place, but Monaco's size (about 5MB) still makes on-demand loading necessary.

Chapter Summaryindex.tsTemplate Explorer is a "white-box probe": it does not run the compiled output, but only displays the compilation process.compileCodeThrough@vue/compiler-domcalls@vue/compiler-ssrorSourceMapConsumer, usesoptions.tsto establish a bidirectional mapping between source code and output, and implements cursor-linked highlighting through Monaco's decorator API.reactiveUsesCompilerOptionsto managewatchEffect, drives recompilation throughhoistStatic, and the linkage relationships between options (such as SSR disablingtheme.ts) are explicitly encoded at the UI layer.

Customize the Monaco theme so that the syntax tokens of templates and output have clear visual distinctions.hoistStaticThe core value of this tool lies in "using tools to infer compiler behavior": when you are unsure what

did to a certain template, open Template Explorer, switch options, and observe changes in the output. This is more intuitive than reading compiler source code and more reliable than guessing.

Chapter Reflections and Self-Testindex.tsQ1: IforiginalPositionForinpos.line === 1 && pos.column === 0is removed, in what scenarios would it cause incorrect highlighting? Why does the compiler generate{ line: 1, column: 0 }such a mapping?

Reference Analysis: The guard is located at📎 packages-private/template-explorer/src/index.ts:231-237. When generating output, the compiler inserts some code that has no corresponding template location, such asimport { createElementVNode as _createElementVNode } from 'vue'helper import statements like this, orexport function render(_ctx, _cache) { ... }function signatures like this. These pieces of code have no original location in the SourceMap,source-map-jswill return{ line: 1, column: 0 }as a placeholder. If the guard is removed, when the user places the cursor on these lines,originalPositionForreturns{ line: 1, column: 0 }, the code will consider this a valid position and create a highlight decorator at the first row and first column of the source editor. The result is: when the user clicks theimportline of the artifact, the first line of the source editor is incorrectly highlighted, causing misleading behavior. The essence of this guard is "distinguishing real mappings from placeholder mappings," and{ line: 1, column: 0 }is thesource-map-jsagreed-upon "no mapping" sentinel value.

Q2: reCompilewhen persisting options in , the conditiontypeof val !== 'object' && val !== defaultOptions[key]skips all options of object type. IfbindingMetadatais modified by the user (for example, through the console), this modification will be lost after refreshing the page. Is this a bug or intentional design? If support forbindingMetadatais to be added in persistence, what problems need to be solved?

Reference analysis: the condition is located at📎 packages-private/template-explorer/src/index.ts:129. This is intentional design, for three reasons: first,bindingMetadata's value is aBindingTypesenum, which becomes a number after serialization, and during deserialization it is impossible to distinguish between "the user explicitly set it to 0" and "the default value"; second,compatConfigis a nested object, andval !== defaultOptions[key]compares references, so it is always true, which would cause all object options to be persisted; third,nodeTransformscontains functions and cannot be serialized, and the source code already handlesdelete persistedState.options?.nodeTransforms. If support for📎 packages-private/template-explorer/src/index.ts:69throughbindingMetadatais to be added, deep comparison (rather than reference comparison) needs to be implemented, and serialization/deserialization of enum values needs to be handled. A more fundamental issue is:bindingMetadatahas no editing entry in the UI, so users can only modify it through the console, and such modifications themselves should not be persisted.

Q3: options.tsincompilerOptionsis created withreactive(Object.assign({}, defaultOptions)). IfObject.assign({}, defaultOptions)is changed to directlyreactive(defaultOptions), what will happen after the user switches the option and refreshes the page? Why?

Reference analysis:Object.assign({}, defaultOptions)is a shallow copy, located at📎 packages-private/template-explorer/src/options.ts:29-31. If changed toreactive(defaultOptions),compilerOptionsanddefaultOptionswill point to the same object. When the user switcheshoistStaticto true,compilerOptions.hoistStaticbecomes true, and at the same timedefaultOptions.hoistStaticalso becomes true. Then the persistence logicreCompilewill compare📎 packages-private/template-explorer/src/index.ts:129inval !== defaultOptions[key]; at this point bothvalanddefaultOptions[key]are true, the condition is false, and the option will not be saved to localStorage. After refreshing the page,defaultOptionsis reinitialized tohoistStatic: false, and the user's modification is lost. More seriously, afterdefaultOptionsis polluted, all subsequent logic that "compares with the default value" will fail, causing the persistence feature to completely break. The insidiousness of this bug is that everything works normally within a single session, and it can only be discovered after refreshing.

---

The next chapter will enterscripts/release.js, to see how Vue uses an interactive state machine to orchestrate the entire process of version number updates, builds, tests, Git commits, tagging, and npm publish. Unlike Template Explorer's "observation," release.js is "execution" - it needs to maintain state across multiple steps, handle failure rollback, and strike a balance between interactive confirmation and automation.

Through Template Explorer, we have learned how to turn the compiler's internal state - AST, compilation output, SourceMap - into interactive visual probes, thereby turning "why the compiler generates it this way" from guesswork into observation. This precise control and orchestration of internal state is also reflected in Vue's release process: the next chapter will go deep into scripts/release.js to see how a state machine of more than 500 lines uses parseArgs to parse more than ten flags, interactively confirms the version number through enquirer, and sequentially triggers builds, tests, Git commits, tagging, and npm publish, revealing the complete state flow and failure rollback strategy behind a formal release.

Turn Any Codebase into a Book You Can Actually Understand

Enjoyed this chapter? Turn your private codebase into an architecture book

Local-first Tauri 2 + Rust architecture. 100% offline security, zero code uploaded. Dual-pane reading with immutable commit line anchors.

⚡ Tauri 2 · Rust Native Core · 100% Offline & Private · Tested on 1M+ LOC

CHAPTER 09

Chapter 9: Release Automation: release.js's State Machine and Interactive Orchestration

Upstream: vuejs/core · Commit @4ab865a8 · Progress: Chapter 9 of 14

In the previous chapter, with the help of template-explorer, we reverse-engineered compiler behavior and mastered the methodology of using tools to observe internal mechanisms. Now, we shift our attention from compile time to release time - this is the most dangerous moment for every open source project: it simultaneously touches four irreversible external systems: version numbers, build artifacts, Git history, and the npm registry. A mistaken npm publish cannot be undone, and a mistaken tag push will pollute dependency resolution for all downstream users. Vue core uses a 537-line scripts/release.js to tame this danger - it is neither a purely automated script nor a purely manual checklist, but an interactive state machine: stopping to ask a human at key nodes, fully automating predictable nodes, and rolling the version number back to the starting point if any step fails. This chapter will break down the three core mechanisms of this orchestrator: argument parsing and state initialization, interactive version decision-making and CI gating, and release order and failure rollback.

Argument Parsing and Global State Initialization

Intuitive model

Think ofrelease.jsas the control panel of an old-fashioned washing machine: the knob (parseArgs) determines which mode to use, the indicator lights (global variables) record which stage is currently active, and the "cancel" button (error handling) must be able to restore the machine to the state before water intake. Without this initialization logic, the script would lose control over the question "what version does the user actually want to release" - either releasing the wrong version number, or getting stuck in CI waiting for a keyboard input that will never come.

Memory layout of flags and global state

[Design Inference and Architectural Trade-offs]

The first thing the script does after startup is parse the command-line arguments into a structured object. This uses Node's built-inparseArgs, rather thanyargsorcommander— this is to eliminate third-party dependencies, because the release script itself must be able to run in any environment, even ifnode_modulesis half-installed.

📎 scripts/release.js:27-62defines 10 options, which can be divided into four categories:

  • Version semantics category:preid(prerelease identifier, such asalpha/beta/rc)、tag(npm dist-tag)
  • Skip category:skipBuild、skipTests、skipGit、skipPrompts— these four boolean switches form the adjustment knobs for the "degree of automation"
  • Execution mode category:dry(dry run),publish(whether to publish directly locally),publishOnly(publish only without updating the version)
  • Target category:registry(custom registry address)

Note thatpublish's default value isfalse 📎 scripts/release.js:51-54, while the other boolean items have no default value (i.e.,undefined). This asymmetry is intentional:publish's semantics are "whether to execute npm publish locally"; by default it does not publish, leaving the publish action to GitHub Actions; whileskipXxxdefaults toundefinedmeaning "unspecified", and subsequent logic will distinguish between "the user explicitly passed--skipTests" and "the user did not pass it".

After parsing is complete, the script flattens the arguments onto a set of module-level variables📎 scripts/release.js:64-66:

js
const preId = args.preid || semver.prerelease(currentVersion)?.[0]
const isDryRun = args.dry
let skipTests = args.skipTests
const skipBuild = args.skipBuild
const skipPrompts = args.skipPrompts
const skipGit = args.skipGit

There are two design points worth pondering here. First,preId's value priority is "explicit command-line specification > inferred from the current version number"📎 scripts/release.js:64-66. If the currentpackage.jsonversion is3.5.0-beta.1, thensemver.prereleasewill return['beta', 1], and taking[0]yields'beta'. This means that when continuously releasing on the beta branch, there is no need to type--preid betaevery time. Second,skipTestsis declared withletwhile the others useconst 📎 scripts/release.js:64-66, because it will be dynamically overwritten inrunTestsIfNeededby the CI result — this is a "deferred decision" state bit.

Next is the package discovery logic📎 scripts/release.js:68-83: read thepackages/directory, filter out non-directory entries, entries withoutpackage.json, and packages withprivate: true. Note that what is read here ispackages/rather thanpackages-private/— the latter is an internal debugging package and is never published.

Sorting algorithm for publish order

📎 scripts/release.js:85-85defines a function that looks simple but is crucial:

js
const sortPackagesForPublishing = (packageNames) => [
  ...packageNames.filter(p => p !== 'vue'),
  ...packageNames.filter(p => p === 'vue'),
]

It placesvue, the entry package, last. The comment📎 scripts/release.js:85-85explains the reason: ifvueis published first, users can install the new version of@vue/runtime-corebefore internal packages such asvueare online, and npm will error because it cannot find matching internal dependencies. This is a compromise for "publish atomicity" in the npm ecosystem — npm has no cross-package transactions, so order is the only way to approximate atomicity.

Dynamic construction of the version increment candidate set

📎 scripts/release.js:111-116constructs the candidates for the interactive menu:

js
const versionIncrements = [
  'patch', 'minor', 'major',
  ...(preId ? ['prepatch', 'preminor', 'premajor', 'prerelease'] : []),
]

This is a conditional spread: only whenpreIdexists (i.e., currently in the prerelease channel, or the user explicitly specified--preid) are the prerelease-related increment types added to the menu. If the current version is stable3.5.43andpreidis not specified, the menu only haspatch/minor/majorthree items — avoiding the user mistakenly turning a stable version into a half-baked prerelease version like3.5.44-0. Function

inc📎 scripts/release.js:120-120wrapssemver.inc, passingpreIdas the third argument. There is a type guard here:typeof preId === 'string' ? preId : undefined— becausepreIdmay bestring | undefined, whilesemver.incexpectsstring | undefined, this ternary expression is to satisfy TS type narrowing.

Execution primitives: the dual-track system of run and dryRun

📎 scripts/release.js:122-123is one of the most ingenious designs in this chapter:

js
const run = async (bin, args, opts = {}) =>
  exec(bin, args, { stdio: 'inherit', ...opts })
const dryRun = async (bin, args, opts = {}) =>
  console.log(pico.blue(`[dryrun] ${bin} ${args.join(' ')}`), opts)
const runIfNotDry = isDryRun ? dryRun : run

runsets the subprocess's stdio toinherit, allowing the build/test output to pass through directly to the terminal — this is crucial for long-running builds, as users can see real-time progress.dryRunonly prints the command without executing it.runIfNotDryis a "strategy selection": at module load time, the function pointer is bound todryRunorrun, and all subsequent call sites no longer need to checkisDryRun。

[Design Inference and Architectural Trade-offs]

This pattern of "deciding the strategy at initialization" is less error-prone than "checking at every call site": if some call site forgets to checkisDryRun, then in dry run mode it will actually execute side effects. ButrunIfNotDrycentralizes the check in one place, eliminating the possibility of such omissions.

mermaid
flowchart TD
    start["node scripts/release.js"] --> parse["parseArgs 解析 10 个选项"]
    parse --> preid{"args.preid 存在?"}
    preid -->|是| use_arg["preId = args.preid"]
    preid -->|否| infer["preId = semver.prerelease(currentVersion)[0]"]
    use_arg --> scan["扫描 packages/ 目录"]
    infer --> scan
    scan --> filter{"是目录 且 有 package.json 且 非 private?"}
    filter -->|否| skip_pkg["排除该包"]
    filter -->|是| keep_pkg["加入 packages 列表"]
    skip_pkg --> build_menu
    keep_pkg --> build_menu
    build_menu{"preId 存在?"} -->|是| full["versionIncrements = patch/minor/major + 4 个 pre*"]
    build_menu -->|否| stable["versionIncrements = patch/minor/major"]
    full --> dispatch{"args.publishOnly?"}
    stable --> dispatch
    dispatch -->|是| publish_only["fnToRun = publishOnly"]
    dispatch -->|否| main_fn["fnToRun = main"]

---

Interactive version decision and CI gate

Intuitive model

This stage is like airport security: first verify your boarding pass (whether the local commit is synchronized with the remote), then confirm where you are going (version number), and finally check whether you have passed security (whether CI has passed). If any step fails, the entire process stops. Without this gate, an unpushed local commit could be tagged and published, causing the source code corresponding to the version on npm to not exist at all on GitHub — this is the most difficult release accident to troubleshoot.

Sync check and version selection

mainThe first thing theisInSyncWithRemote() 📎 scripts/release.js:141-141function does is📎 scripts/release.js:337-363. The logic of this functiongit rev-parse HEADis: get the current branch name, request the GitHub API to obtain the latest commit SHA of that branch, and compare it with the local📎 scripts/release.js:348-355. If they do not match, pop up a red warning confirmation boxfalse, letting the user decide whether to continue. If the API request fails (network problem, no token), directly return📎 scripts/release.js:365-367。

and terminate

[Design Inference and Architectural Trade-offs]

The design philosophy here is "fail means abort": when there is a network anomaly, it is better not to publish than to risk continuing in an unknown state. Because publishing is irreversible, while rerunning the script is very cheap.node scripts/release.js 3.6.0),targetVersionDetermining the version number follows two paths. If the user passed a positional argument on the command line (such as📎 scripts/release.js:141-141directly take that value📎 scripts/release.js:152-176. Otherwise, enter the interactive menucustom: first let the user choose the increment type; if

is chosen, then pop up another input box for the user to manually enter the version number.📎 scripts/release.js:174Note this line

js
targetVersion = release.match(/\((.*)\)/)?.[1] ?? ''

The menu item format ispatch (3.5.44), and this regex extracts the actual version number from the parentheses. If the user chosecustom, a different branch is taken📎 scripts/release.js:164-172。

Then there is a "second parse" logic📎 scripts/release.js:178-182: iftargetVersionhappens to bepatch/minorFor such incremental keywords (the user might directly passnode release.js minor), it callsincto convert it into a concrete version number. Finally, it usessemver.validto validate📎 scripts/release.js:184-186, and throws an error directly for illegal version numbers.

CI gate: the three-state logic of runTestsIfNeeded

This is the most complex control flow in the entire chapter.📎 scripts/release.js:281-317'srunTestsIfNeededis actually a three-state decision machine:

State one: the user explicitly passed--skipTests。skipTestsis initiallytrue, directly skips the entire function body, and prints "Tests skipped."📎 scripts/release.js:314-316。

State two: not skipped, and CI has passed. The script callsgetCIResult() 📎 scripts/release.js:319-335, which requests the GitHub Actions API and checks whether there exists a workflow run namedciwithconclusion === 'success'📎 scripts/release.js:319-335. If it has passed, it asks the user, "CI has passed, skip local tests?"📎 scripts/release.js:288-295. If the user has enabled--skipPrompts, then local tests are automatically skipped📎 scripts/release.js:296-298。

State three: not skipped, and CI has not passed. If--skipPromptsis enabled, directly throw an error📎 scripts/release.js:299-304:

js
throw new Error(
  'CI for the latest commit has not passed yet. ' +
    'Only run the release workflow after the CI has passed.',
)

If--skipPromptsis not enabled, thenskipTestsremainsundefined, and it falls through to the final local test branch📎 scripts/release.js:307-313, executingpnpm run test --run。

There is a subtle detail here📎 scripts/release.js:285:

js
skipTests ||= isCIPassed

||=is logical OR assignment: only whenskipTestsis falsy (undefinedorfalse) is it assignedisCIPassed. This means that if the user explicitly passed--skipTests(true), this line will not change it; if the user did not pass it (undefined), then it is set to the CI result. But immediately afterward📎 scripts/release.js:287-298reassigns it again when CI passes—so the actual effect of the||=line is only "if CI has not passed, setskipTeststofalse", thereby causing the subsequentif (!skipTests)branch to execute local tests.

[Design inference and architectural trade-offs]

This logic goes around in a circle, but the essence is to express: "CI passed -> local tests can be skipped (but ask the user); CI not passed -> local tests must be run (unless the user explicitly requests skipping)." Using||=plus later overwriting is compact, but not very readable, and is a typical code smell of "a state flag modified in multiple places."

mermaid
sequenceDiagram
    participant Dev as 开发者
    participant Main as main()
    participant Git as git CLI
    participant GH as GitHub API
    participant Pnpm as pnpm

    Dev->>Main: node scripts/release.js
    Main->>Git: getBranch() / getSha()
    Git-->>Main: branch, sha
    Main->>GH: fetch commits/{branch}
    GH-->>Main: remote sha
    alt sha 不一致
        Main->>Dev: prompt 确认继续?
        Dev-->>Main: yes/no
    end
    Main->>Dev: prompt 选择版本增量
    Dev-->>Main: "patch (3.5.44)"
    Main->>Main: semver.valid 校验
    Main->>GH: getCIResult() 查询 workflow_runs
    GH-->>Main: workflow_runs[]
    alt CI 通过
        Main->>Dev: prompt 跳过本地测试?
        Dev-->>Main: yes
    else CI 未通过
        Main->>Pnpm: run test --run
        Pnpm-->>Main: exit code
    end
    Main->>Main: updateVersions(targetVersion)

Version number writing: the traversal of updateVersions

📎 scripts/release.js:377-384'supdateVersionsdoes two things: update the rootpackage.json, then traverse all subpackages and callupdatePackage。updatePackage 📎 scripts/release.js:391-398to read JSON, rewritenameandversion, and write back withJSON.stringify(pkg, null, 2) + '\n'—note the trailing\n, which is to keep the file ending with a newline and avoid git diff showing "No newline at end of file."

getNewPackageNameThe default value of thekeepThePackageName 📎 scripts/release.js:105parameter is

---

, meaning the package name is not changed. This parameter exists to support the scenario of "renaming packages when publishing to a custom registry"—although the current call sites all pass the default value, the interface reserves extensibility.

Publish order, idempotency, and failure rollback

Intuitive modelupdateVersionsThis stage is like dominoes:

pushing over the first tile (changing the version number), and then the subsequent changelog, lockfile, commit, tag, and publish fall in sequence. If one tile gets stuck midway, there must be a mechanism to stand the already fallen tiles back up—otherwise the repository will remain in the half-finished state of "version number changed but not published."

Idempotent publishing: isPackagePublished and error fallback

publishPackage 📎 scripts/release.js:439-489[Design inference and architectural trade-offs]📎 scripts/release.js:442-451is the core of publishing. It first determines the dist-tag--tag: prioritize using thealpha/beta/rcparameter, otherwise infer from theversion.includes('alpha')keyword in the version number. Note thatsemver.prereleaseis used here rather than3.5.0-alpha.1,includes—because the version number may look like

, which is simple enough and will not be misjudged.📎 scripts/release.js:453-458:

js
if (!isDryRun && (await isPackagePublished(packageName, version))) {
  console.log(pico.yellow(`Skipping already published: ${pkgVersion}`))
  alreadyPublishedPackages.push(pkgVersion)
  return
}

isPackagePublished 📎 scripts/release.js:491-513Copynpm view <pkg>@<version> versionexecutestrue, returnsfalseif successful, and returns

if an E404-type error is reported. The significance of this check is that the publish process may be rerun due to network interruption, and already published packages should not be published again on rerun (npm will reject duplicate versions).npm viewBut the check itself may also fail—for example,isPackagePublishedthrows a non-E404 error due to a network timeout. At this point📎 scripts/release.js:507-510will throw the error upward

, causing the entire publish to abort. This is another manifestation of "prefer aborting over taking risks."pnpm publishEven if the check passes,publishPackageitself may still fail due to a race condition (another CI just published the same version). So📎 scripts/release.js:480-488:

js
} catch (e) {
  if (e.message?.match(/previously published/)) {
    console.log(pico.red(`Skipping already published: ${pkgVersion}`))
    alreadyPublishedPackages.push(pkgVersion)
  } else {
    throw e
  }
}

Copypreviously publishedOnly when

is matched is the error swallowed; all other errors are rethrown. This is "precise fault tolerance": downgrade handling is done only for known errors that can be safely ignored.

📎 scripts/release.js:412-432Dynamic assembly of publish flagspnpm publishassembles the additional flags for

js
const additionalPublishFlags = []
if (isDryRun) additionalPublishFlags.push('--dry-run')
if (isDryRun || skipGit || process.env.CI)
  additionalPublishFlags.push('--no-git-checks')
if (process.env.CI && !args.registry)
  additionalPublishFlags.push('--provenance')

--no-git-checksCopypnpm publishis enabled in three cases: dry run, skip git, or in CI. The reason is that

--provenanceby default checks whether the workspace is clean, whether the current branch is the release branch, etc., and in CI these checks produce false positives.📎 scripts/release.js:425-427is enabled only in CI and when no custom registry is specified!args.registry. Provenance is npm's supply chain security feature, which signs the source information of the build artifact (which commit, which workflow) and attaches it to the package. But custom registries (such as internal private registries) usually do not support provenance, so the

condition is added.

Failure rollback: the versionUpdated flagmainReturning to the end of📎 scripts/release.js:528-537:

js
fnToRun().catch(err => {
  if (versionUpdated) {
    updateVersions(currentVersion)
  }
  console.error(err)
  process.exit(1)
})

versionUpdatedis a module-level boolean, initiallyfalse 📎 scripts/release.js:24-27, and is immediately set toupdateVersionsafter a successful call totrue 📎 scripts/release.js:208. If any subsequent step (changelog generation, lockfile update, git commit, publish) throws an error, the catch block checks this flag, and if it istrue, rolls the version number back tocurrentVersion。

[Design inference and architectural trade-offs]

This rollback is "best-effort": it only rolls backpackage.jsonthe version number in , and does not roll back the changelog file, the lockfile, or the git commit that has already been executed. If the error occurs after the git commit, the repository is left in an intermediate state where "the version number has been rolled back but the commit already exists." This is a deliberate design trade-off—a complete rollback would requiregit reset, and that would destroy other changes the user may have already made. So the script chooses to roll back only the most critical version number and lets the user handle the rest manually.

NotepublishOnlypath📎 scripts/release.js:519-526does not setversionUpdated, because its semantics are "publish only, do not change the version"—even if it fails, no rollback is needed. But whentargetVersionexists, it callsupdateVersions 📎 scripts/release.js:519-526, and if it fails at that point, the version number will not be rolled back. This is a potential edge-case issue; see the reflection questions at the end of the chapter.

mermaid
flowchart TD
    upd["updateVersions(targetVersion)"] --> flag["versionUpdated = true"]
    flag --> changelog["pnpm run changelog"]
    changelog --> lock["pnpm install --prefer-offline"]
    lock --> gitdiff{"git diff 有输出?"}
    gitdiff -->|是| commit["git add -A && git commit"]
    gitdiff -->|否| nochange["No changes to commit"]
    commit --> pub{"args.publish?"}
    nochange --> pub
    pub -->|是| build["buildPackages()"]
    pub -->|否| push
    build --> publish["publishPackages()"]
    publish --> push["git tag && git push"]
    push --> done["完成"]
    changelog -.->|抛错| rollback["catch: updateVersions(currentVersion)"]
    lock -.->|抛错| rollback
    commit -.->|抛错| rollback
    publish -.->|抛错| rollback
    rollback --> exit["process.exit(1)"]

Publish order and the special handling of the vue package

publishPackages 📎 scripts/release.js:412-432iterates oversortPackagesForPublishing(packages)the result and callspublishPackageone by one. Because the sorting putsvuelast📎 scripts/release.js:85-85, the entire publish sequence ensures that internal packages go live first.

publishPackageinternally usescwd: getPkgRoot(pkgName) 📎 scripts/release.js:475to switch the working directory to the subpackage directory, so thatpnpm publishpublishes the subpackage rather than the root package. The comment📎 scripts/release.js:462-463specifically warns "do not change it to npm publish"—becausepnpm publishcan correctly handle theworkspace:*dependency protocol and convert it into an actual version number, whereasnpm publishwill preserveworkspace:*as-is and cause installation to fail.

---

Design reflections

Why useparseArgsinstead ofyargs?The publish script is the "last line of defense" and must be executable in any environment. If a third-party CLI library fails to load because its dependency tree is broken, the entire publish process is paralyzed. Node's built-inparseArgsis crude in functionality (no subcommand support, no automatic help), but it has zero dependencies and zero risk.

Why setpublishby default tofalse?Because Vue's official release goes through GitHub Actions (see📎 scripts/release.js:256-263the prompt message), and the local script is only responsible for changing the version number, generating the changelog, tagging, and pushing. The actualnpm publishis executed in CI, so that CI's provenance signing and controlled environment can be leveraged.--publishThe flag is an escape hatch for maintainers to publish locally in emergencies.

Why does rollback only roll back the version number?Because a complete rollback would require understanding "which changes were made by the script and which were made by the user," and that cannot be distinguished at the git level. The script chooses to roll back only what it is most certain it changed—package.jsonthe version number—and leaves the rest to the user's judgment.

---

Chapter summary

scripts/release.jsuses 537 lines of code to implement an "interactive state machine," whose core design can be summarized in three points:

1. Parameters are policy: 10 flags are parsed at module load time and flattened into global variables,runIfNotDrybinds the policy during initialization to avoid missing checks at call sites.

2. Gates up front: synchronous checks, version validation, and CI gates are all completed before any side effects occur, ensuring "all or nothing."

3. Precise fault tolerance:isPackagePublishedprecheck +previously publishederror fallback form a double idempotency protection;versionUpdatedthe flag enables minimal rollback.

This mechanism forms an interesting contrast with the Template Explorer from the previous chapter: Template Explorer is "observation"—visualizing the compiler's internal state; release.js is "execution"—making every step of the release process explicit. Both embody the same engineering philosophy:Turn implicit state into explicit state, and uncontrollable side effects into controllable steps。

Chapter reflections and self-test

Q1: If📎 scripts/release.js:285'sskipTests ||= isCIPassedis changed toskipTests = isCIPassed, what happens when the user explicitly passes--skipTestsand CI has not passed? Why?

Reference analysis: In the original logic, when the user passes--skipTests,skipTestsis initiallytrue 📎 scripts/release.js:64-66,||=and will not change it, sorunTestsIfNeededat📎 scripts/release.js:282'sif (!skipTests)evaluates to false and jumps directly to📎 scripts/release.js:314-316printing "Tests skipped." If changed toskipTests = isCIPassed, thenskipTestsis forcibly set tofalse(CI has not passed), and subsequently📎 scripts/release.js:287'sif (isCIPassed)is false, falling through to📎 scripts/release.js:299'selse if (skipPrompts)—if--skipPromptsis not enabled, thenskipTestsremainsfalse, and finally local tests are executed at📎 scripts/release.js:307-313. This violates the user's intent to "explicitly skip tests," and in a CI environment (--skipPrompts) it will even directly throw📎 scripts/release.js:300-303, causing the release to abort.||=exists precisely to respect the user's explicit choice.

Q2: publishOnlypath📎 scripts/release.js:519-526whentargetVersionexists callsupdateVersions, but it does not setversionUpdated. If at this pointbuildPackagesorpublishPackagesthrows, what happens? Is this design reasonable?

Reference analysis:publishOnlycallsupdateVersions(targetVersion) 📎 scripts/release.js:519-526and modifies allpackage.jsonversion numbers, but does not setversionUpdated = true. When a subsequentbuildPackages 📎 scripts/release.js:519-526orpublishPackages 📎 scripts/release.js:519-526throws,fnToRun().catch 📎 scripts/release.js:528-537checksversionUpdatedasfalseand will not roll back the version number. The result is that the repository remains in a state where "the version number has been changed but the release failed." This design is reasonable underpublishOnly's original semantics (publish only, do not change the version)—becausetargetVersionis usually not passed, andupdateVersionsis not executed. But when the user passestargetVersion, this path has a rollback vulnerability. The fix is to add📎 scripts/release.js:519-526afterversionUpdated = true, or havepublishOnlyreusemain's rollback logic.

Q3: isPackagePublished 📎 scripts/release.js:491-513usesnpm viewto check whether the package has already been published. If a network timeout causesnpm viewto throw a non-E404 error, what happens? Is this behavior safe in a CI rerun scenario?

Reference analysis:isPackagePublishedIn the catch block,📎 scripts/release.js:507-510callsisPackageNotFoundErrorto determine the error type. This function📎 scripts/release.js:515-515only matches/E404|No match found|No matching version|notarget/i. The message of a network timeout error does not contain these keywords, soisPackageNotFoundErrorreturnsfalse,isPackagePublishedand rethrows the error📎 scripts/release.js:507-510. This error propagates upward topublishPackage 📎 scripts/release.js:453, causing the entire release to abort. In CI rerun scenarios, this leads to "the package was clearly published, yet the process aborts due to network jitter"—but this is the safe direction of failure: aborting is better than misjudging "not published" and republishing. Republishing triggers npm'spreviously publishederror, which is caught by📎 scripts/release.js:491-492as a fallback, but wastes one network round trip. So "network error means abort" is a conservative but correct choice.

---

The next chapter will move into.github/workflows/, to see how GitHub Actions takes over the subsequent build and release after release.js pushes the tag, as well as the complete implementation of CI gates.

At this point, we have seen clearly how release.js uses a state machine and interactive orchestration to minimize the risk of irreversible releases. But the release script itself is only the executor; what truly determines when to trigger and under what conditions to allow passage is the higher-level automation gatekeeper. The next chapter will analyze the CI/CD system under the .github/workflows directory: how ci.yml enforces the triple gate of lint/typecheck/test during the PR stage, how release.yml triggers releases when tags are pushed, how size-report.yml and size-data.yml track package size regressions, and how autofix.yml automatically fixes formatting issues. You will understand how Vue uses GitHub Actions to solidify engineering standards into an unavoidable pipeline.

Turn Any Codebase into a Book You Can Actually Understand

Enjoyed this chapter? Turn your private codebase into an architecture book

Local-first Tauri 2 + Rust architecture. 100% offline security, zero code uploaded. Dual-pane reading with immutable commit line anchors.

⚡ Tauri 2 · Rust Native Core · 100% Offline & Private · Tested on 1M+ LOC

CHAPTER 10

Chapter 10: CI/CD Workflows: The Automated Gatekeeper from PR to Release

Upstream: vuejs/core · Commit @4ab865a8 · Progress: Chapter 10 of 14

In the previous chapter we sawscripts/release.jshow to connect each step of a release using an interactive state machine. But that script has a prerequisite: it must be actively invoked by someone or some system. In the Vue core repository, this active invoker is not the maintainer's local terminal, but GitHub Actions. release.js is the executor, workflows are the decision-maker—it determines what events trigger what tasks, under what conditions to allow passage, and under what conditions to block. This chapter focuses on.github/workflows/the four files under the directory:ci.yml(PR gates and continuous prerelease),release.yml(tag-triggered official release),size-report.yml(size regression report),autofix.yml(automatic formatting fixes). The core of understanding them is not memorizing YAML syntax, but seeing clearly how the Vue team translates engineering standards into unavoidable pipeline constraints.

1. ci.yml: Triple Gate and Continuous Prerelease

Intuitive model

Think ofci.ymlas an airport security checkpoint. Every PR must pass through this gate: lint checks whether your luggage contains prohibited items, typecheck confirms that your credentials are genuine and valid, and test verifies that you are not carrying dangerous goods. But there is more than one security checkpoint—Vue also attaches a "continuous prerelease" channel here, publishing each PR's build artifact directly to pkg-pr-new, allowing contributors to verify their changes in a real npm installation scenario.

Without this gate, any merge could bring formatting errors, type vulnerabilities, or behavioral regressions into the main branch, and the main branch is the source of all subsequent releases.

Trigger conditions and concurrency control

ci.ymlThe trigger configuration of

📎 .github/workflows/ci.yml:2-11

yaml
on:
  push:
    branches:
      - '**'
    tags:
      - '!**'
  pull_request:
    branches:
      - main
      - minor

CopypushThere are two key designs here. First,'**'the event listens to all branches (tags: ['!**']), but usesrelease.ymlto explicitly exclude all tag pushes. Why exclude tags? Because tag pushes are handled separately byci.yml. Ifpull_requestalso responds to tags, it will cause the release process and the CI process to be triggered repeatedly, wasting runner resources and even creating race conditions. Second,mainonly listens tominorandmaintwo branches—this is Vue's dual-branch strategy:minorcarries the stable version,

📎 .github/workflows/ci.yml:22-22

yaml
concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

CopygroupConcurrency control is the most ingenious touch here.github.event.pull_request.number || github.refThe expression usescancel-in-progressas a fallback: PR events use the PR number as the grouping key, and push events use the ref (branch name) as the grouping key. This means that multiple pushes to the same PR will fall into the same concurrency group. Andtrueis

only for PR events—when you push three commits in succession, the CI for the first two will be automatically canceled, keeping only the latest one.

[Design inference and architectural trade-offs]

The motivation for this design is clear: during the PR stage, developers push frequently, and the CI results of old commits are already meaningless; canceling them saves a large amount of runner time. But pushes to the main branch cannot be canceled—because every push on main may be the last verification before release, and cancellation would create a verification gap.

📎 .github/workflows/ci.yml:22-22

yaml
jobs:
  test:
    if: ${{ ! startsWith(github.event.head_commit.message, 'release:') && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository) }}
    uses: ./.github/workflows/test.yml

CopyifThis&&condition contains two branches of logical AND (

), and each is worth expanding.! startsWith(github.event.head_commit.message, 'release:')The first conditionrelease:At the beginning, skip tests. This is exactly the commit message format pushed by release.js in the previous chapter—release.js has already run the full test suite locally, so CI does not need to re-verify. This is a "trust upstream" optimization.

[Design inference and architectural trade-offs]

The second condition(github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository): push events always run tests; PR events require the PR to come from a fork (head.repo.full_name != github.repository). Why only run for fork PRs? Because PRs from branches in the same repository are usually created by core team members, and their branch pushes have already triggered the push event CI. Fork PRs do not trigger push events (a fork's push does not notify the upstream repository), so they must be covered by the PR event.

Noteuses: ./.github/workflows/test.yml—this is a reusable workflow call.test.ymlIt is an independent workflow file, shared byci.ymlandrelease.yml. This reuse avoids duplicating the lint/typecheck/test steps across multiple workflows.

Continuous prerelease: the role of pkg-pr-new

📎 .github/workflows/ci.yml:25-51

yaml
continuous-release:
  if: github.repository == 'vuejs/core'
  runs-on: ubuntu-latest
  steps:
    - name: Checkout
      uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      with:
        persist-credentials: false
    # ... 安装 pnpm、Node.js、依赖 ...
    - name: Build
      run: pnpm build --withTypes
    - name: Release
      run: pnpx pkg-pr-new publish --compact --pnpm './packages/*' --packageManager=pnpm,npm,yarn

continuous-releaseThe job only runs in thevuejs/coremain repository (if: github.repository == 'vuejs/core'), and does not execute on forks. It does three things: build (pnpm build --withTypes, with type declarations), then usepkg-pr-newto publish all packages under./packages/*to a temporary npm registry.

[Design inference and architectural trade-offs]

The value of this mechanism is that contributors can directlynpm installthis PR's build artifact in their own projects to verify whether the change actually solves the problem. This is more convincing than "seeing CI turn green" because it verifies a real package consumption scenario.

Note that all actions are pinned to commit SHAs (such asactions/checkout@3d3c42e5...), rather than using a floating tag like@v4. This is a hard requirement for supply chain security—preventing malicious code from automatically flowing in after an action repository is compromised.

ci.yml control flow diagram

mermaid
flowchart TD
    trigger{"事件类型?"}
    trigger -->|"push 到任意分支"| push_check{"提交信息以 release: 开头?"}
    trigger -->|"PR 到 main/minor"| pr_check{"PR 来自 fork?"}

    push_check -->|"是"| skip_test["跳过 test job"]
    push_check -->|"否"| run_test["调用 test.yml"]

    pr_check -->|"是"| run_test
    pr_check -->|"否"| skip_test

    run_test --> test_result{"test.yml 通过?"}
    test_result -->|"否"| block["PR 被阻断"]
    test_result -->|"是"| cont_release{"仓库是 vuejs/core?"}

    cont_release -->|"是"| build["pnpm build --withTypes"]
    cont_release -->|"否"| end_node["结束"]
    build --> publish["pkg-pr-new publish"]
    publish --> end_node

---

2. release.yml: release orchestration after tag push

Intuitive model

Ifci.ymlis the security checkpoint,release.ymlis the launch pad. After release.js completes the version number update, commit, tag, and push locally, the tag push event ignites the engine ofrelease.yml. It first runs the full test suite again (to reconfirm), then executesReleasein the protectedpnpm release --publishOnlyenvironment, and finally creates a GitHub Release.

Without it, the tag pushed by release.js would just be a Git reference; there would be no new version on npm, and no Release page on GitHub.

Trigger condition: only tags

📎 .github/workflows/release.yml:3-6

yaml
on:
  push:
    tags:
      - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10

It only listens for tag pushes in thev*format. This complementsci.yml'stags: ['!**']—the two are strictly mutually exclusive and will not trigger at the same time.

Guard conditions for the release job

📎 .github/workflows/release.yml:8-21

yaml
jobs:
  test:
    uses: ./.github/workflows/test.yml

  release:
    if: github.repository == 'vuejs/core'
    needs: [test]
    runs-on: ubuntu-latest
    permissions:
      contents: write
      id-token: write
    environment: Release

There are three layers of guards here, and none of them can be omitted.

First layerif: github.repository == 'vuejs/core': prevents accidental release triggers on forks. If someone forks the repository and pushes av1.0.0tag, this condition will prevent the release process from running.

Second layerneeds: [test]: the release job depends on the test job. The test job callstest.yml; if the tests fail, the release job will not start at all. This is the hard constraint that "tests must pass before release."

[Design inference and architectural trade-offs]

Third layerenvironment: Release: this is a GitHub Environment, which can be configured with deployment protection rules (such as requiring approval from specific people). This means that even if a tag push triggers the workflow, the release step may still require manual approval before execution—this is the last line of defense for an irreversible operation.

In terms of permissions,contents: writeis used to create a GitHub Release,id-token: writeis used for npm provenance authentication (OIDC token). Note that there is nopackages: writehere, because Vue publishes to npm rather than GitHub Packages.

The complete chain of the release step

📎 .github/workflows/release.yml:37-46

yaml
- name: Install deps
  run: pnpm install --frozen-lockfile

- name: Update npm
  run: npm i -g npm@latest

- name: Build and publish
  id: publish
  run: |
    pnpm release --publishOnly
[Design inference and architectural trade-offs]

Each of the three steps has its own purpose.--frozen-lockfileensures that the CI environment installs strictly according to the lockfile, so dependency version drift will not cause the build artifact to differ from local.npm i -g npm@latestis to obtain the latest npm CLI—because provenance and OIDC authentication depend on a newer version of npm, and older versions may not support these features.

pnpm release --publishOnlyis the entry point of release.js from the previous chapter.--publishOnlyThe flag tells release.js: skip interactive version selection, skip Git commit and tagging (because the tag already exists), and only perform the build and npm publish.

Create GitHub Release

📎 .github/workflows/release.yml:48-57

yaml
- name: Create GitHub release
  id: release_tag
  uses: yyx990803/release-tag@8cccf7c5aa332d71d222df46677f70f77a8d2dc0 # v1.0.0
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  with:
    tag_name: ${{ github.ref }}
    body: |
      For stable releases, please refer to [CHANGELOG.md](...) for details.
      For pre-releases, please refer to [CHANGELOG.md](...) of the `minor` branch.
[Design inference and architectural trade-offs]

Here it usesrelease-tag action。tag_name: ${{ github.ref }}maintained by Vue author Evan You himself, directly using the ref of the triggering event (i.e.refs/tags/v3.x.x). The Release body does not write specific change content, but instead points to CHANGELOG.md — because Vue's changelog is automatically generated by conventional-changelog, and manually maintaining the Release body would create inconsistencies with the changelog.

release.yml sequence diagram

mermaid
sequenceDiagram
    participant Dev as "开发者本地"
    participant GH as "GitHub"
    participant Test as "test.yml"
    participant Rel as "release job"
    participant NPM as "npm registry"

    Dev->>GH: "git push origin v3.x.x"
    GH->>Test: "触发 test.yml"
    Test-->>GH: "测试通过"
    GH->>Rel: "needs: [test] 满足"
    Rel->>Rel: "environment: Release 审批"
    Rel->>Rel: "pnpm install --frozen-lockfile"
    Rel->>Rel: "pnpm release --publishOnly"
    Rel->>NPM: "npm publish (OIDC provenance)"
    NPM-->>Rel: "发布成功"
    Rel->>GH: "release-tag 创建 Release"

---

Three, size-report.yml and autofix.yml: size tracking and format self-healing

size-report.yml: cross-workflow size regression report

size-report.ymlThe trigger method is very special — it is not directly triggered by push or PR, but by the completion event of another workflow.

📎 .github/workflows/size-report.yml:3-7

yaml
on:
  workflow_run:
    workflows: ['size data']
    types:
      - completed

workflow_runThe event listener is namedsize dataThe workflow completes. This is a two-stage design:size-data.yml(Source code not provided in this chapter) is responsible for building and measuring size on the PR, and uploading the results as an artifact;size-report.ymlAftersize dataAfter completion, download the artifact, generate a report, and comment on the PR.

📎 .github/workflows/size-report.yml:20-23

yaml
if: >
  github.repository == 'vuejs/core' &&
  github.event.workflow_run.event == 'pull_request' &&
  github.event.workflow_run.conclusion == 'success'

Triple guard: main repository, PR event, upstream workflow success. Ifsize dataIf it fails, the report job will not run — because there is no data to report.

The data flow process is as follows:

📎 .github/workflows/size-report.yml:41-46

yaml
- name: Download Size Data
  uses: dawidd6/action-download-artifact@d63b86af1b34672e53c440b1b83979861906bad7 # v24
  with:
    name: size-data
    run_id: ${{ github.event.workflow_run.id }}
    path: temp/size

Download from the upstream workflow runsize-dataartifact totemp/size. Then read the PR number and base branch in parallel:

📎 .github/workflows/size-report.yml:48-59

yaml
- parallel:
    - name: Read PR Number
      id: pr-number
      uses: juliangruber/read-file-action@271ff311a4947af354c6abcd696a306553b9ec18 # v1.1.8
      with:
        path: temp/size/number.txt
    - name: Read base branch
      id: pr-base
      uses: juliangruber/read-file-action@271ff311a4947af354c6abcd696a306553b9ec18 # v1.1.8
      with:
        path: temp/size/base.txt

parallelIt is GitHub Actions syntactic sugar that allows two independent steps to execute simultaneously.number.txtandbase.txtIssize-data.ymlThe metadata file written during measurement.

Then download the historical size data of the base branch for comparison:

📎 .github/workflows/size-report.yml:61-69

yaml
- name: Download Previous Size Data
  uses: dawidd6/action-download-artifact@d63b86af1b34672e53c440b1b83979861906bad7 # v24
  with:
    branch: ${{ steps.pr-base.outputs.content }}
    workflow: size-data.yml
    event: push
    name: size-data
    path: temp/size-prev
    if_no_artifact_found: warn

Noteif_no_artifact_found: warn— If the base branch does not yet have historical data (such as a new branch), it will not fail, only warn. This ensures that the report can still be generated on the first run, just without a comparison baseline.

Finally, generate the report and comment:

📎 .github/workflows/size-report.yml:71-89

yaml
- name: Prepare report
  run: node scripts/size-report.js > size-report.md

- name: Read Size Report
  id: size-report
  uses: juliangruber/read-file-action@271ff311a4947af354c6abcd696a306553b9ec18 # v1.1.8
  with:
    path: ./size-report.md

- name: Create Comment
  uses: actions-cool/maintain-one-comment-backup@fbbc22ad1809c1bcf46f19b58397b6254773588c # backup for v3.0.0
  with:
    token: ${{ secrets.GITHUB_TOKEN }}
    number: ${{ steps.pr-number.outputs.content }}
    body: |
      ${{ steps.size-report.outputs.content }}
      <!-- VUE_CORE_SIZE -->
    body-include: '<!-- VUE_CORE_SIZE -->'

scripts/size-report.jsReadtemp/sizeandtemp/size-prevGenerate a Markdown report from the data under.maintain-one-comment-backupThe action usesbody-include: '<!-- VUE_CORE_SIZE -->'As a marker, ensure that only one size report comment is kept on the same PR (update rather than append). Note the comment at L81 explains that the original action repository was blocked by GitHub, so a backup repository was used and the commit was pinned.

autofix.yml: automatic repair of formatting issues

autofix.ymlSolves a very practical problem: the code format submitted by contributors does not conform to prettier/eslint standards, CI reports an error, and contributors need to manually runpnpm lint --fixThen submit. This workflow automates this step.

📎 .github/workflows/autofix.yml:3-8

yaml
on:
  pull_request:

concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

Trigger all PRs, concurrency control is similar toci.ymlSimilar — a new push to the same PR cancels the old autofix run.

📎 .github/workflows/autofix.yml:35-41

yaml
- name: Run eslint
  run: pnpm run lint --fix

- name: Run prettier
  run: pnpm run format

- uses: autofix-ci/action@7a166d7532b277f34e16238930461bf77f9d7ed8

First run eslint's--fix, then run prettier formatting, and finallyautofix-ci/actionCommit the modified files directly back to the PR branch. Notepnpm run formatIt is itself a formatting command (no need for--fixFlag, because the format script internally isprettier --write)。

[Design inference and architectural trade-offs]

The key to this mechanism isautofix-ci/actionIt will commit fixes as the PR author, not as a bot. This way contributors do not need extra operations, and format fixes automatically appear in their PR. But this also means that if the contributor's branch has protection rules (bot pushes not allowed), autofix will fail — this is an edge case that contributors need to handle manually.

size-report data flow diagram

mermaid
flowchart LR
    subgraph "size-data.yml (上游)"
        build_pr["构建 PR 分支"] --> measure["测量体积"]
        measure --> artifact_pr["artifact: size-data\n(number.txt, base.txt, 体积数据)"]
    end

    subgraph "size-report.yml (下游)"
        artifact_pr -->|"workflow_run 触发"| download["下载 size-data"]
        download --> read_meta["读取 number.txt / base.txt"]
        read_meta --> download_prev["下载 base 分支历史数据\n(if_no_artifact_found: warn)"]
        download_prev --> gen_report["node scripts/size-report.js"]
        gen_report --> comment["评论到 PR\n(标记: VUE_CORE_SIZE)"]
    end

---

Design thinking: solidify standards into the pipeline

Looking back at these four workflows, several design principles can be seen throughout.

First, least privilege. ci.ymlandautofix.ymlBoth declarepermissions: contents: read, onlyrelease.ymlNeedscontents: writeandid-token: write。size-report.ymlNeedspull-requests: writeandissues: writeTo post comments. Each workflow only gets the permissions it truly needs.

Second, supply chain security.All third-party actions are pinned to commit SHA, not floating tags.size-report.ymlThe comment at L81 directly states that after the original action repository was blocked, it switched to a backup repository and pinned the commit — this is real-world defense against supply chain attacks.

Third, separation of responsibilities and reuse. test.ymlIs used byci.ymlandrelease.ymlShared to avoid duplication of test logic.size-data.ymlandsize-report.ymlSeparated, allowing measurement and reporting to evolve independently.

Fourth, the choice of failure direction. size-report.ymlofif_no_artifact_found: warnChoose "warn rather than fail" because missing historical data should not block the PR. Andrelease.ymlofneeds: [test]Choose "test failure blocks release" because release is an irreversible operation.

Fifth, differentiation of concurrency control.PR events cancel old runs (cancel-in-progress: true), push events do not cancel (cancel-in-progress: false). This difference reflects the semantics of the two events: old commits in a PR are meaningless, while every commit in a push may be the final state.

---

Chapter summary

This chapter analyzed the four core workflows of the Vue core repository:

  • ci.yml: PR gate + continuous prerelease. ThroughifConditions distinguish push/PR and fork/same repository, useconcurrencyCancel outdated PR runs, usepkg-pr-newPublish installable prerelease packages.
  • release.yml: Official release triggered by tag. Three layers of guards (repository check, needs test, environment approval) ensure that only tags that pass tests and are approved can be published to npm.
  • size-report.yml: Cross-workflow size regression report. Throughworkflow_runevent listening for upstreamsize datacompletion, download the artifact and compare it with the base branch data, then feed it back to the PR as a comment.
  • autofix.yml: Automatic format fixing. Run eslint --fix and prettier on the PR, and throughautofix-ci/actioncommit the fixes directly back to the PR branch.

These four workflows together form an "unbypassable pipeline": code style is automatically fixed by autofix, types and tests are enforced by ci.yml, size regression is tracked by size-report, and release is executed by release.yml under multiple guards.

Chapter Review and Self-Test

Q1: If inci.ymlthe value ofcancel-in-progressis changed to always betrue(that is, removing thegithub.event_name == 'pull_request'condition), in what scenarios would this cause problems?

Reference Analysis:cancel-in-progressAlways beingtruemeans that when pushing to the main branch, a new push will cancel the old CI that is running. Consider this scenario: two PRs are merged consecutively on the main branch. The CI of the first PR is running (including the full lint/typecheck/test), and the merge of the second PR triggers a new CI run. Ifcancel-in-progressistrue, the CI of the first PR will be canceled—but the code of the first PR is already on main, and its CI result is crucial for judging the health of the main branch. Canceling it means that a segment of code on the main branch has never been fully verified. And📎 .github/workflows/ci.yml:22-22the conditiongithub.event_name == 'pull_request'is precisely to avoid this problem: only PR events cancel old runs, while push events never cancel.

Q2: release.ymlInreleasethe job'sif: github.repository == 'vuejs/core'andenvironment: Releaserespectively defend against what scenarios? What happens if one of them is removed?

Reference Analysis:if: github.repository == 'vuejs/core' 📎 .github/workflows/release.yml:14defends against the fork scenario. If someone forks vuejs/core and pushes av3.99.0tag, without this condition, the workflow will run in the fork repositorypnpm release --publishOnly. Although the fork repository has no npm token and cannot actually publish, it will waste runner resources and may produce misleading failure notifications.environment: Release 📎 .github/workflows/release.yml:21defends against the risk of "automatic publishing after tag push"—it allows configuring manual approval to ensure that even if a tag is pushed, publishing still requires maintainer confirmation. If theifcondition is removed, forks will waste resources; ifenvironmentis removed, anyone with tag push permission can trigger a release, with no final manual confirmation step. The two are defenses at different levels and cannot replace each other.

Q3: size-report.ymlInif_no_artifact_found: warnthe choice ofrelease.ymland inneeds: [test]the choice of

respectively reflect what kind of failure-direction design philosophy? What would happen if these two strategies were swapped?:if_no_artifact_found: warn 📎 .github/workflows/size-report.yml:69Reference Analysisfailchooses "warn instead of fail when historical data is missing," because the size report is auxiliary information, not a blocking condition. If changed toneeds: [test] 📎 .github/workflows/release.yml:15, then a new branch or a PR running for the first time would fail because base data cannot be found, which is obviously unreasonable.

---

chooses "block release when tests fail," because release is an irreversible operation and code quality must be ensured. If swapped—size-report fails when data is missing, and release still publishes when tests fail—the former would cause many false positives that block normal PRs, and the latter would allow untested code to enter npm. This reflects the failure-direction design principle of "lenient for auxiliary information, strict for irreversible operations."scripts/size-report.jsThe next chapter will go deep into the core of the size budget mechanism:usage-sizehow to parse size data, how to calculate increments, how to format output, and

the measurement philosophy of—why Vue chooses to measure "actual usage size" rather than "full package size."scripts/size-report.jsFrom PR gating to tag release, the four workflow files together form an unbypassable automated gatekeeping chain. But the pipeline can block merges only if it has quantifiable criteria for judgment. The next chapter will focus on Vue's engineering governance of package size as a core metric:scripts/usage-size.jshow to calculate the gzipped size of each artifact and compare it with the baseline,

Turn Any Codebase into a Book You Can Actually Understand

Enjoyed this chapter? Turn your private codebase into an architecture book

Local-first Tauri 2 + Rust architecture. 100% offline security, zero code uploaded. Dual-pane reading with immutable commit line anchors.

⚡ Tauri 2 · Rust Native Core · 100% Offline & Private · Tested on 1M+ LOC

CHAPTER 11

Next chapter: Chapter 11 →

Upstream: vuejs/core · Commit @4ab865a8 · Progress: Chapter 11 of 14

In the previous chapter, we saw how Vue uses GitHub Actions to solidify lint, type checking, testing, and size tracking into an unavoidable pipeline, where size-report.yml and size-data.yml are responsible for leaving size data after each change. But the pipeline is only responsible for execution; what truly answers "how much bigger did it get, and where did it grow" are the two scripts we will break down in this chapter. The core contradiction of size budgeting lies in this: bundle size is a metric that can only be perceived, not precisely attributed. When users complain that "Vue is too big," maintainers need to answer three questions—how much bigger? Where did it grow? Did this change make it bigger? scripts/size-report.js handles comparison, and scripts/usage-size.js handles attribution; together they form the measurement philosophy of size budgeting.

11.1 size-report: Turning size differences into a readable Markdown table

Intuitive model

Imagine you are a quality inspector at a logistics company. Every package (build artifact) must be weighed before leaving the warehouse, and your job is not the weighing itself, but placing "today's weight" and "yesterday's weight" side by side in a table, using bold+2.3 kBto mark which packages got heavier. Without this comparison table, maintainers can only see a pile of isolated numbers and cannot determine whether a PR introduced a size regression.

size-report.jsis exactly this quality inspector. It does not produce size data (that is the job ofusage-size.jsand the build scripts); it only consumes JSON files from two directories and generates a Markdown report.

Data structures and directory conventions

The script's core conventions are hidden in two constants. The current data directory istemp/size, and the historical baseline directory istemp/size-prev。

📎 scripts/size-report.js:23-24

These two directory names are not arbitrary:temp/sizeis generated by thesize-data.ymlworkflow on each run and uploaded as an artifact📎 .github/workflows/size-data.yml:53-57, whiletemp/size-previs obtained bysize-report.ymlafter pulling the baseline artifact and extracting it. The directory names themselves are the contract of the data flow.

The script defines three type aliases, which precisely describe the structure of the JSON files:

📎 scripts/size-report.js:8-21

SizeResulthas three numeric fields:size(uncompressed),gzip、brotli。BundleResultadds afilefield on top of that for displaying the file name.UsageResultis aRecord, where the keys are preset names and the values areSizeResult & { name: string }—note that there is an extranamefield here, because the keys of a JSON object are lost afterObject.values, so the name must be redundantly stored in the value.

Step-by-Step Walkthrough

The main flow is extremely simple, with only two steps plus one output:

📎 scripts/size-report.js:23-38

run()first callsrenderFiles()to render the artifact file table, then callsrenderUsages()to render the usage scenario table, and finally writes the string accumulated in the module-level variableoutputto stdout all at once📎 scripts/size-report.js:25. This "accumulate strings and output them all at once" pattern avoids multipleprocess.stdout.writeconcatenation overheads and also makes the output order fully controllable.

Step 1: Collect the file list and take the union.

📎 scripts/size-report.js:44-49

filterFilesfilters out two types of files: those starting with_(such as_usages.json) and those ending with.txt(such asnumber.txt、base.txt). These two types of files are metadata, not size data. Then it takes the union of the file names in the current directory and the historical directoryfileList—usingSetfor deduplication. Why take the union? Because a file may exist only in the historical directory (this build deleted that artifact), or only in the current directory (this build added an artifact). Both cases need to be reflected in the report.

Step 2: Compare file by file.

📎 scripts/size-report.js:43-75

For each file in the union, try to import JSON from the two directories respectively.importJSONThe implementation of

📎 scripts/size-report.js:112-115

is "return undefined if the file does not exist":import()Here dynamicwith: { type: 'json' }is used together withfs.readFileSync + JSON.parseimport assertions, rather thanimport(). The former is handled by Node's module loader, while the latter requires manual handling of encoding and parsing errors. The cost of choosingrenderFilesis that it returns a Promise, so the entire

is async.if (!curr)The key branch is in~~fileName~~: if the current directory does not have this file, it means the artifact has been deleted, so Markdown strikethrough syntax📎 scripts/size-report.js:60-61is used to markgetDiff. Otherwise, render a normal row, appending

after each numeric value.

📎 scripts/size-report.js:124-130

getDiffStep 3: Calculate the difference.prev === undefinedhas three early return points:diff === 0returns an empty string when (no baseline, cannot compare);prettyBytes(diff)returns an empty string when (no change, do not show noise); otherwise it returns a bold signed difference. Note that-1.2 kBalso handles negative numbers correctly and will output a form likesign, while the+。

variable only adds

📎 scripts/size-report.js:80-103

renderUsageswhen the number is positive.renderFilesStep 4: Render the usage table._usages.jsonThe structural difference betweenObject.values(curr)andprev?.[usage.name]is worth noting: it directly importsname, because the usage data always exists in this one file..filter(usage => !!usage)After converting the Record into an array, it looks up historical data by name throughmap—this is exactly why the

field is stored redundantly.markdown-tableThis line is actually redundant, because📎 scripts/size-report.js:72-74。

mermaid
flowchart TD
    start["run()"] --> rf["renderFiles()"]
    rf --> read_curr["readdir(temp/size)"]
    rf --> read_prev{"existsSync(temp/size-prev)?"}
    read_prev -->|是| read_prev_dir["readdir(temp/size-prev)"]
    read_prev -->|否| empty_prev["prev = []"]
    read_curr --> union["fileList = Set(curr ∪ prev)"]
    read_prev_dir --> union
    empty_prev --> union
    union --> loop{"遍历 fileList"}
    loop -->|每个 file| import_c["importJSON(currPath)"]
    loop -->|每个 file| import_p["importJSON(prevPath)"]
    import_c --> check_curr{"curr 存在?"}
    check_curr -->|否| deleted["push(~~fileName~~)"]
    check_curr -->|是| render_row["push(fileName, size+diff, gzip+diff, brotli+diff)"]
    deleted --> loop
    render_row --> loop
    loop -->|遍历结束| ru["renderUsages()"]
    ru --> import_u["importJSON(_usages.json)"]
    import_u --> table["markdownTable 渲染"]
    table --> out["process.stdout.write(output)"]

Finally, the

library is used to render the two-dimensional array into a Markdown table

Copyimport()Design thinking and pitfallsreadFileSync?[Design inference and architectural trade-offs]import()Why use

filterFilesinstead offile[0] !== '_'dynamicImport assertions for JSON are the standard approach in Node 20+, and they naturally handle JSON loading in an ESM environment. The cost is that they cannot be used in a synchronous context, and each import is cached by the module—but in this one-off script, caching is not a problem.readdirThefile[0]check inundefined,undefined !== '_'.

Handling of deleted artifacts.When an artifact is deleted, the report marks it with strikethrough rather than removing it outright. This is intentional design: maintainers need to see "this file disappeared," not have it silently vanish from the table. If it were simply filtered out, readers would mistakenly assume the artifact never existed.

11.2 usage-size: Simulating real user import scenarios

Intuitive model

size-reportIt tells you "how big the full package is," but that doesn't answer what users actually care about: "If I only usecreateApp, how much code do I actually need to download?" The full package size includes a lot of code you may never use (such asdefineCustomElement、Transition、KeepAlive)。usage-size.js's role is to play a "typical user": write a virtual entry file that only imports specific APIs, bundle it with Rollup, and see how big the final output is.

This is like a restaurant not telling you "the total weight of all ingredients in the kitchen is 50 kg," but instead telling you "if you order one Kung Pao Chicken, the ingredients actually used are 300 grams."

Data structure: Preset array

The script's core data structure is thepresetsarray, where each element describes a usage scenario:

📎 scripts/usage-size.js:27-55

PresetThe type has three fields:name(display name),imports(list of APIs imported from Vue), optionalreplace(additional compile-time replacements). Five presets cover scenarios from smallest to largest:

  • createApp (CAPI only): only importscreateApp, and replaces__VUE_OPTIONS_API__with'false', simulating a pure Composition API user📎 scripts/usage-size.js:35-40
  • createApp: only importscreateApp, retaining Options API📎 scripts/usage-size.js:35-40
  • createSSRApp: SSR scenario📎 scripts/usage-size.js:35-40
  • defineCustomElement: Web Components scenario📎 scripts/usage-size.js:35-40
  • overall: imports six core APIs, simulating a "full-featured" user📎 scripts/usage-size.js:44-54

The entry file is fixed as the runtime-only esm-bundler artifact:

📎 scripts/usage-size.js:24-28

Choosingvue.runtime.esm-bundler.jsinstead of the full buildvue.esm-bundler.jsis because the runtime version does not include the template compiler, which is closer to the actual situation of modern build tool users—they use SFC precompiled templates and do not need the runtime compiler.

Step-by-Step Walkthrough

Step 1: Generate bundles for all presets in parallel.

📎 scripts/usage-size.js:62-69

main()For each preset, creategenerateBundlepromises and execute them in parallel withPromise.all. Parallelism is safe here because eachgenerateBundlecall is independentrollup()and they do not share state.

Step 2: Construct the virtual entry.

📎 scripts/usage-size.js:94-96

This is the most ingenious part of the entire script. It does not write a temporary file to disk, but instead constructs a virtual module IDvirtual:entry, whose content is a re-export statement:export { createApp } from '/absolute/path/to/vue.runtime.esm-bundler.js'. Note thatentryis an absolute path, because Rollup needs to be able to resolve it.

Step 3: Configure the Rollup plugin chain.

📎 scripts/usage-size.js:98-121

The order of the plugin array is crucial:

1. Customusage-size-plugin:resolveIdinterceptsvirtual:entryreturns itself,loadreturns the virtual content📎 scripts/usage-size.js:101-110. This is the standard pattern for Rollup virtual modules.

2. nodeResolve(): resolves imports insidevue.runtime.esm-bundler.js📎 scripts/usage-size.js:111。

3. replace: injects compile-time constants📎 scripts/usage-size.js:112-119。

replaceThe plugin configuration reveals the core mechanism of the esm-bundler artifact: it preserves runtime flags such as__VUE_OPTIONS_API__、__VUE_PROD_DEVTOOLS__, which are replaced by the user's build tool. Here the script performs the replacement on the user's behalf:

  • process.env.NODE_ENV → "production": take the production branch
  • __VUE_PROD_DEVTOOLS__ → 'false': disable devtools support
  • __VUE_PROD_HYDRATION_MISMATCH_DETAILS__ → 'false': disable detailed hydration errors
  • __VUE_OPTIONS_API__ → 'true': retain Options API by default

Then expand...preset.replace, allowing presets to override defaults.createApp (CAPI only)The preset uses exactly this mechanism to change__VUE_OPTIONS_API__to'false' 📎 scripts/usage-size.js:35-40。

preventAssignment: truePrevent replacementobj.process.env.NODE_ENV = xassignments like this📎 scripts/usage-size.js:117。

Step 4: Generate, minify, measure.

📎 scripts/usage-size.js:123-134

result.generate({})produces the code, takeoutput[0].code. Then minify with SWC:

📎 scripts/usage-size.js:125-130

module: trueindicates the input is ESM,toplevel: trueallows minifying top-level scope variable names. After minification, calculate three metrics respectively:minified.length(byte length),gzipSync(minified).length、brotliCompressSync(minified).length。

Note that this usesnode:zlib's synchronous API, not the asynchronous version. In a one-off script, the synchronous API is more concise, and minification itself is a CPU-intensive operation, so asynchrony would not bring parallel gains.

Step 5: Output and persistence.

📎 scripts/usage-size.js:62-86

The results are first printed to the console in a human-readable format, usingpicoto color📎 scripts/usage-size.js:62-86. Then write totemp/size/_usages.json, usingObject.fromEntriesto convert the array back to a Record, with keys being preset names📎 scripts/usage-size.js:81-85。

--writeThe flag controls whether to additionally write each preset's unminified bundle to disk📎 scripts/usage-size.js:136-138, for debugging.

mermaid
flowchart LR
    subgraph preset_loop["presets 并行遍历"]
        p1["Preset: createApp"]
        p2["Preset: overall"]
    end
    p1 --> virtual["virtual:entry\n'export { createApp } from ...'"]
    p2 --> virtual
    virtual --> rollup["rollup({ input: virtual:entry })"]
    rollup --> resolve["nodeResolve()\n解析 vue.runtime.esm-bundler.js"]
    resolve --> replace["replace()\n__VUE_OPTIONS_API__ 等"]
    replace --> gen["result.generate()\noutput[0].code"]
    gen --> minify["swc.minify(module, toplevel)"]
    minify --> metrics["size / gzipSync / brotliCompressSync"]
    metrics --> json["_usages.json"]

Design considerations and pitfalls

[Design inferences and architectural trade-offs]

Why use a virtual module instead of a temporary file?Temporary files require handling paths, cleanup, and concurrent write conflicts. A virtual module keeps the entry content in memory, and Rollup'sresolveId/loadhook naturally supports this pattern. The cost is that the ID must match exactly; any typo will cause Rollup to report "unable to resolve entry."

replace'spreventAssignmentpitfall.IfpreventAssignment: true,replaceis not set, the plugin will also replace assignment statements such asprocess.env.NODE_ENV = 'x', producing"production" = 'x'syntax errors. Vue's source code does contain assignments toprocess.env.NODE_ENV(in test utilities), so this option is necessary.

__VUE_OPTIONS_API__Choice of default value forThe script sets the default value to'true' 📎 scripts/usage-size.js:116, not'false'. This is a conservative choice: if the user does not configure it, Vue will retain Options API support.createApp (CAPI only)The preset explicitly overrides it to'false', demonstrating the size benefit after disabling it. This comparison itself is documentation for users: telling them "how much you can save by turning off Options API."

ParallelPromise.allfailure semantics.If any preset's bundling fails,Promise.allwill immediately reject, but other ongoing bundling will not be cancelled (Rollup does not provide a cancellation mechanism). In CI, this means one failure wastes the computation of other presets, but the script itself exits with a non-zero exit code, which CI can correctly capture.

11.3 From Data to Gatekeeping: How CI Consumes These Reports

Data Flow Overview

To understand these two scripts, they must be placed back into the CI pipeline.size-data.ymlRuns on push to main/minor or on PRpnpm run size 📎 .github/workflows/size-data.yml:45, produces thetemp/sizedirectory, then uploads it as an artifact📎 .github/workflows/size-data.yml:53-57。

For PRs, it additionally writes two metadata files:

📎 .github/workflows/size-data.yml:47-51

number.txtstores the PR number,base.txtstores the target branch name. These two files are exactly thesize-report.jsinfilterFilesthat need to be filtered out.txtfiles📎 scripts/size-report.js:44-45. Their existence is to let the downstreamsize-report.ymlknow "which baseline to compare against."

Baseline Acquisition and Comparison

size-report.yml(detailed in the previous chapter) workflow is: download the current PR'ssize-dataartifact, download the target branch's baseline artifact, extract the baseline totemp/size-prev, then runsize-report.jsto generate a Markdown report and comment on the PR.

There is a key design constraint here:size-report.jsitself is not responsible for acquiring the baseline; it assumestemp/size-prevalready exists. If it does not exist,existsSync(prevDir)returns false,previs an empty array📎 scripts/size-report.js:48, and all diffs are empty strings. This is graceful degradation: when there is no baseline, the report is still generated, it just does not show differences.

Size Gatekeeping Decision Logic

[Design Inference and Architectural Trade-offs]

A common misconception needs to be clarified:size-report.jsitself does not perform gatekeeping decisions. It only generates reports, does not return an exit code, and does not set thresholds. The actual gatekeeping happens at thesize-report.ymlworkflow level—it may include a step that parses the diff values in the report and fails the job if they exceed the threshold.

This "separation of measurement and decision" design has a profound rationale: measurement scripts should remain pure, only responsible for producing facts; decision logic should be at the workflow level, because thresholds may vary by version, branch, and release stage. Hardcoding thresholds intosize-report.jswould make it difficult to reuse.

Design Reflections

Why does size budgeting need two sets of measurements?Full bundle size and usage size answer different questions. Full bundle size is the "upper bound"—it tells you how much users have to download in the worst case. Usage size is the "typical value"—it tells you how much most users actually download. Only by combining both can you get a complete size profile. If there were only full bundle size, maintainers would tend to over-optimize obscure APIs; if there were only usage size, some edge-case size explosions might be overlooked.

The significance of dual gzip and brotli metrics.Modern CDNs generally support brotli, but it is not enabled in all scenarios. Reporting both allows maintainers to assess "what the size looks like in environments that only support gzip." Brotli is typically 15-20% smaller than gzip, and this gap itself is valuable information.

Stability contract of the data format. size-report.jsandusage-size.jsare decoupled through JSON files.usage-size.jswrites_usages.json,size-report.jsreads it. The field names of this contract (name、size、gzip、brotli) are implicit, with no schema validation. Ifusage-size.jschanges a field name and forgets to syncsize-report.js, the report will silently display incorrect data. This is a fragile point in the current design.

Chapter Summary

Chapter Reflections and Self-Test

Q1: size-report.js'sfilterFilesfilters out files starting with_. Ifusage-size.jsrenames the output file from_usages.jsontousages.json, what happens?

Reference Analysis:filterFiles's filter condition isfile[0] !== '_' && !file.endsWith('.txt') 📎 scripts/size-report.js:44-45. If the file is renamed tousages.json, it no longer starts with_, and will befilterFilesretained, entering thefileListunion. ThenrenderFileswill try to process it as a bundle file:importJSONcan successfully import it (it is valid JSON), but its structure isRecord<string, UsageResult>rather thanBundleResult, socurr?.fileisundefined,fileNameis an empty string,curr.sizeis alsoundefined,prettyBytes(undefined)will throw an error or output anomalies. This will cause report generation to fail. The root cause of this problem is thatfilterFilesuses filename prefixes as the basis for distinguishing "metadata vs data," rather than directory structure or an explicit manifest. A more robust approach would be to place usage data in a subdirectory, or maintain an explicit list of metadata files.

Q2: usage-size.jsInPromise.all(tasks)executes bundling for all presets in parallel. If a preset'sreplaceconfiguration omits__VUE_OPTIONS_API__, what happens? Why is the default value set to'true'rather than'false'?

Reference Analysis:replaceIn the plugin configuration,__VUE_OPTIONS_API__: 'true'is the default value, then spreading...preset.replaceallows overriding📎 scripts/usage-size.js:116-118. If a preset omits the configuration, it will use the default value'true', i.e., retaining Options API support, and the size will be larger. Setting the default to'true'is a conservative choice: it reflects "the actual behavior when the user does not configure it." In Vue's esm-bundler output,__VUE_OPTIONS_API__'s default behavior is to retain the Options API (unless the user explicitly disables it). If the default were set to'false', all presets without explicit configuration would show smaller sizes, misleading users into thinking "you can save size by not configuring it."createApp (CAPI only)The preset explicitly sets'false' 📎 scripts/usage-size.js:35-40precisely to demonstrate "the benefit of explicitly disabling it," contrasting with the default value.

Q3: size-report.js'simportJSONuses dynamicimport()rather thanfs.readFileSync. If a JSON file in thetemp/size-prevdirectory is corrupted (invalid JSON), how do the two implementations behave differently?

Reference Analysis: dynamicimport()throwsSyntaxErrorwhen parsing invalid JSON, and this error cannot be caught byimportJSON's internalexistsSynccheck—existsSyncOnly checks whether the file exists, not whether the content is valid.📎 scripts/size-report.js:112-115. Errors propagate upward torenderFiles, causing the entire report generation to fail. If usingfs.readFileSync + JSON.parse, it will also throw an error, but it can be wrapped in a try-catch insideimportJSON, returningundefinedto achieve graceful degradation. The current implementation chooses to let errors propagate, with the implicit assumption that "the JSON in the artifact must be valid"—this assumption usually holds in CI environments because the files are generated byusage-size.jsand build scripts. However, during local debugging, if the JSON file is manually modified and becomes corrupted, the report will crash directly instead of skipping that file. This is a design choice of "trusting the data source."

---

The size budget mechanism solves the problems of "what to measure" and "how to compare," but it relies on a prerequisite: the build artifacts themselves are reproducible. The next chapter will enter the minimal debugging sandbox:vite-debughow to start an interactive Vue development environment with minimal configuration, and how it links with local build artifacts to form a closed loop from source code modification to runtime verification.

At this point, the measurement loop of the size budget is clear: size-report.js uses directory comparison to answer "how much bigger," usage-size.js uses virtual modules to simulate real import scenarios to answer "where it's bigger," and the gatekeeping decision is left to the workflow layer. This mechanism turns size regression from vague complaints into traceable data. But data can only tell you that a problem exists; to truly locate and fix it, you still need a minimal environment that can quickly reproduce the problem. The next chapter will enter packages-private/vite-debug to see how Vue uses Vite + SFC to build a minimalist debugging sandbox, turning "minimal reproduction on real source code" into an actionable daily practice.

Turn Any Codebase into a Book You Can Actually Understand

Enjoyed this chapter? Turn your private codebase into an architecture book

Local-first Tauri 2 + Rust architecture. 100% offline security, zero code uploaded. Dual-pane reading with immutable commit line anchors.

⚡ Tauri 2 · Rust Native Core · 100% Offline & Private · Tested on 1M+ LOC

CHAPTER 12

Chapter 12: Minimal Debugging Sandbox: vite-debug and the Local Development Loop

Upstream: vuejs/core · Commit @4ab865a8 · Progress: Chapter 12 of 14

In the previous chapter, we completed the measurement loop of the size budget: size-report.js answers "how much bigger," usage-size.js answers "where it's bigger," and the workflow layer handles gatekeeping decisions. But this mechanism has an implicit prerequisite—the build artifacts themselves are reproducible. When you find that a certain package's size has abnormally inflated, or that some runtime behavior does not match expectations, you need a minimal environment that can quickly load local source code and immediately see the effect after modification. packages-private/vite-debug is that environment. It has only four files and less than 40 lines of code in total, yet it constitutes the daily practice entry point for "minimal reproduction on real source code" in the Vue core repository. This chapter will break down the construction logic of this sandbox file by file and explain why it is placed under packages-private rather than the packages directory.

1. The skeleton of the sandbox:main.tsandApp.vueminimal mounting chain

Intuitive model

If the entire Vue runtime is compared to an engine, thenvite-debugis a "bare-metal test bench"—no shell, no dashboard, only the minimal wiring to make the engine run. Its value lies not in functional completeness, but ineliminating all interfering variables: when you suspect that a bug is in the reactivity system or inside the renderer, you do not want the complexity of the debugging environment itself to become a source of noise.

Data structures and file layout

First look atmain.tsthe entire contents of:

📎 packages-private/vite-debug/main.ts:4-4

ts
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

app.mount('#app')

These six lines of code are the standard paradigm for starting a Vue application, but each line has a precise engineering meaning in the debugging scenario:

  • L1Inimport { createApp } from 'vue'of'vue', what this module identifier ultimately resolves to is entirely determined by the dependency declarations invite.config.tsandpackage.json. This is the most critical part of the entire sandbox—we will see later how it is pointed to local source code.
  • L2Inimport App from './App.vue'of@vitejs/plugin-vuetriggers the SFC compilation pipeline ofApp.vue: Vite registers this plugin when the dev server starts. When the browser requests<script>、<template>、<style>, the plugin splits it into
  • L4three virtual modules and compiles them separately.createApp(App)Inapp._context、app._instanceof
  • L6creates the application instance. At this point, Vue internally initializes core fields such asapp.mount('#app'), but no rendering has been triggered yet.appIn

ofindex.htmlis the real startup switch: it looks for the container element with idindex.htmlin the DOM, creates the root component instance, and triggers the first render.<div id="app"></div>Note that there is no reference to<script type="module" src="/main.ts"></script>here—Vite's convention is thatapp.mount('#app')in the project root directory serves as the entry HTML, which contains

and

. Although this file is not in this chapter's keyFiles, it is the prerequisite forApp.vueto succeed.

📎 packages-private/vite-debug/App.vue:4-8

vue
<script setup>
import { ref } from 'vue'

const count = ref(0)
</script>

<template>
  <button @click="count++">{{ count }}</button>
</template>

<style>
button {
  color: red;
}
</style>

Now look at, which is the "experimental carrier" of this sandbox:

Copy

@vitejs/plugin-vuePut it into a concrete scenario:App.vueWhen the user clicks the button in the browser, what happens?

  • <script setup>Step 1: SFC compilation phase (when the dev server starts)setup()compilesref(0)into three parts:RefImplThe block is compiled into the component's.valuefunction,0。
  • <template>The call returns a{{ count }}object whose_toDisplayString(count.value),@click="count++"is initiallyonClick: $event => (count.value++)。
  • <style>The block is compiled into a render function,<style>is converted to

is converted toapp.mountThe block is compiled into a CSS module and injected into the DOM through

createApp(App)tags.mount('#app'), it creates the root component'sComponentInternalInstance, executessetup()to getcount's RefImpl, then calls the render function to generate the VNode tree. Readingcount.valuein the render function triggerstrackto collect dependencies—the currently active render effect (ReactiveEffect) is recorded incount'sdep.

Step 3: Click event (during user interaction)

The browser triggers theclickevent, and Vue's event handler executescount.value++. This is a setter operation that triggerstrigger: it iterates over the effects collected incount.depand schedules a re-render. Since it is a synchronous update and not in a batch queue, the render effect is executed immediately, re-invoking the render function to generate a new VNode, which is diffed against the old VNode; it detects that the text content changed from0to1, and updates the real DOM'stextContent。

The entire chain can be represented by the following data flow diagram:

mermaid
flowchart LR
    subgraph compile["编译期 (Vite Dev Server)"]
        sfc["App.vue"] -->|"@vitejs/plugin-vue"| script["setup() 函数"]
        sfc -->|"@vitejs/plugin-vue"| render["渲染函数"]
        sfc -->|"@vitejs/plugin-vue"| style["CSS 模块"]
    end
    subgraph runtime["运行时 (浏览器)"]
        script -->|"ref(0)"| refimpl["RefImpl { value: 0 }"]
        render -->|"读取 count.value"| track["track() 收集依赖"]
        click["用户点击"] -->|"count.value++"| trigger["trigger() 触发更新"]
        trigger -->|"调度渲染副作用"| rerender["重新执行渲染函数"]
        rerender -->|"diff + patch"| dom["更新真实 DOM"]
    end
    track -.->|"dep 记录 ReactiveEffect"| trigger

The key to this diagram is:There are only two coupling points between compile-time artifacts and runtime behavior——ref(0)the RefImpl object returned by , and the read/write ofcount.valuein the render function. This means that if you want to debug a certain branch of the reactivity system (for example,triggerthe scheduling logic in ), you only need to construct the corresponding read/write pattern in thisApp.vue.

Design consideration: whyrefinstead ofreactive?

[Design inference and architectural trade-offs]

Choosingref(0)rather thanreactive({ count: 0 })as the default example implies a debugging-first consideration:ref's.valueaccess path is shorter, and when expanding theRefImplobject in the debugger, you can directly see internal fields such as_value、dep、__v_isRef, whereas expanding the Proxy object returned byreactivein the console triggers the getter, which may interfere with observing the original state. For the "minimal reproduction" scenario, reducing one layer of Proxy indirection means fewer variables.

---

2. Alias resolution:vite.config.tsandpackage.jsonhow to point'vue'to local source code

Intuitive model

vite.config.tshas only six lines, but it is the "routing hub" of the entire sandbox—it determines whether theimport { createApp } from 'vue'in'vue'ultimately loads the published version on npm or the source code under development in the repository. Without the correct alias configuration, the code you modify inApp.vuemay not trigger the Vue source code you are debugging at all, and debugging becomes "shooting at the wrong target."

Data structures and resolution chain

First look atvite.config.ts:

📎 packages-private/vite-debug/vite.config.ts:4-6

ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
})

Herethere is no explicitresolve.aliasconfiguration. So how is'vue'resolved to the local source code? The answer is inpackage.json:

📎 packages-private/vite-debug/package.json:1-15

json
{
  "name": "vite-debug",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "serve": "vite preview"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "catalog:",
    "vite": "catalog:",
    "vue": "workspace:*"
  }
}

The key isL13:"vue": "workspace:*". This is the declaration of the pnpm workspace protocol, indicating thatvite-debugdepends on the local package namedvuein the monorepo, rather than the version on the npm registry. pnpm will create a symlink innode_modules/vue, pointing topackages/vue(Vue's main package directory).

But this is not enough—packages/vue'spackage.jsoninmain/module/exportsthefield usually points tobuild artifactsdist/vue.runtime.esm-bundler.js(such assrc/), rather than the source code underpackages/runtime-core/src/renderer.ts. If you modifydistbut do not rebuild, Vite will still load the old

file.

[Design inference and architectural trade-offs]packages/vue/package.jsonThis is why Vue core repository's"development"usually configuresresolve.conditionsconditional exports or similar source entry mappings—in dev mode, Vite'sdevelopmentwill preferentially match thesrc/index.tscondition, thereby loadingdistinstead ofvite-debug. This mechanism allows

to see the effect immediately through HMR after modifying the source code without explicitly configuring an alias.import 'vue'Scenario-driven Walkthrough: a resolution process of

Put yourself in the scenario:When the Vite dev server receives the browser's request formain.tsand encountersimport { createApp } from 'vue', what is the resolution chain?

mermaid
flowchart TD
    req["浏览器请求 /main.ts"] --> parse["Vite 解析 import 'vue'"]
    parse --> resolve{"resolve 条件匹配"}
    resolve -->|"development 条件命中"| src_entry["packages/vue/src/index.ts"]
    resolve -->|"仅 production 条件"| dist_entry["packages/vue/dist/vue.runtime.esm-bundler.js"]
    src_entry -->|"源码模块图"| hmr["HMR 监听 src/ 变更"]
    dist_entry -->|"预构建产物"| no_hmr["无源码级 HMR"]
    hmr -->|"修改 renderer.ts"| reload["浏览器热更新"]
    no_hmr -->|"修改 renderer.ts"| stale["仍加载旧产物"]
    reload --> verify["验证行为变更"]
    stale --> rebuild["需手动重新构建"]
    rebuild --> verify

This flowchart reveals a key branch:If thedevelopmentcondition is not configured correctly, the browser will not hot-update after modifying the source code, and you will fall into the confusion of "I changed the code but the behavior did not change." The troubleshooting method is to check the actual loading path of thevuemodule in the Network panel of the browser DevTools—if you see thedist/path, it means the source entry mapping is not in effect.

Design consideration: why not explicitly write an alias invite.config.ts?

[Design inference and architectural trade-offs]

A natural question is: why not directly writevite.config.tsinresolve: { alias: { vue: '../../packages/vue/src/index.ts' } }? Although this is intuitive, it has two problems:

1. Breaking subpath imports: Vue's public API includes subpaths such asvue/server-renderer、vue/compiler-sfc. If only'vue'itself is aliased, subpath imports will still go throughdist, causing some modules to come from source code and some from build artifacts, resulting in inconsistent behavior.

2. Bypassing the conditional exports mechanism: Vue'spackage.jsoninexportsthedevelopment/production/browser/nodefield already defines a complete conditional export mapping (

, etc.), and alias will override this mechanism, causing the resolution behavior in the debugging environment to deviate from the real user environment.vite-debugTherefore,package.jsonchooses the combination of "trusting the workspace protocol + conditional exports" to make the resolution chain as close as possible to the real usage scenario. This also explains why"vue": "workspace:*"innode_modules/vueis necessary—it is the prerequisite for triggering the pnpm symlink and enabling Vite to findpackages/vuethrough

.catalog:Production pitfalls:

protocol and version driftpackage.jsonNote thatL11-L12in"catalog:"uses the

json
"@vitejs/plugin-vue": "catalog:",
"vite": "catalog:",

Copypnpm-workspace.yamlThis is pnpm's catalog feature, indicating that the version number is uniformly managed by thecatalogfield in. Its purpose is to。

avoid version drift when multiple packages in the monorepo reference the same dependency

[Design inference and architectural trade-offs]vite-debugWhen encountering a suspected bug in Vite or plugin-vue and wanting to temporarily upgrade the version to verify, directly modifyingpackage.jsonthecatalog:in is ineffective—you need to modifypnpm-workspace.yamlthe catalog definition in, which affects all packages using that catalog. The correct approach is to temporarily change it to an explicit version number (e.g.,"vite": "5.0.0"), and after verification, change it back tocatalog:。

---

III.packages-privateIsolation design: Why the debug sandbox is not published externally

Intuitive model

packages-privateThe directory is like the company's "internal laboratory"—the samples inside are not sold externally, only used for testing and demonstration. It is physically isolated from thepackagesdirectory to prevent debug code from being accidentally published to npm.

Three layers of isolation guarantees

First layer: Directory isolation

packages-private/vite-debugis not underpackages/, whilepnpm-workspace.yamltypically declares bothpackages/*andpackages-private/*as workspace members, but the publish script (e.g.,scripts/release.js) only traverses packages underpackages/.

Second layer:private: true

📎 packages-private/vite-debug/package.json:3

json
"private": true,

This line is a hard constraint of npm/pnpm: packages marked asprivatecannever be published bynpm publish, even manual execution will be rejected. This is the last line of defense against accidental publishing.

Third layer: Noversionfield

Note thatpackage.jsondoes not have theversionfield. The npm specification requires that publishable packages must haveversion, and packages missing this field will error duringnpm publish. This is "double insurance"—even ifprivateis accidentally deleted, the missingversionwill still prevent publishing.

Design thinking: The division of labor between the debug sandbox and Playground

The Vue core repository already has a fully functionalSFC Playground(discussed in Chapter 7), so why isvite-debug?

still needed? [Design inference and architectural trade-offs]

Their positioning is completely different:

DimensionSFC Playgroundvite-debug
Runtime environmentIn-browser (compilation also in browser)Node.js + browser
Source loadingVia CDN or prebuilt artifactsDirectly loads local source code
Debugging capabilityLimited by browser sandboxCan use Node.js debugger, breakpoints
Modify source codeNot supportedSupports HMR
Applicable scenariosVerify compilation output, share reproductionsDebug runtime internal behavior

vite-debugThe core value ofis that it runs in a real Node.js environment, you can usenode --inspectto attach a debugger, set breakpoints inpackages/reactivity/src/effect.ts, and observeReactiveEffectthe creation and scheduling process. This is something Playground cannot provide.

Production pitfalls: HMR boundaries and state loss

[Design inference and architectural trade-offs]

When usingvite-debugfor debugging, a common confusion is: after modifyingApp.vuethe initial value ofcountin, the count in the browser is not reset. This is because Vite's HMR handling of<script setup>blocks is topreserve component state and only replace the render function. If you need to fully reset state, you need to manually refresh the page, or addApp.vueinimport.meta.hot?.invalidate()to force a full page refresh.

Another trap is: when you modify source code underpackages/runtime-core/src/, the HMR propagation chain may not automatically trigger—becausevite-debugthe HMR boundary is defined at theApp.vuelevel, while source changes underpackages/need to propagate through Vite's module graph. If you find that the browser does not respond after modifying source code, check whether the Vite terminal output hashmr updatelogs; if not, you may need to restart the dev server.

---

Chapter summary

packages-private/vite-debuguses four files and less than 40 lines of code to build a complete debugging loop:

1. main.tsprovides the minimal mounting chain:createApp(App).mount('#app'), excluding all unnecessary initialization logic.

2. App.vueserves as the experimental carrier:ref+ template interpolation + event handling, covering the main path of the reactivity system.

3. vite.config.ts + package.jsonThrough theworkspace:*protocol and conditional exports,'vue'is resolved to local source code, achieving "source changes take effect immediately."

4. packages-private + private: true+ noversionthree-layer isolation ensures that debug code will not be accidentally published.

The engineering philosophy of this sandbox is:the complexity of the debugging environment itself should approach zero, leaving all complexity to the source code being debugged. When you encounter a hard-to-reproduce bug inpackages/reactivity,vite-debugprovides an experimental bench that can be modified freely and verified immediately.

Chapter review and self-test

Q1: If you changepackage.jsonthe"vue": "workspace:*"in to"vue": "^3.4.0", after modifyingvite-debuginpackages/reactivity/src/ref.ts, what will happen to the behavior in the browser? Why?

Reference analysis: After changing to"^3.4.0", pnpm will download the published version of Vue 3.4.x from the npm registry instead of linking to the localpackages/vue 📎 packages-private/vite-debug/package.json:13. At this point,import { createApp } from 'vue'resolves tonode_modules/.pnpm/vue@3.4.x/node_modules/vue/dist/vue.runtime.esm-bundler.js, i.e., the prebuilt artifact. Modifyingpackages/reactivity/src/ref.tswill not trigger any HMR, because Vite's module graph does not include this file at all. What runs in the browser is still the npm version of therefimplementation. This experiment inversely verifies thatworkspace:*is a necessary condition for source-level debugging.

Q2: App.vueThe<style>block in does not addscoped. If two component instances are mounted simultaneously in this sandbox, what will happen to the styles? What does this have to do withvite-debugthe debugging goal?

Reference analysis: Withoutscoped,button { color: red }is global style📎 packages-private/vite-debug/App.vue:4-8, and will affect all<button>elements in the page. If two component instances are mounted, the buttons of both instances will turn red. The relationship with the debugging goal is:vite-debugis positioned as "minimal reproduction," not "style isolation verification." Omittingscopedreduces the variables injected at compile time for thedata-v-xxxattribute, making the DOM structure in the debugger cleaner. If you need to debugscopedthe compilation logic of styles, you should explicitly addscopedand observe@vitejs/plugin-vuethe generated attribute injection code.

Q3: Suppose you add a linepackages/runtime-core/src/renderer.tsin thepatchfunction ofconsole.log, but there is no output in the browser console. Please list at least three possible reasons and explain how to troubleshoot them one by one.

Reference analysis:

Reason one:The source entry did not take effect。'vue'resolved to thedistartifact rather thansrc. Troubleshooting: In the DevTools Network panel, check the loading path of thevuemodule. If it starts withdist/, it means the conditional export did not matchdevelopmentcondition📎 packages-private/vite-debug/package.json:13。

Cause 2:HMR not propagated. Vite's module graph did not propagate changes frompackages/runtime-core/src/renderer.tstovite-debug. Troubleshooting: Check whether the Vite terminal hashmr updatelogs; if not, restart the dev server.

Cause 3:patchfunction not called. If the current page does not trigger any DOM update (for example, no button is clicked),patchmay only execute once on first mount, and the first mount happened before you addedconsole.log. Troubleshooting: Refresh the page, or add an operation inApp.vuethat triggers an update.

Cause 4 (supplementary):Build cache. Vite's dependency pre-bundling cache (node_modules/.vite) may still use the old version. Troubleshooting: Deletenode_modules/.viteand restart.

---

The size budget tells you "the problem exists,"vite-debugand lets you "reproduce the problem yourself." But when you try to generalize this sandbox mode to the entire monorepo, you will encounter a series of boundary conditions: differences in resolving the workspace protocol in CI environments,catalog:the upgrade dilemma of version locking,packages-privateand the dependency direction constraints betweenpackagesand

... The next chapter will move into architectural trade-offs and a pitfall-avoidance guide, systematically sorting out the boundary conditions exposed by monorepo engineering in real projects.

Turn Any Codebase into a Book You Can Actually Understand

Enjoyed this chapter? Turn your private codebase into an architecture book

Local-first Tauri 2 + Rust architecture. 100% offline security, zero code uploaded. Dual-pane reading with immutable commit line anchors.

⚡ Tauri 2 · Rust Native Core · 100% Offline & Private · Tested on 1M+ LOC

CHAPTER 13

Next chapter: Chapter 13 →

Upstream: vuejs/core · Commit @4ab865a8 · Progress: Chapter 13 of 14

Verification status: FACT line numbers truly anchoredpackages-private/vite-debugIn the previous chapter, we usedpackagesas an entry point and mastered the debugging paradigm of doing minimal reproduction on real source code. As this kind of internal debugging package grows in number, a practical problem surfaces: they coexist in the same workspace with officially published packages, so how do we ensure the release process does not accidentally affect them? This chapter will go deep into the boundary conditions of monorepo engineering, starting from the dual-directory contract ofpackages-privateand

, analyze the defensive design behind architectural trade-offs, and provide an actionable pitfall-avoidance guide.

13.2 The Iron Law of Timing: Enum Inlining Must Execute Before Rollup

Intuitive modelbuild.jsEnum inlining is like "replacing the labels on parts with numbers before packing." If the packer (Rollup) has already started packing, and you then change the labels, the parts in the box and the labels will no longer match.scanEnums() / removeCache()uses the

pair of functions to strictly sandwich inlining before Rollup.

inline-enums.jsData structure and lifecyclescanEnums()The exportedremoveCachereturns a📎 scripts/build.js:30-34。build.jsclosure, which scans enum definitions in the source code and generates temporary files for Rollup to consume.run()'stry/finallyuses📎 scripts/build.js:81-112:

js
const removeCache = scanEnums()
try {
  // ... buildAll / checkAllSizes / build-dts
} finally {
  removeCache()
}

rollup.config.jsCopyinlineEnums()At the module top level, call[enumPlugin, enumDefines] 📎 rollup.config.js:47-50to getenumPlugin, where📎 rollup.config.js:331-331,enumDefinesis inserted into the plugins array📎 rollup.config.js:222-223。

and merged into the replacement table of the replace plugin

1. build.jsStep-by-Step: The complete lifecycle of an enum in one buildrun()'sscanEnums()first callsremoveCache 📎 scripts/build.js:87-87。

2. buildAll, scans enum definitions in all packages and writes them to the temporary cache, returns📎 scripts/build.js:119-121。

and concurrently starts multiple Rollup processesinlineEnums()3. Each Rollup process executesenumPluginduring the config loading phase, reads the cache generated in the previous step, and obtainsenumDefines 📎 rollup.config.js:47-50。

4. enumPluginandenumDefinesreplaces enum references in the source code with literals during the transform phase;📎 rollup.config.js:222-223。

serves as a supplement to replace, handling cross-module constant replacementfinally5. When the build ends,removeCache()the block calls📎 scripts/build.js:119-121。

mermaid
flowchart LR
  src["源码 enum 定义"] --> scan["scanEnums()<br/>scripts/inline-enums.js"]
  scan --> cache["临时缓存文件"]
  cache --> inline["inlineEnums()<br/>rollup.config.js"]
  inline --> plugin["enumPlugin<br/>transform 阶段替换"]
  inline --> defines["enumDefines<br/>replace 替换表"]
  plugin --> bundle["Rollup 产物<br/>字面量已内联"]
  defines --> bundle
  bundle --> cleanup["removeCache()<br/>finally 块"]

Copy

Design thinking and pitfalls

[Design inference and architectural trade-offs]Why not use a Rollup plugin to scan and use on the fly during the transform phase? Because enum inlining requires:runtime-corea cross-package global viewshared. The referenced enum may be defined inscanEnums(), and a single Rollup process only sees its own package's source tree, so it cannot complete cross-package replacement.

Establishing a global cache before the build is precisely to solve this visibility problem.removeCache()Production pitfall:finallyplacingfinallyintemp/means it will be cleaned up even if the build throws an error midway. But if you manually interrupt the process while debugging (Ctrl+C),

---

may not execute, and the leftover cache files will cause the next build to read stale enums. Troubleshooting method: Check whether there are leftover enum cache files under therelease.jsdirectory, delete them manually, and retry.

13.3 Release orchestrator:

release.js's skip flag matrixskipBuild / skipTests / skipGit / skipPromptsIntuitive modelskipPromptsis like the wedding director,skipGitand the four switches are the buttons for "skip rehearsal," "skip vows," "skip photos," and "skip confirmation." The existence of each button corresponds to a real scenario: CI environments needskipTests。

, local debugging needs

, emergency hotfixes needparseArgsData structure and default values of the flags📎 scripts/release.js:39-50The four skip flags are declared in📎 scripts/release.js:64-66:

js
let skipTests = args.skipTests
const skipBuild = args.skipBuild
const skipPrompts = args.skipPrompts
const skipGit = args.skipGit

CopyskipTestsNote thatletdeclaration, because it will be dynamically rewritten inrunTestsIfNeeded()📎 scripts/release.js:281-317。

Step-by-Step: The Complete Decision Flow of a Release

main()execution order📎 scripts/release.js:143-279:

1. Remote sync check:isInSyncWithRemote()Compare local HEAD with remote branch SHA, and show a confirmation dialog when they differ📎 scripts/release.js:337-363。

2. Version selection: when there are no positional arguments, pop upversionIncrementsselection menu📎 scripts/release.js:152-176。

3. Test decision:runTestsIfNeeded()is where the skip logic is most concentrated📎 scripts/release.js:281-317。

4. Version update:updateVersions()Iterate over all packages and rewritepackage.json 📎 scripts/release.js:377-398。

5. Changelog generation: callpnpm run changelog 📎 scripts/release.js:211-212。

6. Git commit:skipGitWhen true, the entire section is skipped📎 scripts/release.js:231-240。

7. Publish: execute only whenargs.publishis truebuildPackages() + publishPackages() 📎 scripts/release.js:243-246。

runTestsIfNeeded()The branch logic of is worth expanding separately:

mermaid
flowchart TD
  entry["runTestsIfNeeded()"] --> skipFlag{"skipTests?"}
  skipFlag -->|是| done["Tests skipped"]
  skipFlag -->|否| ci["getCIResult()"]
  ci --> ciPass{"CI passed?"}
  ciPass -->|是| promptMode{"skipPrompts?"}
  promptMode -->|是| setSkip["skipTests = true"]
  promptMode -->|否| ask["prompt: Skip local tests?"]
  ask --> setSkip2["skipTests = promptSkipTests"]
  ciPass -->|否| noPrompt{"skipPrompts?"}
  noPrompt -->|是| throwErr["throw Error<br/>CI not passed"]
  noPrompt -->|否| runLocal["run('pnpm', ['run','test','--run'])"]
  setSkip --> done
  setSkip2 --> done
  runLocal --> done

Design reflections and pitfalls

[Design inferences and architectural trade-offs]

skipTestsuseletinstead ofconstThe design of is intended to support the optimization path of "skip local tests automatically if CI has passed." This saves a significant amount of time in CI release scenarios—GitHub Actions'release.ymlhas already run the full test suite, so running it again locally is pure waste.

The hidden contract of publish order:sortPackagesForPublishingputsvuelast📎 scripts/release.js:85-85, and the comment explicitly states that "users must not be able to install the new entry package before the internal packages are available." If you change this ordering, usersnpm install vue@nextmay pull a version whose dependencies have not yet been published, causingERR_MODULE_NOT_FOUND。

Idempotency protection:publishPackagecall before publishingisPackagePublishedto check the registry📎 scripts/release.js:453-458, and when publishing fails, catch thepreviously publishederror and degrade to skipping📎 scripts/release.js:480-488. This allows the release script to be safely retried—after a network interruption, re-running it will not fail entirely because "the package already exists."

Failure rollback:fnToRun().catch()whenversionUpdatedis true, callupdateVersions(currentVersion)to roll back the version number📎 scripts/release.js:528-537. But note: this only rolls backpackage.jsonthe version field inand does not roll back commits that have already beengit commit. If you publish and it fails whileskipGitis false, you need to manuallygit reset。

---

Design reflection: the common pattern across the three trade-offs

Reviewing the three core trade-offs in this chapter, they share the same design philosophy:Turn "runtime checks that are easy to forget" into "structural constraints that cannot be bypassed"。

  • packages-privatePhysical isolation: do not rely on the script author remembering to check theprivatefield, but instead make the scan scope naturally exclude it.
  • Enum inlining upfront: do not rely on the Rollup plugin "happening" to see cross-package enums during transform, but instead build a global cache before the build.
  • release.jsThe skip matrix of : do not rely on the publisher remembering that "if CI has passed, there is no need to run tests locally," but instead have the script automatically query CI status and rewriteskipTests。
[Design inferences and architectural trade-offs]

The cost of this pattern isincreased script complexity:build.jsneeds to maintain theprivatePackageslist,rollup.config.jsneeds to duplicate directory probing logic,release.jsneeds to handle the cross-combinations of four skip flags. But for a repository like Vue that releases multiple times per week, the reliability gains from structural constraints far outweigh the complexity cost.

---

Chapter summary

Starting from the source code, this chapter breaks down three key boundary conditions of the Vue core engineering system:

1. packages-privateandpackagesphysical isolationjointly guaranteed by the workspace glob,build.jsdirectory probing,release.jsand filtering in three places📎 pnpm-workspace.yaml:1-3📎 scripts/build.js:153-170📎 scripts/release.js:68-83。

2. The timing constraint of enum inliningis enforced byscanEnums() / removeCache()'stry/finallystructure, with the Rollup config consuming the cache at the module top level📎 scripts/build.js:81-112📎 rollup.config.js:47-50。

3. release.jsThe skip flag matrix ofserves three scenarios: CI release, local debugging, and emergency hotfixes,skipTestsThe dynamic rewriting and publish order sorting are the two hidden contracts most easily overlooked📎 scripts/release.js:281-317📎 scripts/release.js:85-85。

Chapter reflection and self-test

Q1: If you remove thebuild.jsinbuild(target)theprivatePackages.includes(target)check in the function and uniformly usepackagesaspkgBase, in what scenarios would problems occur?

Reference analysis:build.js:160-164The directory probing of is the only entry point through which private packages can be built. After removing it,nr build vite-debugwill look forpackages/vite-debugunderpackage.json, but that directory does not exist,fs.readFileSyncdirectly throwsENOENT. A more subtle problem is: if someone in the future creates a directory with the same name underpackages/, the build will silently use the config from the wrong directory, and the output paths andbuildOptionswill all be misaligned. In addition,rollup.config.js:37-42has independent directory probing logic, and both places must be modified in sync; otherwise you get the inconsistent state where "build.jsfound the package but Rollup cannot find it."

Q2: release.jsInrunTestsIfNeeded()ofskipTests ||= isCIPassed, this line of code (release.js:285) whenskipPromptsis true and CI has not passed, which branch will it take? If you remove theelse if (skipPrompts)of thethrowbranch, what will be the consequences?

Reference analysis: whenskipPromptsis true and CI has not passed,skipTests ||= isCIPassedinisCIPassedisfalse,skipTestsand keeps its original value (usuallyfalse). Then it enters theelse if (skipPrompts)branch and throwsError(release.js:299-304). If you remove thisthrow, the code will continue to theif (!skipTests)branch and runpnpm run test --runin a non-interactive environment. In CI, this may cause tests to fail due to environment differences, or worse—tests pass but CI actually did not pass (for example, CI ran a different subset of tests), publishing a version that has not been fully verified.

Q3: rollup.config.js:55IninlineEnums()ofbuild.js:87is called at the module top level, whilescanEnums()ofrun()is called inside theinlineEnums()function. If you swap the execution timing of these two (that is, letbuildStartbe called in Rollup's hook), what would be broken?

Reference analysis:scanEnums()must complete before all Rollup processes start, because it needs to scanall packagessource code to build the global enum cache.inlineEnums()is called at the top level of therollup.config.jsmodule, when Rollup has not yet started any builds, so the cache is already ready. If it were changed to be called inbuildStart, each Rollup process would scan independently—butbuildAllis executed concurrently (build.js:119-121), and multiple processes scanning the same batch of files at the same time would create a race: process A may read a cache file that process B has not finished writing, resulting in incomplete enum replacement. More seriously,scanEnums()the returnedremoveCacheclosure depends on the file handle state at scan time, and in concurrent scenarios the cleanup timing cannot be coordinated.

Dual-directory contracts, ownership determination in build scripts, secondary filtering in release scripts—these mechanisms together define the safety boundaries of monorepo engineering. But boundaries are not static: as build tools migrate from Rollup to Rolldown and type testing converges with runtime testing, existing trade-off strategies will face new challenges. In the next chapter, we will look ahead to the evolution direction of the next-generation engineering system based on the change trajectory from 3.0 to 3.4.

Turn Any Codebase into a Book You Can Actually Understand

Enjoyed this chapter? Turn your private codebase into an architecture book

Local-first Tauri 2 + Rust architecture. 100% offline security, zero code uploaded. Dual-pane reading with immutable commit line anchors.

⚡ Tauri 2 · Rust Native Core · 100% Offline & Private · Tested on 1M+ LOC

CHAPTER 14

Chapter 14: Future Evolution: From 3.x to the Next-Generation Engineering System

Upstream: vuejs/core · Commit @4ab865a8 · Progress: Chapter 14 of 14

In the previous chapter, we sorted out the "safety boundaries" of the Vue core engineering system—dual-directory contracts, ownership determination in build scripts, and secondary filtering in release scripts. These mechanisms were not designed all at once, but were repeatedly refined through iterations from 3.0 to 3.4. This chapter takes a different perspective: instead of looking at "what it looks like now," we look at "how it grew into what it is now," and based on that infer where the next-generation engineering system will go. The source materials for this chapter are changelogs/CHANGELOG-3.3.md, changelogs/CHANGELOG-3.4.md, and the package.json at the repository root. Changelogs may look like mere running logs of "what bugs were fixed," but they are the most authentic health report of an engineering system: every commit with the build: prefix, every change with the types: prefix, every dependency version rollback exposes the stress points of the current architecture. What we need to do is read the direction of evolution from these stress points. Treating changelogs as an "observation window into the engineering system" rather than a "feature list" is the core methodology of this chapter. Feature changes tell us what Vue can do, while build-, type-, and CI-related changes tell us where Vue's engineering system "hurts."

I. Stress Points in the Build Toolchain: The Migration Potential from Rollup to Rolldown

Intuitive Model

Imagine the build toolchain as an assembly line: Rollup is the main assembly station, esbuild handles rapid cutting (transpiling TS), and terser handles final bundling and minification. As the product (the Vue runtime) becomes increasingly complex and more processes are added to the assembly station, the main assembly station itself becomes the bottleneck. Rolldown's positioning is to be a main assembly station rewritten in Rust—what it aims to replace is not esbuild, but Rollup itself.

Without this layer of evolutionary pressure, the "disaster" the system faces is not a crash, butbuild time expanding linearly with the number of packages: each additional subpackage requires starting another Rollup process, scanning the enum cache one more time, and running another round of dts generation.

Data Structures and Dependency Layout

First, let's look at a static snapshot of the current toolchain.package.jsonThedevDependenciesin is an precise "assembly station inventory":

📎 package.json:103-106

code
    "rollup": "^4.63.3",
    "rollup-plugin-dts": "^6.5.1",
    "rollup-plugin-esbuild": "^6.2.1",
    "rollup-plugin-polyfill-node": "^0.13.0",

Three key facts can be read from this. First, the Rollup major version is^4.63.3, placing it in the mature phase of Rollup 4.x. Second,rollup-plugin-esbuildhandles TS transpilation, meaning Rollup itself does not parse TS and only processes the JS emitted by esbuild. Third,rollup-plugin-dtsindependently handles.d.tsbundling, which is exactly the material basis for thedts-built-testindependence discussed in the previous chapter.

Now let's look at the entry orchestration of the build scripts:

📎 package.json:8-9

code
    "build": "node scripts/build.js",
    "build-dts": "tsc -p tsconfig.build.json --noCheck && rollup -c rollup.dts.config.js",

build-dtsis "two-stage": firsttsc --noCheckgenerates raw declaration files (--noCheckskips type checking and only performs emit), then usesrollup -c rollup.dts.config.jsto bundle the scattered.d.tsinto a single file. This design itself depends on Rollup's capabilities—rollup-plugin-dtsrequires Rollup's module graph to track type dependencies.

Scenario-Driven: What abuild:Commit Exposed

Entries with thebuild:prefix in the changelog are direct evidence of stress points in the build toolchain. Let's pick three to examine.

The first is the minify configuration alignment in 3.4.32:

📎 changelogs/CHANGELOG-3.4.md:84

code
* **build:** use consistent minify options from previous terser config ([789675f](https://github.com/vuejs/core/commit/789675f65d2b72cf979ba6a29bd323f716154a4b))

The motivation for this commit was "after migrating from terser to esbuild minify, the minification options were inconsistent." It reveals an intermediate state during migration: Vue once used terser for minification, then switched to esbuild (devDependenciesinesbuild: ^0.28.2confirms this), but the minification options were not fully aligned, causing deviations in artifact size or behavior. This is exactly the typical cost of "replacing parts on the assembly station."

The second is the entities version rollback in 3.4.38:

📎 changelogs/CHANGELOG-3.4.md:6

code
* **build:** revert entities to 4.5 to avoid runtime resolution errors ([f349af7](https://github.com/vuejs/core/commit/f349af7b65b9f8605d8b7bafcc06c25ab1f2daf0)), closes [#11603](https://github.com/vuejs/core/issues/11603)

entitiesis an HTML entity decoding library, depended on bycompiler-dom. The rollback to 4.5 was because the new version had problems in runtime parsing. This commit shows:dependency upgrades in the build toolchain are not isolated; a version jump in an indirect dependency can penetrate into runtime behavior。

The third is the server-renderer cjs build contamination in 3.4.29:

📎 changelogs/CHANGELOG-3.4.md:155

code
* **build:** fix accidental inclusion of runtime-core in server-renderer cjs build ([11cc12b](https://github.com/vuejs/core/commit/11cc12b915edfe0e4d3175e57464f73bc2c1cb04)), closes [#11137](https://github.com/vuejs/core/issues/11137)

This is the most typical kind of build bug: under the CJS format,server-rendereraccidentally bundledruntime-coreinto its own artifact. The reason is usually that Rollup'sexternaldetermination fails under the CJS format—ESM can statically identify external dependencies viaimportstatements, while CJS'srequireMore dynamic, prone to missed detection. This commit points directly to the fragility of the logic in the Rollup configuration.externalThe fragility of the logic.

Mermaid depiction of migration momentum

The following diagram depicts the control flow of the current build pipeline and marks the nodes that the Rolldown migration will touch:

mermaid
flowchart TD
    start["node scripts/build.js"] --> scan["scanEnums() 全局扫描"]
    scan --> cache_ok{"enum 缓存就绪?"}
    cache_ok -->|否| err_enum["抛出错误 / 中断构建"]
    cache_ok -->|是| build_all["buildAll() 并发启动"]
    build_all --> rollup_proc["每个包一个 Rollup 进程"]
    rollup_proc --> inline["inlineEnums() 顶层调用"]
    inline --> esbuild_plugin["rollup-plugin-esbuild 转译 TS"]
    esbuild_plugin --> external_check{"external 判定"}
    external_check -->|ESM 格式| ext_ok["静态 import 识别成功"]
    external_check -->|CJS 格式| ext_risk["require 动态性导致漏判"]
    ext_risk --> pollution["runtime-core 被打进 server-renderer"]
    ext_ok --> output["产物输出"]
    pollution --> output
    output --> dts["build-dts 两段式生成"]
    dts --> tsc_emit["tsc --noCheck 生成原始 d.ts"]
    tsc_emit --> rollup_dts["rollup-plugin-dts 打包"]
    rollup_dts --> done["构建完成"]
[Design inference and architectural trade-offs]

The migration value of Rolldown lies in: it replaces the "one process per package" concurrency model with a "parallel within a single process" model,scanEnums()The global scan andinlineEnums()The replacement can be coordinated within the same Rust runtime, and the "concurrent scan race" problem discussed in the previous chapter will disappear at its root. But the resistance to migration also lies here—rollup-plugin-esbuild、rollup-plugin-dtsThese plugin ecosystems require Rolldown to provide a compatibility layer, whileexternalThe decision logic needs to be rewritten.

Design thinking and pitfalls

Why won't the migration happen overnight?Look atpackage.jsonTheenginesField:

📎 package.json:61-63

code
  "engines": {
    "node": ">=20.0.0"
  },

Node 20 is a hard lower bound. As a Rust native module, Rolldown requires corresponding N-API bindings and precompiled binary distribution. Once introduced,pnpm installThe time cost, cross-platform (Windows/macOS/Linux) binary compatibility, and CI caching strategy all need to be redesigned. This is not as simple as "swapping a dependency," but ratherA recalibration of the entire install-build-cache chain。

Production pitfalls:build-dtsThetsc --noCheckIs a double-edged sword. Skipping type checking makes emit faster, but it means.d.tsType errors will not be discovered during the generation phase—type errors can only be caught bypnpm check(tsc --incremental --noEmit) andtest-dtsAs a fallback. If after the Rolldown migration you want to merge these two steps, you must ensure that type checking does not slow down the build, otherwise it violates--noCheckThe original intent.

---

II. The convergence trend of type testing and runtime testing

Intuitive model

Think of type testing and runtime testing as two independent quality inspection gates: one checks whether the "manual (.d.ts) is written correctly," and the other checks whether the "machine (runtime) runs correctly." The two gates each have their own workstation, their own tools, and their own reports. The convergence trend means:Can the same test case verify both the manual and the machine at the same time?

Without convergence, the disaster the system faces isDrift between types and runtime behavior:.d.tsSaysref()ReturnsRef<T>But the shape of the object actually returned at runtime has changed; the type test passes, and the runtime test also passes, but the combination of the two is wrong.

Data structure: the orchestration layout of the test scripts

package.jsonInscriptsThe test-related entries are clearly divided into two groups:

📎 package.json:19-24

code
    "test": "vitest",
    "test-unit": "vitest --project unit*",
    "test-e2e": "node scripts/build.js vue -f global -d && vitest --project e2e --project e2e-browser",
    "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",

The key structure here istest-dtsTherun-s build-dts test-dts-only—it isSerial: first build.d.tsThen run the type tests. Andtest-dts-onlyInternally, it is againTwo independenttscProcesses: one runsdts-built-test(verifying the build artifacts), and one runsdts-test(verifying the source types).

Notetest-unitUsesvitest --project unit*,test-e2eUsesvitest --project e2e --project e2e-browserThis shows that Vitest's--projectMechanism has already divided tests into different projects by "unit/end-to-end/browser."The physical foundation for convergence already exists: Vitest's project mechanism allows different types of tests to run in the same runner.

Scenario-driven: the complete path of onetypes:Commit

In the changelogtypes:The density of prefixed entries is extremely high, which is a direct manifestation of the complexity of the type system. We trace one typical type fix.

The ref type rollback in 3.4.37:

📎 changelogs/CHANGELOG-3.4.md:23-24

code
* Revert "fix(types/ref): allow getter and setter types to be unrelated ([#11442](https://github.com/vuejs/core/issues/11442))" ([b1abac0](https://github.com/vuejs/core/commit/b1abac06cdb198bd72f8e614b1f68b92e1c78339))
* Revert "fix(types/ref): correct type inference for nested refs ([#11536](https://github.com/vuejs/core/issues/11536))" ([3a56315](https://github.com/vuejs/core/commit/3a56315f94bc0e11cfbb288b65482ea8fc3a39b4))

Two consecutive Reverts rolled back two type fixes. Note that in 3.4.35 these two fixes had just been merged:

📎 changelogs/CHANGELOG-3.4.md:55

code
* **types/ref:** allow getter and setter types to be unrelated ([#11442](https://github.com/vuejs/core/issues/11442)) ([e0b2975](https://github.com/vuejs/core/commit/e0b2975ef65ae6a0be0aa0a0df43fb887c665251))

📎 changelogs/CHANGELOG-3.4.md:30

code
* **types/ref:** correct type inference for nested refs ([#11536](https://github.com/vuejs/core/issues/11536)) ([536f623](https://github.com/vuejs/core/commit/536f62332c455ba82ef2979ba634b831f91928ba)), closes [#11532](https://github.com/vuejs/core/issues/11532) [#11537](https://github.com/vuejs/core/issues/11537)

From being merged in 3.4.35 to being reverted in 3.4.37, only one patch version separates them. This rapid "merge-revert" cycle exposes a fundamental dilemma of type testing:Type tests can verify that "the type signature matches expectations," but they cannot verify "whether this type signature is actually usable in real code."。allow getter and setter types to be unrelatedIt may pass completely in type tests, but in actual use it will makerefType inference becomes too loose, undermining the type safety of downstream code.

Mermaid depiction of type testing convergence

The following diagram depicts the current separation structure between type testing and runtime testing, as well as the target form after convergence:

mermaid
flowchart LR
    subgraph current["当前:分离的两条链路"]
        src["packages/*/src/*.ts"] --> tsc_build["tsc -p tsconfig.build.json --noCheck"]
        tsc_build --> raw_dts["散落的 .d.ts"]
        raw_dts --> rollup_dts["rollup -c rollup.dts.config.js"]
        rollup_dts --> built_dts["打包后的 .d.ts"]
        built_dts --> dts_built_test["dts-built-test/tsconfig.json"]
        src --> dts_test["dts-test/tsconfig.test.json"]
        src --> vitest_unit["vitest --project unit*"]
        dts_built_test --> report_a["类型报告"]
        dts_test --> report_a
        vitest_unit --> report_b["运行时报告"]
    end
    subgraph future["融合目标:单一 runner"]
        src2["源码"] --> vitest_all["vitest --project unit --project dts"]
        vitest_all --> unified["统一报告 + 类型断言"]
    end
    current -.演进.-> future
[Design inference and architectural trade-offs]

The technical path for convergence is most likely: encapsulatedts-built-testAnddts-testThetscCalls into a custom Vitest project, allowing type assertions to be inlined in test files in the form ofexpectTypeOfIn this way, a singlevitestCall can run both runtime assertions and type assertions, with unified reporting. But the resistance lies in:tscType checking is "full-volume," while Vitest tests are "per-file," and the incremental strategies of the two are incompatible.

Design thinking and pitfalls

Whydts-built-testMust be independent ofdts-test?This was already discussed in the previous chapter; here we supplement from an evolutionary perspective:dts-built-testVerifiesBuild artifacts(rollup-plugin-dtsAfter bundling.d.ts),dts-testVerifiesSource typesIf the two are merged during convergence, the key checkpoint of "whether the build artifacts are consistent with the source types" will be lost. This commit in 3.4.38 precisely confirms the importance of build artifact types:

📎 changelogs/CHANGELOG-3.4.md:9

code
* **types:** add fallback stub for DOM types when DOM lib is absent ([#11598](https://github.com/vuejs/core/issues/11598)) ([4db0085](https://github.com/vuejs/core/commit/4db0085de316e1b773f474597915f9071d6ae6c6))

"Provide a fallback stub when the DOM lib is missing"—this is a type compatibility fix at the build artifact level, and it can only be discovered in scenarios likedts-built-testThis kind of "consuming the bundled.d.ts" scenario.

Production pitfalls: The "merge-revert" cycle of type testing shows that changes to type signatures requireReal downstream projectsVerification, not just type assertions. Vue's type tests run inpackages-private/dts-testuses the repository's internal test cases, which cannot cover all downstream usages. If the convergence trend only focuses on "merging two runners" without solving "how to introduce real downstream feedback," it is merely formal convergence.

---

III. Fine-Grained Optimization Directions for CI Caching

Intuitive Model

Think of CI caching as a repository's "staging area": every build needs to fetch raw materials (dependencies, build artifacts, type caches) from the staging area. If the staging area has only one big box, and fetching anything requires rummaging through the entire box, then no matter how high the cache hit rate is, it won't be fast. Fine-grained optimization means:Splitting the big box into small compartments categorized by purpose。

Without fine-grained caching, the disaster the system faces isCascading amplification of cache invalidation: changing one line of source code causes the entirenode_modulescache to be invalidated, CI reinstalls all dependencies, and build time goes from 2 minutes to 10 minutes.

Data Structure: Classification of Cacheable Items

Frompackage.jsonwe can identify several categories of cacheable "materials":

The first category: dependency installation artifacts.packageManagerThe field locks the pnpm version:

📎 package.json:4

code
  "packageManager": "pnpm@12.4.2",

pnpm'snode_modulesis a symlink structure; what gets cached is pnpm's content-addressable store, not a flatnode_modules. This means the cache key should be based on the hash ofpnpm-lock.yaml, notpackage.json。

The second category: build artifacts.cleanThe script reveals the physical location of the artifacts:

📎 package.json:10

code
    "clean": "rimraf --glob packages/*/dist temp .eslintcache",

packages/*/dist、temp、.eslintcache— these three types of artifacts can be cached independently.distis the build output,tempis temporary files (such asbench.json),.eslintcacheis the lint cache.

The third category: type-checking cache.checkThe script uses--incremental:

📎 package.json:15

code
    "check": "tsc --incremental --noEmit",

--incrementalgenerates.tsbuildinfofile, which is the incremental cache for type checking. If this file is cached in CI,tsc's second run will be much faster.

Scenario-Driven: CI Execution Flow of a Single PR

Put yourself in a typical scenario: a developer modifiespackages/reactivity/src/ref.tsand submits a PR. Which steps does CI need to run, and which can hit the cache?

Fromscriptswe can infer the CI execution sequence (simple-git-hooks'spre-commitis a local hook; CI will run a more complete sequence):

📎 package.json:48-51

code
  "simple-git-hooks": {
    "pre-commit": "pnpm lint-staged && pnpm check",
    "commit-msg": "node scripts/verify-commit.js"
  },

Localpre-commitrunslint-stagedandcheck. On CI, it runslint、check、test-unit、test-dts、sizeetc. Each step has a different caching strategy:

  • lint: cache.eslintcache, key based on source file hash.
  • check: cache.tsbuildinfo, key based ontsconfigand source hash.
  • test-unit: Vitest has its own cache, but typically CI does not cache test results, only dependencies.
  • test-dts: depends onbuild-dts's artifacts, cache key based onpackages/*/dist's hash.
  • size: depends on build artifacts, cache key same as above.

Mermaid Depiction of CI Cache Optimization

mermaid
flowchart TD
    pr["PR 提交"] --> checkout["checkout 代码"]
    checkout --> cache_deps{"pnpm store 缓存命中?"}
    cache_deps -->|是| install_fast["pnpm install --offline"]
    cache_deps -->|否| install_slow["pnpm install 全量下载"]
    install_fast --> lint_step["pnpm lint"]
    install_slow --> lint_step
    lint_step --> cache_eslint{".eslintcache 命中?"}
    cache_eslint -->|是| lint_inc["增量 lint"]
    cache_eslint -->|否| lint_full["全量 lint"]
    lint_inc --> check_step["pnpm check"]
    lint_full --> check_step
    check_step --> cache_tsbuild{".tsbuildinfo 命中?"}
    cache_tsbuild -->|是| check_inc["增量类型检查"]
    cache_tsbuild -->|否| check_full["全量类型检查"]
    check_inc --> test_unit["pnpm test-unit"]
    check_full --> test_unit
    test_unit --> build_dts["pnpm build-dts"]
    build_dts --> cache_dist{"packages/*/dist 命中?"}
    cache_dist -->|是| dts_cached["复用 dts 产物"]
    cache_dist -->|否| dts_rebuild["重新生成 dts"]
    dts_cached --> test_dts["pnpm test-dts-only"]
    dts_rebuild --> test_dts
    test_dts --> size_check["pnpm size"]
    size_check --> done["CI 通过"]
〔Design Inference and Architectural Trade-offs〕

The core contradiction of fine-grained caching isthe granularity of cache keys: if the key is too coarse (e.g., based only on commit hash), the hit rate is low; if the key is too fine (e.g., based on each file's hash), the overhead of computing keys cancels out the caching benefit. A reasonable strategy for monorepos like Vue is "sharding by package": eachpackages/*sub-package caches independently; changes todist,reactivitywill not invalidatecompiler-core'sdistcache.

Design Thinking and Pitfalls

Why should thesizescript be split into multiple subcommands?Look at these three:

📎 package.json:11-14

code
    "size": "run-s \"size-*\" && node scripts/usage-size.js",
    "size-global": "node scripts/build.js vue runtime-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",

sizeusesrun-s "size-*"to serially run all subcommands with thesize-prefix. This "prefix aggregation" pattern allows each size dimension (global, esm-runtime, esm) to be cached and fail independently. If merged into one big command, any dimension exceeding the limit would cause the entiresizeto fail, making it impossible to locate which dimension is the problem.

Production Pitfalls: The most common pitfall in CI caching iscache pollution—caching the wrong artifacts, causing subsequent builds to be based on dirty data.cleanThe existence of the script is precisely to handle this situation:

📎 package.json:10

code
    "clean": "rimraf --glob packages/*/dist temp .eslintcache",

Note that it cleanspackages/*/dist, notpackages-private/*/dist. This meanspackages-private's artifacts are not within the regular cleanup scope—if CI cachespackages-private's artifacts andcleandoes not clean them, the problem of "caching old-version playground artifacts" may arise. When designing fine-grained caching,packages-privatemust be handled separately.

---

Design Thinking: Engineering System as Product Lifecycle

Connecting the threads of the three sections reveals a clear main line:Vue's engineering system is moving from "usable" to "easy to use," from "manual orchestration" to "declarative configuration."。

The migration of the build toolchain (Rollup → Rolldown) is a "performance-driven" evolution: when the number of packages grows to a certain point, the overhead of process-level concurrency exceeds the benefit, and a lighter concurrency model must be adopted.

The convergence of type testing is a "consistency-driven" evolution: when the frequency of type signature changes exceeds the frequency of runtime behavior changes, two separate test suites become a burden, and they must share the same set of test cases.

The fine-granularization of CI caching is a "cost-driven" evolution: when CI minutes become the bottleneck, the waste of coarse-grained caching becomes unacceptable, and sharding by purpose becomes necessary.

〔Design Inference and Architectural Trade-offs〕

The common constraint of these three evolution lines isbackward compatibility. Vue's release strategy (as seen from theBREAKING CHANGESsection in the changelog) allows "type-only breaking changes" in minor versions, but does not allow runtime breaking changes. This means the evolution of the engineering system must guarantee: no matter how the internal toolchain changes, the public API and runtime behavior of the artifacts cannot change. This is the hard boundary of all evolution decisions.

---

Chapter Summary

Starting from the changelog andpackage.json, this chapter has sorted out the three evolution lines of Vue core's engineering system:

1. Build toolchain: The current combination of Rollup 4.x + esbuild + rollup-plugin-dts has its stress points reflected inbuild:prefixed commits (minify config alignment, entities version rollback, CJS external misjudgment). The momentum for Rolldown migration comes from the replacement of "multi-process concurrency" with "single-process parallelism," while the resistance comes from the plugin ecosystem and cross-platform binary distribution.

2. Type test fusion:test-dtsof therun-s build-dts test-dts-onlyserial structure, as well asdts-built-testanddts-testdualtscprocesses, are the physical evidence of the current separated form. The technical path for fusion is to leverage Vitest's--projectmechanism, and the resistance is thattscfull checks are incompatible with Vitest's per-file incremental testing strategy.

3. CI cache fine-granularization:packageManagerlocks pnpm,cleancleans three types of artifacts,checkuses--incremental、sizeaggregates with prefixes—these are all classification bases for cacheable items. The core contradiction is the granularity of cache keys, and the reasonable strategy is "sharding by package."

The most important cognitive shift is:The engineering system itself is a product, with its own users (contributors), its own performance metrics (build time, CI minutes), and its own compatibility constraints (artifact API unchanged). It requires continuous iteration, not one-time design.

Chapter Review and Self-Assessment

Q1: package.json:9of thebuild-dtsusestsc -p tsconfig.build.json --noCheck. If--noCheckis removed, what chain reactions will occur after the Rolldown migration?

Reference Analysis:--noCheckserves to skip type checking and only perform emit. After removing it,tscwill perform full type checking before generating.d.ts. Under the current Rollup architecture, this only makesbuild-dtsslower; but after the Rolldown migration, the problem will be amplified: Rolldown's core selling point is "single-process parallel builds." If thebuild-dtsphase introduces a fulltsccheck, it becomes a serial bottleneck for the entire pipeline—all package builds must wait for this check to complete. More seriously,tsc's type checking is single-threaded and cannot leverage Rolldown's parallel capabilities. The correct approach is to keep--noCheck, delegate type checking to independentpnpm check(package.json:15) andtest-dts(package.json:22), decoupling builds from checks.

Q2: Changelog 3.4.37 consecutively reverted twotypes/reffixes (CHANGELOG-3.4.md:23-24), and these two fixes were just merged in 3.4.35 (CHANGELOG-3.4.md:30,55). If type tests and runtime tests were already fused, could this "merge-revert" cycle be avoided? Why?

Reference Analysis: It cannot be completely avoided, but the cycle can be shortened. Fused type tests can still only verify that "type signatures conform to assertions," while the problem with fixes likeallow getter and setter types to be unrelatedis that "type signatures are too loose, breaking downstream code's type safety"—this is adownstream usageproblem, not asignature itselfproblem. Where fusion can shorten the cycle is: if type assertions and runtime assertions are written in the same test file, developers can more quickly discover inconsistencies where "type signatures changed but runtime behavior didn't." But to truly avoid reverts, real downstream project type checking must be introduced (e.g., extendingpackages-private/dts-testinto a test suite that "simulates downstream usage"), which goes beyond merely "fusing runners."

Q3: package.json:10'scleanscript cleanspackages/*/dist, but does not cleanpackages-private/*/dist. If CI adopts a "sharding by package" fine-grained caching strategy, what production pitfalls will this asymmetry bring?

Reference Analysis: The pitfall is "caching old artifacts ofpackages-private."packages-privatecontainssfc-playground、template-explorerand other debugging tools. If their build artifacts (such aspackages-private/sfc-playground/dist) are cached by CI, andcleandoes not clean them, the following occurs: source code is updated, but CI reuses old playground artifacts, causingbuild-sfc-playground(package.json:39) verification results to be distorted. More insidiously,dev-sfc-prepare(package.json:34) checks whetherpackages-private's artifacts exist. If old artifacts are cached, it will skip rebuilding, making developers think the environment is fresh. When designing fine-grained caching, a separate cache key must be defined forpackages-private, or simply not cache its artifacts—because it is a debugging tool with low rebuild cost and low cache benefit.

Through the observation window of the changelog, we identified the stress points of the current engineering system and inferred the possible evolution directions of the next-generation system. These directions are not castles in the air, but grew from real production pitfalls and trade-offs. At this point, the book's analysis of Vue's engineering system comes to a close, but the exploration of engineering is endless—the next chapter will serve as the final chapter, pulling the perspective back from Vue itself to discuss how these experiences can be transferred to broader engineering scenarios.

Turn Any Codebase into a Book You Can Actually Understand

Enjoyed this chapter? Turn your private codebase into an architecture book

Local-first Tauri 2 + Rust architecture. 100% offline security, zero code uploaded. Dual-pane reading with immutable commit line anchors.

⚡ Tauri 2 · Rust Native Core · 100% Offline & Private · Tested on 1M+ LOC

To understand any complex project, all you really need is a good book

This book was automatically compiled by AiReadCode by scanning the official repository, with real commit line numbers permanently anchored.

Star GitHub Repo ★ Browse More Books →