Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
140 changes: 140 additions & 0 deletions test/unit-tests/cacheable/cacheable.test.ts
Original file line number Diff line number Diff line change
@@ -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<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 `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<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()
})
})
for (let i = 0; i < times; i++)
await run()
await new Promise<void>(resolve => compiler.close(resolve))
}

const frameworks = [
[
'webpack',
(plugin: UnpluginInstance<any>, 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<any>, 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)
})
}
})
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