diff --git a/CHANGELOG.md b/CHANGELOG.md index b75133f78d6..4ae9a595590 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,36 @@ This fix was contributed by [@lbwa](https://github.com/lbwa). +* Plugins can now specify `sideEffects: false` ([#1009](https://github.com/evanw/esbuild/issues/1009)) + + The default path resolution behavior in esbuild determines if a given file can be considered side-effect free (in the [Webpack-specific sense](https://webpack.js.org/guides/tree-shaking/#mark-the-file-as-side-effect-free)) by reading the contents of the nearest enclosing `package.json` file and looking for `"sideEffects": false`. However, up until now this was impossible to achieve in an esbuild plugin because there was no way of returning this metadata back to esbuild. + + With this release, esbuild plugins can now return `sideEffects: false` to mark a file as having no side effects. Here's an example: + + ```js + esbuild.build({ + entryPoints: ['app.js'], + bundle: true, + plugins: [{ + name: 'env-plugin', + setup(build) { + build.onResolve({ filter: /^env$/ }, args => ({ + path: args.path, + namespace: 'some-ns', + sideEffects: false, + })) + build.onLoad({ filter: /.*/, namespace: 'some-ns' }, () => ({ + contents: `export default self.env || (self.env = getEnv())`, + })) + }, + }], + }) + ``` + + This plugin creates a virtual module that can be generated by importing the string `env`. However, since the plugin returns `sideEffects: false`, the generated virtual module will not be included in the bundle if all of the imported values from the module `env` end up being unused. + + This feature was contributed by [@chriscasola](https://github.com/chriscasola). + ## 0.12.6 * Improve template literal lowering transformation conformance ([#1327](https://github.com/evanw/esbuild/issues/1327)) diff --git a/cmd/esbuild/service.go b/cmd/esbuild/service.go index cef1ec97296..1550606f9c9 100644 --- a/cmd/esbuild/service.go +++ b/cmd/esbuild/service.go @@ -715,6 +715,13 @@ func (service *serviceType) convertPlugins(key int, jsPlugins interface{}) ([]ap if value, ok := response["external"]; ok { result.External = value.(bool) } + if value, ok := response["sideEffects"]; ok { + if value.(bool) { + result.SideEffects = api.SideEffectsTrue + } else { + result.SideEffects = api.SideEffectsFalse + } + } if value, ok := response["pluginData"]; ok { result.PluginData = value.(int) } diff --git a/internal/bundler/bundler.go b/internal/bundler/bundler.go index ed41b3c0b84..33685ed0a5c 100644 --- a/internal/bundler/bundler.go +++ b/internal/bundler/bundler.go @@ -738,10 +738,18 @@ func runOnResolvePlugins( return nil, true, resolver.DebugMeta{} } + var sideEffectsData *resolver.SideEffectsData + if result.IsSideEffectFree { + sideEffectsData = &resolver.SideEffectsData{ + PluginName: pluginName, + } + } + return &resolver.ResolveResult{ - PathPair: resolver.PathPair{Primary: result.Path}, - IsExternal: result.External, - PluginData: result.PluginData, + PathPair: resolver.PathPair{Primary: result.Path}, + IsExternal: result.External, + PluginData: result.PluginData, + PrimarySideEffectsData: sideEffectsData, }, false, resolver.DebugMeta{} } } @@ -1701,19 +1709,24 @@ func (s *scanner) processScannedFiles() []scannerFile { // effect. otherModule.SideEffects.Kind != graph.NoSideEffects_PureData_FromPlugin { var notes []logger.MsgData + var by string if data := otherModule.SideEffects.Data; data != nil { - var text string - if data.IsSideEffectsArrayInJSON { - text = "It was excluded from the \"sideEffects\" array in the enclosing \"package.json\" file" + if data.PluginName != "" { + by = fmt.Sprintf(" by plugin %q", data.PluginName) } else { - text = "\"sideEffects\" is false in the enclosing \"package.json\" file" + var text string + if data.IsSideEffectsArrayInJSON { + text = "It was excluded from the \"sideEffects\" array in the enclosing \"package.json\" file" + } else { + text = "\"sideEffects\" is false in the enclosing \"package.json\" file" + } + tracker := logger.MakeLineColumnTracker(data.Source) + notes = append(notes, logger.RangeData(&tracker, data.Range, text)) } - tracker := logger.MakeLineColumnTracker(data.Source) - notes = append(notes, logger.RangeData(&tracker, data.Range, text)) } s.log.AddRangeWarningWithNotes(&tracker, record.Range, - fmt.Sprintf("Ignoring this import because %q was marked as having no side effects", - otherModule.Source.PrettyPath), notes) + fmt.Sprintf("Ignoring this import because %q was marked as having no side effects%s", + otherModule.Source.PrettyPath, by), notes) } } } diff --git a/internal/config/config.go b/internal/config/config.go index 118b57e8a2f..49b7fed7beb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -497,9 +497,10 @@ type OnResolveArgs struct { type OnResolveResult struct { PluginName string - Path logger.Path - External bool - PluginData interface{} + Path logger.Path + External bool + IsSideEffectFree bool + PluginData interface{} Msgs []logger.Msg ThrownError error diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index b69b807a9fb..91e9354dce6 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -83,6 +83,9 @@ type SideEffectsData struct { Source *logger.Source Range logger.Range + // If non-empty, this false value came from a plugin + PluginName string + // If true, "sideEffects" was an array. If false, "sideEffects" was false. IsSideEffectsArrayInJSON bool } diff --git a/lib/shared/common.ts b/lib/shared/common.ts index d1e41756a8e..a4a206ba6b8 100644 --- a/lib/shared/common.ts +++ b/lib/shared/common.ts @@ -780,6 +780,7 @@ export function createChannel(streamIn: StreamIn): StreamOut { let path = getFlag(result, keys, 'path', mustBeString); let namespace = getFlag(result, keys, 'namespace', mustBeString); let external = getFlag(result, keys, 'external', mustBeBoolean); + let sideEffects = getFlag(result, keys, 'sideEffects', mustBeBoolean); let pluginData = getFlag(result, keys, 'pluginData', canBeAnything); let errors = getFlag(result, keys, 'errors', mustBeArray); let warnings = getFlag(result, keys, 'warnings', mustBeArray); @@ -792,6 +793,7 @@ export function createChannel(streamIn: StreamIn): StreamOut { if (path != null) response.path = path; if (namespace != null) response.namespace = namespace; if (external != null) response.external = external; + if (sideEffects != null) response.sideEffects = sideEffects; if (pluginData != null) response.pluginData = stash.store(pluginData); if (errors != null) response.errors = sanitizeMessages(errors, 'errors', stash, name); if (warnings != null) response.warnings = sanitizeMessages(warnings, 'warnings', stash, name); diff --git a/lib/shared/stdio_protocol.ts b/lib/shared/stdio_protocol.ts index d01402c8b17..cc1d8884c3f 100644 --- a/lib/shared/stdio_protocol.ts +++ b/lib/shared/stdio_protocol.ts @@ -157,6 +157,7 @@ export interface OnResolveResponse { path?: string; external?: boolean; + sideEffects?: boolean; namespace?: string; pluginData?: number; diff --git a/lib/shared/types.ts b/lib/shared/types.ts index bd0a2c29965..470f15cd6c5 100644 --- a/lib/shared/types.ts +++ b/lib/shared/types.ts @@ -241,6 +241,7 @@ export interface OnResolveResult { path?: string; external?: boolean; + sideEffects?: boolean; namespace?: string; pluginData?: any; diff --git a/pkg/api/api.go b/pkg/api/api.go index 44dffa2d5a0..42768af2ccd 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -427,6 +427,13 @@ func Serve(serveOptions ServeOptions, buildOptions BuildOptions) (ServeResult, e //////////////////////////////////////////////////////////////////////////////// // Plugin API +type SideEffects uint8 + +const ( + SideEffectsTrue SideEffects = iota + SideEffectsFalse +) + type Plugin struct { Name string Setup func(PluginBuild) @@ -465,10 +472,11 @@ type OnResolveResult struct { Errors []Message Warnings []Message - Path string - External bool - Namespace string - PluginData interface{} + Path string + External bool + SideEffects SideEffects + Namespace string + PluginData interface{} WatchFiles []string WatchDirs []string diff --git a/pkg/api/api_impl.go b/pkg/api/api_impl.go index 3bab967a8ce..35f5e533609 100644 --- a/pkg/api/api_impl.go +++ b/pkg/api/api_impl.go @@ -1470,6 +1470,7 @@ func (impl *pluginImpl) OnResolve(options OnResolveOptions, callback func(OnReso result.Path = logger.Path{Text: response.Path, Namespace: response.Namespace} result.External = response.External + result.IsSideEffectFree = response.SideEffects == SideEffectsFalse result.PluginData = response.PluginData // Convert log messages diff --git a/scripts/plugin-tests.js b/scripts/plugin-tests.js index 5bf797cff3a..a3e0de719c2 100644 --- a/scripts/plugin-tests.js +++ b/scripts/plugin-tests.js @@ -709,6 +709,80 @@ let pluginTests = { assert.strictEqual(result.default, 123) }, + async resolveWithSideEffectsFalse({ esbuild, testDir }) { + const input = path.join(testDir, 'in.js') + + await writeFileAsync(input, ` + import './re-export-unused' + import {a, b, c} from './re-export-used' + import './import-unused' + use([a, b, c]) + `) + await writeFileAsync(path.join(testDir, 're-export-unused.js'), ` + export {default as a} from 'plugin:unused-false' + export {default as b} from 'plugin:unused-true' + export {default as c} from 'plugin:unused-none' + `) + await writeFileAsync(path.join(testDir, 're-export-used.js'), ` + export {default as a} from 'plugin:used-false' + export {default as b} from 'plugin:used-true' + export {default as c} from 'plugin:used-none' + `) + await writeFileAsync(path.join(testDir, 'import-unused.js'), ` + import 'plugin:ignored-false' + import 'plugin:ignored-true' + import 'plugin:ignored-none' + `) + + const result = await esbuild.build({ + entryPoints: [input], + bundle: true, + write: false, + format: 'cjs', + logLevel: 'error', + plugins: [{ + name: 'name', + setup(build) { + build.onResolve({ filter: /^plugin:/ }, args => { + return { + path: args.path, + namespace: 'ns', + sideEffects: + args.path.endsWith('-true') ? true : + args.path.endsWith('-false') ? false : + undefined, + }; + }); + build.onLoad({ filter: /^plugin:/ }, args => { + return { contents: `export default use(${JSON.stringify(args.path)})` }; + }); + }, + }], + }) + + // Validate that the unused "sideEffects: false" files were omitted + const used = []; + new Function('use', result.outputFiles[0].text)(x => used.push(x)); + assert.deepStrictEqual(used, [ + 'plugin:unused-true', + 'plugin:unused-none', + + 'plugin:used-false', + 'plugin:used-true', + 'plugin:used-none', + + 'plugin:ignored-true', + 'plugin:ignored-none', + + [3, 4, 5], + ]) + + // Check that the warning for "sideEffect: false" imports mentions the plugin + assert.strictEqual(result.warnings.length, 1) + assert.strictEqual(result.warnings[0].text, + 'Ignoring this import because "ns:plugin:ignored-false" was marked as having no side effects by plugin "name"') + }, + async noResolveDirInFileModule({ esbuild, testDir }) { const input = path.join(testDir, 'in.js') const output = path.join(testDir, 'out.js')