Advanced Vite Plugin Configuration
Vite’s plugin architecture extends the Rollup interface with dev-server-specific hooks, environment-aware execution contexts, and a tightly coupled esbuild pre-bundler. For build engineers and framework maintainers, mastering this interface is the precondition for extending the Vite Configuration & Ecosystem without introducing pipeline bottlenecks or hydration mismatches — read that overview first if you have not pinned your Vite and Rollup versions. The guides here isolate plugin lifecycle management, hook orchestration, SSR branching, and asset pipeline construction, with exact configuration patterns, CLI diagnostics, and measurable performance baselines.
The reason plugin configuration is hard is that Vite is not one build system but two systems wearing the same config file. During development there is no bundle: Vite serves ES modules over native import, transforming each file lazily the first time the browser requests it, and it pre-bundles third-party dependencies with esbuild so CommonJS packages and deep import chains do not flood the browser with hundreds of requests. During production there is a real bundle: Rollup walks the module graph, tree-shakes, splits chunks, and writes hashed assets to disk. A plugin author writes one object and it is consumed by both engines, which run overlapping but not identical hook sets. Almost every “works in dev, breaks in build” report — and its mirror image — traces back to a hook that only one of the two engines calls, or to a transform whose output the other engine re-processes.
Where a plugin sits in this pipeline determines what it can see. A transform that runs before Vite’s import-analysis pass sees bare specifiers and raw JSX; the same transform run after that pass sees rewritten import paths and injected HMR boilerplate. Getting the position wrong does not usually throw — it silently produces the wrong output, which is worse, because it surfaces as a hydration mismatch, a duplicated dependency, or a cache that never invalidates rather than as a stack trace. The cost of a misplaced hook is paid continuously: every cold start re-parses modules a second engine already parsed, every HMR update ships a larger payload, and every CI build spends wall-clock time on work that a correctly-lane’d plugin would have skipped. Treating the pipeline order as load-bearing configuration, not an implementation detail, is what separates a plugin that scales to a thousand-module app from one that only works on the example.
A Vite plugin is a plain object (usually returned from a factory function) whose keys are hooks. At dev time Vite runs the universal hooks plus its own dev-only hooks (configureServer, handleHotUpdate, transformIndexHtml); at build time it hands the same plugin objects to Rollup, which runs the build hooks. The two properties that decide when a hook fires relative to everyone else — enforce and apply — are the source of most plugin bugs, so the diagram below fixes the canonical order in your head before any code.
Prerequisites
These guides assume Vite 5.x or 6.x on Node 18.19+ / 20.x, with Rollup 4.x as the build backend. Install the toolchain and the two diagnostic plugins referenced throughout:
# Vite 5.x / 6.x, Node 20+
npm create vite@latest plugin-lab -- --template vanilla-ts
cd plugin-lab
npm install
npm install -D vite-plugin-inspect
Type definitions ship with vite; import Plugin and PluginOption from it directly. Declare vite as a peerDependency (not a hard dependency) in any plugin you publish so consumers control the installed version — a hard dependency ships a second copy of Vite into the consumer’s tree and breaks singleton assumptions in the dev server.
The singleton point deserves elaboration because the failure mode is opaque. Vite’s dev server holds module-level state: the module graph, the transform cache, the WebSocket server for HMR. If a plugin depends on its own copy of vite, the ViteDevServer instance the plugin’s configureServer hook receives and the one the CLI booted are different objects backed by different module registries. Calls like server.moduleGraph.invalidateModule then mutate a graph nobody is serving from, so HMR silently no-ops and you chase a “changes not reflecting” bug that has nothing to do with your file watcher. Pinning vite to a peerDependency range such as ^5.0.0 || ^6.0.0 and adding it to devDependencies for local type-checking is the only configuration that guarantees the consumer’s single instance is the one your hooks touch. Run npm ls vite in the consumer project after install; a healthy tree shows exactly one resolved version, and any deduplication warning is a defect to fix before publishing.
The vite-plugin-inspect install is not optional tooling for this material. Nearly every diagnosis below assumes you can open /__inspect/ and read the exact sequence of transforms applied to a given module, with the input and output of each hook. Without it you are reasoning about hook order from the outside; with it you are reading the ground truth the dev server recorded.
Core Mechanics: enforce, apply, and Hook Order
Vite plugins operate across two execution phases: the development server (serve) and the production bundler (build). The apply and enforce properties dictate when and where a plugin executes relative to Vite’s core transformation chain. Misaligned precedence causes redundant transpilation, inflating cold-start times by 15–30% in large dependency graphs.
enforce sorts plugins into three lanes — 'pre', undefined (normal), and 'post' — and Vite’s own plugins (alias resolution, the import-analysis transform, the esbuild loader) sit in the normal lane. A transform that must see raw source before Vite touches it belongs in 'pre'; a minifier or instrumentation pass that must see the final code belongs in 'post'. The precise resolution rules, plus how to log the real order, live in Debugging Vite Plugin Hook Order with enforce and apply. apply is the orthogonal control: it gates the whole plugin to one phase, while enforce orders it within a phase.
How enforce ordering actually resolves
Internally Vite does not sort your plugins array once and run it. It builds three concatenated lists — the 'pre' plugins in array order, then the normal plugins, then Vite’s own core plugins, then the 'post' plugins — and every hook is invoked by walking that flattened list. There are two consequences that trip people up. First, core Vite plugins live at the end of the normal lane, not interleaved with your normal-lane plugins, so an unmarked plugin of yours always runs before alias resolution and import analysis; if you needed to run after them you needed 'post', and the fact that your unmarked plugin “seemed to work” was luck about what the core plugins happened to emit. Second, ordering is deterministic within a lane but only by array position, so a refactor that reorders the plugins array — or a framework preset that spreads an array of sub-plugins into yours — can move a hook relative to its neighbor without any type error. This is why the recommendation is to be explicit: give a plugin enforce: 'pre' when it genuinely must precede core, rather than relying on it appearing early in the array.
Hooks that return a value participate in a first-non-null-wins protocol for the resolution hooks and a chained protocol for the transform hooks. resolveId and load stop at the first plugin that returns a non-null result — order decides the winner, and a 'pre' plugin that resolves an id denies every later plugin the chance to see it. transform is different: it is a pipeline, so every plugin whose transform returns non-null feeds its output as the input to the next plugin’s transform. That distinction is the mechanism behind most “my transform sees already-rewritten code” reports: the code is not raw because an earlier plugin in the chain already rewrote it, and the fix is to move up a lane, not to change the regex.
apply is a phase switch; enforce is the ordering within whichever phase the plugin runs.Implementation Workflow
- Initialize a factory function returning a type-safe
Plugin(orPluginOption[]) value. - Define
applyto restrict execution to'serve','build', or a conditional predicate. - Set
enforce('pre','post', or omit it) to position hooks relative to Vite’s native transforms. - Register the plugin in
vite.config.tswith explicit array ordering for deterministic resolution within a lane.
// vite-plugin-archetype.ts — Vite 5.x / 6.x, Rollup 4.x
import type { Plugin } from 'vite';
interface ArchetypeOptions {
enforce?: 'pre' | 'post';
}
export function archetypePlugin(options: ArchetypeOptions = {}): Plugin {
return {
name: 'vite-plugin-archetype',
// Restrict to production builds; a predicate also receives { command, mode }
apply: (config, { command }) => command === 'build',
enforce: options.enforce,
configResolved(config) {
console.log(`[archetype] resolved for ${config.command} | Node ${process.version}`);
},
transform(code, id) {
if (!id.endsWith('.ts')) return null;
// Returning null skips this module; otherwise return { code, map }
return { code, map: null };
},
};
}
Configuration Patterns
// vite.config.ts — Vite 5.x / 6.x
import { defineConfig } from 'vite';
import { archetypePlugin } from './vite-plugin-archetype';
export default defineConfig({
plugins: [
// Runs before Vite's core JS/TS transforms because of enforce: 'pre'
archetypePlugin({ enforce: 'pre' }),
// Runs after core transforms — sees the post-esbuild output
archetypePlugin({ enforce: 'post' }),
],
});
Debugging & Diagnostics
- Run
vite --debug pluginto print the hook invocation sequence and the applied plugin list. - Install vite-plugin-inspect and open
/__inspect/to visualize the transform chain per module in the browser. - Verify precedence by inspecting
configResolvedoutput; two plugins with the sameenforcevalue resolve in array order, which is easy to break during refactors.
Read those three tools as a sequence, not alternatives. vite --debug plugin answers “which plugins are loaded and in what flattened order”, which is the first thing to confirm because a plugin conditionally excluded by apply simply will not appear — its absence, not a misfire, is often the whole bug. vite-plugin-inspect answers the next question, “what did each transform do to this specific module”, by recording the input and output of every hook per id; when a transform’s output looks pre-rewritten, the inspector shows you which earlier plugin rewrote it, turning a guessing game into a diff. The configResolved log is the cheapest confirmation that array order is what you think it is: print config.plugins.map(p => p.name) once and diff it against your mental model whenever you touch the config or bump a framework preset. Confirm a fix by re-running the same module through the inspector and checking that the offending intermediate step is gone, not by checking that the final output happens to look right.
A frequent edge case: apply accepts a predicate, and a predicate that reads mode rather than command will run in vite build --mode development, which is a real configuration teams use for staging bundles. If your plugin injects debug instrumentation, gate it on command === 'serve' or explicitly on mode, not on a loose truthiness check, or staging builds will ship the instrumentation. Confirm the gate by running vite build --mode staging and grepping dist/ for the instrumentation marker.
Performance Impact: Correct enforce alignment eliminates duplicate AST parsing. In benchmarks with 500+ modules, moving heavy regex transforms to enforce: 'post' reduces dev-server CPU overhead by ~18% and cuts HMR payload serialization by 12–15ms per update.
The mechanism behind that number is worth stating so you can predict it rather than measure it every time. A transform that runs in the normal lane executes before esbuild’s TS-to-JS pass, so if it emits code that still contains TypeScript syntax, esbuild re-parses the entire module to strip types — the parse you paid for in your transform is thrown away and paid for again. Moving a transform that only needs to see post-JS output to 'post' lets it operate on the already-stripped source, so the module is parsed once. The HMR payload effect follows from the same cause: a normal-lane transform that expands code (adds imports, inlines constants) enlarges the string that the dev server diffs and serializes on every keystroke-triggered update, and a 'post' transform that runs after Vite has already computed the HMR boundary does not participate in that per-update cost at all.
Orchestrating Virtual Modules and esbuild Interop
Vite delegates dependency pre-bundling to esbuild before handing resolved modules to Rollup. Advanced configurations intercept this pipeline without invalidating the pre-bundle cache or triggering duplicate transformations. Proper alignment with Optimizing Vite Dev Server and HMR ensures custom hooks do not degrade cold-start performance or inflate HMR payloads. The \0 prefix is the load-bearing convention here: it marks an id as internal so no other plugin, and not esbuild, attempts to resolve it against the filesystem.
Pre-bundling runs once, on the first dev start, and Vite caches the result in node_modules/.vite/deps keyed by a hash of your lockfile, the relevant vite.config fields, and the discovered dependency set. A virtual module that a real module imports participates in dependency scanning, and this is where interception gets delicate: if your resolveId returns a virtual id during esbuild’s scan phase but your load produces different content on the next run, the cache hash does not change and the browser is served stale synthetic code until you delete the cache directory. The rule that keeps this correct is that virtual-module content should be a pure function of inputs Vite already hashes; when it genuinely must vary — a build timestamp, a git SHA — you either accept that the value is fixed per dev session or you register the dependency explicitly so a change busts the cache. Reaching for Date.now() inside load, as the archetype below does for illustration, is fine for a build-time constant but is exactly the kind of impurity that produces “the value froze” confusion in development.
Why the null byte specifically? Rollup and Vite treat any id containing \0 as synthetic and skip filesystem resolution, source-map file reads, and the “does this path exist” checks that would otherwise fire. It is also a signal to other well-behaved plugins to leave the id alone, because the convention is documented in Rollup’s plugin contract. Omit it and two things break: esbuild’s dependency scanner tries to stat a file named virtual:build-info and logs a resolution failure, and any plugin doing path-based filtering may match your virtual id as if it were a real file, double-transforming it. The prefix is one character but it is the difference between an id that flows cleanly to your load hook and one that half the pipeline fights over.
load fills it.Implementation Workflow
- Identify target import specifiers in the
resolveIdhook. - Return
\0-prefixed virtual module ids so other plugins (and esbuild) leave them alone. - Implement the
loadhook to generate synthetic module content on demand. - Apply
transformonly to the virtual ids, leaving real modules to Vite’s core.
// vite-plugin-virtual-interceptor.ts — Vite 5.x / 6.x, Rollup 4.x
import type { Plugin } from 'vite';
const VIRTUAL_ID = 'virtual:build-info';
const RESOLVED_ID = '\0' + VIRTUAL_ID; // \0 marks an internal id Rollup won't touch
export function virtualInterceptor(): Plugin {
return {
name: 'virtual-interceptor',
resolveId(id) {
if (id === VIRTUAL_ID) return RESOLVED_ID;
return null;
},
load(id) {
if (id === RESOLVED_ID) {
return `export const BUILD_ID = ${JSON.stringify(String(Date.now()))};`;
}
return null;
},
};
}
Configuration Patterns
// vite.config.ts — Vite 5.x / 6.x
import { defineConfig } from 'vite';
import { virtualInterceptor } from './vite-plugin-virtual-interceptor';
export default defineConfig({
optimizeDeps: {
// Keep esbuild from scanning a package you resolve yourself
exclude: ['my-custom-pkg'],
include: ['lodash-es'],
},
plugins: [virtualInterceptor()],
});
Debugging & Diagnostics
- Watch
node_modules/.vite/depsfor unexpected cache invalidation; deleted.js/.jsonfiles signal hook misalignment. - Run
vite --debug optimizeto confirm esbuild exclusion boundaries and dependency resolution paths. - Inspect the browser Network tab for duplicate requests to the same virtual id, which usually means a missing
\0prefix inresolveId.
Each of those symptoms has a distinct root cause worth spelling out. Unexpected invalidation in .vite/deps means the pre-bundle hash changed, which is almost always because a dependency your virtual module pulls in was newly discovered or a optimizeDeps.include/exclude entry moved; the fix is to make the include set explicit rather than relying on scan-time discovery, and you confirm it by starting the server twice and checking that the second start logs a cache hit rather than “optimized dependencies changed, reloading”. A vite --debug optimize run that shows esbuild reaching into a package you meant to own yourself means your exclude entry does not match the resolved specifier — bare name versus subpath — and the confirmation is that the package disappears from the “Pre-bundling dependencies” list. Duplicate network requests to a virtual id are the null-byte symptom: without the prefix the id is treated as a URL, requested over HTTP, missed in the module graph, and requested again; adding \0 in resolveId collapses it to a single graph node, which you verify by watching the request count drop to one.
A second worked example makes the optimizeDeps boundary concrete. Suppose a plugin generates a virtual barrel that re-exports icons from a large CommonJS package. If esbuild pre-bundles that package, it flattens the CommonJS into one ESM chunk and your virtual barrel imports the whole thing; if you exclude it, the package stays as native modules and your barrel can import a single icon.
// vite.config.ts — Vite 5.x / 6.x
import { defineConfig } from 'vite';
export default defineConfig({
optimizeDeps: {
// Force the big icon set to stay unbundled so a virtual barrel can
// import one entry point instead of pulling the flattened CJS chunk.
exclude: ['@acme/icon-set'],
// Pre-bundle the ESM utility eagerly so its first request is warm.
include: ['@acme/utils'],
// esbuild options apply to the pre-bundle pass only, not the app code.
esbuildOptions: { target: 'es2020' },
},
});
Performance Impact: Excluding non-standard modules from optimizeDeps stops esbuild from parsing unnecessary CommonJS wrappers, trimming dev boot time by 40–60ms per excluded package and eliminating ~200KB of redundant HMR WebSocket payloads during rapid iteration.
The trade-off runs both ways, so exclusion is not a free optimization. A package you exclude is served as native ES modules, and if it has a deep internal import graph the browser now makes one request per internal file on first load, which can be slower than one pre-bundled chunk for a package with hundreds of modules. The heuristic is: exclude packages you resolve or rewrite yourself, or whose flattening defeats your tree-shaking; pre-bundle everything else. Measure the boundary with the Network panel’s request count on a cold, cache-cleared start rather than assuming.
Context-Aware Plugin Logic for SSR and SSG
Server-side rendering and static generation require plugins to conditionally alter resolution, externalization, and execution context. The options.ssr flag passed to transform/load and the resolve.conditions array give precise control over Node vs. browser entry points. Aligning plugin behavior with Vite SSR and SSG Integration prevents hydration mismatches and produces a correct server dependency graph. The same source module is transformed twice — once for each target — so the ssr flag is how one plugin emits two different outputs from one input.
The double-transform is the mechanism most people miss, and it is the direct cause of hydration mismatches. When a page is server-rendered and hydrated, the same component module passes through your transform twice: once with options.ssr === true to produce the code Node executes to generate HTML, and once with options.ssr === false to produce the code the browser executes to attach event handlers. If those two outputs diverge in anything that affects rendered markup — a different default value, a branch that includes a timestamp on the server but not the client, a polyfill that changes formatting — React or Vue sees server HTML that does not match what the client would have produced and throws a hydration warning, then discards the server markup and re-renders. The discipline is that the ssr branch may differ in how it obtains a value (Node built-in versus browser shim) but must not differ in what value ends up in the DOM. Confirm parity by rendering a component both ways and diffing the serialized output, not by eyeballing the two code paths.
There is a subtlety about when the flag is reliable. The ssr argument is passed to transform, load, and resolveId, but configResolved and config run once and cannot know which target is being built — Vite may run a client build and an SSR build as two separate passes over the same config. A plugin that decides behavior in configResolved based on config.build.ssr is reading the flag for the current pass, which is correct for build but meaningless in the dev server where both graphs coexist. Read the target per-hook from the ssr argument for anything that must be correct in development; reserve config.build.ssr for build-only decisions like output directory.
ssr flag forks one transform into a server output and a browser output.Implementation Workflow
- Read the
ssrflag from the third argument ofload,transform, andresolveId. - Conditionally apply
external/noExternalrules based on the execution target. - Set
resolve.conditions('node','browser','module','default') for the right entry points. - Branch
build.rollupOptions.outputfor dual-target builds when needed.
// vite-plugin-ssr-branching.ts — Vite 5.x / 6.x
import type { Plugin } from 'vite';
export function ssrBranchingPlugin(): Plugin {
return {
name: 'ssr-branching',
transform(code, id, options) {
if (!id.endsWith('runtime.ts')) return null;
if (options?.ssr) {
// Node-native runtime, server execution only
return { code: `import { promisify } from 'node:util';\n${code}`, map: null };
}
// Lightweight browser stub so the import never fails client-side
return { code: `const promisify = (fn) => fn; /* browser stub */\n${code}`, map: null };
},
};
}
Configuration Patterns
// vite.config.ts — Vite 5.x / 6.x
import { defineConfig } from 'vite';
import { ssrBranchingPlugin } from './vite-plugin-ssr-branching';
export default defineConfig({
resolve: { conditions: ['module', 'browser', 'default'] },
ssr: { noExternal: ['my-internal-ssr-lib'] },
build: { ssr: true, rollupOptions: { external: ['fs', 'path', 'crypto'] } },
plugins: [ssrBranchingPlugin()],
});
Debugging & Diagnostics
- Run
vite build --ssrand inspect the dependency tree viavite --debug resolve. - Compare hydration warnings in the browser console against the SSR HTML output.
- Check
ssr.noExternalfor incorrectly bundled Node built-ins; these surface asERR_REQUIRE_ESMorprocess is not definedat runtime.
Externalization is the SSR decision that most often goes wrong, so it is worth separating the two knobs. By default Vite externalizes every dependency in the SSR build — it leaves import 'foo' in the output and lets Node resolve foo from node_modules at runtime, which is fast and correct for packages that ship valid Node-resolvable entry points. ssr.noExternal forces a package to be bundled into the server output instead, which you need when the package ships only browser-oriented or ESM-only code that Node’s require chain cannot resolve, or when it must be transformed by your plugins. ssr.external is the explicit opposite for packages the auto-detection bundles but shouldn’t. The ERR_REQUIRE_ESM symptom means an ESM-only package was externalized into a CommonJS server bundle; the fix is to add it to noExternal so it is bundled and transformed rather than required raw, and you confirm the fix by running the server entry under Node and watching the error disappear. process is not defined in the browser means a Node-targeted branch leaked into the client bundle, which points back to an ssr flag you read incorrectly or a resolve.conditions order that put 'node' ahead of 'browser'.
The resolve.conditions order is not cosmetic. Node’s export-conditions algorithm takes the first matching condition in the array, so ['node', 'browser', 'default'] and ['browser', 'node', 'default'] resolve a dual-published package to different files. For the client build you want 'browser' to win; for the SSR build you want 'node'. Vite manages sensible defaults, but a plugin that overrides resolve.conditions globally, as the configuration below does, applies that order to both graphs — verify with vite --debug resolve that each target lands on the file you expect before shipping.
Asset Transformation and Post-Processing Pipelines
Beyond JS modules, plugins frequently handle images, fonts, and custom formats. Robust asset pipelines lean on assetsInclude, regex-based id filtering, and this.emitFile for deterministic chunk generation. For a full walkthrough, see Writing a Custom Vite Plugin for Asset Transformation. The pattern below focuses on cache-aware processing and source-map preservation. The key discipline is where each step runs: filtering and rewriting happen in transform, but emitting derived files happens in generateBundle, after all chunks are final, so there is no rebuild loop.
The reason emission cannot live in transform is the hash algorithm. Vite’s production output uses content-hashed filenames — logo.4f3a2b.png — so that a changed asset gets a new URL and an unchanged asset keeps its URL and stays in the browser cache. That hash is computed from the asset’s final bytes, which are not known until every transform in the graph has run. If you call this.emitFile from transform, you are emitting into a graph that is still mutating, so a later transform can change a dependency, shift a hash, and either invalidate the file you just emitted or, worse, trigger the graph to be re-walked and your emitFile to run twice. generateBundle runs once, after the module graph is frozen and every chunk’s content and hash are final, which is why a manifest emitted there is deterministic: rebuild the project with no changes and you get byte-identical output, which is the property CI cache keys and CDN immutability headers depend on.
assetsInclude and createFilter do related but distinct jobs, and conflating them produces the “my transform never runs” symptom. assetsInclude tells Vite’s core to treat a matching extension as an asset — to give its import a URL and copy it to dist/assets — rather than trying to parse it as JavaScript. createFilter is your plugin’s guard inside transform, deciding which ids your hook acts on. A file can be matched by your filter but not registered in assetsInclude, in which case Vite’s core tries to JS-parse it first and throws before your transform sees it; or registered as an asset but not matched by your filter, in which case it is copied verbatim with no processing. You usually need both, aligned on the same extension.
generateBundle so the manifest is deterministic and cannot trigger a rebuild.Implementation Workflow
- Register custom extensions via
assetsInclude: ['**/*.custom']. - Implement
transformwith strict regex id filtering (@rollup/pluginutilscreateFilter). - Generate source maps with
this.getCombinedSourcemap()and return themap. - Emit processed assets via
this.emitFile({ type: 'asset', source, fileName })ingenerateBundle.
// vite-plugin-asset-pipeline.ts — Vite 5.x / 6.x, Rollup 4.x
import type { Plugin } from 'vite';
import { createFilter } from '@rollup/pluginutils';
export function assetPipelinePlugin(): Plugin {
const filter = createFilter(['**/*.custom']);
return {
name: 'asset-pipeline',
transform(code, id) {
if (!filter(id)) return null;
const processed = code.replace(/CUSTOM_TOKEN/g, 'REPLACED');
// Chain upstream maps so DevTools still points at the original file
return { code: `export default ${JSON.stringify(processed)};`, map: this.getCombinedSourcemap() };
},
generateBundle() {
// Emitted after all chunks are finalized — deterministic, no rebuild loop
this.emitFile({
type: 'asset',
fileName: 'asset-manifest.json',
source: JSON.stringify({ generatedAt: Date.now() }),
});
},
};
}
Configuration Patterns
// vite.config.ts — Vite 5.x / 6.x
import { defineConfig } from 'vite';
import { assetPipelinePlugin } from './vite-plugin-asset-pipeline';
export default defineConfig({
assetsInclude: ['**/*.svg', '**/*.custom'],
build: { sourcemap: true },
plugins: [assetPipelinePlugin()],
});
Debugging & Diagnostics
- Verify source-map integrity with
vite build --sourcemapand the DevTools Sources panel. - Check
dist/assets/for orphaned or duplicated files, which indicateemitFilemisconfiguration. - Run
vite --debug transformto trace asset pipeline order, cache hit ratios, and transform latency.
Source maps are the asset detail that quietly rots if you ignore it. When your transform rewrites a file, the browser’s DevTools and every downstream tool need a map from the emitted code back to the original bytes, and if an earlier plugin already transformed the module there is an upstream map you must compose with your own or the mapping points at the wrong lines. this.getCombinedSourcemap() returns the accumulated map of every transform applied to this module so far; returning it as your map chains your rewrite onto that history. Return null and you break the chain — DevTools will show the post-transform code as if it were the source, and stack traces in errors will point at generated line numbers. This is only valid inside transform; calling it in load or generateBundle throws because there is no per-module transform context there. Confirm the chain is intact by opening the Sources panel on a production build with sourcemap: true and checking that a breakpoint in the original file lands on the original line.
The orphaned-file symptom in dist/assets/ almost always means emitFile ran in a hook that executes per-module rather than once. If you see the same derived file emitted with two different hashes, the emission is inside transform or buildStart and is firing for each entry point; move it to generateBundle and the duplication collapses to one file. Confirm by counting occurrences in the build log’s emitted-assets summary.
Performance Impact: Cache-aware transforms avoid redundant image/font work, saving 200–400ms per build in projects with 100+ custom assets. getCombinedSourcemap() chaining preserves original references, and generateBundle emission guarantees deterministic manifests without incremental-rebuild loops.
Compatibility Matrix
| Plugin feature | Vite 5.x | Vite 6.x | Rollup backend | Notes |
|---|---|---|---|---|
enforce / apply |
Yes | Yes | 4.x | Identical semantics across both lines |
options.ssr arg in transform |
Yes | Yes | 4.x | Replaces older this.environment.ssr reads |
Environment API (this.environment) |
Experimental | Stable | 4.x | Prefer the ssr flag for portable plugins |
\0 virtual module ids |
Yes | Yes | 4.x | Required so Rollup skips path resolution |
this.getCombinedSourcemap() |
Yes | Yes | 4.x | Only valid inside transform |
| Node version | 18.19+ / 20.x | 18.19+ / 20.x / 22.x | — | Vite 6 drops Node 18 EOL targets |
Related
- Writing a Custom Vite Plugin for Asset Transformation —
resolveId/load/transformfor non-JS formats, source maps, and the Rollup handoff. - Debugging Vite Plugin Hook Order with enforce and apply — why a transform runs too early or late, and a logging plugin to prove the real order.
- Migrating from Webpack 5 to Vite — mapping loaders to plugins,
DefinePlugintodefine, andprocess.envtoimport.meta.env. - Vite Configuration & Ecosystem — the parent overview covering env modes, dev-server tuning, SSR, and library mode.