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', () => {