Debugging Vite Plugin Hook Order with enforce and apply
Your transform hook fires, but it sees the wrong input — already-compiled JSX instead of raw source, or raw TypeScript instead of the minified output you wanted to instrument — and the cause is almost always plugin ordering. This guide explains how enforce and apply decide that order and how to print the real sequence; for the underlying hook model it builds on, read Advanced Vite Plugin Configuration first.
The reason this class of bug is so common is that Vite hides the ordering from you by default. You hand defineConfig a flat plugins array and assume the array is the pipeline, but Vite reorders that array before a single module is processed: it splits your plugins into three lanes, interleaves its own built-in plugins into the middle lane, and only then runs the transform hooks in the resulting sequence. The array you wrote is a suggestion about ties, not the execution order. Nothing in the terminal tells you the reordering happened, so a plugin that “obviously runs before React” in the source file quietly runs after it, and your transform receives _jsx(...) calls where you expected angle-bracket JSX.
This matters because transform is not idempotent across the pipeline — each plugin’s output is the next plugin’s input. A source-map-injecting plugin that needs original line numbers must run before TypeScript is stripped; a plugin that rewrites import.meta.env references must run after Vite’s define replacement or it will rewrite nothing. Getting the lane wrong does not throw. It produces a build that succeeds, ships, and misbehaves at runtime — a broken source map, a missing environment variable, an un-instrumented bundle — which is the most expensive kind of failure to trace back to its cause. The fix is almost never new code; it is one enforce field and a way to prove where in the pipeline your hook actually sits.
Prerequisites & Reproducible Setup
# Vite 5.x / 6.x, Node 20+
npm create vite@latest hook-order -- --template react-ts
cd hook-order && npm install
npm install -D vite-plugin-inspect
The React template is deliberate: @vitejs/plugin-react installs an enforce-unmarked transform that compiles JSX, which is the most common thing user plugins collide with. Almost every “my transform sees garbage” report reduces to a user plugin landing in the same lane as plugin-react and running just after it. Reproducing the collision on purpose means you can watch the fix work rather than reasoning about it in the abstract, and it keeps the failure mode in front of you while you wire up the probes below. vite-plugin-inspect is the second half of the setup: it exposes a /__inspect/ panel that lists, per module, every transform that touched it in order — the browser-side counterpart to the console probes.
enforce: 'pre' is the fix.How enforce and apply Decide Order
Two facts explain nearly every ordering bug:
enforcesorts plugins into three lanes that run in series:'pre', then unmarked, then'post'. Vite’s own plugins — alias resolution,@vitejs/plugin-react’s JSX transform, the import-analysis pass — sit in the unmarked lane. So a plugin withenforce: 'pre'sees raw source before JSX/TS is stripped; a plugin withenforce: 'post'sees the output after.applydecides whether a plugin is loaded at all for the current command.apply: 'serve'plugins never run duringvite build;apply: 'build'plugins never run duringvite dev. A predicateapply: (config, { command }) => command === 'build'gives finer control (e.g. only for--mode staging).
Within a single lane, plugins run in the order they appear in the plugins array. Nested arrays are flattened, and false/null/undefined entries are dropped — which is how conditional plugins disappear silently.
How the sort works under the hood
Vite does the reordering once, in resolvePlugins, before the dev server or the Rollup build starts. It partitions the flattened plugin array by reading each plugin’s enforce field and produces the concatenation [...prePlugins, ...vitePrePlugins, ...normalPlugins, ...viteCorePlugins, ...postPlugins, ...vitePostPlugins]. The partition is stable: within each bucket the relative order of your plugins is preserved from the array, which is exactly why array position only ever breaks ties inside a lane and never crosses one. The result is frozen into config.plugins, and every hook — resolveId, load, transform — walks that same frozen list. There is no per-hook reordering; the lane you land in decides your position for the entire pipeline.
The consequence worth internalising: enforce is coarse and apply is binary. You get three ordering slots, not a priority number, so you cannot say “run just before the CSS plugin but after alias resolution.” If two of your own plugins need a strict order relative to each other, they must share a lane and you sequence them by array position; if one needs to straddle a Vite core plugin, the only lever is which lane you pick relative to that core plugin’s known lane. This is deliberate — Vite treats fine-grained ordering as a smell and pushes you toward the render hooks (renderChunk, generateBundle) when you genuinely need “after everything.”
Diagnosis Workflow
Work top-down; stop at the first step that explains the symptom.
- Confirm whether the plugin even runs. Before debugging order, rule out non-existence. If a build-time transform never fires in
vite build, the plugin is probably gated out byapply: 'serve'or a predicate that excludesbuild, and no amount ofenforcewill make an absent plugin run. Add a one-lineconsole.loginconfigResolvedrather than intransform:configResolvedruns exactly once per command and unconditionally for every plugin that survives theapplyfilter, so its silence is a clean signal that the plugin was dropped. If the log fires buttransformnever does, the plugin exists but itstransformnever matched the module — check theidfilter, not the ordering. - Check the lane. Once you know the plugin runs, decide whether it is in the right lane by looking at what its
transformreceives, not where it sits in the array. Atransformthat receives compiled output when you wanted raw source is one lane too late — move it toenforce: 'pre'so it runs ahead of Vite’s core transforms. The mirror-image symptom, wanting the final emitted code but receiving untouched source, means the plugin is one lane too early and needsenforce: 'post'. If neither raw nor final is right and you need the output of one specific plugin,enforceis too coarse and you should be looking at the render hooks instead. - Print the resolved plugin list and order. Stop guessing and make Vite tell you the sequence it actually built. Run with the debug flag:
# Vite 5.x / 6.x
vite --debug plugin-transform 2>&1 | grep "your-plugin-name"
The plugin-transform namespace logs every module as it passes through each plugin’s transform, so the order of lines for a single module id is the real execution order — including Vite’s built-ins, which do not appear in your config file. Grep for your plugin name to see exactly which core plugins bracket it. Use --debug plugin-resolve or the broader --debug if you need the resolveId/load phases too; the namespaces keep the output survivable.
4. Inspect per-module transforms in the browser. Start the dev server, open http://localhost:5173/__inspect/, pick a module, and read the ordered list of transforms applied to it. This is the ground truth for dev-time order, and unlike the debug log it shows you the before/after code diff at each step, so you can see the exact stage where your transform’s input changed shape. It only covers the dev server — the build-time order can differ because apply-gated plugins come and go — so cross-check with step 3 for vite build.
5. Diff against built-ins. If a Vite core plugin — CSS handling, asset URL rewriting, import.meta.env replacement — runs at the wrong time relative to yours, accept that you cannot reorder core plugins among themselves; they occupy a fixed slot in the middle lane. Your only two levers are which lane you choose (pre to precede them, post to follow them) and array position against your own plugins. When even that is not enough — you need to sit between two core plugins — the ordering model has run out of room and the work belongs in a Rollup output hook.
The Logging Plugin
Drop this in to print, per module, exactly when each lettered probe runs. Register three copies — pre, default, and post — so the console shows the lane order directly.
// plugins/order-probe.ts — Vite 5.x / 6.x, Rollup 4.x
import type { Plugin } from 'vite';
export function orderProbe(label: string, enforce?: 'pre' | 'post'): Plugin {
return {
name: `order-probe:${label}`,
enforce,
configResolved(config) {
// Runs once; prints the full ordered plugin list for this command
if (label === 'pre') {
const names = config.plugins.map((p) => p.name).join(' -> ');
console.log(`[order-probe] command=${config.command} plugins: ${names}`);
}
},
transform(code, id) {
// Only log app source, not node_modules, to keep output readable
if (id.includes('/node_modules/')) return null;
const head = code.slice(0, 24).replace(/\n/g, ' ');
console.log(`[order-probe:${label}] transform ${id.split('/').pop()} :: "${head}"`);
return null; // never mutate — this is a probe
},
};
}
// vite.config.ts — Vite 5.x / 6.x
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import Inspect from 'vite-plugin-inspect';
import { orderProbe } from './plugins/order-probe';
export default defineConfig({
plugins: [
orderProbe('pre', 'pre'), // fires before plugin-react's JSX transform
react(), // unmarked lane
orderProbe('normal'), // fires after react, in array order
orderProbe('post', 'post'), // fires last, sees compiled output
Inspect(), // adds the /__inspect/ panel (dev only)
],
});
Run vite dev and open a .tsx file in the browser. The console prints the three probes in lane order. The pre probe shows raw import ... from 'react' JSX; the post probe shows the same module after @vitejs/plugin-react has rewritten JSX to _jsx(...) calls — concrete proof of which lane sees what.
The probe returns null from transform, which is the contract for “I did not change this module”: Vite keeps the previous plugin’s output and moves on. Returning the code string unchanged would work too, but null is cheaper because it skips the source-map merge step Vite performs when a plugin claims a modification. Keeping probes side-effect-free matters — a probe that mutates code changes the very order you are trying to observe, because a downstream plugin’s input now depends on the probe.
A worked example: placing a raw-source transform correctly
Suppose you have a real plugin — not a probe — that reads a leading // @track comment in each component file and injects an analytics import. It must see the raw file: once plugin-react has compiled the module, the comment may be stripped and the top-of-file shape is gone. The lane, not the logic, is what makes or breaks it.
// plugins/inject-tracking.ts — Vite 5.x / 6.x, Rollup 4.x
import type { Plugin } from 'vite';
export function injectTracking(): Plugin {
return {
name: 'inject-tracking',
enforce: 'pre', // must run before plugin-react strips the comment
transform(code, id) {
if (!/\.[jt]sx?$/.test(id)) return null; // only source modules
if (id.includes('/node_modules/')) return null;
if (!code.startsWith('// @track')) return null; // opt-in per file
const banner = `import { track } from '/src/track';\ntrack(${JSON.stringify(id)});\n`;
return { code: banner + code, map: null }; // map:null → Vite regenerates
},
};
}
Drop injectTracking() anywhere in the plugins array and, because it carries enforce: 'pre', it runs ahead of plugin-react regardless of position. Delete the enforce line and the same plugin lands in the unmarked lane after React, the startsWith('// @track') check fails against compiled output, and the injection silently never happens — the exact bug this guide exists to catch. Run the --debug plugin-transform command from the diagnosis workflow and confirm inject-tracking appears before vite:react-babel (or vite:react-swc) in the per-module line order.
Verification
apply, one cross-checking the tools.- The
[order-probe]lines appear in the orderpre→normal→postfor every app module. - The
preprobe’s logged head still contains JSX angle brackets ortsxsyntax; thepostprobe’s does not. - Run
vite buildand confirm theapply-gated plugins behave:Inspect()(dev-only by default) does not appear in theconfigResolvedplugin list, while the probes do. - The
/__inspect/panel lists your probe transforms in the same relative position as the console output.
Gotchas & Edge Cases
apply/hook-boundary traps on top, two ordering-intuition traps below.apply: 'serve'plugins are invisible in CI. Symptom: a transform that works locally undervite devproduces nothing indist/, and the failure only shows up on the CI build or in production. Root cause: the plugin carriesapply: 'serve', or a predicate resolving tocommand !== 'build', so it is filtered out of the build entirely — the plugin is not late, it is absent. Fix: remove the gate if the plugin should run in both commands, or replace the string with a predicate that returnstruefor the commands you actually want. Confirm by grepping the step-3 debug output from avite build: an absent plugin never appears in the per-module line order.enforce: 'post'does not beat Rollup’s render hooks. Symptom: aposttransform still sees per-module code, not the concatenated, tree-shaken, minified chunk. Root cause:enforceonly orders the module-graph hooks (resolveId/load/transform);transformas a phase always precedes Rollup’s output phase, so even the latestposttransform runs before the firstrenderChunk. Fix: move work that needs the final bundled output intorenderChunkorgenerateBundle, which operate on emitted chunks after bundling. Confirm by logging in both hooks — therenderChunkline always follows everytransformline for the same build.- Array order only breaks ties within a lane. Symptom: you moved your plugin above
react()in thepluginsarray and the order did not change. Root cause: both plugins are unmarked, so they share the middle lane, and array position only sequences plugins that are already in the same lane — it cannot promote one across a lane boundary. Fix: addenforce: 'pre'(or'post'); lanes always win over array position. Confirm with/__inspect/— your transform should now bracket React’s rather than trail it. vite-plugin-inspectshifts timing slightly. Symptom: the/__inspect/panel and your console probes disagree by one position. Root cause: Inspect registers its own transforms to capture the before/after snapshots, so it inserts itself into the very pipeline it is measuring — a mild observer effect. Fix: treat the console probes as authoritative when the two disagree by a single step, and remove Inspect from the config before benchmarking cold-start or HMR latency so its bookkeeping does not skew the numbers.
Related
- Advanced Vite Plugin Configuration — the full hook pipeline,
enforcelanes, virtual modules, and SSR branching. - Writing a Custom Vite Plugin for Asset Transformation — where
enforce: 'pre'matters so your loader sees raw files. - Optimizing Vite Dev Server and HMR — how transform order affects cold-start and HMR latency.