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.

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.

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.

Diagnosis Workflow

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

  1. Confirm whether the plugin even runs. If a build-time transform never fires in vite build, check for apply: 'serve' (or a predicate that excludes build). Add a one-line console.log in configResolved — it runs once per command, so silence means the plugin is gated out.
  2. Check the lane. A transform that receives compiled output when you wanted raw source is in the wrong lane. Move it to enforce: 'pre'. The opposite — wanting final code but receiving source — means enforce: 'post'.
  3. Print the resolved plugin list and order. Run with the debug flag:
# Vite 5.x / 6.x
vite --debug plugin-transform 2>&1 | grep "your-plugin-name"
  1. 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.
  2. Diff against built-ins. If a Vite core plugin (e.g. CSS handling, asset URL rewriting) runs at the wrong time relative to yours, your only levers are enforce and array position — you cannot reorder core plugins among themselves.

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.

Verification

  • 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

  • apply: 'serve' plugins are invisible in CI. A transform that works in vite dev but is missing from dist/ usually has apply: 'serve' (or command !== 'build'). Remove the gate or switch to a predicate.
  • enforce: 'post' does not beat Rollup’s render hooks. transform always precedes renderChunk. To touch the final bundled output, use renderChunk/generateBundle, not a post transform.
  • Array order only breaks ties within a lane. Putting your plugin before react() in the array does nothing if both are unmarked and you needed enforce: 'pre' — lanes win over array position.
  • vite-plugin-inspect shifts timing slightly. It registers its own transforms; trust the console probes over /__inspect/ if the two disagree by one position, and remove Inspect before benchmarking.