diff --git a/docs/05-plugin-development.md b/docs/05-plugin-development.md
index ef2def09112..ab5ead9f000 100644
--- a/docs/05-plugin-development.md
+++ b/docs/05-plugin-development.md
@@ -102,7 +102,7 @@ Notifies a plugin when watcher process closes and all open resources should be c
#### `load`
-**Type:** `(id: string) => string | null | {code: string, map?: string | SourceMap, ast? : ESTree.Program, moduleSideEffects?: boolean | "no-treeshake" | null, syntheticNamedExports?: boolean | string | null, meta?: {[plugin: string]: any} | null}`
**Kind:** `async, first`
**Previous Hook:** [`resolveId`](guide/en/#resolveid) or [`resolveDynamicImport`](guide/en/#resolvedynamicimport) where the loaded id was resolved. Additionally, this hook can be triggered at any time from plugin hooks by calling [`this.load`](guide/en/#thisload) to preload the module corresponding to an id.
**Next Hook:** [`transform`](guide/en/#transform) to transform the loaded file.
+**Type:** `(id: string) => string | null | {code: string, map?: string | SourceMap, ast? : ESTree.Program, moduleSideEffects?: boolean | "no-treeshake" | null, syntheticNamedExports?: boolean | string | null, meta?: {[plugin: string]: any} | null}`
**Kind:** `async, first`
**Previous Hook:** [`resolveId`](guide/en/#resolveid) or [`resolveDynamicImport`](guide/en/#resolvedynamicimport) where the loaded id was resolved. Additionally, this hook can be triggered at any time from plugin hooks by calling [`this.load`](guide/en/#thisload) to preload the module corresponding to an id.
**Next Hook:** [`transform`](guide/en/#transform) to transform the loaded file if no cache was used, or there was no cached copy with the same `code`, otherwise [`shouldTransformCachedModule`](guide/en/#shouldtransformcachedmodule).
Defines a custom loader. Returning `null` defers to other `load` functions (and eventually the default behavior of loading from the file system). To prevent additional parsing overhead in case e.g. this hook already used `this.parse` to generate an AST for some reason, this hook can optionally return a `{ code, ast, map }` object. The `ast` must be a standard ESTree AST with `start` and `end` properties for each node. If the transformation does not move code, you can preserve existing sourcemaps by setting `map` to `null`. Otherwise you might need to generate the source map. See [the section on source code transformations](#source-code-transformations).
@@ -116,7 +116,7 @@ You can use [`this.getModuleInfo`](guide/en/#thisgetmoduleinfo) to find out the
#### `moduleParsed`
-**Type:** `(moduleInfo: ModuleInfo) => void`
**Kind:** `async, parallel`
**Previous Hook:** [`transform`](guide/en/#transform) where the currently handled file was transformed.
NextHook: [`resolveId`](guide/en/#resolveid) and [`resolveDynamicImport`](guide/en/#resolvedynamicimport) to resolve all discovered static and dynamic imports in parallel if present, otherwise [`buildEnd`](guide/en/#buildend).
+**Type:** `(moduleInfo: ModuleInfo) => void`
**Kind:** `async, parallel`
**Previous Hook:** [`transform`](guide/en/#transform) where the currently handled file was transformed.
**Next Hook:** [`resolveId`](guide/en/#resolveid) and [`resolveDynamicImport`](guide/en/#resolvedynamicimport) to resolve all discovered static and dynamic imports in parallel if present, otherwise [`buildEnd`](guide/en/#buildend).
This hook is called each time a module has been fully parsed by Rollup. See [`this.getModuleInfo`](guide/en/#thisgetmoduleinfo) for what information is passed to this hook.
@@ -222,9 +222,19 @@ Note that while `resolveId` will be called for each import of a module and can t
When triggering this hook from a plugin via [`this.resolve`](guide/en/#thisresolve), it is possible to pass a custom options object to this hook. While this object will be passed unmodified, plugins should follow the convention of adding a `custom` property with an object where the keys correspond to the names of the plugins that the options are intended for. For details see [custom resolver options](guide/en/#custom-resolver-options).
+#### `shouldTransformCachedModule`
+
+**Type:** `({id: string, code: string, ast: ESTree.Program, meta: {[plugin: string]: any}, moduleSideEffects: boolean | "no-treeshake", syntheticNamedExports: string | boolean}) => boolean`
**Kind:** `async, first`
**Previous Hook:** [`load`](guide/en/#load) where the cached file was loaded to compare its code with the cached version.
**Next Hook:** [`moduleParsed`](guide/en/#moduleparsed) if no plugin returns `true`, otherwise [`transform`](guide/en/#transform).
+
+If the Rollup cache is used (e.g. in watch mode or explicitly via the JavaScript API), Rollup will skip the [`transform`](guide/en/#transform) hook of a module if after the [`load`](guide/en/#transform) hook, the loaded `code` is identical to the code of the cached copy. To prevent this, discard the cached copy and instead transform a module, plugins can implement this hook and return `true`.
+
+This hook can also be used to find out which modules were cached and access their cached meta information.
+
+If a plugin does not return `true`, Rollup will trigger this hook for other plugins, otherwise all remaining plugins will be skipped.
+
#### `transform`
-**Type:** `(code: string, id: string) => string | null | {code?: string, map?: string | SourceMap, ast? : ESTree.Program, moduleSideEffects?: boolean | "no-treeshake" | null, syntheticNamedExports?: boolean | string | null, meta?: {[plugin: string]: any} | null}`
**Kind:** `async, sequential`
**Previous Hook:** [`load`](guide/en/#load) where the currently handled file was loaded.
NextHook: [`moduleParsed`](guide/en/#moduleparsed) once the file has been processed and parsed.
+**Type:** `(code: string, id: string) => string | null | {code?: string, map?: string | SourceMap, ast? : ESTree.Program, moduleSideEffects?: boolean | "no-treeshake" | null, syntheticNamedExports?: boolean | string | null, meta?: {[plugin: string]: any} | null}`
**Kind:** `async, sequential`
**Previous Hook:** [`load`](guide/en/#load) where the currently handled file was loaded. If caching is used and there was a cached copy of that module, [`shouldTransformCachedModule`](guide/en/#shouldtransformcachedmodule) if a plugin returned `true` for that hook.
**Next Hook:** [`moduleParsed`](guide/en/#moduleparsed) once the file has been processed and parsed.
Can be used to transform individual modules. To prevent additional parsing overhead in case e.g. this hook already used `this.parse` to generate an AST for some reason, this hook can optionally return a `{ code, ast, map }` object. The `ast` must be a standard ESTree AST with `start` and `end` properties for each node. If the transformation does not move code, you can preserve existing sourcemaps by setting `map` to `null`. Otherwise you might need to generate the source map. See [the section on source code transformations](#source-code-transformations).
diff --git a/docs/build-hooks.mmd b/docs/build-hooks.mmd
index bdb2695fcda..512dc5916b0 100644
--- a/docs/build-hooks.mmd
+++ b/docs/build-hooks.mmd
@@ -25,6 +25,9 @@ flowchart TB
resolveid("resolveId"):::hook-first
click resolveid "/guide/en/#resolveid" _parent
+ shouldtransformcachedmodule("shouldTransformCachedModule"):::hook-first
+ click shouldtransformcachedmodule "/guide/en/#shouldtransformcachedmodule" _parent
+
transform("transform"):::hook-sequential
click transform "/guide/en/#transform" _parent
@@ -41,10 +44,17 @@ flowchart TB
resolveid
--> |non-external|load
- --> transform
+ --> |not cached|transform
--> moduleparsed
.-> |no imports|buildend
+ load
+ --> |cached|shouldtransformcachedmodule
+ --> |false|moduleparsed
+
+ shouldtransformcachedmodule
+ --> |true|transform
+
moduleparsed
--> |"each import()"|resolvedynamicimport
--> |non-external|load
diff --git a/src/ModuleLoader.ts b/src/ModuleLoader.ts
index ede3546b14f..648d9aba78b 100644
--- a/src/ModuleLoader.ts
+++ b/src/ModuleLoader.ts
@@ -274,7 +274,17 @@ export class ModuleLoader {
if (
cachedModule &&
!cachedModule.customTransformCache &&
- cachedModule.originalCode === sourceDescription.code
+ cachedModule.originalCode === sourceDescription.code &&
+ !(await this.pluginDriver.hookFirst('shouldTransformCachedModule', [
+ {
+ ast: cachedModule.ast,
+ code: cachedModule.code,
+ id: cachedModule.id,
+ meta: cachedModule.meta,
+ moduleSideEffects: cachedModule.moduleSideEffects,
+ syntheticNamedExports: cachedModule.syntheticNamedExports
+ }
+ ]))
) {
if (cachedModule.transformFiles) {
for (const emittedFile of cachedModule.transformFiles)
diff --git a/src/rollup/types.d.ts b/src/rollup/types.d.ts
index 1197dc41e8a..45cabb78c10 100644
--- a/src/rollup/types.d.ts
+++ b/src/rollup/types.d.ts
@@ -101,7 +101,7 @@ export interface SourceDescription extends Partial> {
map?: SourceMapInput;
}
-export interface TransformModuleJSON extends Partial> {
+export interface TransformModuleJSON {
ast?: AcornNode;
code: string;
// note if plugins use new this.cache to opt-out auto transform cache
@@ -113,7 +113,7 @@ export interface TransformModuleJSON extends Partial>
transformDependencies: string[];
}
-export interface ModuleJSON extends TransformModuleJSON {
+export interface ModuleJSON extends TransformModuleJSON, ModuleOptions {
ast: AcornNode;
dependencies: string[];
id: string;
@@ -242,6 +242,18 @@ export type ResolveIdHook = (
options: { custom?: CustomPluginOptions; isEntry: boolean }
) => Promise | ResolveIdResult;
+export type ShouldTransformCachedModuleHook = (
+ this: PluginContext,
+ options: {
+ ast: AcornNode;
+ code: string;
+ id: string;
+ meta: CustomPluginOptions;
+ moduleSideEffects: boolean | 'no-treeshake';
+ syntheticNamedExports: boolean | string;
+ }
+) => Promise | boolean;
+
export type IsExternal = (
source: string,
importer: string | undefined,
@@ -367,6 +379,7 @@ export interface PluginHooks extends OutputPluginHooks {
) => Promise | InputOptions | null | undefined;
resolveDynamicImport: ResolveDynamicImportHook;
resolveId: ResolveIdHook;
+ shouldTransformCachedModule: ShouldTransformCachedModuleHook;
transform: TransformHook;
watchChange: WatchChangeHook;
}
@@ -419,6 +432,7 @@ export type AsyncPluginHooks =
| 'renderStart'
| 'resolveDynamicImport'
| 'resolveId'
+ | 'shouldTransformCachedModule'
| 'transform'
| 'writeBundle'
| 'closeBundle';
@@ -434,7 +448,8 @@ export type FirstPluginHooks =
| 'resolveDynamicImport'
| 'resolveFileUrl'
| 'resolveId'
- | 'resolveImportMeta';
+ | 'resolveImportMeta'
+ | 'shouldTransformCachedModule';
export type SequentialPluginHooks =
| 'augmentChunkHash'
diff --git a/src/utils/PluginDriver.ts b/src/utils/PluginDriver.ts
index 1a77fe57851..b96b54880b1 100644
--- a/src/utils/PluginDriver.ts
+++ b/src/utils/PluginDriver.ts
@@ -55,6 +55,7 @@ const inputHookNames: {
options: 1,
resolveDynamicImport: 1,
resolveId: 1,
+ shouldTransformCachedModule: 1,
transform: 1,
watchChange: 1
};
diff --git a/src/utils/transform.ts b/src/utils/transform.ts
index 16ea7de1319..67cc23a3e20 100644
--- a/src/utils/transform.ts
+++ b/src/utils/transform.ts
@@ -165,7 +165,6 @@ export default async function transform(
ast,
code,
customTransformCache,
- meta: module.info.meta,
originalCode,
originalSourcemap,
sourcemapChain,
diff --git a/test/cli/samples/watch/watch-config-early-update/_config.js b/test/cli/samples/watch/watch-config-early-update/_config.js
index 65197b87546..02e3884aa4d 100644
--- a/test/cli/samples/watch/watch-config-early-update/_config.js
+++ b/test/cli/samples/watch/watch-config-early-update/_config.js
@@ -1,6 +1,6 @@
const fs = require('fs');
const path = require('path');
-const { atomicWriteFileSync } = require('../../../../utils');
+const { writeAndSync } = require('../../../../utils');
let configFile;
@@ -26,7 +26,7 @@ module.exports = {
format: 'es'
}
}),
- 3000
+ 2000
);
});`
);
@@ -36,7 +36,7 @@ module.exports = {
},
abortOnStderr(data) {
if (data === 'initial\n') {
- atomicWriteFileSync(
+ writeAndSync(
configFile,
`
console.error('updated');
diff --git a/test/incremental/index.js b/test/incremental/index.js
index 1595ca248d7..794fcc6a17f 100644
--- a/test/incremental/index.js
+++ b/test/incremental/index.js
@@ -336,4 +336,54 @@ describe('incremental', () => {
assert.strictEqual(transformCalls, 2);
assert.strictEqual(moduleParsedCalls, 4); // should not be cached
});
+
+ it('runs shouldTransformCachedModule when using a cached module', async () => {
+ let shouldTransformCachedModuleCalls = 0;
+
+ const transformPlugin = {
+ async shouldTransformCachedModule({ ast, id, meta, ...other }) {
+ shouldTransformCachedModuleCalls++;
+ assert.strictEqual(ast.type, 'Program');
+ assert.deepStrictEqual(other, {
+ code: modules[id],
+ moduleSideEffects: true,
+ syntheticNamedExports: false
+ });
+ switch (id) {
+ case 'foo':
+ assert.deepStrictEqual(meta, { transform: { calls: 1, id } });
+ // we return promises to ensure they are awaited
+ return Promise.resolve(false);
+ case 'entry':
+ assert.deepStrictEqual(meta, { transform: { calls: 0, id } });
+ return Promise.resolve(true);
+ default:
+ throw new Error(`Unexpected id ${id}.`);
+ }
+ },
+ transform: (code, id) => {
+ return { meta: { transform: { calls: transformCalls, id } } };
+ }
+ };
+ const cache = await rollup.rollup({
+ input: 'entry',
+ plugins: [transformPlugin, plugin]
+ });
+ assert.strictEqual(shouldTransformCachedModuleCalls, 0);
+ assert.strictEqual(transformCalls, 2);
+
+ const {
+ cache: { modules: cachedModules }
+ } = await rollup.rollup({
+ input: 'entry',
+ plugins: [transformPlugin, plugin],
+ cache
+ });
+ assert.strictEqual(shouldTransformCachedModuleCalls, 2);
+ assert.strictEqual(transformCalls, 3);
+ assert.strictEqual(cachedModules[0].id, 'foo');
+ assert.deepStrictEqual(cachedModules[0].meta, { transform: { calls: 1, id: 'foo' } });
+ assert.strictEqual(cachedModules[1].id, 'entry');
+ assert.deepStrictEqual(cachedModules[1].meta, { transform: { calls: 2, id: 'entry' } });
+ });
});
diff --git a/test/utils.js b/test/utils.js
index d8896c1d306..7bd767d5751 100644
--- a/test/utils.js
+++ b/test/utils.js
@@ -16,6 +16,7 @@ exports.assertDirectoriesAreEqual = assertDirectoriesAreEqual;
exports.assertFilesAreEqual = assertFilesAreEqual;
exports.assertIncludes = assertIncludes;
exports.atomicWriteFileSync = atomicWriteFileSync;
+exports.writeAndSync = writeAndSync;
exports.getFileNamesAndRemoveOutput = getFileNamesAndRemoveOutput;
function normaliseError(error) {
@@ -232,3 +233,11 @@ function atomicWriteFileSync(filePath, contents) {
fs.writeFileSync(stagingPath, contents);
fs.renameSync(stagingPath, filePath);
}
+
+// It appears that on MacOS, it sometimes takes long for the file system to update
+function writeAndSync(filePath, contents) {
+ const file = fs.openSync(filePath, 'w');
+ fs.writeSync(file, contents);
+ fs.fsyncSync(file);
+ fs.closeSync(file);
+}