Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
e87c40cb7d fix: bundler plugins with different namespaces and same path now work correctly
Previously, when multiple plugins resolved different imports to the same
path but different namespaces, the bundler would incorrectly treat them
as the same module. This was because PathToSourceIndexMap used only the
path text as the cache key, ignoring the namespace.

This fix makes the cache namespace-aware by:
- Including namespace in the cache key for non-file namespaces
- Using "namespace:::::path" format to avoid collisions
- Updating all PathToSourceIndexMap operations to handle namespaces
- Making the resolve queue namespace-aware to prevent duplicate ParseTasks

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 01:05:19 +00:00
3 changed files with 121 additions and 18 deletions

View File

@@ -5,38 +5,62 @@ const PathToSourceIndexMap = @This();
/// We assume it's arena allocated.
map: Map = .{},
const Map = bun.StringHashMapUnmanaged(Index.Int);
/// HashMap context that makes path lookups namespace-aware.
/// For file namespace, uses only path.text for backwards compatibility.
/// For other namespaces, combines namespace and path in the hash.
const PathHashContext = struct {
pub fn hash(_: @This(), path: Fs.Path) u64 {
return path.hashKey();
}
pub fn eql(_: @This(), a: Fs.Path, b: Fs.Path) bool {
// For file namespace, only compare path text
if (a.isFile() and b.isFile()) {
return bun.strings.eqlLong(a.text, b.text, true);
}
// For non-file namespaces, compare both namespace and path
return bun.strings.eqlLong(a.namespace, b.namespace, true) and
bun.strings.eqlLong(a.text, b.text, true);
}
};
const Map = std.HashMapUnmanaged(Fs.Path, Index.Int, PathHashContext, std.hash_map.default_max_load_percentage);
pub fn getPath(this: *const PathToSourceIndexMap, path: *const Fs.Path) ?Index.Int {
return this.get(path.text);
return this.map.get(path.*);
}
pub fn get(this: *const PathToSourceIndexMap, text: []const u8) ?Index.Int {
return this.map.get(text);
const file_path = Fs.Path.init(text);
return this.map.get(file_path);
}
pub fn putPath(this: *PathToSourceIndexMap, allocator: std.mem.Allocator, path: *const Fs.Path, value: Index.Int) bun.OOM!void {
try this.map.put(allocator, path.text, value);
try this.map.put(allocator, path.*, value);
}
pub fn put(this: *PathToSourceIndexMap, allocator: std.mem.Allocator, text: []const u8, value: Index.Int) bun.OOM!void {
try this.map.put(allocator, text, value);
const file_path = Fs.Path.init(text);
try this.map.put(allocator, file_path, value);
}
pub fn getOrPutPath(this: *PathToSourceIndexMap, allocator: std.mem.Allocator, path: *const Fs.Path) bun.OOM!Map.GetOrPutResult {
return this.getOrPut(allocator, path.text);
return try this.map.getOrPut(allocator, path.*);
}
pub fn getOrPut(this: *PathToSourceIndexMap, allocator: std.mem.Allocator, text: []const u8) bun.OOM!Map.GetOrPutResult {
return try this.map.getOrPut(allocator, text);
const file_path = Fs.Path.init(text);
return try this.map.getOrPut(allocator, file_path);
}
pub fn remove(this: *PathToSourceIndexMap, text: []const u8) bool {
return this.map.remove(text);
const file_path = Fs.Path.init(text);
return this.map.remove(file_path);
}
pub fn removePath(this: *PathToSourceIndexMap, path: *const Fs.Path) bool {
return this.remove(path.text);
return this.map.remove(path.*);
}
const std = @import("std");

View File

@@ -657,7 +657,7 @@ pub const BundleV2 = struct {
const entry = bun.handleOom(this.pathToSourceIndexMap(target).getOrPut(this.allocator(), path.text));
if (!entry.found_existing) {
path.* = bun.handleOom(this.pathWithPrettyInitialized(path.*, target));
entry.key_ptr.* = path.text;
entry.key_ptr.* = path.*;
const loader: Loader = brk: {
const record: *ImportRecord = &this.graph.ast.items(.import_records)[import_record.importer_source_index].slice()[import_record.import_record_index];
if (record.loader) |out_loader| {
@@ -699,9 +699,9 @@ pub const BundleV2 = struct {
.browser => .{ this.pathToSourceIndexMap(this.transpiler.options.target), this.pathToSourceIndexMap(.bake_server_components_ssr) },
.bake_server_components_ssr => .{ this.pathToSourceIndexMap(this.transpiler.options.target), this.pathToSourceIndexMap(.browser) },
};
bun.handleOom(a.put(this.allocator(), entry.key_ptr.*, entry.value_ptr.*));
bun.handleOom(a.putPath(this.allocator(), entry.key_ptr, entry.value_ptr.*));
if (this.framework.?.server_components.?.separate_ssr_graph)
bun.handleOom(b.put(this.allocator(), entry.key_ptr.*, entry.value_ptr.*));
bun.handleOom(b.putPath(this.allocator(), entry.key_ptr, entry.value_ptr.*));
}
} else {
out_source_index = Index.init(entry.value_ptr.*);
@@ -736,7 +736,7 @@ pub const BundleV2 = struct {
path = bun.handleOom(this.pathWithPrettyInitialized(path, target));
path.assertPrettyIsValid();
entry.key_ptr.* = path.text;
entry.key_ptr.* = path;
entry.value_ptr.* = source_index.get();
bun.handleOom(this.graph.ast.append(this.allocator(), JSAst.empty));
@@ -798,7 +798,7 @@ pub const BundleV2 = struct {
path.* = bun.handleOom(this.pathWithPrettyInitialized(path.*, target));
path.assertPrettyIsValid();
entry.key_ptr.* = path.text;
entry.key_ptr.* = path.*;
entry.value_ptr.* = source_index.get();
bun.handleOom(this.graph.ast.append(this.allocator(), JSAst.empty));
@@ -2455,7 +2455,8 @@ pub const BundleV2 = struct {
if (!existing.found_existing) {
this.free_list.appendSlice(&.{ result.namespace, result.path }) catch {};
path = bun.handleOom(this.pathWithPrettyInitialized(path, resolve.import_record.original_target));
existing.key_ptr.* = path.text;
// Update the key to use the arena-allocated path strings
existing.key_ptr.* = path;
// We need to parse this
const source_index = Index.init(@as(u32, @intCast(this.graph.ast.len)));
@@ -3429,7 +3430,7 @@ pub const BundleV2 = struct {
const is_html_entrypoint = import_record_loader == .html and target.isServerSide() and this.transpiler.options.dev_server == null;
if (this.pathToSourceIndexMap(target).get(path.text)) |id| {
if (this.pathToSourceIndexMap(target).getPath(path)) |id| {
if (this.transpiler.options.dev_server != null and loader != .html) {
import_record.path = this.graph.input_files.items(.source)[id].path;
} else {
@@ -3442,7 +3443,13 @@ pub const BundleV2 = struct {
import_record.kind = .html_manifest;
}
const resolve_entry = resolve_queue.getOrPut(path.text) catch |err| bun.handleOom(err);
// Generate namespace-aware key for the resolve queue
const resolve_key = if (path.isFile())
path.text
else
std.fmt.allocPrint(this.allocator(), "{s}:::::{s}", .{ path.namespace, path.text }) catch |err| bun.handleOom(err);
const resolve_entry = resolve_queue.getOrPut(resolve_key) catch |err| bun.handleOom(err);
if (resolve_entry.found_existing) {
import_record.path = resolve_entry.value_ptr.*.path;
continue;
@@ -3451,7 +3458,11 @@ pub const BundleV2 = struct {
path.* = bun.handleOom(this.pathWithPrettyInitialized(path.*, target));
import_record.path = path.*;
resolve_entry.key_ptr.* = path.text;
// Update key to use the arena-allocated path after pathWithPrettyInitialized
resolve_entry.key_ptr.* = if (path.isFile())
path.text
else
std.fmt.allocPrint(this.allocator(), "{s}:::::{s}", .{ path.namespace, path.text }) catch |err| bun.handleOom(err);
debug("created ParseTask: {s}", .{path.text});
const resolve_task = bun.handleOom(bun.default_allocator.create(ParseTask));
resolve_task.* = ParseTask.init(&resolve_result, Index.invalid, this);

View File

@@ -301,5 +301,73 @@ describe("bundler", () => {
};
});
}
// Test that onLoad is called for different namespaces with same path
itBundled("plugin/NamespaceOnLoadCalled", ({ root }) => {
const callOrder: string[] = [];
return {
files: {
"index.ts": /* ts */ `
import { value1 } from "module1";
import { value2 } from "module2";
console.log(value1, value2);
`,
},
plugins: [
{
name: "pluginA",
setup(builder) {
// Resolve module1 to namespace plugin-a
builder.onResolve({ filter: /^module1$/ }, args => {
callOrder.push("pluginA-resolve");
return {
path: "shared-path.js",
namespace: "plugin-a",
};
});
},
},
{
name: "pluginB",
setup(builder) {
// Resolve module2 to namespace plugin-b
builder.onResolve({ filter: /^module2$/ }, args => {
callOrder.push("pluginB-resolve");
return {
path: "shared-path.js",
namespace: "plugin-b",
};
});
// Handle onLoad for namespace plugin-a
builder.onLoad({ filter: /.*/, namespace: "plugin-a" }, args => {
callOrder.push("pluginB-load-a");
return {
contents: 'export const value1 = "from plugin-a namespace";',
loader: "js",
};
});
// Handle onLoad for namespace plugin-b
builder.onLoad({ filter: /.*/, namespace: "plugin-b" }, args => {
callOrder.push("pluginB-load-b");
return {
contents: 'export const value2 = "from plugin-b namespace";',
loader: "js",
};
});
},
},
],
run: {
stdout: "from plugin-a namespace from plugin-b namespace",
},
onAfterBundle() {
// Both onLoad callbacks should have been called with their respective namespaces
expect(callOrder).toEqual(["pluginA-resolve", "pluginB-resolve", "pluginB-load-a", "pluginB-load-b"]);
},
};
});
});
});