Skip to content

Commit

Permalink
Allow OnResolve plugins to mark modules as side effect free (#1313)
Browse files Browse the repository at this point in the history
  • Loading branch information
chriscasola committed Jun 8, 2021
1 parent a5f1387 commit 6ea75fe
Show file tree
Hide file tree
Showing 11 changed files with 159 additions and 18 deletions.
30 changes: 30 additions & 0 deletions CHANGELOG.md
Expand Up @@ -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))
Expand Down
7 changes: 7 additions & 0 deletions cmd/esbuild/service.go
Expand Up @@ -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)
}
Expand Down
35 changes: 24 additions & 11 deletions internal/bundler/bundler.go
Expand Up @@ -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{}
}
}
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
7 changes: 4 additions & 3 deletions internal/config/config.go
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions internal/resolver/resolver.go
Expand Up @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions lib/shared/common.ts
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions lib/shared/stdio_protocol.ts
Expand Up @@ -157,6 +157,7 @@ export interface OnResolveResponse {

path?: string;
external?: boolean;
sideEffects?: boolean;
namespace?: string;
pluginData?: number;

Expand Down
1 change: 1 addition & 0 deletions lib/shared/types.ts
Expand Up @@ -241,6 +241,7 @@ export interface OnResolveResult {

path?: string;
external?: boolean;
sideEffects?: boolean;
namespace?: string;
pluginData?: any;

Expand Down
16 changes: 12 additions & 4 deletions pkg/api/api.go
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions pkg/api/api_impl.go
Expand Up @@ -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
Expand Down
74 changes: 74 additions & 0 deletions scripts/plugin-tests.js
Expand Up @@ -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')
Expand Down

0 comments on commit 6ea75fe

Please sign in to comment.