Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/guide/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)<sup>3</sup> | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |

::: 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.

:::

Expand Down
1 change: 1 addition & 0 deletions src/rspack/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
}

Expand Down
9 changes: 9 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
1 change: 1 addition & 0 deletions src/webpack/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
}

Expand Down
115 changes: 115 additions & 0 deletions test/unit-tests/cacheable/cacheable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
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<string, number> = {}
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 twice with the same compiler so the second build can reuse the in-memory cache
async function buildTwice(compiler: Compiler): Promise<void> {
const run = () => new Promise<void>((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()
})
})
await run()
await run()
await new Promise<void>(resolve => compiler.close(resolve))
}

const frameworks = [
[
'webpack',
(plugin: UnpluginInstance<any>) => webpackBuild({
mode: 'development',
cache: { 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<any>) => rspackBuild({
mode: 'development',
cache: true,
context: root,
entry: join(root, 'entry.js'),
output: {
path: join(root, 'dist-rspack'),
},
plugins: [
plugin.rspack(),
],
}) as unknown as Compiler,
],
] as const

describe('this.cacheable', () => {
for (const [name, createCompiler] of frameworks) {
it(name, async () => {
const { calls, plugin } = createPlugin()

await buildTwice(createCompiler(plugin))

// `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
expect(calls).toEqual({
'load loaded.js': 2,
'transform entry.js': 1,
'transform loaded.js': 2,
'transform transformed.js': 2,
})
})
}
})
11 changes: 11 additions & 0 deletions test/unit-tests/rspack/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
11 changes: 11 additions & 0 deletions test/unit-tests/webpack/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down