Plugin lifecycle and hooks

It has a name and some hooks. A hook is an object carrying a handler — there is one form, and rollup's bare-function shorthand is refused rather than accepted, because taking both would make the filter, the order and the context argument optional extras on somebody else's design.

JavaScript
const text = {
  name: "text",
  transform: {
    filter: { id: /\.txt$/ },
    handler(code, id, ctx) {
      return {
        code: `export default ${JSON.stringify(code.trim())};`,
        type: "js",
      };
    },
  },
};

That is a complete, working plugin: it makes import greeting from "./hello.txt" mean the file's contents. Hand it to a build and run it —

JavaScript
import { build } from "runtime:build";

const bundle = await build({ input: "app/main.js", plugins: [text] });
const { output } = await bundle.generate({ format: "esm", codeSplitting: false });
console.log(output[0].code);
await bundle.close();
Shell
esdev build.mjs

Returning null — or nothing — means not mine, and the next plugin gets a turn. It is the one rollup convention worth keeping, because a hook has to be able to decline. Anything else must be the object: a bare string of code is refused by name.

type is worth the eight characters. It says how the code you just returned should be treated, and omitting it leaves that to the extension — which for .txt is not JavaScript, so the module you carefully wrote comes out the far side as a string literal instead of an export. Set it whenever the code you return is a different language from the file it came from: "js", "jsx", "ts", "css".

The six hooks

HookHandlerReturns
start(ctx){ dependsOn }, or nothing
resolve(source, importer, ctx){ id, external?, virtual? }, or null
load(id, ctx){ code, type?, map?, dependsOn? }, or null
transform(code, id, ctx){ code, type?, map?, dependsOn? }, or null
end(error, ctx)nothing
bundle(output, ctx)nothing

Six, against rollup's twenty-odd. Each is a promise whatever bundler sits behind this has to keep, so the list is short on purpose. Data comes first and the context comes last, with nothing positional in between: anything one particular hook needs to say — isEntry, on a resolve — rides on the context rather than shifting the signature.

jsx: what only the compiler can do

Not a hook. A hook is handed a module's source and hands source back, which is enough for almost everything — but the JSX pass runs inside the bundler, and your plugin has no way to reach it.

JavaScript
export default {
  name: "react-refresh",
  jsx: { refresh: true },
  transform: { filter: { id: /\.[jt]sx$/ }, handler },
};

jsx.refresh asks for a registration per component and a signature per hook-using function — what a component-refresh scheme matches components up by so an edit re-renders in place instead of remounting. Finding those needs the syntax tree the compiler already has. The per-module half is yours, in a transform: the registrations are a global call, and it has to mean "register under this module's id" while this module is evaluating.

It is honoured only in a hot dev build of a target that named a refresh scheme — the calls it inserts reach globals that only a hot loop installs, so emitting them into a release build would ship calls to something undefined.

Always write a filter

JavaScript
transform: {
  filter: { id: /\.mdx$/, code: /^---/ },
  handler(code, id, ctx) { … },
}
filter.ida string (exact, not a substring), a RegExp, or an array of either
filter.codethe same, matched against the source — transform only
both givena module has to satisfy each; within one, any pattern will do

This is the part with no equivalent in rollup, and the part that decides whether your dev server feels fast. In rollup a hook that returns null costs a function call. Here it costs a round trip into your isolate, so an unfiltered transform is one crossing per module in the graph — four hundred of them on a middling app, to reach the one .mdx file you wanted. The filter is matched on the host's side, before anything crosses.

A pattern the matcher cannot evaluate — JavaScript's regular expressions are larger than what it supports, so lookbehind and backreferences are out — stops filtering rather than failing. The hook is then called for everything and your own code decides, because excluding modules a plugin was meant to see is the expensive way to be wrong.

start and end run once for the whole build, with no module in hand, so a filter on either is refused rather than ignored.

A module with no file

resolve says where a specifier points; load says what is there. Together they are a virtual module — a specifier that exists on no disk, whose contents the plugin invents.

JavaScript
const nav = {
  name: "nav",
  resolve: {
    filter: { id: "@app/nav" },
    handler: () => ({ id: "@app/nav", virtual: true }),
  },
  load: {
    filter: { id: "@app/nav" },
    async handler(id, ctx) {
      return {
        code: `export default ${JSON.stringify(await readNav())};`,
        dependsOn: ["docs/_meta.js"],
      };
    },
  },
};

virtual: true is how you say there is no file behind the id. Rollup signals that by gluing a NUL byte to the front of the id, and every bundler descended from it inherited the convention; here the notation is the backend's business, applied and stripped where it belongs, so your load filter still matches the id you actually wrote.

Last updated on
Edit this page