Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
506862f705 fix(install): don't hoist packages when multiple versions exist in isolated installs
When multiple versions of the same package exist in the dependency graph,
the isolated linker would hoist only the first version encountered to
.bun/node_modules/. This caused issues where TypeScript would find the
wrong version of @types/* packages through Node's fallback resolution.

The fix tracks the pkg_id for each hoisted package name. When a different
version of the same package is encountered, both the previous and current
entries are marked as not hoisted. This ensures consistent behavior: if
multiple versions exist, none are hoisted to .bun/node_modules/.

Users who need types hoisted can use publicHoistPattern (which hoists to
root node_modules) or configure tsconfig.json paths.

Fixes #25336

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 19:35:54 +00:00
2 changed files with 126 additions and 9 deletions

View File

@@ -420,7 +420,13 @@ 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);
// Track hoisted packages by name -> (pkg_id, entry_id) so we can detect version conflicts
// and un-hoist when multiple versions of the same package exist
const HoistedInfo = struct {
pkg_id: PackageID,
entry_id: Store.Entry.Id,
};
var hidden_hoisted: bun.StringArrayHashMap(HoistedInfo) = .init(manager.allocator);
defer hidden_hoisted.deinit();
// Second pass: Deduplicate nodes when the pkg_id and peer set match an existing entry.
@@ -516,6 +522,9 @@ 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);
// entry_id is needed before we create the entry to track it in hidden_hoisted
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 +532,33 @@ 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_check_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_check_hoist) {
break :hoisted false;
}
const hoist_entry = try hidden_hoisted.getOrPut(dep_name);
if (!hoist_entry.found_existing) {
// First time seeing this package name - mark for hoisting
hoist_entry.value_ptr.* = .{
.pkg_id = pkg_id,
.entry_id = new_entry_id,
};
break :hoisted true;
}
// Package name was already seen - check if it's the same version
const prev_info = hoist_entry.value_ptr.*;
if (prev_info.pkg_id != pkg_id) {
// Different version! Un-hoist the previous entry and don't hoist this one.
// This ensures consistent behavior: if multiple versions exist, none are hoisted.
const entries = store.slice();
const entry_hoisted = entries.items(.hoisted);
entry_hoisted[prev_info.entry_id.get()] = false;
}
break :hoisted false;
@@ -544,7 +572,6 @@ pub fn installIsolatedPackages(
.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

@@ -1234,3 +1234,93 @@ test("runs lifecycle scripts correctly", async () => {
expect(lifecyclePostinstallDir).toEqual(["lifecycle-postinstall"]);
expect(allLifecycleScriptsDir).toEqual(["all-lifecycle-scripts"]);
});
describe("hidden hoisting with multiple versions", () => {
test("does not hoist packages when multiple versions exist (issue #25336)", async () => {
// When multiple versions of the same package exist in the dependency graph,
// neither should be hoisted to .bun/node_modules/ to avoid TypeScript
// resolving the wrong version through the fallback mechanism.
const { packageDir } = await registry.createTestDir({
bunfigOpts: { linker: "isolated" },
files: {
"package.json": JSON.stringify({
name: "monorepo-multi-versions",
workspaces: ["packages/*"],
}),
// Workspace 1: uses @types/is-number@1.0.0
"packages/pkg-v1/package.json": JSON.stringify({
name: "pkg-v1",
version: "1.0.0",
dependencies: {
"@types/is-number": "1.0.0",
},
}),
// Workspace 2: uses @types/is-number@2.0.0
"packages/pkg-v2/package.json": JSON.stringify({
name: "pkg-v2",
version: "1.0.0",
dependencies: {
"@types/is-number": "2.0.0",
},
}),
},
});
await runBunInstall(bunEnv, packageDir);
// Verify that pkg-v1 has @types/is-number@1.0.0
expect(
await file(join(packageDir, "packages/pkg-v1/node_modules/@types/is-number/package.json")).json(),
).toMatchObject({
name: "@types/is-number",
version: "1.0.0",
});
// Verify that pkg-v2 has @types/is-number@2.0.0
expect(
await file(join(packageDir, "packages/pkg-v2/node_modules/@types/is-number/package.json")).json(),
).toMatchObject({
name: "@types/is-number",
version: "2.0.0",
});
// The key fix: @types/is-number should NOT be hoisted to .bun/node_modules/
// because multiple versions exist. This prevents TypeScript from finding
// the wrong version through fallback resolution.
expect(existsSync(join(packageDir, "node_modules/.bun/node_modules/@types/is-number"))).toBeFalse();
});
test("hoists packages when only one version exists", async () => {
// When only one version of a package exists, it should be hoisted normally.
const { packageDir } = await registry.createTestDir({
bunfigOpts: { linker: "isolated" },
files: {
"package.json": JSON.stringify({
name: "monorepo-single-version",
workspaces: ["packages/*"],
}),
// Both workspaces use the same version
"packages/pkg-a/package.json": JSON.stringify({
name: "pkg-a",
version: "1.0.0",
dependencies: {
"@types/is-number": "1.0.0",
},
}),
"packages/pkg-b/package.json": JSON.stringify({
name: "pkg-b",
version: "1.0.0",
dependencies: {
"@types/is-number": "1.0.0",
},
}),
},
});
await runBunInstall(bunEnv, packageDir);
// When only one version exists, it should be hoisted to .bun/node_modules/
expect(existsSync(join(packageDir, "node_modules/.bun/node_modules/@types/is-number"))).toBeTrue();
expect(lstatSync(join(packageDir, "node_modules/.bun/node_modules/@types/is-number")).isSymbolicLink()).toBeTrue();
});
});