From 1de599f089a2e19f6e3a01bd759dbe448363270c Mon Sep 17 00:00:00 2001 From: Lukas Taegert-Atkinson Date: Wed, 15 May 2019 08:09:44 +0200 Subject: [PATCH] Add options and hooks to control module side effects (#2844) * Implement basic support for returning the `pure` flag from `resolveId` * Handle conflicts between different resolutions by defaulting to impure * Add no-inferrable-types rule * Handle conflicts between resolutions without an opinion about pureness and others * Add purity to getModuleInfo, change logic to only use the first resolution to determine purity * Implement pureInternalModules and use as default * Split test, regenerate iterator on demand * Switch form pure to moduleSideEffects * Replace pureInternalModules with hasModuleSideEffects and refine logic * Implement load and transform hook handling * Test all versions of the moduleSideEffects option * Explain deprecation alternatives in JSDoc * Document new options * Try to fix Windows tests * Rename test folder, mark modules as executed in LocalVariable * Refine and simplify interaction of plugins with the user option. Now plugins always override the user option. * Add an option for this.resolve to skip the plugin calling it * Add "isEntry" to "getModuleInfo" * Provide "isEntry" information already in the load hook. --- docs/05-plugins.md | 43 ++++-- docs/999-big-list-of-options.md | 111 ++++++++++---- src/Chunk.ts | 8 +- src/ExternalModule.ts | 11 +- src/Graph.ts | 27 ++-- src/Module.ts | 46 +++--- src/ModuleLoader.ts | 143 +++++++++++++----- src/ast/scopes/BlockScope.ts | 2 +- src/ast/scopes/CatchScope.ts | 2 +- src/ast/values.ts | 4 +- src/ast/variables/LocalVariable.ts | 4 + src/ast/variables/NamespaceVariable.ts | 4 +- src/ast/variables/Variable.ts | 8 +- src/rollup/types.d.ts | 66 +++++--- src/utils/defaultPlugin.ts | 2 +- src/utils/error.ts | 8 + src/utils/executionOrder.ts | 7 - src/utils/mergeOptions.ts | 1 - src/utils/pluginDriver.ts | 32 ++-- src/utils/renderHelpers.ts | 8 +- src/utils/timers.ts | 4 +- src/utils/transform.ts | 48 ++++-- src/utils/traverseStaticDependencies.ts | 15 +- src/watch/index.ts | 4 +- .../context-resolve-skipself/_config.js | 42 +++++ .../context-resolve-skipself/existing.js | 1 + .../samples/context-resolve-skipself/main.js | 16 ++ .../samples/context-resolve/_config.js | 34 +++-- .../module-side-effects/array/_config.js | 56 +++++++ .../samples/module-side-effects/array/main.js | 8 + .../external-false/_config.js | 42 +++++ .../external-false/main.js | 3 + .../global-false/_config.js | 40 +++++ .../module-side-effects/global-false/main.js | 3 + .../invalid-option/_config.js | 15 ++ .../invalid-option/main.js | 1 + .../module-side-effects/load/_config.js | 43 ++++++ .../samples/module-side-effects/load/main.js | 9 ++ .../module-side-effects/reexports/_config.js | 17 +++ .../module-side-effects/reexports/dep1.js | 3 + .../module-side-effects/reexports/dep2.js | 3 + .../module-side-effects/reexports/lib.js | 4 + .../module-side-effects/reexports/main.js | 1 + .../resolve-id-external/_config.js | 84 ++++++++++ .../resolve-id-external/main.js | 25 +++ .../module-side-effects/resolve-id/_config.js | 87 +++++++++++ .../module-side-effects/resolve-id/main.js | 25 +++ .../module-side-effects/transform/_config.js | 71 +++++++++ .../module-side-effects/transform/main.js | 29 ++++ .../plugin-module-information/_config.js | 69 +++++---- test/incremental/index.js | 4 +- tslint.json | 1 + 52 files changed, 1099 insertions(+), 245 deletions(-) create mode 100644 test/function/samples/context-resolve-skipself/_config.js create mode 100644 test/function/samples/context-resolve-skipself/existing.js create mode 100644 test/function/samples/context-resolve-skipself/main.js create mode 100644 test/function/samples/module-side-effects/array/_config.js create mode 100644 test/function/samples/module-side-effects/array/main.js create mode 100644 test/function/samples/module-side-effects/external-false/_config.js create mode 100644 test/function/samples/module-side-effects/external-false/main.js create mode 100644 test/function/samples/module-side-effects/global-false/_config.js create mode 100644 test/function/samples/module-side-effects/global-false/main.js create mode 100644 test/function/samples/module-side-effects/invalid-option/_config.js create mode 100644 test/function/samples/module-side-effects/invalid-option/main.js create mode 100644 test/function/samples/module-side-effects/load/_config.js create mode 100644 test/function/samples/module-side-effects/load/main.js create mode 100644 test/function/samples/module-side-effects/reexports/_config.js create mode 100644 test/function/samples/module-side-effects/reexports/dep1.js create mode 100644 test/function/samples/module-side-effects/reexports/dep2.js create mode 100644 test/function/samples/module-side-effects/reexports/lib.js create mode 100644 test/function/samples/module-side-effects/reexports/main.js create mode 100644 test/function/samples/module-side-effects/resolve-id-external/_config.js create mode 100644 test/function/samples/module-side-effects/resolve-id-external/main.js create mode 100644 test/function/samples/module-side-effects/resolve-id/_config.js create mode 100644 test/function/samples/module-side-effects/resolve-id/main.js create mode 100644 test/function/samples/module-side-effects/transform/_config.js create mode 100644 test/function/samples/module-side-effects/transform/main.js diff --git a/docs/05-plugins.md b/docs/05-plugins.md index 4049760b826..80d43290036 100644 --- a/docs/05-plugins.md +++ b/docs/05-plugins.md @@ -140,10 +140,14 @@ Kind: `async, parallel` Cf. [`output.intro/output.outro`](guide/en#output-intro-output-outro). #### `load` -Type: `(id: string) => string | null | { code: string, map?: string | SourceMap }`
+Type: `(id: string) => string | null | { code: string, map?: string | SourceMap, ast? : ESTree.Program, moduleSideEffects?: boolean | null }`
Kind: `async, first` -Defines a custom loader. Returning `null` defers to other `load` functions (and eventually the default behavior of loading from the file system). +Defines a custom loader. Returning `null` defers to other `load` functions (and eventually the default behavior of loading from the file system). To prevent additional parsing overhead in case e.g. this hook already used `this.parse` to generate an AST for some reason, this hook can optionally return a `{ code, ast }` object. The `ast` must be a standard ESTree AST with `start` and `end` properties for each node. + +If `false` is returned for `moduleSideEffects` and no other module imports anything from this module, then this module will not be included in the bundle without checking for actual side-effects inside the module. If `true` is returned, Rollup will use its default algorithm to include all statements in the module that have side-effects (such as modifying a global or exported variable). If `null` is returned or the flag is omitted, then `moduleSideEffects` will be determined by the first `resolveId` hook that resolved this module, the `treeshake.moduleSideEffects` option, or eventually default to `true`. The `transform` hook can override this. + +You can use [`this.getModuleInfo`](guide/en#this-getmoduleinfo-moduleid-string-moduleinfo) to find out the previous value of `moduleSideEffects` inside this hook. #### `options` Type: `(options: InputOptions) => InputOptions | null`
@@ -224,10 +228,10 @@ resolveFileUrl({fileName}) { ``` #### `resolveId` -Type: `(source: string, importer: string) => string | false | null | {id: string, external?: boolean}`
+Type: `(source: string, importer: string) => string | false | null | {id: string, external?: boolean, moduleSideEffects?: boolean | null}`
Kind: `async, first` -Defines a custom resolver. A resolver can be useful for e.g. locating third-party dependencies. Returning `null` defers to other `resolveId` functions (and eventually the default resolution behavior); returning `false` signals that `source` should be treated as an external module and not included in the bundle. +Defines a custom resolver. A resolver can be useful for e.g. locating third-party dependencies. Returning `null` defers to other `resolveId` functions and eventually the default resolution behavior; returning `false` signals that `source` should be treated as an external module and not included in the bundle. If you return an object, then it is possible to resolve an import to a different id while excluding it from the bundle at the same time. This allows you to replace dependencies with external dependencies without the need for the user to mark them as "external" manually via the `external` option: @@ -240,6 +244,8 @@ resolveId(source) { } ``` +If `false` is returned for `moduleSideEffects` in the first hook that resolves a module id and no other module imports anything from this module, then this module will not be included without checking for actual side-effects inside the module. If `true` is returned, Rollup will use its default algorithm to include all statements in the module that have side-effects (such as modifying a global or exported variable). If `null` is returned or the flag is omitted, then `moduleSideEffects` will be determined by the `treeshake.moduleSideEffects` option or default to `true`. The `load` and `transform` hooks can override this. + #### `resolveImportMeta` Type: `(property: string | null, {chunkId: string, moduleId: string, format: string}) => string | null`
Kind: `sync, first` @@ -263,11 +269,16 @@ resolveImportMeta(property, {moduleId}) { Note that since this hook has access to the filename of the current chunk, its return value will not be considered when generating the hash of this chunk. #### `transform` -Type: `(code: string, id: string) => string | { code: string, map?: string | SourceMap, ast? : ESTree.Program } | null` -
+Type: `(code: string, id: string) => string | null | { code: string, map?: string | SourceMap, ast? : ESTree.Program, moduleSideEffects?: boolean | null }`
Kind: `async, sequential` -Can be used to transform individual modules. Note that in watch mode, the result of this hook is cached when rebuilding and the hook is only triggered again for a module `id` if either the `code` of the module has changed or a file has changed that was added via `this.addWatchFile` the last time the hook was triggered for this module. +Can be used to transform individual modules. To prevent additional parsing overhead in case e.g. this hook already used `this.parse` to generate an AST for some reason, this hook can optionally return a `{ code, ast }` object. The `ast` must be a standard ESTree AST with `start` and `end` properties for each node. + +Note that in watch mode, the result of this hook is cached when rebuilding and the hook is only triggered again for a module `id` if either the `code` of the module has changed or a file has changed that was added via `this.addWatchFile` the last time the hook was triggered for this module. + +If `false` is returned for `moduleSideEffects` and no other module imports anything from this module, then this module will not be included without checking for actual side-effects inside the module. If `true` is returned, Rollup will use its default algorithm to include all statements in the module that have side-effects (such as modifying a global or exported variable). If `null` is returned or the flag is omitted, then `moduleSideEffects` will be determined by the first `resolveId` hook that resolved this module, the `treeshake.moduleSideEffects` option, or eventually default to `true`. + +You can use [`this.getModuleInfo`](guide/en#this-getmoduleinfo-moduleid-string-moduleinfo) to find out the previous value of `moduleSideEffects` inside this hook. #### `watchChange` Type: `(id: string) => void`
@@ -346,11 +357,13 @@ Get the file name of an emitted chunk. The file name will be relative to `output Returns additional information about the module in question in the form -```js +``` { - id, // the id of the module, for convenience - isExternal, // for external modules that are not included in the graph - importedIds // the module ids imported by this module + id: string, // the id of the module, for convenience + isEntry: boolean, // is this a user- or plugin-defined entry point + isExternal: boolean, // for external modules that are not included in the graph + importedIds: string[], // the module ids imported by this module + hasModuleSideEffects: boolean // are imports of this module included if nothing is imported from it } ``` @@ -374,9 +387,11 @@ or converted into an Array via `Array.from(this.moduleIds)`. Use Rollup's internal acorn instance to parse code to an AST. -#### `this.resolve(source: string, importer: string) => Promise<{id: string, external: boolean} | null>` +#### `this.resolve(source: string, importer: string, options?: {skipSelf: boolean}) => Promise<{id: string, external: boolean} | null>` Resolve imports to module ids (i.e. file names) using the same plugins that Rollup uses, and determine if an import should be external. If `null` is returned, the import could not be resolved by Rollup or any plugin but was not explicitly marked as external by the user. +If you pass `skipSelf: true`, then the `resolveId` hook of the plugin from which `this.resolve` is called will be skipped when resolving. + #### `this.setAssetSource(assetReferenceId: string, source: string | Buffer) => void` Set the deferred source of an asset. @@ -502,10 +517,6 @@ export const size = 6; If you build this code, both the main chunk and the worklet will share the code from `config.js` via a shared chunk. This enables us to make use of the browser cache to reduce transmitted data and speed up loading the worklet. -### Advanced Loaders - -The `load` hook can optionally return a `{ code, ast }` object. The `ast` must be a standard ESTree AST with `start` and `end` properties for each node. - ### Transformers Transformer plugins (i.e. those that return a `transform` function for e.g. transpiling non-JS files) should support `options.include` and `options.exclude`, both of which can be a minimatch pattern or an array of minimatch patterns. If `options.include` is omitted or of zero length, files should be included by default; otherwise they should only be included if the ID matches one of the patterns. diff --git a/docs/999-big-list-of-options.md b/docs/999-big-list-of-options.md index ca20a3acff9..538136052c1 100755 --- a/docs/999-big-list-of-options.md +++ b/docs/999-big-list-of-options.md @@ -729,32 +729,13 @@ Default: `false` If this option is provided, bundling will not fail if bindings are imported from a file that does not define these bindings. Instead, new variables will be created for these bindings with the value `undefined`. #### treeshake -Type: `boolean | { propertyReadSideEffects?: boolean, annotations?: boolean, pureExternalModules?: boolean }`
+Type: `boolean | { annotations?: boolean, moduleSideEffects?: ModuleSideEffectsOption, propertyReadSideEffects?: boolean }`
CLI: `--treeshake`/`--no-treeshake`
Default: `true` Whether or not to apply tree-shaking and to fine-tune the tree-shaking process. Setting this option to `false` will produce bigger bundles but may improve build performance. If you discover a bug caused by the tree-shaking algorithm, please file an issue! Setting this option to an object implies tree-shaking is enabled and grants the following additional options: -**treeshake.propertyReadSideEffects** -Type: `boolean`
-CLI: `--treeshake.propertyReadSideEffects`/`--no-treeshake.propertyReadSideEffects`
-Default: `true` - -If `false`, assume reading a property of an object never has side-effects. Depending on your code, disabling this option can significantly reduce bundle size but can potentially break functionality if you rely on getters or errors from illegal property access. - -```javascript -// Will be removed if treeshake.propertyReadSideEffects === false -const foo = { - get bar() { - console.log('effect'); - return 'bar'; - } -} -const result = foo.bar; -const illegalAccess = foo.quux.tooDeep; -``` - **treeshake.annotations**
Type: `boolean`
CLI: `--treeshake.annotations`/`--no-treeshake.annotations`
@@ -774,12 +755,12 @@ class Impure { /*@__PURE__*/new Impure(); ``` -**treeshake.pureExternalModules**
-Type: `boolean`
-CLI: `--treeshake.pureExternalModules`/`--no-treeshake.pureExternalModules`
-Default: `false` +**treeshake.moduleSideEffects**
+Type: `boolean | "no-external" | string[] | (id: string, external: boolean) => boolean`
+CLI: `--treeshake.moduleSideEffects`/`--no-treeshake.moduleSideEffects`
+Default: `true` -If `true`, assume external dependencies from which nothing is imported do not have other side-effects like mutating global variables or logging. +If `false`, assume modules and external dependencies from which nothing is imported do not have other side-effects like mutating global variables or logging without checking. For external dependencies, this will suppress empty imports: ```javascript // input file @@ -789,17 +770,61 @@ console.log(42); ``` ```javascript -// output with treeshake.pureExternalModules === false +// output with treeshake.moduleSideEffects === true import 'external-a'; import 'external-b'; console.log(42); ``` ```javascript -// output with treeshake.pureExternalModules === true +// output with treeshake.moduleSideEffects === false +console.log(42); +``` + +For non-external modules, `false` will not include any statements from a module unless at least one import from this module is included: + +```javascript +// input file a.js +import {unused} from './b.js'; +console.log(42); + +// input file b.js +console.log('side-effect'); +``` + +```javascript +// output with treeshake.moduleSideEffects === true +console.log('side-effect'); + console.log(42); ``` +```javascript +// output with treeshake.moduleSideEffects === false +console.log(42); +``` + +You can also supply a list of modules with side-effects or a function to determine it for each module individually. The value `"no-external"` will only remove external imports if possible and is equivalent to the function `(id, external) => !external`; + +**treeshake.propertyReadSideEffects** +Type: `boolean`
+CLI: `--treeshake.propertyReadSideEffects`/`--no-treeshake.propertyReadSideEffects`
+Default: `true` + +If `false`, assume reading a property of an object never has side-effects. Depending on your code, disabling this option can significantly reduce bundle size but can potentially break functionality if you rely on getters or errors from illegal property access. + +```javascript +// Will be removed if treeshake.propertyReadSideEffects === false +const foo = { + get bar() { + console.log('effect'); + return 'bar'; + } +} +const result = foo.bar; +const illegalAccess = foo.quux.tooDeep; +``` + ### Experimental options These options reflect new features that have not yet been fully finalized. Availability, behaviour and usage may therefore be subject to change between minor versions. @@ -903,3 +928,35 @@ export default { } }; ``` + +### Deprecated options + +☢️ These options have been deprecated and may be removed in a future Rollup version. + +#### treeshake.pureExternalModules +Type: `boolean | string[] | (id: string) => boolean | null`
+CLI: `--treeshake.pureExternalModules`/`--no-treeshake.pureExternalModules`
+Default: `false` + +If `true`, assume external dependencies from which nothing is imported do not have other side-effects like mutating global variables or logging. + +```javascript +// input file +import {unused} from 'external-a'; +import 'external-b'; +console.log(42); +``` + +```javascript +// output with treeshake.pureExternalModules === false +import 'external-a'; +import 'external-b'; +console.log(42); +``` + +```javascript +// output with treeshake.pureExternalModules === true +console.log(42); +``` + +You can also supply a list of external ids to be considered pure or a function that is called whenever an external import could be removed. diff --git a/src/Chunk.ts b/src/Chunk.ts index f6df6ad2ba6..f27ed7e1ee5 100644 --- a/src/Chunk.ts +++ b/src/Chunk.ts @@ -114,10 +114,10 @@ export function isChunkRendered(chunk: Chunk): boolean { export default class Chunk { entryModules: Module[] = []; execIndex: number; - exportMode: string = 'named'; + exportMode = 'named'; facadeModule: Module | null = null; graph: Graph; - hasDynamicImport: boolean = false; + hasDynamicImport = false; id: string = undefined; indentString: string = undefined; isEmpty: boolean; @@ -135,7 +135,7 @@ export default class Chunk { private exportNames: { [name: string]: Variable } = Object.create(null); private exports = new Set(); private imports = new Set(); - private needsExportsShim: boolean = false; + private needsExportsShim = false; private renderedDeclarations: { dependencies: ChunkDependencies; exports: ChunkExports; @@ -753,7 +753,7 @@ export default class Chunk { if (depModule instanceof Module) { dependency = depModule.chunk; } else { - if (!depModule.used && this.graph.isPureExternalModule(depModule.id)) { + if (!(depModule.used || depModule.moduleSideEffects)) { continue; } dependency = depModule; diff --git a/src/ExternalModule.ts b/src/ExternalModule.ts index 0b5dae5f44c..3086891b7f9 100644 --- a/src/ExternalModule.ts +++ b/src/ExternalModule.ts @@ -10,13 +10,13 @@ export default class ExternalModule { execIndex: number; exportedVariables: Map; exportsNames = false; - exportsNamespace: boolean = false; + exportsNamespace = false; id: string; - isEntryPoint = false; isExternal = true; - mostCommonSuggestion: number = 0; + moduleSideEffects: boolean; + mostCommonSuggestion = 0; nameSuggestions: { [name: string]: number }; - reexported: boolean = false; + reexported = false; renderPath: string = undefined; renormalizeRenderPath = false; used = false; @@ -24,10 +24,11 @@ export default class ExternalModule { private graph: Graph; - constructor({ graph, id }: { graph: Graph; id: string }) { + constructor(graph: Graph, id: string, moduleSideEffects: boolean) { this.graph = graph; this.id = id; this.execIndex = Infinity; + this.moduleSideEffects = moduleSideEffects; const parts = id.split(/[\\/]/); this.variableName = makeLegal(parts.pop()); diff --git a/src/Graph.ts b/src/Graph.ts index dd40cb55caa..c45b0ed1528 100644 --- a/src/Graph.ts +++ b/src/Graph.ts @@ -67,10 +67,9 @@ export default class Graph { curChunkIndex = 0; deoptimizationTracker: EntityPathTracker; getModuleContext: (id: string) => string; - isPureExternalModule: (id: string) => boolean; moduleById = new Map(); moduleLoader: ModuleLoader; - needsTreeshakingPass: boolean = false; + needsTreeshakingPass = false; phase: BuildPhase = BuildPhase.LOAD_AND_PARSE; pluginDriver: PluginDriver; preserveModules: boolean; @@ -114,23 +113,17 @@ export default class Graph { this.treeshakingOptions = options.treeshake ? { annotations: (options.treeshake).annotations !== false, + moduleSideEffects: (options.treeshake).moduleSideEffects, propertyReadSideEffects: (options.treeshake).propertyReadSideEffects !== false, pureExternalModules: (options.treeshake).pureExternalModules } - : { propertyReadSideEffects: true, annotations: true, pureExternalModules: false }; - if (this.treeshakingOptions.pureExternalModules === true) { - this.isPureExternalModule = () => true; - } else if (typeof this.treeshakingOptions.pureExternalModules === 'function') { - this.isPureExternalModule = this.treeshakingOptions.pureExternalModules; - } else if (Array.isArray(this.treeshakingOptions.pureExternalModules)) { - const pureExternalModules = new Set(this.treeshakingOptions.pureExternalModules); - this.isPureExternalModule = id => pureExternalModules.has(id); - } else { - this.isPureExternalModule = () => false; - } - } else { - this.isPureExternalModule = () => false; + : { + annotations: true, + moduleSideEffects: true, + propertyReadSideEffects: true, + pureExternalModules: false + }; } this.contextParse = (code: string, options: acorn.Options = {}) => @@ -193,7 +186,9 @@ export default class Graph { this.moduleById, this.pluginDriver, options.external, - typeof options.manualChunks === 'function' && options.manualChunks + typeof options.manualChunks === 'function' && options.manualChunks, + this.treeshake ? this.treeshakingOptions.moduleSideEffects : null, + this.treeshake ? this.treeshakingOptions.pureExternalModules : false ); } diff --git a/src/Module.ts b/src/Module.ts index 0d415a4b4b5..afd720b00dc 100644 --- a/src/Module.ts +++ b/src/Module.ts @@ -34,7 +34,8 @@ import { RawSourceMap, ResolvedIdMap, RollupError, - RollupWarning + RollupWarning, + TransformModuleJSON } from './rollup/types'; import { error } from './utils/error'; import getCodeFrame from './utils/getCodeFrame'; @@ -46,7 +47,7 @@ import relativeId from './utils/relativeId'; import { RenderOptions } from './utils/renderHelpers'; import { SOURCEMAPPING_URL_RE } from './utils/sourceMappingURL'; import { timeEnd, timeStart } from './utils/timers'; -import { visitStaticModuleDependencies } from './utils/traverseStaticDependencies'; +import { markModuleAndImpureDependenciesAsExecuted } from './utils/traverseStaticDependencies'; import { MISSING_EXPORT_SHIM_VARIABLE } from './utils/variableNames'; export interface CommentDescription { @@ -176,7 +177,7 @@ export default class Module { }[] = []; entryPointsHash: Uint8Array = new Uint8Array(10); excludeFromSourcemap: boolean; - execIndex: number = Infinity; + execIndex = Infinity; exportAllModules: (Module | ExternalModule)[] = null; exportAllSources: string[] = []; exports: { [name: string]: ExportDescription } = Object.create(null); @@ -187,11 +188,12 @@ export default class Module { importDescriptions: { [name: string]: ImportDescription } = Object.create(null); importMetas: MetaProperty[] = []; imports = new Set(); - isEntryPoint: boolean = false; - isExecuted: boolean = false; + isEntryPoint: boolean; + isExecuted = false; isExternal: false; - isUserDefinedEntryPoint: boolean = false; + isUserDefinedEntryPoint = false; manualChunkAlias: string = null; + moduleSideEffects: boolean; originalCode: string; originalSourcemap: RawSourceMap | void; reexports: { [name: string]: ReexportDescription } = Object.create(null); @@ -200,7 +202,7 @@ export default class Module { sourcemapChain: RawSourceMap[]; sources: string[] = []; transformAssets: Asset[]; - usesTopLevelAwait: boolean = false; + usesTopLevelAwait = false; private ast: Program; private astContext: AstContext; @@ -211,11 +213,13 @@ export default class Module { private namespaceVariable: NamespaceVariable = undefined; private transformDependencies: string[]; - constructor(graph: Graph, id: string) { + constructor(graph: Graph, id: string, moduleSideEffects: boolean, isEntry: boolean) { this.id = id; this.graph = graph; this.excludeFromSourcemap = /\0/.test(id); this.context = graph.getModuleContext(id); + this.moduleSideEffects = moduleSideEffects; + this.isEntryPoint = isEntry; } basename() { @@ -410,11 +414,7 @@ export default class Module { includeAllExports() { if (!this.isExecuted) { this.graph.needsTreeshakingPass = true; - visitStaticModuleDependencies(this, module => { - if (module instanceof ExternalModule || module.isExecuted) return true; - module.isExecuted = true; - return false; - }); + markModuleAndImpureDependenciesAsExecuted(this); } for (const exportName of this.getExports()) { @@ -480,21 +480,25 @@ export default class Module { } setSource({ + ast, code, + customTransformCache, + moduleSideEffects, originalCode, originalSourcemap, - ast, - sourcemapChain, resolvedIds, - transformDependencies, - customTransformCache - }: ModuleJSON) { + sourcemapChain, + transformDependencies + }: TransformModuleJSON) { this.code = code; this.originalCode = originalCode; this.originalSourcemap = originalSourcemap; - this.sourcemapChain = sourcemapChain; + this.sourcemapChain = sourcemapChain as RawSourceMap[]; this.transformDependencies = transformDependencies; this.customTransformCache = customTransformCache; + if (typeof moduleSideEffects === 'boolean') { + this.moduleSideEffects = moduleSideEffects; + } timeStart('generate ast', 3); @@ -570,6 +574,7 @@ export default class Module { customTransformCache: this.customTransformCache, dependencies: this.dependencies.map(module => module.id), id: this.id, + moduleSideEffects: this.moduleSideEffects, originalCode: this.originalCode, originalSourcemap: this.originalSourcemap, resolvedIds: this.resolvedIds, @@ -768,11 +773,12 @@ export default class Module { } private includeVariable(variable: Variable) { + const variableModule = variable.module; if (!variable.included) { variable.include(); this.graph.needsTreeshakingPass = true; } - if (variable.module && variable.module !== this) { + if (variableModule && variableModule !== this) { this.imports.add(variable); } } diff --git a/src/ModuleLoader.ts b/src/ModuleLoader.ts index 140d83c0049..a729469fc20 100644 --- a/src/ModuleLoader.ts +++ b/src/ModuleLoader.ts @@ -6,10 +6,12 @@ import { ExternalOption, GetManualChunk, IsExternal, - ModuleJSON, + ModuleSideEffectsOption, + PureModulesOption, ResolvedId, ResolveIdResult, - SourceDescription + SourceDescription, + TransformModuleJSON } from './rollup/types'; import { errBadLoader, @@ -18,6 +20,7 @@ import { errChunkReferenceIdNotFoundForFilename, errEntryCannotBeExternal, errInternalIdCannotBeExternal, + errInvalidOption, errNamespaceConflict, error, errUnresolvedEntry, @@ -44,6 +47,52 @@ function normalizeRelativeExternalId(importer: string, source: string) { return isRelative(source) ? resolve(importer, '..', source) : source; } +function getIdMatcher>( + option: boolean | string[] | ((id: string, ...args: T) => boolean | void) +): (id: string, ...args: T) => boolean { + if (option === true) { + return () => true; + } else if (typeof option === 'function') { + return (id, ...args) => (!id.startsWith('\0') && option(id, ...args)) || false; + } else if (option) { + const ids = new Set(Array.isArray(option) ? option : option ? [option] : []); + return id => ids.has(id); + } else { + return () => false; + } +} + +function getHasModuleSideEffects( + moduleSideEffectsOption: ModuleSideEffectsOption, + pureExternalModules: PureModulesOption, + graph: Graph +): (id: string, external: boolean) => boolean { + if (typeof moduleSideEffectsOption === 'boolean') { + return () => moduleSideEffectsOption; + } + if (moduleSideEffectsOption === 'no-external') { + return (_id, external) => !external; + } + if (typeof moduleSideEffectsOption === 'function') { + return (id, external) => + !id.startsWith('\0') ? moduleSideEffectsOption(id, external) !== false : true; + } + if (Array.isArray(moduleSideEffectsOption)) { + const ids = new Set(moduleSideEffectsOption); + return id => ids.has(id); + } + if (moduleSideEffectsOption) { + graph.warn( + errInvalidOption( + 'treeshake.moduleSideEffects', + 'please use one of false, "no-external", a function or an array' + ) + ); + } + const isPureExternalModule = getIdMatcher(pureExternalModules); + return (id, external) => !(external && isPureExternalModule(id)); +} + export class ModuleLoader { readonly isExternal: IsExternal; private readonly entriesByReferenceId = new Map< @@ -53,6 +102,7 @@ export class ModuleLoader { private readonly entryModules: Module[] = []; private readonly getManualChunk: GetManualChunk; private readonly graph: Graph; + private readonly hasModuleSideEffects: (id: string, external: boolean) => boolean; private latestLoadModulesPromise: Promise = Promise.resolve(); private readonly manualChunkModules: Record = {}; private readonly modulesById: Map; @@ -63,18 +113,19 @@ export class ModuleLoader { modulesById: Map, pluginDriver: PluginDriver, external: ExternalOption, - getManualChunk: GetManualChunk | null + getManualChunk: GetManualChunk | null, + moduleSideEffects: ModuleSideEffectsOption, + pureExternalModules: PureModulesOption ) { this.graph = graph; this.modulesById = modulesById; this.pluginDriver = pluginDriver; - if (typeof external === 'function') { - this.isExternal = (id, parentId, isResolved) => - !id.startsWith('\0') && external(id, parentId, isResolved); - } else { - const ids = new Set(Array.isArray(external) ? external : external ? [external] : []); - this.isExternal = id => ids.has(id); - } + this.isExternal = getIdMatcher(external); + this.hasModuleSideEffects = getHasModuleSideEffects( + moduleSideEffects, + pureExternalModules, + graph + ); this.getManualChunk = typeof getManualChunk === 'function' ? getManualChunk : () => null; } @@ -108,7 +159,9 @@ export class ModuleLoader { newEntryModules: Module[]; }> { const loadNewEntryModulesPromise = Promise.all( - unresolvedEntryModules.map(this.loadEntryModule) + unresolvedEntryModules.map(unresolvedEntryModule => + this.loadEntryModule(unresolvedEntryModule, true) + ) ).then(entryModules => { for (const entryModule of entryModules) { entryModule.isUserDefinedEntryPoint = entryModule.isUserDefinedEntryPoint || isUserDefined; @@ -135,7 +188,9 @@ export class ModuleLoader { } } const loadNewManualChunkModulesPromise = Promise.all( - unresolvedManualChunks.map(this.loadEntryModule) + unresolvedManualChunks.map(unresolvedManualChunk => + this.loadEntryModule(unresolvedManualChunk, false) + ) ).then(manualChunkModules => { for (let index = 0; index < manualChunkModules.length; index++) { this.addToManualChunk( @@ -160,11 +215,11 @@ export class ModuleLoader { return fileName; } - resolveId(source: string, importer: string): Promise { + resolveId(source: string, importer: string, skip?: number | null): Promise { return Promise.resolve( this.isExternal(source, importer, false) ? { id: source, external: true } - : this.pluginDriver.hookFirst('resolveId', [source, importer]) + : this.pluginDriver.hookFirst('resolveId', [source, importer], null, skip) ).then((result: ResolveIdResult) => this.normalizeResolveIdResult(result, importer, source)); } @@ -224,14 +279,21 @@ export class ModuleLoader { ).then(() => fetchDynamicImportsPromise); } - private fetchModule(id: string, importer: string): Promise { + private fetchModule( + id: string, + importer: string, + moduleSideEffects: boolean, + isEntry: boolean + ): Promise { const existingModule = this.modulesById.get(id); if (existingModule) { - if (existingModule.isExternal) throw new Error(`Cannot fetch external module ${id}`); - return Promise.resolve(existingModule); + if (existingModule instanceof ExternalModule) + throw new Error(`Cannot fetch external module ${id}`); + existingModule.isEntryPoint = existingModule.isEntryPoint || isEntry; + return Promise.resolve(existingModule); } - const module: Module = new Module(this.graph, id); + const module: Module = new Module(this.graph, id, moduleSideEffects, isEntry); this.modulesById.set(id, module); const manualChunkAlias = this.getManualChunk(id); if (typeof manualChunkAlias === 'string') { @@ -252,19 +314,11 @@ export class ModuleLoader { }) .then(source => { timeEnd('load modules', 3); - if (typeof source === 'string') return source; + if (typeof source === 'string') return { code: source }; if (source && typeof source === 'object' && typeof source.code === 'string') return source; error(errBadLoader(id)); }) - .then(source => { - const sourceDescription: SourceDescription = - typeof source === 'string' - ? { - ast: null, - code: source - } - : source; - + .then(sourceDescription => { const cachedModule = this.graph.cachedModules.get(id); if ( cachedModule && @@ -279,11 +333,13 @@ export class ModuleLoader { return cachedModule; } + if (typeof sourceDescription.moduleSideEffects === 'boolean') { + module.moduleSideEffects = sourceDescription.moduleSideEffects; + } return transform(this.graph, sourceDescription, module); }) - .then((source: ModuleJSON) => { + .then((source: TransformModuleJSON) => { module.setSource(source); - this.modulesById.set(id, module); return this.fetchAllDependencies(module).then(() => { @@ -319,7 +375,7 @@ export class ModuleLoader { if (!this.modulesById.has(resolvedId.id)) { this.modulesById.set( resolvedId.id, - new ExternalModule({ graph: this.graph, id: resolvedId.id }) + new ExternalModule(this.graph, resolvedId.id, resolvedId.moduleSideEffects) ); } @@ -329,7 +385,7 @@ export class ModuleLoader { } return Promise.resolve(externalModule); } else { - return this.fetchModule(resolvedId.id, importer); + return this.fetchModule(resolvedId.id, importer, resolvedId.moduleSideEffects, false); } } @@ -343,12 +399,15 @@ export class ModuleLoader { error(errUnresolvedImport(source, importer)); } this.graph.warn(errUnresolvedImportTreatedAsExternal(source, importer)); - return { id: source, external: true }; + return { id: source, external: true, moduleSideEffects: true }; } return resolvedId; } - private loadEntryModule = ({ alias, unresolvedId }: UnresolvedModuleWithAlias): Promise => + private loadEntryModule = ( + { alias, unresolvedId }: UnresolvedModuleWithAlias, + isEntry: boolean + ): Promise => this.pluginDriver .hookFirst('resolveId', [unresolvedId, undefined]) .then((resolveIdResult: ResolveIdResult) => { @@ -364,7 +423,7 @@ export class ModuleLoader { : resolveIdResult; if (typeof id === 'string') { - return this.fetchModule(id, undefined).then(module => { + return this.fetchModule(id, undefined, true, isEntry).then(module => { if (alias !== null) { if (module.chunkAlias !== null && module.chunkAlias !== alias) { error(errCannotAssignModuleToChunk(module.id, alias, module.chunkAlias)); @@ -384,12 +443,16 @@ export class ModuleLoader { ): ResolvedId | null { let id = ''; let external = false; + let moduleSideEffects = null; if (resolveIdResult) { if (typeof resolveIdResult === 'object') { id = resolveIdResult.id; if (resolveIdResult.external) { external = true; } + if (typeof resolveIdResult.moduleSideEffects === 'boolean') { + moduleSideEffects = resolveIdResult.moduleSideEffects; + } } else { id = resolveIdResult; if (this.isExternal(id, importer, true)) { @@ -406,7 +469,14 @@ export class ModuleLoader { } external = true; } - return { id, external }; + return { + external, + id, + moduleSideEffects: + typeof moduleSideEffects === 'boolean' + ? moduleSideEffects + : this.hasModuleSideEffects(id, external) + }; } private resolveAndFetchDependency( @@ -441,6 +511,7 @@ export class ModuleLoader { } return { external: false, + moduleSideEffects: true, ...resolution }; } diff --git a/src/ast/scopes/BlockScope.ts b/src/ast/scopes/BlockScope.ts index 5464683bda9..b50141b778b 100644 --- a/src/ast/scopes/BlockScope.ts +++ b/src/ast/scopes/BlockScope.ts @@ -10,7 +10,7 @@ export default class BlockScope extends ChildScope { identifier: Identifier, context: AstContext, init: ExpressionEntity | null = null, - isHoisted: boolean = false + isHoisted = false ) { if (isHoisted) { return this.parent.addDeclaration( diff --git a/src/ast/scopes/CatchScope.ts b/src/ast/scopes/CatchScope.ts index 913c4dc9e31..bafa90a1e06 100644 --- a/src/ast/scopes/CatchScope.ts +++ b/src/ast/scopes/CatchScope.ts @@ -9,7 +9,7 @@ export default class CatchScope extends ParameterScope { identifier: Identifier, context: AstContext, init: ExpressionEntity | null = null, - isHoisted: boolean = false + isHoisted = false ) { if (isHoisted) { return this.parent.addDeclaration(identifier, context, init, true) as LocalVariable; diff --git a/src/ast/values.ts b/src/ast/values.ts index 4e68c6c9eed..329a60dcae0 100644 --- a/src/ast/values.ts +++ b/src/ast/values.ts @@ -79,7 +79,7 @@ const callsArgReturnsUnknown: RawMemberDescription = { }; export class UnknownArrayExpression implements ExpressionEntity { - included: boolean = false; + included = false; deoptimizePath() {} @@ -275,7 +275,7 @@ const returnsString: RawMemberDescription = { }; export class UnknownObjectExpression implements ExpressionEntity { - included: boolean = false; + included = false; deoptimizePath() {} diff --git a/src/ast/variables/LocalVariable.ts b/src/ast/variables/LocalVariable.ts index 54d82cc255a..964f83a622a 100644 --- a/src/ast/variables/LocalVariable.ts +++ b/src/ast/variables/LocalVariable.ts @@ -1,4 +1,5 @@ import Module, { AstContext } from '../../Module'; +import { markModuleAndImpureDependenciesAsExecuted } from '../../utils/traverseStaticDependencies'; import CallOptions from '../CallOptions'; import { DeoptimizableEntity } from '../DeoptimizableEntity'; import { ExecutionPathOptions } from '../ExecutionPathOptions'; @@ -173,6 +174,9 @@ export default class LocalVariable extends Variable { include() { if (!this.included) { this.included = true; + if (!this.module.isExecuted) { + markModuleAndImpureDependenciesAsExecuted(this.module); + } for (const declaration of this.declarations) { // If node is a default export, it can save a tree-shaking run to include the full declaration now if (!declaration.included) declaration.include(false); diff --git a/src/ast/variables/NamespaceVariable.ts b/src/ast/variables/NamespaceVariable.ts index b25570560ea..3f18b668c29 100644 --- a/src/ast/variables/NamespaceVariable.ts +++ b/src/ast/variables/NamespaceVariable.ts @@ -11,8 +11,8 @@ export default class NamespaceVariable extends Variable { memberVariables: { [name: string]: Variable } = Object.create(null); module: Module; - private containsExternalNamespace: boolean = false; - private referencedEarly: boolean = false; + private containsExternalNamespace = false; + private referencedEarly = false; private references: Identifier[] = []; constructor(context: AstContext) { diff --git a/src/ast/variables/Variable.ts b/src/ast/variables/Variable.ts index 272e53368b4..c7b2b5d21ec 100644 --- a/src/ast/variables/Variable.ts +++ b/src/ast/variables/Variable.ts @@ -10,15 +10,15 @@ import { LiteralValueOrUnknown, ObjectPath, UNKNOWN_EXPRESSION, UNKNOWN_VALUE } export default class Variable implements ExpressionEntity { exportName: string | null = null; - included: boolean = false; + included = false; isDefault?: boolean; isExternal?: boolean; - isId: boolean = false; + isId = false; isNamespace?: boolean; - isReassigned: boolean = false; + isReassigned = false; module: Module | ExternalModule | null; name: string; - reexported: boolean = false; + reexported = false; renderBaseName: string | null = null; renderName: string | null = null; safeExportName: string | null = null; diff --git a/src/rollup/types.d.ts b/src/rollup/types.d.ts index d2a83d7bff2..eec9479c6ab 100644 --- a/src/rollup/types.d.ts +++ b/src/rollup/types.d.ts @@ -65,27 +65,32 @@ export interface SourceDescription { ast?: ESTree.Program; code: string; map?: string | RawSourceMap; + moduleSideEffects?: boolean | null; } export interface TransformSourceDescription extends SourceDescription { dependencies?: string[]; } -export interface ModuleJSON { +export interface TransformModuleJSON { ast: ESTree.Program; code: string; // note if plugins use new this.cache to opt-out auto transform cache customTransformCache: boolean; - dependencies: string[]; - id: string; + moduleSideEffects: boolean | null; originalCode: string; originalSourcemap: RawSourceMap | void; - resolvedIds: ResolvedIdMap; - sourcemapChain: RawSourceMap[]; - transformAssets: Asset[] | void; + resolvedIds?: ResolvedIdMap; + sourcemapChain: (RawSourceMap | { missing: true; plugin: string })[]; transformDependencies: string[] | null; } +export interface ModuleJSON extends TransformModuleJSON { + dependencies: string[]; + id: string; + transformAssets: Asset[] | void; +} + export interface Asset { fileName: string; name: string; @@ -117,20 +122,26 @@ export interface PluginContext extends MinimalPluginContext { getModuleInfo: ( moduleId: string ) => { + hasModuleSideEffects: boolean; id: string; importedIds: string[]; + isEntry: boolean; isExternal: boolean; }; - /** @deprecated */ + /** @deprecated Use `this.resolve` instead */ isExternal: IsExternal; moduleIds: IterableIterator; parse: (input: string, options: any) => ESTree.Program; - resolve: (source: string, importer: string) => Promise; - /** @deprecated */ + resolve: ( + source: string, + importer: string, + options?: { skipSelf: boolean } + ) => Promise; + /** @deprecated Use `this.resolve` instead */ resolveId: (source: string, importer: string) => Promise; setAssetSource: (assetReferenceId: string, source: string | Buffer) => void; warn: (warning: RollupWarning | string, pos?: { column: number; line: number }) => void; - /** @deprecated */ + /** @deprecated Use `this.addWatchFile` and the `watchChange` hook instead */ watcher: EventEmitter; } @@ -141,13 +152,18 @@ export interface PluginContextMeta { export interface ResolvedId { external: boolean; id: string; + moduleSideEffects: boolean; } export interface ResolvedIdMap { [key: string]: ResolvedId; } -type PartialResolvedId = Partial & { id: string }; +interface PartialResolvedId { + external?: boolean; + id: string; + moduleSideEffects?: boolean | null; +} export type ResolveIdResult = string | false | void | PartialResolvedId; @@ -159,20 +175,22 @@ export type ResolveIdHook = ( export type IsExternal = (source: string, importer: string, isResolved: boolean) => boolean | void; +export type IsPureModule = (id: string) => boolean | void; + +export type HasModuleSideEffects = (id: string, external: boolean) => boolean; + export type LoadHook = ( this: PluginContext, id: string ) => Promise | SourceDescription | string | null; +export type TransformResult = string | void | TransformSourceDescription; + export type TransformHook = ( this: PluginContext, code: string, id: string -) => - | Promise - | TransformSourceDescription - | string - | void; +) => Promise | TransformResult; export type TransformChunkHook = ( this: PluginContext, @@ -267,13 +285,13 @@ export interface PluginHooks { isWrite: boolean ) => void | Promise; load?: LoadHook; - /** @deprecated */ + /** @deprecated Use `generateBundle` instead */ ongenerate?: ( this: PluginContext, options: OnGenerateOptions, chunk: OutputChunk ) => void | Promise; - /** @deprecated */ + /** @deprecated Use `writeBundle` instead */ onwrite?: ( this: PluginContext, options: OnWriteOptions, @@ -284,16 +302,16 @@ export interface PluginHooks { renderChunk?: RenderChunkHook; renderError?: (this: PluginContext, err?: Error) => Promise | void; renderStart?: (this: PluginContext) => Promise | void; - /** @deprecated */ + /** @deprecated Use `resolveFileUrl` instead */ resolveAssetUrl?: ResolveAssetUrlHook; resolveDynamicImport?: ResolveDynamicImportHook; resolveFileUrl?: ResolveFileUrlHook; resolveId?: ResolveIdHook; resolveImportMeta?: ResolveImportMetaHook; transform?: TransformHook; - /** @deprecated */ + /** @deprecated Use `renderChunk` instead */ transformBundle?: TransformChunkHook; - /** @deprecated */ + /** @deprecated Use `renderChunk` instead */ transformChunk?: TransformChunkHook; watchChange?: (id: string) => void; writeBundle?: (this: PluginContext, bundle: OutputBundle) => void | Promise; @@ -310,16 +328,20 @@ export interface Plugin extends PluginHooks { export interface TreeshakingOptions { annotations?: boolean; + moduleSideEffects?: ModuleSideEffectsOption; propertyReadSideEffects?: boolean; - pureExternalModules?: boolean; + /** @deprecated Use `moduleSideEffects` instead */ + pureExternalModules?: PureModulesOption; } export type GetManualChunk = (id: string) => string | void; export type ExternalOption = string[] | IsExternal; +export type PureModulesOption = boolean | string[] | IsPureModule; export type GlobalsOption = { [name: string]: string } | ((name: string) => string); export type InputOption = string | string[] | { [entryAlias: string]: string }; export type ManualChunksOption = { [chunkAlias: string]: string[] } | GetManualChunk; +export type ModuleSideEffectsOption = boolean | 'no-external' | string[] | HasModuleSideEffects; export interface InputOptions { acorn?: any; diff --git a/src/utils/defaultPlugin.ts b/src/utils/defaultPlugin.ts index f447699ff2e..9aafad010ad 100644 --- a/src/utils/defaultPlugin.ts +++ b/src/utils/defaultPlugin.ts @@ -73,7 +73,7 @@ function createResolveId(preserveSymlinks: boolean) { }; } -const getResolveUrl = (path: string, URL: string = 'URL') => `new ${URL}(${path}).href`; +const getResolveUrl = (path: string, URL = 'URL') => `new ${URL}(${path}).href`; const getUrlFromDocument = (chunkId: string) => `(document.currentScript && document.currentScript.src || new URL('${chunkId}', document.baseURI).href)`; diff --git a/src/utils/error.ts b/src/utils/error.ts index 95e1f460805..2ef5f2fce2d 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -42,6 +42,7 @@ export enum Errors { INVALID_ASSET_NAME = 'INVALID_ASSET_NAME', INVALID_CHUNK = 'INVALID_CHUNK', INVALID_EXTERNAL_ID = 'INVALID_EXTERNAL_ID', + INVALID_OPTION = 'INVALID_OPTION', INVALID_PLUGIN_HOOK = 'INVALID_PLUGIN_HOOK', INVALID_ROLLUP_PHASE = 'INVALID_ROLLUP_PHASE', NAMESPACE_CONFLICT = 'NAMESPACE_CONFLICT', @@ -149,6 +150,13 @@ export function errInternalIdCannotBeExternal(source: string, importer: string) }; } +export function errInvalidOption(option: string, explanation: string) { + return { + code: Errors.INVALID_OPTION, + message: `Invalid value for option "${option}" - ${explanation}.` + }; +} + export function errInvalidRollupPhaseForAddWatchFile() { return { code: Errors.INVALID_ROLLUP_PHASE, diff --git a/src/utils/executionOrder.ts b/src/utils/executionOrder.ts index 29f685abb78..fb02212b2ae 100644 --- a/src/utils/executionOrder.ts +++ b/src/utils/executionOrder.ts @@ -15,7 +15,6 @@ export function sortByExecutionOrder(units: OrderedExecutionUnit[]) { export function analyseModuleExecution(entryModules: Module[]) { let nextExecIndex = 0; - let inStaticGraph = true; const cyclePaths: string[][] = []; const analysedModules: { [id: string]: boolean } = {}; const orderedModules: Module[] = []; @@ -31,9 +30,6 @@ export function analyseModuleExecution(entryModules: Module[]) { return; } - if (inStaticGraph) { - module.isExecuted = true; - } for (const dependency of module.dependencies) { if (dependency.id in parents) { if (!analysedModules[dependency.id]) { @@ -57,14 +53,11 @@ export function analyseModuleExecution(entryModules: Module[]) { }; for (const curEntry of entryModules) { - curEntry.isEntryPoint = true; if (!parents[curEntry.id]) { parents[curEntry.id] = null; analyseModule(curEntry); } } - - inStaticGraph = false; for (const curEntry of dynamicImports) { if (!parents[curEntry.id]) { parents[curEntry.id] = null; diff --git a/src/utils/mergeOptions.ts b/src/utils/mergeOptions.ts index 63d2a2fcb14..37016d94b5f 100644 --- a/src/utils/mergeOptions.ts +++ b/src/utils/mergeOptions.ts @@ -56,7 +56,6 @@ const getOnWarn = ( ? warning => config.onwarn(warning, defaultOnWarnHandler) : defaultOnWarnHandler; -// TODO Lukas manual chunks should receive the same treatment const getExternal = (config: GenericConfigObject, command: GenericConfigObject) => { const configExternal = config.external; return typeof configExternal === 'function' diff --git a/src/utils/pluginDriver.ts b/src/utils/pluginDriver.ts index cea0d193bbe..0101233a8df 100644 --- a/src/utils/pluginDriver.ts +++ b/src/utils/pluginDriver.ts @@ -1,5 +1,6 @@ import { EventEmitter } from 'events'; import { version as rollupVersion } from 'package.json'; +import ExternalModule from '../ExternalModule'; import Graph from '../Graph'; import Module from '../Module'; import { @@ -32,7 +33,8 @@ export interface PluginDriver { hookFirst>( hook: H, args: Args, - hookContext?: HookContext + hookContext?: HookContext | null, + skip?: number ): Promise; hookFirstSync>( hook: H, @@ -174,25 +176,34 @@ export function createPluginDriver( } return { + hasModuleSideEffects: foundModule.moduleSideEffects, id: foundModule.id, - importedIds: foundModule.isExternal - ? [] - : (foundModule as Module).sources.map(id => (foundModule as Module).resolvedIds[id].id), - isExternal: !!foundModule.isExternal + importedIds: + foundModule instanceof ExternalModule + ? [] + : foundModule.sources.map(id => foundModule.resolvedIds[id].id), + isEntry: foundModule instanceof Module && foundModule.isEntryPoint, + isExternal: foundModule instanceof ExternalModule }; }, meta: { rollupVersion }, - moduleIds: graph.moduleById.keys(), + get moduleIds() { + return graph.moduleById.keys(); + }, parse: graph.contextParse, resolveId(source, importer) { return graph.moduleLoader .resolveId(source, importer) .then(resolveId => resolveId && resolveId.id); }, - resolve(source, importer) { - return graph.moduleLoader.resolveId(source, importer); + resolve(source, importer, options?: { skipSelf: boolean }) { + return graph.moduleLoader.resolveId( + source, + importer, + options && options.skipSelf ? pidx : null + ); }, setAssetSource, warn(warning) { @@ -262,7 +273,7 @@ export function createPluginDriver( args: any[], pluginIndex: number, permitValues = false, - hookContext?: HookContext + hookContext?: HookContext | null ): Promise { const plugin = plugins[pluginIndex]; let context = pluginContexts[pluginIndex]; @@ -322,9 +333,10 @@ export function createPluginDriver( }, // chains, first non-null result stops and returns - hookFirst(name, args, hookContext) { + hookFirst(name, args, hookContext, skip) { let promise: Promise = Promise.resolve(); for (let i = 0; i < plugins.length; i++) { + if (skip === i) continue; promise = promise.then((result: any) => { if (result != null) return result; return runHook(name, args, i, false, hookContext); diff --git a/src/utils/renderHelpers.ts b/src/utils/renderHelpers.ts index 00ce060c94b..2bd6a63ebf9 100644 --- a/src/utils/renderHelpers.ts +++ b/src/utils/renderHelpers.ts @@ -23,11 +23,7 @@ export interface NodeRenderOptions { export const NO_SEMICOLON: NodeRenderOptions = { isNoStatement: true }; -export function findFirstOccurrenceOutsideComment( - code: string, - searchString: string, - start: number = 0 -) { +export function findFirstOccurrenceOutsideComment(code: string, searchString: string, start = 0) { let searchPos, charCodeAfterSlash; searchPos = code.indexOf(searchString, start); while (true) { @@ -50,7 +46,7 @@ export function findFirstOccurrenceOutsideComment( } } -export function findFirstLineBreakOutsideComment(code: string, start: number = 0) { +export function findFirstLineBreakOutsideComment(code: string, start = 0) { let lineBreakPos, charCodeAfterSlash; lineBreakPos = code.indexOf('\n', start); while (true) { diff --git a/src/utils/timers.ts b/src/utils/timers.ts index 884b62dd484..f974273a7a3 100644 --- a/src/utils/timers.ts +++ b/src/utils/timers.ts @@ -48,7 +48,7 @@ function getPersistedLabel(label: string, level: number) { } } -function timeStartImpl(label: string, level: number = 3) { +function timeStartImpl(label: string, level = 3) { label = getPersistedLabel(label, level); if (!timers.hasOwnProperty(label)) { timers[label] = { @@ -64,7 +64,7 @@ function timeStartImpl(label: string, level: number = 3) { timers[label].startMemory = currentMemory; } -function timeEndImpl(label: string, level: number = 3) { +function timeEndImpl(label: string, level = 3) { label = getPersistedLabel(label, level); if (timers.hasOwnProperty(label)) { const currentMemory = getMemory(); diff --git a/src/utils/transform.ts b/src/utils/transform.ts index 21b32dda282..56b1f321d10 100644 --- a/src/utils/transform.ts +++ b/src/utils/transform.ts @@ -1,17 +1,18 @@ -import * as ESTree from 'estree'; import { decode } from 'sourcemap-codec'; -import Program from '../ast/nodes/Program'; import Graph from '../Graph'; import Module from '../Module'; import { Asset, EmitAsset, + ExistingRawSourceMap, Plugin, PluginCache, PluginContext, RawSourceMap, RollupError, RollupWarning, + TransformModuleJSON, + TransformResult, TransformSourceDescription } from '../rollup/types'; import { createTransformEmitAsset } from './assetHooks'; @@ -23,9 +24,9 @@ export default function transform( graph: Graph, source: TransformSourceDescription, module: Module -) { +): Promise { const id = module.id; - const sourcemapChain: RawSourceMap[] = []; + const sourcemapChain: (RawSourceMap | { missing: true; plugin: string })[] = []; const originalSourcemap = typeof source.map === 'string' ? JSON.parse(source.map) : source.map; if (originalSourcemap && typeof originalSourcemap.mappings === 'string') @@ -33,19 +34,25 @@ export default function transform( const baseEmitAsset = graph.pluginDriver.emitAsset; const originalCode = source.code; - let ast = source.ast; + let ast = source.ast; let transformDependencies: string[]; let assets: Asset[]; let customTransformCache = false; + let moduleSideEffects: boolean | null = null; let trackedPluginCache: { cache: PluginCache; used: boolean }; let curPlugin: Plugin; const curSource: string = source.code; - function transformReducer(this: PluginContext, code: string, result: any, plugin: Plugin) { + function transformReducer( + this: PluginContext, + code: string, + result: TransformResult, + plugin: Plugin + ) { // track which plugins use the custom this.cache to opt-out of transform caching if (!customTransformCache && trackedPluginCache.used) customTransformCache = true; if (customTransformCache) { - if (result && Array.isArray(result.dependencies)) { + if (result && typeof result === 'object' && Array.isArray(result.dependencies)) { for (const dep of result.dependencies) { const depId = resolve(dirname(id), dep); if (!graph.watchFiles[depId]) graph.watchFiles[depId] = true; @@ -55,7 +62,7 @@ export default function transform( // assets emitted by transform are transformDependencies if (assets.length) module.transformAssets = assets; - if (result && Array.isArray(result.dependencies)) { + if (result && typeof result === 'object' && Array.isArray(result.dependencies)) { // not great, but a useful way to track this without assuming WeakMap if (!(curPlugin).warnedTransformDependencies) this.warn({ @@ -69,7 +76,7 @@ export default function transform( } } - if (result == null) return code; + if (!result) return code; if (typeof result === 'string') { result = { @@ -77,18 +84,26 @@ export default function transform( code: result, map: undefined }; - } else if (typeof result.map === 'string') { - // `result.map` can only be a string if `result` isn't - result.map = JSON.parse(result.map); + } else { + if (typeof result.map === 'string') { + result.map = JSON.parse(result.map); + } + if (typeof result.moduleSideEffects === 'boolean') { + moduleSideEffects = result.moduleSideEffects; + } } - if (result.map && typeof result.map.mappings === 'string') { - result.map.mappings = decode(result.map.mappings); + if (result.map && typeof (result.map as ExistingRawSourceMap).mappings === 'string') { + (result.map as ExistingRawSourceMap).mappings = decode( + (result.map as ExistingRawSourceMap).mappings + ); } // strict null check allows 'null' maps to not be pushed to the chain, while 'undefined' gets the missing map warning if (result.map !== null) { - sourcemapChain.push(result.map || { missing: true, plugin: plugin.name }); + sourcemapChain.push( + (result.map as ExistingRawSourceMap) || { missing: true, plugin: plugin.name } + ); } ast = result.ast; @@ -162,9 +177,10 @@ export default function transform( if (!customTransformCache && setAssetSourceErr) throw setAssetSourceErr; return { - ast: ast, + ast, code, customTransformCache, + moduleSideEffects, originalCode, originalSourcemap, sourcemapChain, diff --git a/src/utils/traverseStaticDependencies.ts b/src/utils/traverseStaticDependencies.ts index a2cb4b12ac3..e8cc8345308 100644 --- a/src/utils/traverseStaticDependencies.ts +++ b/src/utils/traverseStaticDependencies.ts @@ -2,16 +2,19 @@ import ExternalModule from '../ExternalModule'; import Module from '../Module'; import { NameCollection } from './reservedNames'; -export function visitStaticModuleDependencies( - baseModule: Module | ExternalModule, - areDependenciesSkipped: (module: Module | ExternalModule) => boolean -) { +export function markModuleAndImpureDependenciesAsExecuted(baseModule: Module) { + baseModule.isExecuted = true; const modules = [baseModule]; const visitedModules: NameCollection = {}; for (const module of modules) { - if (areDependenciesSkipped(module) || module instanceof ExternalModule) continue; for (const dependency of module.dependencies) { - if (!visitedModules[dependency.id]) { + if ( + !(dependency instanceof ExternalModule) && + !dependency.isExecuted && + dependency.moduleSideEffects && + !visitedModules[dependency.id] + ) { + dependency.isExecuted = true; visitedModules[dependency.id] = true; modules.push(dependency); } diff --git a/src/watch/index.ts b/src/watch/index.ts index 519ad166a2e..fc809b0a567 100644 --- a/src/watch/index.ts +++ b/src/watch/index.ts @@ -23,9 +23,9 @@ export class Watcher { private buildTimeout: NodeJS.Timer; private invalidatedIds: Set = new Set(); - private rerun: boolean = false; + private rerun = false; private running: boolean; - private succeeded: boolean = false; + private succeeded = false; private tasks: Task[]; constructor(configs: RollupWatchOptions[]) { diff --git a/test/function/samples/context-resolve-skipself/_config.js b/test/function/samples/context-resolve-skipself/_config.js new file mode 100644 index 00000000000..8ed89faf6e3 --- /dev/null +++ b/test/function/samples/context-resolve-skipself/_config.js @@ -0,0 +1,42 @@ +const path = require('path'); + +module.exports = { + description: 'allows a plugin to skip its own resolveId hook when using this.resolve', + options: { + plugins: [ + { + resolveId(id) { + if (id === 'resolutions') { + return id; + } + if (id.startsWith('test')) { + return 'own-resolution'; + } + }, + load(id) { + if (id === 'resolutions') { + const importer = path.resolve(__dirname, 'main.js'); + return Promise.all([ + this.resolve('test', importer).then(result => ({ id: result.id, text: 'all' })), + this.resolve('test', importer, { skipSelf: false }).then(result => ({ + id: result.id, + text: 'unskipped' + })), + this.resolve('test', importer, { skipSelf: true }).then(result => ({ + id: result.id, + text: 'skipped' + })) + ]).then(result => `export default ${JSON.stringify(result)};`); + } + } + }, + { + resolveId(id) { + if (id.startsWith('test')) { + return 'other-resolution'; + } + } + } + ] + } +}; diff --git a/test/function/samples/context-resolve-skipself/existing.js b/test/function/samples/context-resolve-skipself/existing.js new file mode 100644 index 00000000000..9486260537f --- /dev/null +++ b/test/function/samples/context-resolve-skipself/existing.js @@ -0,0 +1 @@ +console.log('existing'); diff --git a/test/function/samples/context-resolve-skipself/main.js b/test/function/samples/context-resolve-skipself/main.js new file mode 100644 index 00000000000..a0be821e3ed --- /dev/null +++ b/test/function/samples/context-resolve-skipself/main.js @@ -0,0 +1,16 @@ +import resolutions from 'resolutions'; + +assert.deepStrictEqual(resolutions, [ + { + id: 'own-resolution', + text: 'all' + }, + { + id: 'own-resolution', + text: 'unskipped' + }, + { + id: 'other-resolution', + text: 'skipped' + } +]); diff --git a/test/function/samples/context-resolve/_config.js b/test/function/samples/context-resolve/_config.js index 8a6d1eff991..88aed16c12a 100644 --- a/test/function/samples/context-resolve/_config.js +++ b/test/function/samples/context-resolve/_config.js @@ -4,7 +4,11 @@ const assert = require('assert'); const tests = [ { source: './existing', - expected: { id: path.resolve(__dirname, 'existing.js'), external: false } + expected: { + id: path.resolve(__dirname, 'existing.js'), + external: false, + moduleSideEffects: true + } }, { source: './missing-relative', @@ -16,35 +20,47 @@ const tests = [ }, { source: './marked-directly-external-relative', - expected: { id: path.resolve(__dirname, 'marked-directly-external-relative'), external: true } + expected: { + id: path.resolve(__dirname, 'marked-directly-external-relative'), + external: true, + moduleSideEffects: true + } }, { source: './marked-external-relative', - expected: { id: path.resolve(__dirname, 'marked-external-relative'), external: true } + expected: { + id: path.resolve(__dirname, 'marked-external-relative'), + external: true, + moduleSideEffects: true + } }, { source: 'marked-external-absolute', - expected: { id: 'marked-external-absolute', external: true } + expected: { id: 'marked-external-absolute', external: true, moduleSideEffects: true } }, { source: 'resolved-name', - expected: { id: 'resolved:resolved-name', external: false } + expected: { id: 'resolved:resolved-name', external: false, moduleSideEffects: true } }, { source: 'resolved-false', - expected: { id: 'resolved-false', external: true } + expected: { id: 'resolved-false', external: true, moduleSideEffects: true } }, { source: 'resolved-object', - expected: { id: 'resolved:resolved-object', external: false } + expected: { id: 'resolved:resolved-object', external: false, moduleSideEffects: true } }, { source: 'resolved-object-non-external', - expected: { id: 'resolved:resolved-object-non-external', external: false } + expected: { + id: 'resolved:resolved-object-non-external', + external: false, + moduleSideEffects: true + } }, { source: 'resolved-object-external', - expected: { id: 'resolved:resolved-object-external', external: true } + expected: { id: 'resolved:resolved-object-external', external: true, moduleSideEffects: true } } ]; diff --git a/test/function/samples/module-side-effects/array/_config.js b/test/function/samples/module-side-effects/array/_config.js new file mode 100644 index 00000000000..979d9f7d3d4 --- /dev/null +++ b/test/function/samples/module-side-effects/array/_config.js @@ -0,0 +1,56 @@ +const assert = require('assert'); +const path = require('path'); +const sideEffects = []; + +module.exports = { + description: 'supports setting module side effects via an array', + context: { + require(id) { + sideEffects.push(id); + return { value: id }; + }, + sideEffects + }, + exports() { + assert.deepStrictEqual(sideEffects, [ + 'pluginsideeffects-null-external-listed', + 'pluginsideeffects-true-external-listed', + 'pluginsideeffects-true', + 'pluginsideeffects-null-listed', + 'pluginsideeffects-true-listed' + ]); + }, + options: { + external: [ + 'pluginsideeffects-null-external', + 'pluginsideeffects-true-external', + 'pluginsideeffects-null-external-listed', + 'pluginsideeffects-true-external-listed' + ], + treeshake: { + moduleSideEffects: [ + 'pluginsideeffects-null-listed', + 'pluginsideeffects-true-listed', + 'pluginsideeffects-null-external-listed', + 'pluginsideeffects-true-external-listed' + ] + }, + plugins: { + name: 'test-plugin', + resolveId(id) { + if (!path.isAbsolute(id)) { + const moduleSideEffects = JSON.parse(id.split('-')[1]); + if (moduleSideEffects) { + return { id, moduleSideEffects }; + } + return id; + } + }, + load(id) { + if (!path.isAbsolute(id)) { + return `export const value = '${id}'; sideEffects.push(value);`; + } + } + } + } +}; diff --git a/test/function/samples/module-side-effects/array/main.js b/test/function/samples/module-side-effects/array/main.js new file mode 100644 index 00000000000..c04fccbe59e --- /dev/null +++ b/test/function/samples/module-side-effects/array/main.js @@ -0,0 +1,8 @@ +import 'pluginsideeffects-null'; +import 'pluginsideeffects-true'; +import 'pluginsideeffects-null-external'; +import 'pluginsideeffects-true-external'; +import 'pluginsideeffects-null-listed'; +import 'pluginsideeffects-true-listed'; +import 'pluginsideeffects-null-external-listed'; +import 'pluginsideeffects-true-external-listed'; diff --git a/test/function/samples/module-side-effects/external-false/_config.js b/test/function/samples/module-side-effects/external-false/_config.js new file mode 100644 index 00000000000..41d1e313413 --- /dev/null +++ b/test/function/samples/module-side-effects/external-false/_config.js @@ -0,0 +1,42 @@ +const assert = require('assert'); +const path = require('path'); +const sideEffects = []; + +module.exports = { + description: 'supports setting module side effects to false for external modules', + context: { + require(id) { + sideEffects.push(id); + return { value: id }; + }, + sideEffects + }, + exports() { + assert.deepStrictEqual(sideEffects, ['pluginsideeffects-true', 'internal']); + }, + options: { + treeshake: { + moduleSideEffects: 'no-external' + }, + plugins: { + name: 'test-plugin', + resolveId(id) { + if (!path.isAbsolute(id)) { + if (id === 'internal') { + return id; + } + const moduleSideEffects = JSON.parse(id.split('-')[1]); + if (moduleSideEffects) { + return { id, moduleSideEffects, external: true }; + } + return { id, external: true }; + } + }, + load(id) { + if (!path.isAbsolute(id)) { + return `export const value = '${id}'; sideEffects.push(value);`; + } + } + } + } +}; diff --git a/test/function/samples/module-side-effects/external-false/main.js b/test/function/samples/module-side-effects/external-false/main.js new file mode 100644 index 00000000000..7a0b4385f92 --- /dev/null +++ b/test/function/samples/module-side-effects/external-false/main.js @@ -0,0 +1,3 @@ +import 'pluginsideeffects-false'; +import 'pluginsideeffects-true'; +import 'internal'; diff --git a/test/function/samples/module-side-effects/global-false/_config.js b/test/function/samples/module-side-effects/global-false/_config.js new file mode 100644 index 00000000000..c45c07363a0 --- /dev/null +++ b/test/function/samples/module-side-effects/global-false/_config.js @@ -0,0 +1,40 @@ +const assert = require('assert'); +const path = require('path'); +const sideEffects = []; + +module.exports = { + description: 'supports setting module side effects to false for all modules', + context: { + require(id) { + sideEffects.push(id); + return { value: id }; + }, + sideEffects + }, + exports() { + assert.deepStrictEqual(sideEffects, ['pluginsideeffects-true']); + }, + options: { + external: ['external'], + treeshake: { + moduleSideEffects: false + }, + plugins: { + name: 'test-plugin', + resolveId(id) { + if (!path.isAbsolute(id)) { + const moduleSideEffects = JSON.parse(id.split('-')[1]); + if (moduleSideEffects) { + return { id, moduleSideEffects }; + } + return id; + } + }, + load(id) { + if (!path.isAbsolute(id)) { + return `export const value = '${id}'; sideEffects.push(value);`; + } + } + } + } +}; diff --git a/test/function/samples/module-side-effects/global-false/main.js b/test/function/samples/module-side-effects/global-false/main.js new file mode 100644 index 00000000000..788c803a4a3 --- /dev/null +++ b/test/function/samples/module-side-effects/global-false/main.js @@ -0,0 +1,3 @@ +import 'pluginsideeffects-null'; +import 'pluginsideeffects-true'; +import 'external'; diff --git a/test/function/samples/module-side-effects/invalid-option/_config.js b/test/function/samples/module-side-effects/invalid-option/_config.js new file mode 100644 index 00000000000..0641fcdbfb2 --- /dev/null +++ b/test/function/samples/module-side-effects/invalid-option/_config.js @@ -0,0 +1,15 @@ +module.exports = { + description: 'warns for invalid options', + options: { + treeshake: { + moduleSideEffects: 'what-is-this?' + } + }, + warnings: [ + { + code: 'INVALID_OPTION', + message: + 'Invalid value for option "treeshake.moduleSideEffects" - please use one of false, "no-external", a function or an array.' + } + ] +}; diff --git a/test/function/samples/module-side-effects/invalid-option/main.js b/test/function/samples/module-side-effects/invalid-option/main.js new file mode 100644 index 00000000000..46d3ca8c61f --- /dev/null +++ b/test/function/samples/module-side-effects/invalid-option/main.js @@ -0,0 +1 @@ +export const value = 42; diff --git a/test/function/samples/module-side-effects/load/_config.js b/test/function/samples/module-side-effects/load/_config.js new file mode 100644 index 00000000000..f9ce4f8def8 --- /dev/null +++ b/test/function/samples/module-side-effects/load/_config.js @@ -0,0 +1,43 @@ +const assert = require('assert'); +const path = require('path'); +const sideEffects = []; + +module.exports = { + description: 'handles setting moduleSideEffects in the load hook', + context: { + sideEffects + }, + exports() { + assert.deepStrictEqual(sideEffects, [ + 'sideeffects-null-load-null', + 'sideeffects-true-load-null', + 'sideeffects-false-load-true', + 'sideeffects-null-load-true', + 'sideeffects-true-load-true' + ]); + }, + options: { + treeshake: { + moduleSideEffects(id) { + return JSON.parse(id.split('-')[1]); + } + }, + plugins: { + name: 'test-plugin', + resolveId(id) { + if (!path.isAbsolute(id)) { + return id; + } + }, + load(id) { + if (!path.isAbsolute(id)) { + const moduleSideEffects = JSON.parse(id.split('-')[3]); + return { + code: `export const value = '${id}'; sideEffects.push(value);`, + moduleSideEffects + }; + } + } + } + } +}; diff --git a/test/function/samples/module-side-effects/load/main.js b/test/function/samples/module-side-effects/load/main.js new file mode 100644 index 00000000000..d8ca61ae01e --- /dev/null +++ b/test/function/samples/module-side-effects/load/main.js @@ -0,0 +1,9 @@ +import 'sideeffects-false-load-false'; +import 'sideeffects-null-load-false'; +import 'sideeffects-true-load-false'; +import 'sideeffects-false-load-null'; +import 'sideeffects-null-load-null'; +import 'sideeffects-true-load-null'; +import 'sideeffects-false-load-true'; +import 'sideeffects-null-load-true'; +import 'sideeffects-true-load-true'; diff --git a/test/function/samples/module-side-effects/reexports/_config.js b/test/function/samples/module-side-effects/reexports/_config.js new file mode 100644 index 00000000000..3e5604fcb08 --- /dev/null +++ b/test/function/samples/module-side-effects/reexports/_config.js @@ -0,0 +1,17 @@ +const assert = require('assert'); +const sideEffects = []; + +module.exports = { + description: 'handles reexporting values when module side-effects are false', + context: { + sideEffects + }, + exports() { + assert.deepStrictEqual(sideEffects, ['dep1']); + }, + options: { + treeshake: { + moduleSideEffects: false + } + } +}; diff --git a/test/function/samples/module-side-effects/reexports/dep1.js b/test/function/samples/module-side-effects/reexports/dep1.js new file mode 100644 index 00000000000..46bc1ac980b --- /dev/null +++ b/test/function/samples/module-side-effects/reexports/dep1.js @@ -0,0 +1,3 @@ +sideEffects.push('dep1'); + +export const value = 'dep1'; diff --git a/test/function/samples/module-side-effects/reexports/dep2.js b/test/function/samples/module-side-effects/reexports/dep2.js new file mode 100644 index 00000000000..b2e6c9091ba --- /dev/null +++ b/test/function/samples/module-side-effects/reexports/dep2.js @@ -0,0 +1,3 @@ +sideEffects.push('dep2'); + +export const value = 'dep2'; diff --git a/test/function/samples/module-side-effects/reexports/lib.js b/test/function/samples/module-side-effects/reexports/lib.js new file mode 100644 index 00000000000..135998ae68f --- /dev/null +++ b/test/function/samples/module-side-effects/reexports/lib.js @@ -0,0 +1,4 @@ +sideEffects.push('main'); + +export { value as value1 } from './dep1.js'; +export { value as value2 } from './dep2.js'; diff --git a/test/function/samples/module-side-effects/reexports/main.js b/test/function/samples/module-side-effects/reexports/main.js new file mode 100644 index 00000000000..802983788a3 --- /dev/null +++ b/test/function/samples/module-side-effects/reexports/main.js @@ -0,0 +1 @@ +export { value1 } from './lib.js'; diff --git a/test/function/samples/module-side-effects/resolve-id-external/_config.js b/test/function/samples/module-side-effects/resolve-id-external/_config.js new file mode 100644 index 00000000000..e3ea26cb413 --- /dev/null +++ b/test/function/samples/module-side-effects/resolve-id-external/_config.js @@ -0,0 +1,84 @@ +const assert = require('assert'); +const path = require('path'); +const sideEffects = []; + +module.exports = { + description: 'does not include modules without used exports if moduleSideEffect is false', + context: { + require(id) { + sideEffects.push(id); + return { value: id }; + } + }, + exports() { + assert.deepStrictEqual(sideEffects, [ + 'sideeffects-false-usereffects-false-used-import', + 'sideeffects-null-usereffects-false-used-import', + 'sideeffects-true-usereffects-false', + 'sideeffects-true-usereffects-false-unused-import', + 'sideeffects-true-usereffects-false-used-import', + 'sideeffects-false-usereffects-true-used-import', + 'sideeffects-null-usereffects-true', + 'sideeffects-null-usereffects-true-unused-import', + 'sideeffects-null-usereffects-true-used-import', + 'sideeffects-true-usereffects-true', + 'sideeffects-true-usereffects-true-unused-import', + 'sideeffects-true-usereffects-true-used-import' + ]); + }, + options: { + treeshake: { + moduleSideEffects(id) { + return JSON.parse(id.split('-')[3]); + } + }, + plugins: { + name: 'test-plugin', + resolveId(id) { + if (!path.isAbsolute(id)) { + return { + id, + external: true, + moduleSideEffects: JSON.parse(id.split('-')[1]) + }; + } + }, + buildEnd() { + assert.deepStrictEqual( + Array.from(this.moduleIds) + .filter(id => !path.isAbsolute(id)) + .sort() + .map(id => ({ id, hasModuleSideEffects: this.getModuleInfo(id).hasModuleSideEffects })), + [ + { id: 'sideeffects-false-usereffects-false', hasModuleSideEffects: false }, + { + id: 'sideeffects-false-usereffects-false-unused-import', + hasModuleSideEffects: false + }, + { id: 'sideeffects-false-usereffects-false-used-import', hasModuleSideEffects: false }, + { id: 'sideeffects-false-usereffects-true', hasModuleSideEffects: false }, + { id: 'sideeffects-false-usereffects-true-unused-import', hasModuleSideEffects: false }, + { id: 'sideeffects-false-usereffects-true-used-import', hasModuleSideEffects: false }, + { id: 'sideeffects-null-usereffects-false', hasModuleSideEffects: false }, + { id: 'sideeffects-null-usereffects-false-unused-import', hasModuleSideEffects: false }, + { id: 'sideeffects-null-usereffects-false-used-import', hasModuleSideEffects: false }, + { id: 'sideeffects-null-usereffects-true', hasModuleSideEffects: true }, + { id: 'sideeffects-null-usereffects-true-unused-import', hasModuleSideEffects: true }, + { id: 'sideeffects-null-usereffects-true-used-import', hasModuleSideEffects: true }, + { id: 'sideeffects-true-usereffects-false', hasModuleSideEffects: true }, + { id: 'sideeffects-true-usereffects-false-unused-import', hasModuleSideEffects: true }, + { id: 'sideeffects-true-usereffects-false-used-import', hasModuleSideEffects: true }, + { id: 'sideeffects-true-usereffects-true', hasModuleSideEffects: true }, + { id: 'sideeffects-true-usereffects-true-unused-import', hasModuleSideEffects: true }, + { id: 'sideeffects-true-usereffects-true-used-import', hasModuleSideEffects: true } + ] + ); + } + } + }, + warnings(warnings) { + for (const warning of warnings) { + assert.strictEqual(warning.code, 'UNUSED_EXTERNAL_IMPORT'); + } + } +}; diff --git a/test/function/samples/module-side-effects/resolve-id-external/main.js b/test/function/samples/module-side-effects/resolve-id-external/main.js new file mode 100644 index 00000000000..dba6f1c07dc --- /dev/null +++ b/test/function/samples/module-side-effects/resolve-id-external/main.js @@ -0,0 +1,25 @@ +import 'sideeffects-false-usereffects-false'; +import { value as unusedValue1 } from 'sideeffects-false-usereffects-false-unused-import'; +import { value as usedValue1 } from 'sideeffects-false-usereffects-false-used-import'; + +import 'sideeffects-null-usereffects-false'; +import { value as unusedValue2 } from 'sideeffects-null-usereffects-false-unused-import'; +import { value as usedValue2 } from 'sideeffects-null-usereffects-false-used-import'; + +import 'sideeffects-true-usereffects-false'; +import { value as unusedValue3 } from 'sideeffects-true-usereffects-false-unused-import'; +import { value as usedValue3 } from 'sideeffects-true-usereffects-false-used-import'; + +import 'sideeffects-false-usereffects-true'; +import { value as unusedValue4 } from 'sideeffects-false-usereffects-true-unused-import'; +import { value as usedValue4 } from 'sideeffects-false-usereffects-true-used-import'; + +import 'sideeffects-null-usereffects-true'; +import { value as unusedValue5 } from 'sideeffects-null-usereffects-true-unused-import'; +import { value as usedValue5 } from 'sideeffects-null-usereffects-true-used-import'; + +import 'sideeffects-true-usereffects-true'; +import { value as unusedValue6 } from 'sideeffects-true-usereffects-true-unused-import'; +import { value as usedValue6 } from 'sideeffects-true-usereffects-true-used-import'; + +export const values = [usedValue1, usedValue2, usedValue3, usedValue4, usedValue5, usedValue6]; diff --git a/test/function/samples/module-side-effects/resolve-id/_config.js b/test/function/samples/module-side-effects/resolve-id/_config.js new file mode 100644 index 00000000000..1c23ce2ad35 --- /dev/null +++ b/test/function/samples/module-side-effects/resolve-id/_config.js @@ -0,0 +1,87 @@ +const assert = require('assert'); +const path = require('path'); +const sideEffects = []; + +module.exports = { + description: 'does not include modules without used exports if moduleSideEffect is false', + context: { + sideEffects + }, + exports() { + assert.deepStrictEqual(sideEffects, [ + 'sideeffects-false-usereffects-false-used-import', + 'sideeffects-null-usereffects-false-used-import', + 'sideeffects-true-usereffects-false', + 'sideeffects-true-usereffects-false-unused-import', + 'sideeffects-true-usereffects-false-used-import', + 'sideeffects-false-usereffects-true-used-import', + 'sideeffects-null-usereffects-true', + 'sideeffects-null-usereffects-true-unused-import', + 'sideeffects-null-usereffects-true-used-import', + 'sideeffects-true-usereffects-true', + 'sideeffects-true-usereffects-true-unused-import', + 'sideeffects-true-usereffects-true-used-import' + ]); + }, + options: { + treeshake: { + moduleSideEffects(id) { + return JSON.parse(id.split('-')[3]); + } + }, + plugins: { + name: 'test-plugin', + resolveId(id) { + if (!path.isAbsolute(id)) { + return { + id, + external: false, + moduleSideEffects: JSON.parse(id.split('-')[1]) + }; + } + }, + load(id) { + if (!path.isAbsolute(id)) { + const sideEffects = JSON.parse(id.split('-')[1]); + const userEffects = JSON.parse(id.split('-')[3]); + assert.strictEqual( + this.getModuleInfo(id).hasModuleSideEffects, + typeof sideEffects === 'boolean' ? sideEffects : userEffects + ); + return `export const value = '${id}'; sideEffects.push(value);`; + } + }, + buildEnd() { + assert.deepStrictEqual( + Array.from(this.moduleIds) + .filter(id => !path.isAbsolute(id)) + .sort() + .map(id => ({ id, hasModuleSideEffects: this.getModuleInfo(id).hasModuleSideEffects })), + [ + { id: 'sideeffects-false-usereffects-false', hasModuleSideEffects: false }, + { + id: 'sideeffects-false-usereffects-false-unused-import', + hasModuleSideEffects: false + }, + { id: 'sideeffects-false-usereffects-false-used-import', hasModuleSideEffects: false }, + { id: 'sideeffects-false-usereffects-true', hasModuleSideEffects: false }, + { id: 'sideeffects-false-usereffects-true-unused-import', hasModuleSideEffects: false }, + { id: 'sideeffects-false-usereffects-true-used-import', hasModuleSideEffects: false }, + { id: 'sideeffects-null-usereffects-false', hasModuleSideEffects: false }, + { id: 'sideeffects-null-usereffects-false-unused-import', hasModuleSideEffects: false }, + { id: 'sideeffects-null-usereffects-false-used-import', hasModuleSideEffects: false }, + { id: 'sideeffects-null-usereffects-true', hasModuleSideEffects: true }, + { id: 'sideeffects-null-usereffects-true-unused-import', hasModuleSideEffects: true }, + { id: 'sideeffects-null-usereffects-true-used-import', hasModuleSideEffects: true }, + { id: 'sideeffects-true-usereffects-false', hasModuleSideEffects: true }, + { id: 'sideeffects-true-usereffects-false-unused-import', hasModuleSideEffects: true }, + { id: 'sideeffects-true-usereffects-false-used-import', hasModuleSideEffects: true }, + { id: 'sideeffects-true-usereffects-true', hasModuleSideEffects: true }, + { id: 'sideeffects-true-usereffects-true-unused-import', hasModuleSideEffects: true }, + { id: 'sideeffects-true-usereffects-true-used-import', hasModuleSideEffects: true } + ] + ); + } + } + } +}; diff --git a/test/function/samples/module-side-effects/resolve-id/main.js b/test/function/samples/module-side-effects/resolve-id/main.js new file mode 100644 index 00000000000..dba6f1c07dc --- /dev/null +++ b/test/function/samples/module-side-effects/resolve-id/main.js @@ -0,0 +1,25 @@ +import 'sideeffects-false-usereffects-false'; +import { value as unusedValue1 } from 'sideeffects-false-usereffects-false-unused-import'; +import { value as usedValue1 } from 'sideeffects-false-usereffects-false-used-import'; + +import 'sideeffects-null-usereffects-false'; +import { value as unusedValue2 } from 'sideeffects-null-usereffects-false-unused-import'; +import { value as usedValue2 } from 'sideeffects-null-usereffects-false-used-import'; + +import 'sideeffects-true-usereffects-false'; +import { value as unusedValue3 } from 'sideeffects-true-usereffects-false-unused-import'; +import { value as usedValue3 } from 'sideeffects-true-usereffects-false-used-import'; + +import 'sideeffects-false-usereffects-true'; +import { value as unusedValue4 } from 'sideeffects-false-usereffects-true-unused-import'; +import { value as usedValue4 } from 'sideeffects-false-usereffects-true-used-import'; + +import 'sideeffects-null-usereffects-true'; +import { value as unusedValue5 } from 'sideeffects-null-usereffects-true-unused-import'; +import { value as usedValue5 } from 'sideeffects-null-usereffects-true-used-import'; + +import 'sideeffects-true-usereffects-true'; +import { value as unusedValue6 } from 'sideeffects-true-usereffects-true-unused-import'; +import { value as usedValue6 } from 'sideeffects-true-usereffects-true-used-import'; + +export const values = [usedValue1, usedValue2, usedValue3, usedValue4, usedValue5, usedValue6]; diff --git a/test/function/samples/module-side-effects/transform/_config.js b/test/function/samples/module-side-effects/transform/_config.js new file mode 100644 index 00000000000..95e29e40eba --- /dev/null +++ b/test/function/samples/module-side-effects/transform/_config.js @@ -0,0 +1,71 @@ +const assert = require('assert'); +const path = require('path'); +const sideEffects = []; + +module.exports = { + description: 'handles setting moduleSideEffects in the transform hook', + context: { + sideEffects + }, + exports() { + assert.deepStrictEqual(sideEffects, [ + 'sideeffects-null-1-null-2-null', + 'sideeffects-true-1-null-2-null', + 'sideeffects-false-1-true-2-null', + 'sideeffects-null-1-true-2-null', + 'sideeffects-true-1-true-2-null', + 'sideeffects-false-1-false-2-true', + 'sideeffects-null-1-false-2-true', + 'sideeffects-true-1-false-2-true', + 'sideeffects-false-1-null-2-true', + 'sideeffects-null-1-null-2-true', + 'sideeffects-true-1-null-2-true', + 'sideeffects-false-1-true-2-true', + 'sideeffects-null-1-true-2-true', + 'sideeffects-true-1-true-2-true' + ]); + }, + options: { + treeshake: { + moduleSideEffects(id) { + return JSON.parse(id.split('-')[1]); + } + }, + plugins: [ + { + name: 'test-plugin-1', + resolveId(id) { + if (!path.isAbsolute(id)) { + return id; + } + }, + load(id) { + if (!path.isAbsolute(id)) { + return `export const value = '${id}'; sideEffects.push(value);`; + } + }, + transform(code, id) { + if (!path.isAbsolute(id)) { + const moduleSideEffects = JSON.parse(id.split('-')[3]); + return { + code, + moduleSideEffects + }; + } + } + }, + { + name: 'test-plugin-2', + transform(code, id) { + if (!path.isAbsolute(id)) { + const moduleSideEffects = JSON.parse(id.split('-')[5]); + return { + code, + moduleSideEffects + }; + } + } + } + ] + } +}; diff --git a/test/function/samples/module-side-effects/transform/main.js b/test/function/samples/module-side-effects/transform/main.js new file mode 100644 index 00000000000..7e52713945b --- /dev/null +++ b/test/function/samples/module-side-effects/transform/main.js @@ -0,0 +1,29 @@ +import 'sideeffects-false-1-false-2-false'; +import 'sideeffects-null-1-false-2-false'; +import 'sideeffects-true-1-false-2-false'; +import 'sideeffects-false-1-null-2-false'; +import 'sideeffects-null-1-null-2-false'; +import 'sideeffects-true-1-null-2-false'; +import 'sideeffects-false-1-true-2-false'; +import 'sideeffects-null-1-true-2-false'; +import 'sideeffects-true-1-true-2-false'; + +import 'sideeffects-false-1-false-2-null'; +import 'sideeffects-null-1-false-2-null'; +import 'sideeffects-true-1-false-2-null'; +import 'sideeffects-false-1-null-2-null'; +import 'sideeffects-null-1-null-2-null'; +import 'sideeffects-true-1-null-2-null'; +import 'sideeffects-false-1-true-2-null'; +import 'sideeffects-null-1-true-2-null'; +import 'sideeffects-true-1-true-2-null'; + +import 'sideeffects-false-1-false-2-true'; +import 'sideeffects-null-1-false-2-true'; +import 'sideeffects-true-1-false-2-true'; +import 'sideeffects-false-1-null-2-true'; +import 'sideeffects-null-1-null-2-true'; +import 'sideeffects-true-1-null-2-true'; +import 'sideeffects-false-1-true-2-true'; +import 'sideeffects-null-1-true-2-true'; +import 'sideeffects-true-1-true-2-true'; diff --git a/test/function/samples/plugin-module-information/_config.js b/test/function/samples/plugin-module-information/_config.js index 10b7d66b691..6a9a20b4734 100644 --- a/test/function/samples/plugin-module-information/_config.js +++ b/test/function/samples/plugin-module-information/_config.js @@ -12,34 +12,49 @@ module.exports = { description: 'provides module information on the plugin context', options: { external: ['path'], - plugins: [ - { - renderStart() { - rendered = true; - assert.deepEqual(Array.from(this.moduleIds), [ID_MAIN, ID_FOO, ID_NESTED, ID_PATH]); - assert.deepEqual(this.getModuleInfo(ID_MAIN), { - id: ID_MAIN, - importedIds: [ID_FOO, ID_NESTED], - isExternal: false - }); - assert.deepEqual(this.getModuleInfo(ID_FOO), { - id: ID_FOO, - importedIds: [ID_PATH], - isExternal: false - }); - assert.deepEqual(this.getModuleInfo(ID_NESTED), { - id: ID_NESTED, - importedIds: [ID_FOO], - isExternal: false - }); - assert.deepEqual(this.getModuleInfo(ID_PATH), { - id: ID_PATH, - importedIds: [], - isExternal: true - }); - } + plugins: { + load(id) { + assert.deepStrictEqual(this.getModuleInfo(id), { + hasModuleSideEffects: true, + id, + importedIds: [], + isEntry: id === ID_MAIN, + isExternal: false + }); + }, + renderStart() { + rendered = true; + assert.deepStrictEqual(Array.from(this.moduleIds), [ID_MAIN, ID_FOO, ID_NESTED, ID_PATH]); + assert.deepStrictEqual(this.getModuleInfo(ID_MAIN), { + hasModuleSideEffects: true, + id: ID_MAIN, + importedIds: [ID_FOO, ID_NESTED], + isEntry: true, + isExternal: false + }); + assert.deepStrictEqual(this.getModuleInfo(ID_FOO), { + hasModuleSideEffects: true, + id: ID_FOO, + importedIds: [ID_PATH], + isEntry: false, + isExternal: false + }); + assert.deepStrictEqual(this.getModuleInfo(ID_NESTED), { + hasModuleSideEffects: true, + id: ID_NESTED, + importedIds: [ID_FOO], + isEntry: false, + isExternal: false + }); + assert.deepStrictEqual(this.getModuleInfo(ID_PATH), { + hasModuleSideEffects: true, + id: ID_PATH, + importedIds: [], + isEntry: false, + isExternal: true + }); } - ] + } }, bundle() { assert.ok(rendered); diff --git a/test/incremental/index.js b/test/incremental/index.js index 78158a89325..ee7a5983208 100644 --- a/test/incremental/index.js +++ b/test/incremental/index.js @@ -240,8 +240,8 @@ describe('incremental', () => { assert.equal(bundle.cache.modules[1].id, 'entry'); assert.deepEqual(bundle.cache.modules[1].resolvedIds, { - foo: { id: 'foo', external: false }, - external: { id: 'external', external: true } + foo: { id: 'foo', external: false, moduleSideEffects: true }, + external: { id: 'external', external: true, moduleSideEffects: true } }); }); }); diff --git a/tslint.json b/tslint.json index a7c778c62f2..c0c1012ee19 100644 --- a/tslint.json +++ b/tslint.json @@ -10,6 +10,7 @@ "alphabetize": true } ], + "no-inferrable-types": true, "no-string-literal": true, "no-unnecessary-type-assertion": true, "object-literal-shorthand": true,