diff --git a/docs/05-plugin-development.md b/docs/05-plugin-development.md index 9d6fa5447a3..3fb8f7eed8f 100644 --- a/docs/05-plugin-development.md +++ b/docs/05-plugin-development.md @@ -51,7 +51,7 @@ export default ({ - Plugins should have a clear name with `rollup-plugin-` prefix. - Include `rollup-plugin` keyword in `package.json`. - Plugins should be tested. We recommend [mocha](https://github.com/mochajs/mocha) or [ava](https://github.com/avajs/ava) which support Promises out of the box. -- Use asynchronous methods when it is possible. +- Use asynchronous methods when it is possible, e.g. `fs.readFile` instead of `fs.readFileSync`. - Document your plugin in English. - Make sure your plugin outputs correct source mappings if appropriate. - If your plugin uses 'virtual modules' (e.g. for helper functions), prefix the module ID with `\0`. This prevents other plugins from trying to process it. @@ -66,12 +66,58 @@ The name of the plugin, for use in error messages and warnings. ### Build Hooks -To interact with the build process, your plugin object includes 'hooks'. Hooks are functions which are called at various stages of the build. Hooks can affect how a build is run, provide information about a build, or modify a build once complete. There are different kinds of hooks: +To interact with the build process, your plugin object includes "hooks". Hooks are functions which are called at various stages of the build. Hooks can affect how a build is run, provide information about a build, or modify a build once complete. There are different kinds of hooks: - `async`: The hook may also return a Promise resolving to the same type of value; otherwise, the hook is marked as `sync`. - `first`: If several plugins implement this hook, the hooks are run sequentially until a hook returns a value other than `null` or `undefined`. -- `sequential`: If several plugins implement this hook, all of them will be run in the specified plugin order. If a hook is async, subsequent hooks of this kind will wait until the current hook is resolved. -- `parallel`: If several plugins implement this hook, all of them will be run in the specified plugin order. If a hook is async, subsequent hooks of this kind will be run in parallel and not wait for the current hook. +- `sequential`: If several plugins implement this hook, all of them will be run in the specified plugin order. If a hook is `async`, subsequent hooks of this kind will wait until the current hook is resolved. +- `parallel`: If several plugins implement this hook, all of them will be run in the specified plugin order. If a hook is `async`, subsequent hooks of this kind will be run in parallel and not wait for the current hook. + +Instead of a function, hooks can also be objects. In that case, the actual hook function (or value for `banner/footer/intro/outro`) must be specified as `handler`. This allows you to provide additional optional properties that change hook execution: + +- `order: "pre" | "post" | null`
If there are several plugins implementing this hook, either run this plugin first (`"pre"`), last (`"post"`), or in the user-specified position (no value or `null`). + + ```js + export default function resolveFirst() { + return { + name: 'resolve-first', + resolveId: { + order: 'pre', + handler(source) { + if (source === 'external') { + return { id: source, external: true }; + } + return null; + } + } + }; + } + ``` + + If several plugins use `"pre"` or `"post"`, Rollup runs them in the user-specified order. This option can be used for all plugin hooks. For parallel hooks, it changes the order in which the synchronous part of the hook is run. + +- `sequential: boolean`
Do not run this hook in parallel with the same hook of other plugins. Can only be used for `parallel` hooks. Using this option will make Rollup await the results of all previous plugins, then execute the plugin hook, and then run the remaining plugins in parallel again. E.g. when you have plugins `A`, `B`, `C`, `D`, `E` that all implement the same parallel hook and the middle plugin `C` has `sequential: true`, then Rollup will first run `A + B` in parallel, then `C` on its own, then `D + E` in parallel. + + This can be useful when you need to run several command line tools in different [`writeBundle`](guide/en/#writebundle) hooks that depend on each other (note that if possible, it is recommended to add/remove files in the sequential [`generateBundle`](guide/en/#generatebundle) hook, though, which is faster, works with pure in-memory builds and permits other in-memory build plugins to see the files). You can combine this option with `order` for additional sorting. + + ```js + import { resolve } from 'node:path'; + import { readdir } from 'node:fs/promises'; + + export default function getFilesOnDisk() { + return { + name: 'getFilesOnDisk', + writeBundle: { + sequential: true, + order: 'post', + async handler({ dir }) { + const topLevelFiles = await readdir(resolve(dir)); + console.log(topLevelFiles); + } + } + }; + } + ``` Build hooks are run during the build phase, which is triggered by `rollup.rollup(inputOptions)`. They are mainly concerned with locating, providing and transforming input files before they are processed by Rollup. The first hook of the build phase is [`options`](guide/en/#options), the last one is always [`buildEnd`](guide/en/#buildend). If there is a build error, [`closeBundle`](guide/en/#closebundle) will be called after that. diff --git a/src/rollup/rollup.ts b/src/rollup/rollup.ts index f3809b8bf8d..8f9ec428c28 100644 --- a/src/rollup/rollup.ts +++ b/src/rollup/rollup.ts @@ -1,6 +1,7 @@ import { version as rollupVersion } from 'package.json'; import Bundle from '../Bundle'; import Graph from '../Graph'; +import { getSortedValidatedPlugins } from '../utils/PluginDriver'; import type { PluginDriver } from '../utils/PluginDriver'; import { ensureArray } from '../utils/ensureArray'; import { errAlreadyClosed, errCannotEmitFromOptionsHook, error } from '../utils/error'; @@ -112,7 +113,10 @@ async function getInputOptions( if (!rawInputOptions) { throw new Error('You must supply an options object to rollup'); } - const rawPlugins = ensureArray(rawInputOptions.plugins) as Plugin[]; + const rawPlugins = getSortedValidatedPlugins( + 'options', + ensureArray(rawInputOptions.plugins) as Plugin[] + ); const { options, unsetOptions } = normalizeInputOptions( await rawPlugins.reduce(applyOptionHook(watchMode), Promise.resolve(rawInputOptions)) ); @@ -125,16 +129,13 @@ function applyOptionHook(watchMode: boolean) { inputOptions: Promise, plugin: Plugin ): Promise => { - if (plugin.options) { - return ( - ((await plugin.options.call( - { meta: { rollupVersion, watchMode } }, - await inputOptions - )) as GenericConfigObject) || inputOptions - ); - } - - return inputOptions; + const handler = 'handler' in plugin.options! ? plugin.options.handler : plugin.options!; + return ( + ((await handler.call( + { meta: { rollupVersion, watchMode } }, + await inputOptions + )) as GenericConfigObject) || inputOptions + ); }; } diff --git a/src/rollup/types.d.ts b/src/rollup/types.d.ts index 3528f98209b..29e6cb4db37 100644 --- a/src/rollup/types.d.ts +++ b/src/rollup/types.d.ts @@ -244,7 +244,7 @@ export type ResolveIdHook = ( source: string, importer: string | undefined, options: { custom?: CustomPluginOptions; isEntry: boolean } -) => Promise | ResolveIdResult; +) => ResolveIdResult; export type ShouldTransformCachedModuleHook = ( this: PluginContext, @@ -257,7 +257,7 @@ export type ShouldTransformCachedModuleHook = ( resolvedSources: ResolvedIdMap; syntheticNamedExports: boolean | string; } -) => Promise | boolean; +) => boolean; export type IsExternal = ( source: string, @@ -269,9 +269,9 @@ export type IsPureModule = (id: string) => boolean | null | void; export type HasModuleSideEffects = (id: string, external: boolean) => boolean; -type LoadResult = SourceDescription | string | null | void; +export type LoadResult = SourceDescription | string | null | void; -export type LoadHook = (this: PluginContext, id: string) => Promise | LoadResult; +export type LoadHook = (this: PluginContext, id: string) => LoadResult; export interface TransformPluginContext extends PluginContext { getCombinedSourcemap: () => SourceMap; @@ -283,27 +283,22 @@ export type TransformHook = ( this: TransformPluginContext, code: string, id: string -) => Promise | TransformResult; +) => TransformResult; -export type ModuleParsedHook = (this: PluginContext, info: ModuleInfo) => Promise | void; +export type ModuleParsedHook = (this: PluginContext, info: ModuleInfo) => void; export type RenderChunkHook = ( this: PluginContext, code: string, chunk: RenderedChunk, options: NormalizedOutputOptions -) => - | Promise<{ code: string; map?: SourceMapInput } | null> - | { code: string; map?: SourceMapInput } - | string - | null - | undefined; +) => { code: string; map?: SourceMapInput } | string | null | undefined; export type ResolveDynamicImportHook = ( this: PluginContext, specifier: string | AcornNode, importer: string -) => Promise | ResolveIdResult; +) => ResolveIdResult; export type ResolveImportMetaHook = ( this: PluginContext, @@ -344,7 +339,7 @@ export type WatchChangeHook = ( this: PluginContext, id: string, change: { event: ChangeEvent } -) => Promise | void; +) => void; /** * use this type for plugin annotation @@ -371,32 +366,21 @@ export interface OutputBundleWithPlaceholders { [fileName: string]: OutputAsset | OutputChunk | FilePlaceholder; } -export interface PluginHooks extends OutputPluginHooks { - buildEnd: (this: PluginContext, err?: Error) => Promise | void; - buildStart: (this: PluginContext, options: NormalizedInputOptions) => Promise | void; - closeBundle: (this: PluginContext) => Promise | void; - closeWatcher: (this: PluginContext) => Promise | void; - load: LoadHook; - moduleParsed: ModuleParsedHook; - options: ( - this: MinimalPluginContext, - options: InputOptions - ) => Promise | InputOptions | null | void; - resolveDynamicImport: ResolveDynamicImportHook; - resolveId: ResolveIdHook; - shouldTransformCachedModule: ShouldTransformCachedModuleHook; - transform: TransformHook; - watchChange: WatchChangeHook; -} - -interface OutputPluginHooks { +export interface FunctionPluginHooks { augmentChunkHash: (this: PluginContext, chunk: PreRenderedChunk) => string | void; + buildEnd: (this: PluginContext, err?: Error) => void; + buildStart: (this: PluginContext, options: NormalizedInputOptions) => void; + closeBundle: (this: PluginContext) => void; + closeWatcher: (this: PluginContext) => void; generateBundle: ( this: PluginContext, options: NormalizedOutputOptions, bundle: OutputBundle, isWrite: boolean - ) => void | Promise; + ) => void; + load: LoadHook; + moduleParsed: ModuleParsedHook; + options: (this: MinimalPluginContext, options: InputOptions) => InputOptions | null | void; outputOptions: (this: PluginContext, options: OutputOptions) => OutputOptions | null | void; renderChunk: RenderChunkHook; renderDynamicImport: ( @@ -408,45 +392,52 @@ interface OutputPluginHooks { targetModuleId: string | null; } ) => { left: string; right: string } | null | void; - renderError: (this: PluginContext, err?: Error) => Promise | void; + renderError: (this: PluginContext, err?: Error) => void; renderStart: ( this: PluginContext, outputOptions: NormalizedOutputOptions, inputOptions: NormalizedInputOptions - ) => Promise | void; + ) => void; /** @deprecated Use `resolveFileUrl` instead */ resolveAssetUrl: ResolveAssetUrlHook; + resolveDynamicImport: ResolveDynamicImportHook; resolveFileUrl: ResolveFileUrlHook; + resolveId: ResolveIdHook; resolveImportMeta: ResolveImportMetaHook; + shouldTransformCachedModule: ShouldTransformCachedModuleHook; + transform: TransformHook; + watchChange: WatchChangeHook; writeBundle: ( this: PluginContext, options: NormalizedOutputOptions, bundle: OutputBundle - ) => void | Promise; + ) => void; } -export type AsyncPluginHooks = - | 'options' - | 'buildEnd' - | 'buildStart' +export type OutputPluginHooks = + | 'augmentChunkHash' | 'generateBundle' - | 'load' - | 'moduleParsed' + | 'outputOptions' | 'renderChunk' + | 'renderDynamicImport' | 'renderError' | 'renderStart' - | 'resolveDynamicImport' - | 'resolveId' - | 'shouldTransformCachedModule' - | 'transform' - | 'writeBundle' - | 'closeBundle' - | 'closeWatcher' - | 'watchChange'; + | 'resolveAssetUrl' + | 'resolveFileUrl' + | 'resolveImportMeta' + | 'writeBundle'; + +export type InputPluginHooks = Exclude; -export type PluginValueHooks = 'banner' | 'footer' | 'intro' | 'outro'; +export type SyncPluginHooks = + | 'augmentChunkHash' + | 'outputOptions' + | 'renderDynamicImport' + | 'resolveAssetUrl' + | 'resolveFileUrl' + | 'resolveImportMeta'; -export type SyncPluginHooks = Exclude; +export type AsyncPluginHooks = Exclude; export type FirstPluginHooks = | 'load' @@ -466,37 +457,38 @@ export type SequentialPluginHooks = | 'renderChunk' | 'transform'; -export type ParallelPluginHooks = - | 'banner' - | 'buildEnd' - | 'buildStart' - | 'footer' - | 'intro' - | 'moduleParsed' - | 'outro' - | 'renderError' - | 'renderStart' - | 'writeBundle' - | 'closeBundle' - | 'closeWatcher' - | 'watchChange'; +export type ParallelPluginHooks = Exclude< + keyof FunctionPluginHooks | AddonHooks, + FirstPluginHooks | SequentialPluginHooks +>; -interface OutputPluginValueHooks { - banner: AddonHook; - cacheKey: string; - footer: AddonHook; - intro: AddonHook; - outro: AddonHook; -} +export type AddonHooks = 'banner' | 'footer' | 'intro' | 'outro'; -export interface Plugin extends Partial, Partial { - // for inter-plugin communication - api?: any; +type MakeAsync = Fn extends (this: infer This, ...args: infer Args) => infer Return + ? (this: This, ...args: Args) => Return | Promise + : never; + +// eslint-disable-next-line @typescript-eslint/ban-types +type ObjectHook = T | ({ handler: T; order?: 'pre' | 'post' | null } & O); + +export type PluginHooks = { + [K in keyof FunctionPluginHooks]: ObjectHook< + K extends AsyncPluginHooks ? MakeAsync : FunctionPluginHooks[K], + // eslint-disable-next-line @typescript-eslint/ban-types + K extends ParallelPluginHooks ? { sequential?: boolean } : {} + >; +}; + +export interface OutputPlugin + extends Partial<{ [K in OutputPluginHooks]: PluginHooks[K] }>, + Partial<{ [K in AddonHooks]: ObjectHook }> { + cacheKey?: string; name: string; } -export interface OutputPlugin extends Partial, Partial { - name: string; +export interface Plugin extends OutputPlugin, Partial { + // for inter-plugin communication + api?: any; } type TreeshakingPreset = 'smallest' | 'safest' | 'recommended'; diff --git a/src/utils/PluginContext.ts b/src/utils/PluginContext.ts index f1ca5bc4dd9..88d06332889 100644 --- a/src/utils/PluginContext.ts +++ b/src/utils/PluginContext.ts @@ -80,7 +80,7 @@ export function getPluginContext( cacheInstance = getCacheForUncacheablePlugin(plugin.name); } - const context: PluginContext = { + return { addWatchFile(id) { if (graph.phase >= BuildPhase.GENERATE) { return this.error(errInvalidRollupPhaseForAddWatchFile()); @@ -193,5 +193,4 @@ export function getPluginContext( options.onwarn(warning); } }; - return context; } diff --git a/src/utils/PluginDriver.ts b/src/utils/PluginDriver.ts index a436bc35586..daf20271a1c 100644 --- a/src/utils/PluginDriver.ts +++ b/src/utils/PluginDriver.ts @@ -3,47 +3,47 @@ import type Graph from '../Graph'; import type Module from '../Module'; import type { AddonHookFunction, + AddonHooks, AsyncPluginHooks, EmitFile, FirstPluginHooks, + FunctionPluginHooks, NormalizedInputOptions, NormalizedOutputOptions, OutputBundleWithPlaceholders, - OutputPluginHooks, ParallelPluginHooks, Plugin, PluginContext, - PluginHooks, - PluginValueHooks, SequentialPluginHooks, SerializablePluginCache, SyncPluginHooks } from '../rollup/types'; +import { InputPluginHooks } from '../rollup/types'; import { FileEmitter } from './FileEmitter'; import { getPluginContext } from './PluginContext'; -import { errInputHookInOutputPlugin, error } from './error'; +import { + errInputHookInOutputPlugin, + errInvalidAddonPluginHook, + errInvalidFunctionPluginHook, + error +} from './error'; +import { getOrCreate } from './getOrCreate'; import { throwPluginError, warnDeprecatedHooks } from './pluginUtils'; -/** - * Get the inner type from a promise - * @example ResolveValue> -> string - */ -type ResolveValue = T extends Promise ? K : T; /** * Coerce a promise union to always be a promise. * @example EnsurePromise> -> Promise */ -type EnsurePromise = Promise>; +type EnsurePromise = Promise>; /** * Get the type of the first argument in a function. * @example Arg0<(a: string, b: number) => void> -> string */ -type Arg0 = Parameters[0]; +type Arg0 = Parameters[0]; // This will make sure no input hook is omitted -type Subtract = T extends U ? never : T; const inputHookNames: { - [P in Subtract]: 1; + [P in InputPluginHooks]: 1; } = { buildEnd: 1, buildStart: 1, @@ -62,13 +62,6 @@ const inputHooks = Object.keys(inputHookNames); export type ReplaceContext = (context: PluginContext, plugin: Plugin) => PluginContext; -function throwInvalidHookError(hookName: string, pluginName: string): never { - return error({ - code: 'INVALID_PLUGIN_HOOK', - message: `Error running plugin hook ${hookName} for ${pluginName}, expected a function hook.` - }); -} - export type HookAction = [plugin: string, hook: string, args: unknown[]]; export class PluginDriver { @@ -82,20 +75,19 @@ export class PluginDriver { ) => void; private readonly fileEmitter: FileEmitter; - private readonly pluginCache: Record | undefined; private readonly pluginContexts: ReadonlyMap; private readonly plugins: readonly Plugin[]; + private readonly sortedPlugins = new Map(); private readonly unfulfilledActions = new Set(); constructor( private readonly graph: Graph, private readonly options: NormalizedInputOptions, userPlugins: readonly Plugin[], - pluginCache: Record | undefined, + private readonly pluginCache: Record | undefined, basePluginDriver?: PluginDriver ) { warnDeprecatedHooks(userPlugins, options); - this.pluginCache = pluginCache; this.fileEmitter = new FileEmitter( graph, options, @@ -137,16 +129,16 @@ export class PluginDriver { // chains, first non-null result stops and returns hookFirst( hookName: H, - args: Parameters, + args: Parameters, replaceContext?: ReplaceContext | null, skipped?: ReadonlySet | null - ): EnsurePromise> { - let promise: EnsurePromise> = Promise.resolve(undefined as any); - for (const plugin of this.plugins) { + ): Promise | null> { + let promise: Promise | null> = Promise.resolve(null); + for (const plugin of this.getSortedPlugins(hookName)) { if (skipped && skipped.has(plugin)) continue; promise = promise.then(result => { if (result != null) return result; - return this.runHook(hookName, args, plugin, false, replaceContext); + return this.runHook(hookName, args, plugin, replaceContext); }); } return promise; @@ -155,52 +147,56 @@ export class PluginDriver { // chains synchronously, first non-null result stops and returns hookFirstSync( hookName: H, - args: Parameters, + args: Parameters, replaceContext?: ReplaceContext - ): ReturnType { - for (const plugin of this.plugins) { + ): ReturnType | null { + for (const plugin of this.getSortedPlugins(hookName)) { const result = this.runHookSync(hookName, args, plugin, replaceContext); if (result != null) return result; } - return null as any; + return null; } // parallel, ignores returns - hookParallel( + async hookParallel( hookName: H, - args: Parameters, + args: Parameters, replaceContext?: ReplaceContext ): Promise { - const promises: Promise[] = []; - for (const plugin of this.plugins) { - const hookPromise = this.runHook(hookName, args, plugin, false, replaceContext); - if (!hookPromise) continue; - promises.push(hookPromise); + const parallelPromises: Promise[] = []; + for (const plugin of this.getSortedPlugins(hookName)) { + if ((plugin[hookName] as { sequential?: boolean }).sequential) { + await Promise.all(parallelPromises); + parallelPromises.length = 0; + await this.runHook(hookName, args, plugin, replaceContext); + } else { + parallelPromises.push(this.runHook(hookName, args, plugin, replaceContext)); + } } - return Promise.all(promises).then(() => {}); + await Promise.all(parallelPromises); } // chains, reduces returned value, handling the reduced value as the first hook argument hookReduceArg0( hookName: H, - [arg0, ...rest]: Parameters, + [arg0, ...rest]: Parameters, reduce: ( reduction: Arg0, - result: ResolveValue>, + result: ReturnType, plugin: Plugin ) => Arg0, replaceContext?: ReplaceContext ): Promise> { let promise = Promise.resolve(arg0); - for (const plugin of this.plugins) { - promise = promise.then(arg0 => { - const args = [arg0, ...rest] as Parameters; - const hookPromise = this.runHook(hookName, args, plugin, false, replaceContext); - if (!hookPromise) return arg0; - return hookPromise.then(result => - reduce.call(this.pluginContexts.get(plugin), arg0, result, plugin) - ); - }); + for (const plugin of this.getSortedPlugins(hookName)) { + promise = promise.then(arg0 => + this.runHook( + hookName, + [arg0, ...rest] as Parameters, + plugin, + replaceContext + ).then(result => reduce.call(this.pluginContexts.get(plugin), arg0, result, plugin)) + ); } return promise; } @@ -208,53 +204,54 @@ export class PluginDriver { // chains synchronously, reduces returned value, handling the reduced value as the first hook argument hookReduceArg0Sync( hookName: H, - [arg0, ...rest]: Parameters, - reduce: (reduction: Arg0, result: ReturnType, plugin: Plugin) => Arg0, + [arg0, ...rest]: Parameters, + reduce: ( + reduction: Arg0, + result: ReturnType, + plugin: Plugin + ) => Arg0, replaceContext?: ReplaceContext ): Arg0 { - for (const plugin of this.plugins) { - const args = [arg0, ...rest] as Parameters; + for (const plugin of this.getSortedPlugins(hookName)) { + const args = [arg0, ...rest] as Parameters; const result = this.runHookSync(hookName, args, plugin, replaceContext); arg0 = reduce.call(this.pluginContexts.get(plugin), arg0, result, plugin); } return arg0; } - // chains, reduces returned value to type T, handling the reduced value separately. permits hooks as values. - hookReduceValue( + // chains, reduces returned value to type string, handling the reduced value separately. permits hooks as values. + async hookReduceValue( hookName: H, - initialValue: T | Promise, + initialValue: string | Promise, args: Parameters, - reduce: ( - reduction: T, - result: ResolveValue>, - plugin: Plugin - ) => T, - replaceContext?: ReplaceContext - ): Promise { - let promise = Promise.resolve(initialValue); - for (const plugin of this.plugins) { - promise = promise.then(value => { - const hookPromise = this.runHook(hookName, args, plugin, true, replaceContext); - if (!hookPromise) return value; - return hookPromise.then(result => - reduce.call(this.pluginContexts.get(plugin), value, result, plugin) - ); - }); + reducer: (result: string, next: string) => string + ): Promise { + const results: string[] = []; + const parallelResults: (string | Promise)[] = []; + for (const plugin of this.getSortedPlugins(hookName, validateAddonPluginHandler)) { + if ((plugin[hookName] as { sequential?: boolean }).sequential) { + results.push(...(await Promise.all(parallelResults))); + parallelResults.length = 0; + results.push(await this.runHook(hookName, args, plugin)); + } else { + parallelResults.push(this.runHook(hookName, args, plugin)); + } } - return promise; + results.push(...(await Promise.all(parallelResults))); + return results.reduce(reducer, await initialValue); } // chains synchronously, reduces returned value to type T, handling the reduced value separately. permits hooks as values. hookReduceValueSync( hookName: H, initialValue: T, - args: Parameters, - reduce: (reduction: T, result: ReturnType, plugin: Plugin) => T, + args: Parameters, + reduce: (reduction: T, result: ReturnType, plugin: Plugin) => T, replaceContext?: ReplaceContext ): T { let acc = initialValue; - for (const plugin of this.plugins) { + for (const plugin of this.getSortedPlugins(hookName)) { const result = this.runHookSync(hookName, args, plugin, replaceContext); acc = reduce.call(this.pluginContexts.get(plugin), acc, result, plugin); } @@ -264,16 +261,23 @@ export class PluginDriver { // chains, ignores returns hookSeq( hookName: H, - args: Parameters, + args: Parameters, replaceContext?: ReplaceContext ): Promise { - let promise = Promise.resolve(); - for (const plugin of this.plugins) { - promise = promise.then( - () => this.runHook(hookName, args, plugin, false, replaceContext) as Promise - ); + let promise: Promise = Promise.resolve(); + for (const plugin of this.getSortedPlugins(hookName)) { + promise = promise.then(() => this.runHook(hookName, args, plugin, replaceContext)); } - return promise; + return promise.then(noReturn); + } + + private getSortedPlugins( + hookName: keyof FunctionPluginHooks | AddonHooks, + validateHandler?: (handler: unknown, hookName: string, plugin: Plugin) => void + ): Plugin[] { + return getOrCreate(this.sortedPlugins, hookName, () => + getSortedValidatedPlugins(hookName, this.plugins, validateHandler) + ); } /** @@ -281,50 +285,45 @@ export class PluginDriver { * @param hookName Name of the plugin hook. Must be either in `PluginHooks` or `OutputPluginValueHooks`. * @param args Arguments passed to the plugin hook. * @param plugin The actual pluginObject to run. - * @param permitValues If true, values can be passed instead of functions for the plugin hook. - * @param hookContext When passed, the plugin context can be overridden. + * @param replaceContext When passed, the plugin context can be overridden. */ - private runHook( + private runHook( hookName: H, args: Parameters, - plugin: Plugin, - permitValues: true, - hookContext?: ReplaceContext | null + plugin: Plugin ): EnsurePromise>; private runHook( hookName: H, - args: Parameters, + args: Parameters, plugin: Plugin, - permitValues: false, - hookContext?: ReplaceContext | null - ): EnsurePromise>; - private runHook( + replaceContext?: ReplaceContext | null + ): Promise>; + // Implementation signature + private runHook( hookName: H, - args: Parameters, + args: unknown[], plugin: Plugin, - permitValues: boolean, - hookContext?: ReplaceContext | null - ): EnsurePromise> { - const hook = plugin[hookName]; - if (!hook) return undefined as any; + replaceContext?: ReplaceContext | null + ): Promise { + // We always filter for plugins that support the hook before running it + const hook = plugin[hookName]!; + const handler = typeof hook === 'object' ? hook.handler : hook; let context = this.pluginContexts.get(plugin)!; - if (hookContext) { - context = hookContext(context, plugin); + if (replaceContext) { + context = replaceContext(context, plugin); } let action: [string, string, Parameters] | null = null; return Promise.resolve() .then(() => { - // permit values allows values to be returned instead of a functional hook - if (typeof hook !== 'function') { - if (permitValues) return hook; - return throwInvalidHookError(hookName, plugin.name); + if (typeof handler !== 'function') { + return handler; } // eslint-disable-next-line @typescript-eslint/ban-types - const hookResult = (hook as Function).apply(context, args); + const hookResult = (handler as Function).apply(context, args); - if (!hookResult || !hookResult.then) { + if (!hookResult?.then) { // short circuit for non-thenables and non-Promises return hookResult; } @@ -360,31 +359,71 @@ export class PluginDriver { * @param hookName Name of the plugin hook. Must be in `PluginHooks`. * @param args Arguments passed to the plugin hook. * @param plugin The acutal plugin - * @param hookContext When passed, the plugin context can be overridden. + * @param replaceContext When passed, the plugin context can be overridden. */ private runHookSync( hookName: H, - args: Parameters, + args: Parameters, plugin: Plugin, - hookContext?: ReplaceContext - ): ReturnType { - const hook = plugin[hookName]; - if (!hook) return undefined as any; + replaceContext?: ReplaceContext + ): ReturnType { + const hook = plugin[hookName]!; + const handler = typeof hook === 'object' ? hook.handler : hook; let context = this.pluginContexts.get(plugin)!; - if (hookContext) { - context = hookContext(context, plugin); + if (replaceContext) { + context = replaceContext(context, plugin); } try { - // permit values allows values to be returned instead of a functional hook - if (typeof hook !== 'function') { - return throwInvalidHookError(hookName, plugin.name); - } // eslint-disable-next-line @typescript-eslint/ban-types - return (hook as Function).apply(context, args); + return (handler as Function).apply(context, args); } catch (err: any) { return throwPluginError(err, plugin.name, { hook: hookName }); } } } + +export function getSortedValidatedPlugins( + hookName: keyof FunctionPluginHooks | AddonHooks, + plugins: readonly Plugin[], + validateHandler = validateFunctionPluginHandler +): Plugin[] { + const pre: Plugin[] = []; + const normal: Plugin[] = []; + const post: Plugin[] = []; + for (const plugin of plugins) { + const hook = plugin[hookName]; + if (hook) { + if (typeof hook === 'object') { + validateHandler(hook.handler, hookName, plugin); + if (hook.order === 'pre') { + pre.push(plugin); + continue; + } + if (hook.order === 'post') { + post.push(plugin); + continue; + } + } else { + validateHandler(hook, hookName, plugin); + } + normal.push(plugin); + } + } + return [...pre, ...normal, ...post]; +} + +function validateFunctionPluginHandler(handler: unknown, hookName: string, plugin: Plugin) { + if (typeof handler !== 'function') { + error(errInvalidFunctionPluginHook(hookName, plugin.name)); + } +} + +function validateAddonPluginHandler(handler: unknown, hookName: string, plugin: Plugin) { + if (typeof handler !== 'string' && typeof handler !== 'function') { + return error(errInvalidAddonPluginHook(hookName, plugin.name)); + } +} + +function noReturn() {} diff --git a/src/utils/error.ts b/src/utils/error.ts index f17ea17f0ab..44f1972700e 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -255,6 +255,24 @@ export function errInvalidOption( }; } +export function errInvalidAddonPluginHook(hook: string, plugin: string): RollupLogProps { + return { + code: Errors.INVALID_PLUGIN_HOOK, + hook, + message: `Error running plugin hook ${hook} for plugin ${plugin}, expected a string, a function hook or an object with a "handler" string or function.`, + plugin + }; +} + +export function errInvalidFunctionPluginHook(hook: string, plugin: string): RollupLogProps { + return { + code: Errors.INVALID_PLUGIN_HOOK, + hook, + message: `Error running plugin hook ${hook} for plugin ${plugin}, expected a function hook or an object with a "handler" function.`, + plugin + }; +} + export function errInvalidRollupPhaseForAddWatchFile(): RollupLogProps { return { code: Errors.INVALID_ROLLUP_PHASE, diff --git a/test/form/samples/enforce-addon-order/_config.js b/test/form/samples/enforce-addon-order/_config.js new file mode 100644 index 00000000000..730f6eda454 --- /dev/null +++ b/test/form/samples/enforce-addon-order/_config.js @@ -0,0 +1,40 @@ +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const acorn = require('acorn'); + +const ID_MAIN = path.join(__dirname, 'main.js'); +const code = fs.readFileSync(ID_MAIN, 'utf8'); + +const hooks = ['banner', 'footer', 'intro', 'outro']; + +const plugins = []; +addPlugin(null); +addPlugin('pre'); +addPlugin('post'); +addPlugin('post'); +addPlugin('pre'); +addPlugin(undefined); +function addPlugin(order) { + const name = `${order}-${(plugins.length >> 1) + 1}`; + const plugin = { name }; + const stringPlugin = { name: `string-${name}` }; + for (const hook of hooks) { + plugin[hook] = { + order, + handler: () => `// ${hook}-${name}` + }; + stringPlugin[hook] = { + order, + handler: `// ${hook}-string-${name}` + }; + } + plugins.push(plugin, stringPlugin); +} + +module.exports = { + description: 'allows to enforce addon order', + options: { + plugins + } +}; diff --git a/test/form/samples/enforce-addon-order/_expected.js b/test/form/samples/enforce-addon-order/_expected.js new file mode 100644 index 00000000000..ac10e4bb458 --- /dev/null +++ b/test/form/samples/enforce-addon-order/_expected.js @@ -0,0 +1,77 @@ + +// banner-pre-2 +// banner-string-pre-2 +// banner-pre-5 +// banner-string-pre-5 +// banner-null-1 +// banner-string-null-1 +// banner-undefined-6 +// banner-string-undefined-6 +// banner-post-3 +// banner-string-post-3 +// banner-post-4 +// banner-string-post-4 +// intro-pre-2 + +// intro-string-pre-2 + +// intro-pre-5 + +// intro-string-pre-5 + +// intro-null-1 + +// intro-string-null-1 + +// intro-undefined-6 + +// intro-string-undefined-6 + +// intro-post-3 + +// intro-string-post-3 + +// intro-post-4 + +// intro-string-post-4 + +console.log('main'); + + + +// outro-pre-2 + +// outro-string-pre-2 + +// outro-pre-5 + +// outro-string-pre-5 + +// outro-null-1 + +// outro-string-null-1 + +// outro-undefined-6 + +// outro-string-undefined-6 + +// outro-post-3 + +// outro-string-post-3 + +// outro-post-4 + +// outro-string-post-4 + +// footer-pre-2 +// footer-string-pre-2 +// footer-pre-5 +// footer-string-pre-5 +// footer-null-1 +// footer-string-null-1 +// footer-undefined-6 +// footer-string-undefined-6 +// footer-post-3 +// footer-string-post-3 +// footer-post-4 +// footer-string-post-4 diff --git a/test/form/samples/enforce-addon-order/main.js b/test/form/samples/enforce-addon-order/main.js new file mode 100644 index 00000000000..c0b933d7b56 --- /dev/null +++ b/test/form/samples/enforce-addon-order/main.js @@ -0,0 +1 @@ +console.log('main'); diff --git a/test/function/samples/enforce-plugin-order/_config.js b/test/function/samples/enforce-plugin-order/_config.js new file mode 100644 index 00000000000..edc2b2faf7c --- /dev/null +++ b/test/function/samples/enforce-plugin-order/_config.js @@ -0,0 +1,108 @@ +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const acorn = require('acorn'); + +const ID_MAIN = path.join(__dirname, 'main.js'); +const code = fs.readFileSync(ID_MAIN, 'utf8'); + +const hooks = [ + 'augmentChunkHash', + 'buildEnd', + 'buildStart', + 'generateBundle', + 'load', + 'moduleParsed', + 'options', + 'outputOptions', + 'renderDynamicImport', + 'renderChunk', + 'renderStart', + 'resolveDynamicImport', + 'resolveFileUrl', + 'resolveId', + 'resolveImportMeta', + 'shouldTransformCachedModule', + 'transform' +]; + +const calledHooks = {}; +for (const hook of hooks) { + calledHooks[hook] = []; +} + +const plugins = [ + { + name: 'emitter', + resolveId(source) { + if (source === 'dep') { + return source; + } + }, + load(source) { + if (source === 'dep') { + return `assert.okt(import.meta.url);\nassert.ok(import.meta.ROLLUP_FILE_URL_${this.emitFile( + { + type: 'asset', + source: 'test' + } + )});`; + } + } + } +]; +addPlugin(null); +addPlugin('pre'); +addPlugin('post'); +addPlugin('post'); +addPlugin('pre'); +addPlugin(undefined); +function addPlugin(order) { + const name = `${order}-${plugins.length}`; + const plugin = { name }; + for (const hook of hooks) { + plugin[hook] = { + order, + handler() { + if (!calledHooks[hook].includes(name)) { + calledHooks[hook].push(name); + } + } + }; + } + plugins.push(plugin); +} + +module.exports = { + description: 'allows to enforce plugin hook order', + options: { + plugins, + cache: { + modules: [ + { + id: ID_MAIN, + ast: acorn.parse(code, { + ecmaVersion: 2020, + sourceType: 'module' + }), + code, + dependencies: [], + dynamicDependencies: [], + originalCode: code, + resolvedIds: {}, + sourcemapChain: [], + transformDependencies: [] + } + ] + } + }, + exports() { + for (const hook of hooks) { + assert.deepStrictEqual( + calledHooks[hook], + ['pre-2', 'pre-5', 'null-1', 'undefined-6', 'post-3', 'post-4'], + hook + ); + } + } +}; diff --git a/test/function/samples/enforce-plugin-order/main.js b/test/function/samples/enforce-plugin-order/main.js new file mode 100644 index 00000000000..17f9d42facd --- /dev/null +++ b/test/function/samples/enforce-plugin-order/main.js @@ -0,0 +1 @@ +import('dep'); diff --git a/test/function/samples/enforce-sequential-plugin-order/_config.js b/test/function/samples/enforce-sequential-plugin-order/_config.js new file mode 100644 index 00000000000..f774ed6bad6 --- /dev/null +++ b/test/function/samples/enforce-sequential-plugin-order/_config.js @@ -0,0 +1,91 @@ +const assert = require('assert'); +const { wait } = require('../../../utils'); + +const hooks = [ + 'banner', + 'buildEnd', + 'buildStart', + 'footer', + 'intro', + 'moduleParsed', + 'outro', + 'renderStart' +]; + +const calledHooks = {}; +const activeHooks = {}; +for (const hook of hooks) { + calledHooks[hook] = []; + activeHooks[hook] = new Set(); +} + +const plugins = []; +addPlugin(null, true); +addPlugin('pre', false); +addPlugin('post', false); +addPlugin('post', false); +addPlugin('pre', false); +addPlugin(undefined, true); +addPlugin(null, false); +addPlugin('pre', true); +addPlugin('post', true); +addPlugin('post', true); +addPlugin('pre', true); +addPlugin(undefined, false); + +function addPlugin(order, sequential) { + const name = `${order}-${sequential ? 'seq-' : ''}${plugins.length + 1}`; + const plugin = { name }; + for (const hook of hooks) { + plugin[hook] = { + order, + async handler() { + const active = activeHooks[hook]; + if (!calledHooks[hook].includes(name)) { + calledHooks[hook].push(sequential ? name : [name, [...active]]); + } + if (sequential) { + if (active.size > 0) { + throw new Error(`Detected parallel hook runs in ${hook}.`); + } + } + active.add(name); + // A setTimeout always takes longer than any chain of immediately + // resolved promises + await wait(0); + active.delete(name); + }, + sequential + }; + } + plugins.push(plugin); +} + +module.exports = { + description: 'allows to enforce sequential plugin hook order for parallel plugin hooks', + options: { + plugins + }, + exports() { + for (const hook of hooks) { + assert.deepStrictEqual( + calledHooks[hook], + [ + ['pre-2', []], + ['pre-5', ['pre-2']], + 'pre-seq-8', + 'pre-seq-11', + 'null-seq-1', + 'undefined-seq-6', + ['null-7', []], + ['undefined-12', ['null-7']], + ['post-3', ['null-7', 'undefined-12']], + ['post-4', ['null-7', 'undefined-12', 'post-3']], + 'post-seq-9', + 'post-seq-10' + ], + hook + ); + } + } +}; diff --git a/test/function/samples/enforce-sequential-plugin-order/main.js b/test/function/samples/enforce-sequential-plugin-order/main.js new file mode 100644 index 00000000000..cc1d88a24fa --- /dev/null +++ b/test/function/samples/enforce-sequential-plugin-order/main.js @@ -0,0 +1 @@ +assert.ok(true); diff --git a/test/function/samples/invalid-addon-hook/_config.js b/test/function/samples/invalid-addon-hook/_config.js new file mode 100644 index 00000000000..95617db72ea --- /dev/null +++ b/test/function/samples/invalid-addon-hook/_config.js @@ -0,0 +1,13 @@ +module.exports = { + description: 'throws when providing a non-string value for an addon hook', + options: { + plugins: { + intro: 42 + } + }, + generateError: { + code: 'ADDON_ERROR', + message: + 'Could not retrieve intro. Check configuration of plugin at position 1.\n\tError Message: Error running plugin hook intro for plugin at position 1, expected a string, a function hook or an object with a "handler" string or function.' + } +}; diff --git a/test/function/samples/invalid-addon-hook/foo.js b/test/function/samples/invalid-addon-hook/foo.js new file mode 100644 index 00000000000..7a4e8a723a4 --- /dev/null +++ b/test/function/samples/invalid-addon-hook/foo.js @@ -0,0 +1 @@ +export default 42; diff --git a/test/function/samples/invalid-addon-hook/main.js b/test/function/samples/invalid-addon-hook/main.js new file mode 100644 index 00000000000..a25cfbd9058 --- /dev/null +++ b/test/function/samples/invalid-addon-hook/main.js @@ -0,0 +1 @@ +export default () => import('./foo.js'); diff --git a/test/function/samples/non-function-hook-async/_config.js b/test/function/samples/non-function-hook-async/_config.js index d17469d3926..bfba8d840ea 100644 --- a/test/function/samples/non-function-hook-async/_config.js +++ b/test/function/samples/non-function-hook-async/_config.js @@ -6,10 +6,10 @@ module.exports = { } }, error: { - code: 'PLUGIN_ERROR', + code: 'INVALID_PLUGIN_HOOK', hook: 'resolveId', - message: 'Error running plugin hook resolveId for at position 1, expected a function hook.', - plugin: 'at position 1', - pluginCode: 'INVALID_PLUGIN_HOOK' + message: + 'Error running plugin hook resolveId for plugin at position 1, expected a function hook or an object with a "handler" function.', + plugin: 'at position 1' } }; diff --git a/test/function/samples/non-function-hook-sync/_config.js b/test/function/samples/non-function-hook-sync/_config.js index c447f84ec77..0349ba53f78 100644 --- a/test/function/samples/non-function-hook-sync/_config.js +++ b/test/function/samples/non-function-hook-sync/_config.js @@ -6,10 +6,10 @@ module.exports = { } }, generateError: { - code: 'PLUGIN_ERROR', + code: 'INVALID_PLUGIN_HOOK', hook: 'outputOptions', - message: 'Error running plugin hook outputOptions for at position 1, expected a function hook.', - plugin: 'at position 1', - pluginCode: 'INVALID_PLUGIN_HOOK' + message: + 'Error running plugin hook outputOptions for plugin at position 1, expected a function hook or an object with a "handler" function.', + plugin: 'at position 1' } }; diff --git a/test/hooks/index.js b/test/hooks/index.js index b776bb9e808..1a6f71919f0 100644 --- a/test/hooks/index.js +++ b/test/hooks/index.js @@ -1,43 +1,38 @@ const assert = require('assert'); -const { readdirSync } = require('fs'); const path = require('path'); -const { removeSync } = require('fs-extra'); +const { outputFile, readdir, remove } = require('fs-extra'); const rollup = require('../../dist/rollup.js'); -const { loader } = require('../utils.js'); +const { loader, wait } = require('../utils.js'); const TEMP_DIR = path.join(__dirname, 'tmp'); describe('hooks', () => { - it('allows to replace file with dir in the outputOptions hook', () => - rollup - .rollup({ - input: 'input', - treeshake: false, - plugins: [ - loader({ - input: `console.log('input');import('other');`, - other: `console.log('other');` - }), - { - outputOptions(options) { - const newOptions = { ...options, dir: TEMP_DIR, chunkFileNames: 'chunk.js' }; - delete newOptions.file; - return newOptions; - } + it('allows to replace file with dir in the outputOptions hook', async () => { + const bundle = await rollup.rollup({ + input: 'input', + treeshake: false, + plugins: [ + loader({ + input: `console.log('input');import('other');`, + other: `console.log('other');` + }), + { + outputOptions(options) { + const newOptions = { ...options, dir: TEMP_DIR, chunkFileNames: 'chunk.js' }; + delete newOptions.file; + return newOptions; } - ] - }) - .then(bundle => - bundle.write({ - file: path.join(TEMP_DIR, 'bundle.js'), - format: 'es' - }) - ) - .then(() => { - const fileNames = readdirSync(TEMP_DIR).sort(); - assert.deepStrictEqual(fileNames, ['chunk.js', 'input.js']); - return removeSync(TEMP_DIR); - })); + } + ] + }); + await bundle.write({ + file: path.join(TEMP_DIR, 'bundle.js'), + format: 'es' + }); + const fileNames = (await readdir(TEMP_DIR)).sort(); + await remove(TEMP_DIR); + assert.deepStrictEqual(fileNames, ['chunk.js', 'input.js']); + }); it('supports buildStart and buildEnd hooks', () => { let buildStartCnt = 0; @@ -545,38 +540,35 @@ describe('hooks', () => { }) .then(bundle => bundle.generate({ format: 'es' }))); - it('supports writeBundle hook', () => { + it('supports writeBundle hook', async () => { const file = path.join(TEMP_DIR, 'bundle.js'); - let bundle; + let generatedBundle; let callCount = 0; - return rollup - .rollup({ - input: 'input', - plugins: [ - loader({ - input: `export { a as default } from 'dep';`, - dep: `export var a = 1; export var b = 2;` - }), - { - generateBundle(options, outputBundle, isWrite) { - bundle = outputBundle; - assert.strictEqual(isWrite, true); - } - }, - { - writeBundle(options, outputBundle) { - assert.deepStrictEqual(options.file, file); - assert.deepStrictEqual(outputBundle, bundle); - callCount++; - } + const bundle = await rollup.rollup({ + input: 'input', + plugins: [ + loader({ + input: `export { a as default } from 'dep';`, + dep: `export var a = 1; export var b = 2;` + }), + { + generateBundle(options, outputBundle, isWrite) { + generatedBundle = outputBundle; + assert.strictEqual(isWrite, true); } - ] - }) - .then(bundle => bundle.write({ format: 'es', file })) - .then(() => { - assert.strictEqual(callCount, 1); - return removeSync(TEMP_DIR); - }); + }, + { + writeBundle(options, outputBundle) { + assert.deepStrictEqual(options.file, file); + assert.deepStrictEqual(outputBundle, generatedBundle); + callCount++; + } + } + ] + }); + await bundle.write({ format: 'es', file }); + await remove(TEMP_DIR); + assert.strictEqual(callCount, 1); }); it('supports this.cache for plugins', () => @@ -1121,6 +1113,202 @@ describe('hooks', () => { }); }); + it('allows to enforce plugin hook order in watch mode', async () => { + const hooks = ['closeBundle', 'closeWatcher', 'renderError', 'watchChange', 'writeBundle']; + + const calledHooks = {}; + for (const hook of hooks) { + calledHooks[hook] = []; + } + + let first = true; + const plugins = [ + { + name: 'render-error', + renderChunk() { + if (first) { + first = false; + throw new Error('Expected render error'); + } + } + } + ]; + addPlugin(null); + addPlugin('pre'); + addPlugin('post'); + addPlugin('post'); + addPlugin('pre'); + addPlugin(undefined); + function addPlugin(order) { + const name = `${order}-${plugins.length}`; + const plugin = { name }; + for (const hook of hooks) { + plugin[hook] = { + order, + handler() { + if (!calledHooks[hook].includes(name)) { + calledHooks[hook].push(name); + } + } + }; + } + plugins.push(plugin); + } + + const ID_MAIN = path.join(TEMP_DIR, 'main.js'); + await outputFile(ID_MAIN, 'console.log(42);'); + await wait(100); + + const watcher = rollup.watch({ + input: ID_MAIN, + output: { + format: 'es', + dir: path.join(TEMP_DIR, 'out') + }, + plugins + }); + + return new Promise((resolve, reject) => { + watcher.on('event', async event => { + if (event.code === 'ERROR') { + if (event.error.message !== 'Expected render error') { + reject(event.error); + } + await wait(300); + await outputFile(ID_MAIN, 'console.log(43);'); + } else if (event.code === 'BUNDLE_END') { + await event.result.close(); + resolve(); + } + }); + }).finally(async () => { + await watcher.close(); + await remove(TEMP_DIR); + for (const hook of hooks) { + assert.deepStrictEqual( + calledHooks[hook], + ['pre-2', 'pre-5', 'null-1', 'undefined-6', 'post-3', 'post-4'], + hook + ); + } + }); + }); + + it('allows to enforce sequential plugin hook order in watch mode', async () => { + const hooks = ['closeBundle', 'closeWatcher', 'renderError', 'watchChange', 'writeBundle']; + + const calledHooks = {}; + const activeHooks = {}; + for (const hook of hooks) { + calledHooks[hook] = []; + activeHooks[hook] = new Set(); + } + + let first = true; + const plugins = [ + { + name: 'render-error', + renderChunk() { + if (first) { + first = false; + throw new Error('Expected render error'); + } + } + } + ]; + addPlugin(null, true); + addPlugin('pre', false); + addPlugin('post', false); + addPlugin('post', false); + addPlugin('pre', false); + addPlugin(undefined, true); + addPlugin(null, false); + addPlugin('pre', true); + addPlugin('post', true); + addPlugin('post', true); + addPlugin('pre', true); + addPlugin(undefined, false); + + function addPlugin(order, sequential) { + const name = `${order}-${sequential ? 'seq-' : ''}${plugins.length}`; + const plugin = { name }; + for (const hook of hooks) { + plugin[hook] = { + order, + async handler() { + const active = activeHooks[hook]; + if (!calledHooks[hook].includes(name)) { + calledHooks[hook].push(sequential ? name : [name, [...active]]); + } + if (sequential) { + if (active.size > 0) { + throw new Error(`Detected parallel hook runs in ${hook}.`); + } + } + active.add(name); + // A setTimeout always takes longer than any chain of immediately + // resolved promises + await wait(0); + active.delete(name); + }, + sequential + }; + } + plugins.push(plugin); + } + + const ID_MAIN = path.join(TEMP_DIR, 'main.js'); + await outputFile(ID_MAIN, 'console.log(42);'); + await wait(100); + + const watcher = rollup.watch({ + input: ID_MAIN, + output: { + format: 'es', + dir: path.join(TEMP_DIR, 'out') + }, + plugins + }); + + return new Promise((resolve, reject) => { + watcher.on('event', async event => { + if (event.code === 'ERROR') { + if (event.error.message !== 'Expected render error') { + reject(event.error); + } + await wait(300); + await outputFile(ID_MAIN, 'console.log(43);'); + } else if (event.code === 'BUNDLE_END') { + await event.result.close(); + resolve(); + } + }); + }).finally(async () => { + await watcher.close(); + await remove(TEMP_DIR); + for (const hook of hooks) { + assert.deepStrictEqual( + calledHooks[hook], + [ + ['pre-2', []], + ['pre-5', ['pre-2']], + 'pre-seq-8', + 'pre-seq-11', + 'null-seq-1', + 'undefined-seq-6', + ['null-7', []], + ['undefined-12', ['null-7']], + ['post-3', ['null-7', 'undefined-12']], + ['post-4', ['null-7', 'undefined-12', 'post-3']], + 'post-seq-9', + 'post-seq-10' + ], + hook + ); + } + }); + }); + describe('deprecated', () => { it('caches chunk emission in transform hook', () => { let cache; diff --git a/test/typescript/index.ts b/test/typescript/index.ts index a8b838b388e..ee9d433fbe9 100644 --- a/test/typescript/index.ts +++ b/test/typescript/index.ts @@ -5,14 +5,45 @@ import * as rollup from './dist/rollup'; interface Options { extensions?: string | string[]; } + const plugin: rollup.PluginImpl = (options = {}) => { const extensions = options.extensions || ['.js']; - return { name: 'my-plugin' }; + return { + name: 'my-plugin', + resolveId: { + handler(source, importer, options) { + // @ts-expect-error source is typed as string + const s: number = source; + } + } + }; }; plugin(); plugin({ extensions: ['.js', 'json'] }); +const pluginHooks: rollup.Plugin = { + buildStart: { + handler() {}, + sequential: true + }, + async load(id) { + // @ts-expect-error id is typed as string + const i: number = id; + await this.resolve('rollup'); + }, + name: 'test', + resolveId: { + async handler(source, importer, options) { + await this.resolve('rollup'); + // @ts-expect-error source is typed as string + const s: number = source; + }, + // @ts-expect-error sequential is only supported for parallel hooks + sequential: true + } +}; + const amdOutputOptions: rollup.OutputOptions['amd'][] = [ {}, {