Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
eada7f7fb6 fix(bundler): preserve sideEffects from package.json when onResolve plugin returns null
Fixes #2952

When an onResolve plugin returns null (no match), the bundler falls back to
default file resolution. Previously, the sideEffects field from package.json
was not being properly propagated in this code path, causing tree-shaking to
fail for packages like lodash-es that rely on sideEffects: false.

This fix:
1. Updates enqueueParseTask to use resolve_result.primary_side_effects_data
   instead of loader.sideEffects()
2. Adds lookupSideEffectsForPath helper to look up sideEffects from package.json
   when an onResolve plugin returns a path in the "file" namespace
3. Uses the looked-up sideEffects in the onResolve success handler

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 01:15:37 +00:00
2 changed files with 168 additions and 3 deletions

View File

@@ -1364,7 +1364,7 @@ pub const BundleV2 = struct {
this.graph.input_files.append(this.allocator(), .{
.source = source.*,
.loader = loader,
.side_effects = loader.sideEffects(),
.side_effects = resolve_result.primary_side_effects_data,
}) catch |err| bun.handleOom(err);
var task = bun.handleOom(this.allocator().create(ParseTask));
task.* = ParseTask.init(resolve_result, source_index, this);
@@ -2467,6 +2467,30 @@ pub const BundleV2 = struct {
}
}
/// Look up the sideEffects field from package.json for a given file path.
/// This is used when an onResolve plugin returns a path in the "file" namespace
/// to ensure tree-shaking still works properly.
fn lookupSideEffectsForPath(this: *BundleV2, absolute_path: []const u8, target: options.Target) _resolver.SideEffects {
const dir_path = bun.path.dirname(absolute_path, .auto);
const resolver = &this.transpilerForTarget(target).resolver;
var dir_info: ?*const _resolver.DirInfo = resolver.readDirInfoIgnoreError(dir_path);
// Walk up directory tree to find package.json
while (dir_info) |info| {
if (info.package_json) |package_json| {
return switch (package_json.side_effects) {
.unspecified => .has_side_effects,
.false => .no_side_effects__package_json,
.map => |map| if (map.contains(bun.StringHashMapUnowned.Key.init(absolute_path))) .has_side_effects else .no_side_effects__package_json,
.glob, .mixed => if (package_json.side_effects.hasSideEffects(absolute_path)) .has_side_effects else .no_side_effects__package_json,
};
}
dir_info = info.getParent();
}
return .has_side_effects;
}
pub fn onResolveFromJsLoop(resolve: *bun.jsc.API.JSBundler.Resolve) void {
onResolve(resolve, resolve.bv2);
}
@@ -2556,6 +2580,12 @@ pub const BundleV2 = struct {
this.graph.ast.append(this.allocator(), JSAst.empty) catch unreachable;
const loader = path.loader(&this.transpiler.options.loaders) orelse options.Loader.file;
// Look up sideEffects from package.json for file namespace paths
const side_effects: _resolver.SideEffects = if (strings.eqlComptime(path.namespace, "file"))
this.lookupSideEffectsForPath(path.text, resolve.import_record.original_target)
else
.has_side_effects;
this.graph.input_files.append(this.allocator(), .{
.source = .{
.path = path,
@@ -2563,7 +2593,7 @@ pub const BundleV2 = struct {
.index = source_index,
},
.loader = loader,
.side_effects = .has_side_effects,
.side_effects = side_effects,
}) catch unreachable;
var task = bun.default_allocator.create(ParseTask) catch unreachable;
task.* = ParseTask{
@@ -2576,7 +2606,7 @@ pub const BundleV2 = struct {
.file = bun.invalid_fd,
},
},
.side_effects = .has_side_effects,
.side_effects = side_effects,
.jsx = this.transpilerForTarget(resolve.import_record.original_target).options.jsx,
.source_index = source_index,
.module_type = .unknown,

View File

@@ -0,0 +1,135 @@
/**
* Regression test for GitHub issue #2952
* https://github.com/oven-sh/bun/issues/2952
*
* When an onResolve plugin returns null (no match), the bundler should still
* respect the sideEffects field from package.json for tree-shaking.
*
* This is a critical issue because packages like lodash-es use sideEffects: false
* to enable proper tree-shaking of unused exports.
*
* The bug manifested when:
* 1. A plugin's onResolve callback returned null for all resolutions
* 2. The resolved module had sideEffects: false in package.json
* 3. The module used barrel exports (re-exports from individual files)
*
* The fix ensures that when onResolve returns null and the bundler falls back
* to default resolution, the sideEffects field from package.json is properly
* propagated to the parse task.
*/
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
import * as path from "path";
test("issue#2952: onResolve plugin returning null should preserve sideEffects for tree-shaking", async () => {
using dir = tempDir("issue-2952", {
"entry.ts": `
import { isArray } from "tree-shakeable-lib";
export default function isArray2(value: any): boolean {
return isArray(value);
}
`,
"node_modules/tree-shakeable-lib/index.js": `
export { default as isArray } from './isArray.js';
export { default as isString } from './isString.js';
export { default as isNumber } from './isNumber.js';
export { default as isObject } from './isObject.js';
`,
"node_modules/tree-shakeable-lib/isArray.js": `
export default function isArray(value) {
return Array.isArray(value);
}
`,
"node_modules/tree-shakeable-lib/isString.js": `
export default function isString(value) {
console.log("TREESHAKE_FAILED_isString");
return typeof value === "string";
}
`,
"node_modules/tree-shakeable-lib/isNumber.js": `
export default function isNumber(value) {
console.log("TREESHAKE_FAILED_isNumber");
return typeof value === "number";
}
`,
"node_modules/tree-shakeable-lib/isObject.js": `
export default function isObject(value) {
console.log("TREESHAKE_FAILED_isObject");
return typeof value === "object" && value !== null;
}
`,
"node_modules/tree-shakeable-lib/package.json": JSON.stringify({
name: "tree-shakeable-lib",
main: "index.js",
sideEffects: false,
}),
"build-with-plugin.ts": `
import type { BunPlugin } from "bun";
const myPlugin: BunPlugin = {
name: "Test plugin",
setup(build) {
build.onResolve({ filter: /.*/ }, async (args) => {
return null; // Return null to let default resolution handle it
});
},
};
const result = await Bun.build({
entrypoints: ["entry.ts"],
minify: true,
outdir: "dist-with-plugin",
plugins: [myPlugin],
});
if (!result.success) {
console.error("Build failed");
process.exit(1);
}
`,
"build-without-plugin.ts": `
const result = await Bun.build({
entrypoints: ["entry.ts"],
minify: true,
outdir: "dist-without-plugin",
});
if (!result.success) {
console.error("Build failed");
process.exit(1);
}
`,
});
// Build without plugin
await using procWithout = Bun.spawn({
cmd: [bunExe(), "build-without-plugin.ts"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
await procWithout.exited;
// Build with plugin
await using procWith = Bun.spawn({
cmd: [bunExe(), "build-with-plugin.ts"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
await procWith.exited;
// Read outputs
const outputWithout = await Bun.file(path.join(String(dir), "dist-without-plugin/entry.js")).text();
const outputWith = await Bun.file(path.join(String(dir), "dist-with-plugin/entry.js")).text();
// Both should tree-shake correctly
expect(outputWithout).not.toContain("TREESHAKE_FAILED");
expect(outputWith).not.toContain("TREESHAKE_FAILED");
// Output sizes should be similar (both properly tree-shaken)
// Allow some variance for potential formatting differences
expect(Math.abs(outputWithout.length - outputWith.length)).toBeLessThan(100);
});