Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow OnResolve plugins to mark modules as side effect free #1313

Merged
merged 6 commits into from Jun 8, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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