diff --git a/docs/guide/index.md b/docs/guide/index.md index 64ad94ce..039023d8 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -363,11 +363,13 @@ const plugin = createUnplugin(() => ({ | [`this.getWatchFiles`](https://rollupjs.org/plugin-development/#this-getwatchfiles) | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | | [`this.warn`](https://rollupjs.org/plugin-development/#this-warn) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [`this.error`](https://rollupjs.org/plugin-development/#this-error) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [`this.cacheable`](https://webpack.js.org/api/loaders/#thiscacheable)3 | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | ::: info Notice 1. For bundlers other than Rollup, Rolldown, or Vite, `setParseImpl` must be called to manually provide a parser implementation. Parsers such as [Acorn](https://github.com/acornjs/acorn), [Babel](https://babeljs.io/), or [Oxc](https://oxc.rs/) can be used. 2. Currently, [`this.emitFile`](https://rollupjs.org/plugin-development/#thisemitfile) only supports the `EmittedAsset` variant. +3. Only in the `load` and `transform` hooks. Call `this.cacheable(false)` when the result can't be cached, for example when it depends on state the bundler doesn't track, so the module is built again on every rebuild. On other bundlers `this.cacheable` is `undefined`, so call it as `this.cacheable?.(false)` in plugins that run on several bundlers. ::: diff --git a/src/rspack/context.ts b/src/rspack/context.ts index f9cc1c10..13ef9cec 100644 --- a/src/rspack/context.ts +++ b/src/rspack/context.ts @@ -46,6 +46,7 @@ export function createContext(loader: LoaderContext): UnpluginContext { return { error: error => loader.emitError(normalizeMessage(error)), warn: message => loader.emitWarning(normalizeMessage(message)), + cacheable: flag => loader.cacheable(flag), } } diff --git a/src/types.ts b/src/types.ts index 9b9a20cf..9500de58 100644 --- a/src/types.ts +++ b/src/types.ts @@ -242,4 +242,13 @@ export interface UnpluginMessage { export interface UnpluginContext { error: (message: string | UnpluginMessage) => void warn: (message: string | UnpluginMessage) => void + /** + * Pass `false` to tell the bundler that the result of this `load` or `transform` call can't be + * cached, so the module is built again on every rebuild instead of being restored from the cache. + * + * Only available in `load` and `transform` on webpack and Rspack (including Rsbuild). + * + * @see https://webpack.js.org/api/loaders/#thiscacheable + */ + cacheable?: ((flag?: boolean) => void) | undefined } diff --git a/src/webpack/context.ts b/src/webpack/context.ts index ded3a752..8d833cba 100644 --- a/src/webpack/context.ts +++ b/src/webpack/context.ts @@ -68,6 +68,7 @@ export function createContext(loader: LoaderContext<{ unpluginName: string }>): return { error: error => loader.emitError(normalizeMessage(error)), warn: message => loader.emitWarning(normalizeMessage(message)), + cacheable: flag => loader.cacheable(flag), } } diff --git a/test/unit-tests/cacheable/cacheable.test.ts b/test/unit-tests/cacheable/cacheable.test.ts new file mode 100644 index 00000000..66fa504b --- /dev/null +++ b/test/unit-tests/cacheable/cacheable.test.ts @@ -0,0 +1,140 @@ +import type { UnpluginInstance } from 'unplugin' +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { createUnplugin } from 'unplugin' +import { afterAll, describe, expect, it } from 'vitest' +import { rspackBuild, webpackBuild } from '../utils' + +const root = realpathSync(mkdtempSync(join(tmpdir(), 'unplugin-cacheable-'))) + +writeFileSync(join(root, 'entry.js'), [ + `import loaded from './loaded.js'`, + `import transformed from './transformed.js'`, + `console.log(loaded, transformed)`, +].join('\n')) +writeFileSync(join(root, 'loaded.js'), `export default 'loaded'`) +writeFileSync(join(root, 'transformed.js'), `export default 'transformed'`) + +afterAll(() => rmSync(root, { recursive: true, force: true })) + +function createPlugin() { + const calls: Record = {} + const count = (key: string) => { + calls[key] = (calls[key] ?? 0) + 1 + } + const plugin = createUnplugin(() => ({ + name: 'cacheable', + load(id) { + if (basename(id) === 'loaded.js') { + count('load loaded.js') + this.cacheable?.(false) + } + }, + transform(_code, id) { + count(`transform ${basename(id)}`) + if (basename(id) === 'transformed.js') + this.cacheable?.(false) + }, + })) + return { calls, plugin } +} + +interface Compiler { + run: (callback: (error: Error | null, stats?: { hasErrors: () => boolean, toString: (options: any) => string }) => void) => void + close: (callback: () => void) => void +} + +// Build `times` times with the same compiler, then close it so a persistent cache is written to disk +async function build(compiler: Compiler, times = 1): Promise { + const run = () => new Promise((resolve, reject) => { + compiler.run((error, stats) => { + if (error) + reject(error) + else if (!stats || stats.hasErrors()) + reject(new Error(stats?.toString({ all: false, errors: true }) ?? 'no stats')) + else + resolve() + }) + }) + for (let i = 0; i < times; i++) + await run() + await new Promise(resolve => compiler.close(resolve)) +} + +const frameworks = [ + [ + 'webpack', + (plugin: UnpluginInstance, persistent = false) => webpackBuild({ + mode: 'development', + cache: persistent + ? { + type: 'filesystem', + cacheDirectory: join(root, 'cache-webpack'), + } + : { type: 'memory' }, + context: root, + entry: join(root, 'entry.js'), + output: { + path: join(root, 'dist-webpack'), + }, + plugins: [ + plugin.webpack(), + ], + }) as unknown as Compiler, + ], + [ + 'rspack', + (plugin: UnpluginInstance, persistent = false) => rspackBuild({ + mode: 'development', + cache: persistent + ? { + type: 'persistent', + storage: { + type: 'filesystem', + directory: join(root, 'cache-rspack'), + }, + } + : true, + context: root, + entry: join(root, 'entry.js'), + output: { + path: join(root, 'dist-rspack'), + }, + plugins: [ + plugin.rspack(), + ], + }) as unknown as Compiler, + ], +] as const + +// `entry.js` comes from the cache on the second build, while the modules marked +// with `this.cacheable(false)` in `load` or `transform` are built again +const expected = { + 'load loaded.js': 2, + 'transform entry.js': 1, + 'transform loaded.js': 2, + 'transform transformed.js': 2, +} + +describe('this.cacheable', () => { + for (const [name, createCompiler] of frameworks) { + it(name, async () => { + const { calls, plugin } = createPlugin() + + await build(createCompiler(plugin), 2) + + expect(calls).toEqual(expected) + }) + + // A new compiler only has the cache on disk, like a restarted dev server + it(`${name} with a persistent cache`, async () => { + const { calls, plugin } = createPlugin() + + await build(createCompiler(plugin, true)) + await build(createCompiler(plugin, true)) + + expect(calls).toEqual(expected) + }) + } +}) diff --git a/test/unit-tests/rspack/context.test.ts b/test/unit-tests/rspack/context.test.ts index dfce35a5..466b7bfd 100644 --- a/test/unit-tests/rspack/context.test.ts +++ b/test/unit-tests/rspack/context.test.ts @@ -86,4 +86,15 @@ describe('createBuildContext', () => { context.warn('testWarning') expect(loaderContext.emitWarning).toHaveBeenCalledWith(new Error('testWarning')) }) + + it('createContext - should forward cacheable to the loader', () => { + const loaderContext = { + cacheable: vi.fn(), + } + + const context = createContext(loaderContext as any) + context.cacheable!(false) + + expect(loaderContext.cacheable).toHaveBeenCalledWith(false) + }) }) diff --git a/test/unit-tests/webpack/context.test.ts b/test/unit-tests/webpack/context.test.ts index 51ce8ba1..fe2c1f58 100644 --- a/test/unit-tests/webpack/context.test.ts +++ b/test/unit-tests/webpack/context.test.ts @@ -103,6 +103,17 @@ describe('webpack - utils', () => { expect(mockLoader.emitError).toHaveBeenCalledWith(expect.any(Error)) expect(mockLoader.emitWarning).toHaveBeenCalledWith(expect.any(Error)) }) + + it('should forward cacheable to the loader', () => { + const mockLoader = { + cacheable: vi.fn(), + } as unknown as LoaderContext<{ unpluginName: string }> + + const context = createContext(mockLoader) + context.cacheable!(false) + + expect(mockLoader.cacheable).toHaveBeenCalledWith(false) + }) }) describe('normalizeMessage', () => {