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.

enforce lanes and apply gating Three enforce lanes — pre, normal, post — run in order, with Vite core plugins in the normal lane, and an apply gate deciding serve versus build. Execution order within one hook enforce: 'pre' sees raw source normal (default) Vite core plugins here esbuild, import-analysis enforce: 'post' sees final code apply gate (does the plugin load at all?) apply: 'serve' dev server only apply: 'build' vite build only enforce orders the plugins that run; apply decides which ones exist in this command.
Figure: `enforce` orders plugins within a hook (pre → normal → post); `apply` gates whether a plugin runs in `serve` or `build` at all.

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.

Why the React template reproduces ordering bugs The React template installs plugin-react in the unmarked lane; a user transform placed in the same lane collides with the JSX compilation, so it sees compiled output instead of raw source. @vitejs/plugin-reactunmarked lane · compiles JSX your transform (unmarked)collides — sees _jsx(...) move to enforce: 'pre'now sees raw JSX The collision the repro sets up on purpose
Figure: same lane as plugin-react means you see its output; enforce: 'pre' is the fix.

How enforce and apply Decide Order

Two facts explain nearly every ordering bug:

  • enforce sorts 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 with enforce: 'pre' sees raw source before JSX/TS is stripped; a plugin with enforce: 'post' sees the output after.
  • apply decides whether a plugin is loaded at all for the current command. apply: 'serve' plugins never run during vite build; apply: 'build' plugins never run during vite dev. A predicate apply: (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.”

Within-lane ordering rules Within one lane plugins run in array order, nested arrays are flattened in place, and falsy entries are dropped — so a conditional plugin that evaluates to false silently disappears from the pipeline. array orderties broken by positionwithin the same lane nested arrays flatten[a, [b, c]] → a, b, cin place falsy entries droppedfalse / null / undefined→ silent disappearance The three within-lane rules (lanes still win over all of them)
Figure: array position only breaks ties inside a lane — a falsy conditional just vanishes with no error.

Diagnosis Workflow

Work top-down; stop at the first step that explains the symptom.

Five-step hook-order diagnosis Confirm the plugin runs at all, check whether it is in the right enforce lane, print the resolved plugin order with the debug flag, inspect per-module transforms in the browser, then diff your position against Vite core plugins. 1 · does it run?apply gate 2 · which lane?raw vs final 3 · print order--debug plugin 4 · /__inspect/per module 5 · diff corereposition
Figure: existence first, then lane, then the two order-printing tools, then the one thing you cannot reorder — core plugins.
  1. 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 by apply: 'serve' or a predicate that excludes build, and no amount of enforce will make an absent plugin run. Add a one-line console.log in configResolved rather than in transform: configResolved runs exactly once per command and unconditionally for every plugin that survives the apply filter, so its silence is a clean signal that the plugin was dropped. If the log fires but transform never does, the plugin exists but its transform never matched the module — check the id filter, not the ordering.
  2. Check the lane. Once you know the plugin runs, decide whether it is in the right lane by looking at what its transform receives, not where it sits in the array. A transform that receives compiled output when you wanted raw source is one lane too late — move it to enforce: '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 needs enforce: 'post'. If neither raw nor final is right and you need the output of one specific plugin, enforce is too coarse and you should be looking at the render hooks instead.
  3. 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.

What each probe sees for one module The pre probe logs raw JSX with angle brackets, the normal probe runs after plugin-react in array order, and the post probe logs the same module after JSX has been rewritten to _jsx calls. pre probesees raw source<App /> JSX intact normal probeafter react()array-order position post probesees final code_jsx(...) calls Same module, three vantage points
Figure: the pre/post probes bracket plugin-react, proving which lane sees JSX and which sees compiled output.
// 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

Four checks that confirm the order The probes log in pre then normal then post order, the pre probe still shows JSX while the post probe does not, apply-gated dev-only plugins are absent from the build, and the inspect panel matches the console order. probes log pre → normal → post for every app module pre head shows JSX; post head shows _jsx(...) dev-only Inspect absent from the build plugin list /__inspect/ panel matches the console order
Figure: four independent confirmations — two on lane order, one on apply, one cross-checking the tools.
  • The [order-probe] lines appear in the order prenormalpost for every app module.
  • The pre probe’s logged head still contains JSX angle brackets or tsx syntax; the post probe’s does not.
  • Run vite build and confirm the apply-gated plugins behave: Inspect() (dev-only by default) does not appear in the configResolved plugin list, while the probes do.
  • The /__inspect/ panel lists your probe transforms in the same relative position as the console output.

Gotchas & Edge Cases

Four hook-order edge cases apply serve plugins are invisible in CI; enforce post does not beat Rollup render hooks; array order only breaks ties within a lane; and vite-plugin-inspect shifts timing slightly so trust the console probes. apply: 'serve' invisible in CI works in dev, missing from dist — remove the gate or use a predicate. post ≠ render hooks transform always precedes renderChunk — touch final output in generateBundle. array order only breaks ties placing before react() does nothing if both are unmarked — lanes win. Inspect shifts timing it registers its own transforms — trust the console probes if they disagree.
Figure: two apply/hook-boundary traps on top, two ordering-intuition traps below.
  • apply: 'serve' plugins are invisible in CI. Symptom: a transform that works locally under vite dev produces nothing in dist/, and the failure only shows up on the CI build or in production. Root cause: the plugin carries apply: 'serve', or a predicate resolving to command !== '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 returns true for the commands you actually want. Confirm by grepping the step-3 debug output from a vite build: an absent plugin never appears in the per-module line order.
  • enforce: 'post' does not beat Rollup’s render hooks. Symptom: a post transform still sees per-module code, not the concatenated, tree-shaken, minified chunk. Root cause: enforce only orders the module-graph hooks (resolveId/load/transform); transform as a phase always precedes Rollup’s output phase, so even the latest post transform runs before the first renderChunk. Fix: move work that needs the final bundled output into renderChunk or generateBundle, which operate on emitted chunks after bundling. Confirm by logging in both hooks — the renderChunk line always follows every transform line for the same build.
  • Array order only breaks ties within a lane. Symptom: you moved your plugin above react() in the plugins array 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: add enforce: '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-inspect shifts 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.