Compare commits

...

4 Commits

Author SHA1 Message Date
autofix-ci[bot]
94691cb8fd [autofix.ci] apply automated fixes 2026-01-16 20:31:52 +00:00
Alistair Smith
d5dd25dc8b Merge branch 'main' into claude/fix-hoisted-version-selection 2026-01-16 12:30:14 -08:00
Claude Bot
d0ca03cfec test: simplify assertions per review feedback
Remove unused stdout capture and fragile stderr string assertions.
Only check exit code for install success.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 17:36:11 +00:00
Claude Bot
15c990bf2e fix(install): hoist highest version in isolated installs
Previously, the hoisted version in `.bun/node_modules/` was determined by
alphabetical workspace ordering - whichever workspace came first alphabetically
would have its version of a shared dependency hoisted. This caused issues in
monorepos where different workspaces depend on different versions of the same
package (e.g., @types/react@18 vs @types/react@19).

Now, when multiple versions of a package are encountered, the highest version
(by semver) is hoisted instead of the first-seen version. This ensures
consistent behavior regardless of workspace naming.

Fixes #26140

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 17:26:06 +00:00
3 changed files with 169 additions and 11 deletions

View File

@@ -1089,8 +1089,8 @@ const string = []const u8;
const Environment = @import("../../env.zig");
const std = @import("std");
const FetchRedirect = @import("../../http/FetchRedirect.zig").FetchRedirect;
const FetchCacheMode = @import("../../http/FetchCacheMode.zig").FetchCacheMode;
const FetchRedirect = @import("../../http/FetchRedirect.zig").FetchRedirect;
const FetchRequestMode = @import("../../http/FetchRequestMode.zig").FetchRequestMode;
const Method = @import("../../http/Method.zig").Method;

View File

@@ -420,7 +420,11 @@ pub fn installIsolatedPackages(
var public_hoisted: bun.StringArrayHashMap(void) = .init(manager.allocator);
defer public_hoisted.deinit();
var hidden_hoisted: bun.StringArrayHashMap(void) = .init(manager.allocator);
const HoistInfo = struct {
entry_id: Store.Entry.Id,
pkg_id: PackageID,
};
var hidden_hoisted: bun.StringArrayHashMap(HoistInfo) = .init(manager.allocator);
defer hidden_hoisted.deinit();
// Second pass: Deduplicate nodes when the pkg_id and peer set match an existing entry.
@@ -516,6 +520,8 @@ pub fn installIsolatedPackages(
var new_entry_parents: std.ArrayListUnmanaged(Store.Entry.Id) = try .initCapacity(lockfile.allocator, 1);
new_entry_parents.appendAssumeCapacity(entry.entry_parent_id);
const new_entry_id: Store.Entry.Id = .from(@intCast(store.len));
const hoisted = hoisted: {
if (new_entry_dep_id == invalid_dependency_id) {
break :hoisted false;
@@ -523,14 +529,47 @@ pub fn installIsolatedPackages(
const dep_name = dependencies[new_entry_dep_id].name.slice(string_buf);
const hoist_pattern = manager.options.hoist_pattern orelse {
const hoist_entry = try hidden_hoisted.getOrPut(dep_name);
break :hoisted !hoist_entry.found_existing;
};
const should_hoist = if (manager.options.hoist_pattern) |hoist_pattern|
hoist_pattern.isMatch(dep_name)
else
true;
if (hoist_pattern.isMatch(dep_name)) {
const hoist_entry = try hidden_hoisted.getOrPut(dep_name);
break :hoisted !hoist_entry.found_existing;
if (!should_hoist) {
break :hoisted false;
}
const hoist_entry = try hidden_hoisted.getOrPut(dep_name);
if (!hoist_entry.found_existing) {
// First time seeing this package name, hoist it
hoist_entry.value_ptr.* = .{
.entry_id = new_entry_id,
.pkg_id = pkg_id,
};
break :hoisted true;
}
// Package with this name already hoisted - compare versions
// Hoist the highest version (semver comparison)
const existing_pkg_id = hoist_entry.value_ptr.pkg_id;
const existing_res = pkg_resolutions[existing_pkg_id];
const new_res = pkg_resolutions[pkg_id];
// Only compare npm versions; for other resolution types, keep first-seen behavior
if (existing_res.tag == .npm and new_res.tag == .npm) {
const existing_version = existing_res.value.npm.version;
const new_version = new_res.value.npm.version;
if (new_version.order(existing_version, string_buf, string_buf) == .gt) {
// New version is higher, un-hoist the old entry and hoist this one
const old_entry_id = hoist_entry.value_ptr.entry_id;
store.slice().items(.hoisted)[old_entry_id.get()] = false;
hoist_entry.value_ptr.* = .{
.entry_id = new_entry_id,
.pkg_id = pkg_id,
};
break :hoisted true;
}
}
break :hoisted false;
@@ -543,8 +582,6 @@ pub fn installIsolatedPackages(
.peer_hash = new_entry_peer_hash,
.hoisted = hoisted,
};
const new_entry_id: Store.Entry.Id = .from(@intCast(store.len));
try store.append(lockfile.allocator, new_entry);
if (entry.entry_parent_id.tryGet()) |entry_parent_id| skip_adding_dependency: {

View File

@@ -0,0 +1,121 @@
import { file } from "bun";
import { expect, test } from "bun:test";
import { readlinkSync } from "fs";
import { bunEnv, bunExe, tempDir } from "harness";
import { join } from "path";
// Test for https://github.com/oven-sh/bun/issues/26140
// Bug: @types/* packages in .bun/node_modules/ are resolved based on
// alphabetical workspace package name ordering, causing type resolution
// issues in monorepos with mixed dependency versions.
//
// Expected: The highest version should be hoisted regardless of workspace
// package name ordering.
test("hoists highest version regardless of alphabetical workspace order", async () => {
// Setup: Two workspaces with different versions of is-number
// - aaa-pkg (alphabetically first) depends on is-number@5.0.0
// - bbb-pkg (alphabetically second) depends on is-number@7.0.0
//
// Before fix: is-number@5.0.0 would be hoisted because aaa-pkg comes first
// After fix: is-number@7.0.0 should be hoisted because it's the higher version
using dir = tempDir("issue-26140", {
"package.json": JSON.stringify({
name: "test-monorepo",
workspaces: ["packages/*"],
}),
"bunfig.toml": `
[install]
linker = "isolated"
`,
"packages/aaa-pkg/package.json": JSON.stringify({
name: "@test/aaa-pkg",
version: "1.0.0",
dependencies: {
"is-number": "5.0.0",
},
}),
"packages/bbb-pkg/package.json": JSON.stringify({
name: "@test/bbb-pkg",
version: "1.0.0",
dependencies: {
"is-number": "7.0.0",
},
}),
});
const packageDir = String(dir);
await using proc = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: packageDir,
env: bunEnv,
});
const exitCode = await proc.exited;
expect(exitCode).toBe(0);
// The hoisted symlink should point to the higher version (7.0.0)
const hoistedLink = readlinkSync(join(packageDir, "node_modules", ".bun", "node_modules", "is-number"));
expect(hoistedLink).toContain("is-number@7.0.0");
// Verify both versions are installed in their respective locations
const pkg1 = await file(
join(packageDir, "node_modules", ".bun", "is-number@5.0.0", "node_modules", "is-number", "package.json"),
).json();
expect(pkg1.version).toBe("5.0.0");
const pkg2 = await file(
join(packageDir, "node_modules", ".bun", "is-number@7.0.0", "node_modules", "is-number", "package.json"),
).json();
expect(pkg2.version).toBe("7.0.0");
});
test("hoists highest version when alphabetically later workspace has higher version", async () => {
// Setup: Two workspaces with different versions of is-number
// - aaa-pkg (alphabetically first) depends on is-number@7.0.0
// - zzz-pkg (alphabetically last) depends on is-number@5.0.0
//
// This test ensures version 7.0.0 is still hoisted even though it's in
// the alphabetically first workspace (confirms we don't regress when the
// order already works)
using dir = tempDir("issue-26140-alt", {
"package.json": JSON.stringify({
name: "test-monorepo",
workspaces: ["packages/*"],
}),
"bunfig.toml": `
[install]
linker = "isolated"
`,
"packages/aaa-pkg/package.json": JSON.stringify({
name: "@test/aaa-pkg",
version: "1.0.0",
dependencies: {
"is-number": "7.0.0",
},
}),
"packages/zzz-pkg/package.json": JSON.stringify({
name: "@test/zzz-pkg",
version: "1.0.0",
dependencies: {
"is-number": "5.0.0",
},
}),
});
const packageDir = String(dir);
await using proc = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: packageDir,
env: bunEnv,
});
const exitCode = await proc.exited;
expect(exitCode).toBe(0);
// The hoisted symlink should still point to the higher version (7.0.0)
const hoistedLink = readlinkSync(join(packageDir, "node_modules", ".bun", "node_modules", "is-number"));
expect(hoistedLink).toContain("is-number@7.0.0");
});