3.3 · difficulty 2/4 · 14 min read
Plugin Execution Order In Depth
How does enforce pre/normal/post ordering work? Where do built-in plugins cut in line? Visualize the entire execution chain with the PluginPipeline component.
The complete execution order
Laying all plugins out in sequence, the order looks roughly like this:
1. User plugins with enforce: "pre"
2. Vite built-in plugins (alias resolution, import analysis, etc.)
3. User plugins with no enforce value
4. Vite built-in core plugins (file serving, CSS, JSON...)
5. User plugins with enforce: "post"
6. Vite built-in post plugins (build output minification, etc.)This chain is computed per hook individually — it's not about ordering entire plugins front to back. Each time a hook is called, the order above determines who runs first.
Use PluginPipeline to visualize a typical configuration:
pre
enforce: "pre"
normal
no enforce
post
enforce: "post"
Walking through transform step by step
Suppose the browser requests src/App.tsx. After Vite receives the file, it calls each plugin's transform in sequence:
Request: src/App.tsx
│
├── [pre] vite-plugin-html.transform → doesn't handle .tsx, return null → skip
├── [pre] my-virtual-plugin.transform → doesn't handle .tsx, return null → skip
├── [built-in] vite:define.transform → replaces import.meta.env variables
├── [normal] @vitejs/plugin-react.transform → compiles JSX → return { code, map }
│ ↑ next plugin receives this code
├── [built-in] vite:esbuild.transform → TypeScript type stripping
└── [post] ... → usually no post transformEach transform receives the output of the previous one (code + sourcemap), passing through the pipeline like a chain.
resolveId's first-wins execution
Unlike transform's sequential execution, resolveId uses the first-wins mode:
import "virtual:my-module"
│
├── [pre] my-virtual-plugin.resolveId → return "\0virtual:my-module" ✓
│ ← returned! plugins below are not called
├── [built-in] vite:alias → not called
└── ...This is a sensible design: once a path is resolved, there's no need to keep looking. For this reason, virtual module plugins typically set enforce: "pre", ensuring they match before Vite's built-in resolver.
Multiple plugins processing the same file
In a real project, a single .vue file might pass through three or four plugins:
App.vue
↓ @vitejs/plugin-vue.transform → compiles template/script/style, outputs JS
↓ vite:css-inline.transform → processes CSS extracted from <style>
↓ vite:define.transform → replaces environment variables
↓ vite:esbuild.transform → handles remaining TS typesEach plugin only handles the part it cares about, returning null for everything else (meaning "I'm not handling this; pass it to the next one").
The difference between null and return { code }:
return null/return undefined→ no modification; the next plugin receives the code unchangedreturn { code }→ this plugin modified the code; the next plugin receives the modified versionreturn { code, map }→ also provides a source map, ensuring accurate debugging
Accessing the Rolldown context via this
Inside a hook function, this refers to Rolldown's plugin context, which provides many useful methods:
import type { Plugin } from "vite"
export function myPlugin(): Plugin {
return {
name: "vite-plugin-my",
async transform(code, id) {
// Load another module (triggers the full resolve + load pipeline)
const { code: utilsCode } = await this.load({ id: "/src/utils.ts" })
// Emit a warning (doesn't interrupt the build)
this.warn(`Encountered a compatibility issue while processing ${id}`)
// Throw an error (interrupts the build)
// this.error("Cannot process this file")
// Watch this file for changes
this.addWatchFile("/src/config.json")
return { code }
},
}
}Debugging plugin execution order
Not sure whether your plugin is being executed? Add console.log at key hooks:
import type { Plugin } from "vite"
export function debugPlugin(): Plugin {
return {
name: "vite-plugin-debug",
enforce: "pre", // run as early as possible
transform(code, id) {
if (id.endsWith(".tsx")) {
console.log(`[debug] transform: ${id.split("/").slice(-2).join("/")}`)
}
return null // don't modify the code
},
}
}You can also start Vite in debug mode to get more detailed plugin execution output:
DEBUG=vite:* pnpm devSelf-check
- Does a plugin with
enforce: "pre"always run before all Vite built-in plugins? Give a counterexample. - If two plugins both have
enforce: "pre"and both have atransformhook, which one runs first? - Why do virtual module plugins (where
resolveIdreturns an ID starting with\0) typically setenforce: "pre"? - What is the difference between returning
nulland returningreturn { code: originalCode }intransform?
// Write a plugin that, after all .ts files have been transformed, counts:
// - How many .ts files were processed in total
// - How many of them contain a "TODO" comment
// Print the statistics in buildEnd
import type { Plugin } from "vite"
export function todoCounter(): Plugin {
// TODO
}