Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 42 additions & 6 deletions src/js_printer/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5244,18 +5244,22 @@
self.print_whitespacer(ws!(b"from "));
}

let irp = &self.import_record(s.import_record_index as usize).path.text;
self.print_import_record_path(
self.import_record(s.import_record_index as usize),
);
let record = self.import_record(s.import_record_index as usize);
let irp = &record.path.text;
let implies_json = Self::record_implies_json_type(record);
self.print_import_record_path(record);
if IS_BUN_PLATFORM && implies_json {
self.print_whitespacer(ws!(b" with { type: \"json\" }"));
}
self.print_semicolon_after_statement();

if Self::MAY_HAVE_MODULE_INFO {
if let Some(mi) = self.module_info() {
let irp_id = mi.str(irp);
use analyze_transpiled_module::FetchParameters as FP;
mi.request_module(
irp_id,
analyze_transpiled_module::FetchParameters::None,
if implies_json { FP::Json } else { FP::None },
);
if let Some(alias) = &s.alias {
let alias_id = mi.str(alias.original_name.slice());
Expand Down Expand Up @@ -5481,7 +5485,11 @@

self.print_whitespacer(ws!(b"} from "));
let irp = &import_record.path.text;
let implies_json = Self::record_implies_json_type(import_record);
self.print_import_record_path(import_record);
if IS_BUN_PLATFORM && implies_json {
self.print_whitespacer(ws!(b" with { type: \"json\" }"));
}
self.print_semicolon_after_statement();

if Self::MAY_HAVE_MODULE_INFO && self.module_info.is_some() {
Expand All @@ -5490,7 +5498,8 @@
let irp_id = {
let mi = self.module_info().expect("infallible: module_info enabled");
let id = mi.str(irp);
mi.request_module(id, analyze_transpiled_module::FetchParameters::None);
use analyze_transpiled_module::FetchParameters as FP;
mi.request_module(id, if implies_json { FP::Json } else { FP::None });
id
};
for item in slice_of(s.items).iter() {
Expand Down Expand Up @@ -5999,6 +6008,8 @@
self.print_whitespacer(ws!(b" with { type: \"md\" }"))
}
}
} else if Self::record_implies_json_type(record) {
self.print_whitespacer(ws!(b" with { type: \"json\" }"));

Check failure on line 6012 in src/js_printer/lib.rs

View check run for this annotation

Claude / Claude Code Review

RuntimeTranspilerCache EXPECTED_VERSION not bumped

This changes bun-target printer output (adds `with { type: "json" }`) and the serialized `esm_record` (`FetchParameters::None` → `Json`) for attribute-less `.json` import records — both persisted in `.pile` entries — but `EXPECTED_VERSION` in `src/jsc/RuntimeTranspilerCache.rs:46` is still `23`. A warm-cache importer ≥4 KB written by a pre-PR Bun will hit unchanged and serve the old un-attributed output + `FP::None`, silently reinstating the identity fork this PR fixes; bump to `24` with a one-l
Comment thread
robobun marked this conversation as resolved.
}
}
self.print_semicolon_after_statement();
Expand Down Expand Up @@ -6039,6 +6050,8 @@
Loader::Json5 => FP::host_defined(mi.str(b"json5")),
Loader::Md => FP::host_defined(mi.str(b"md")),
}
} else if Self::record_implies_json_type(record) {
FP::Json
} else {
FP::None
}
Expand Down Expand Up @@ -6179,6 +6192,29 @@
self.print(b"module.exports");
}

/// No `with { type }` on a `.json` specifier: emit one so JSC's
/// `(specifier, ScriptFetchParameters::Type)` module-map key matches
/// the attributed form. Skips filenames `loader_for_path` routes to
/// jsonc, where a synthesized `type: "json"` would force strict JSON.
Comment thread
robobun marked this conversation as resolved.
Outdated
fn record_implies_json_type(record: &ImportRecord) -> bool {
if record.loader.is_some() {
return false;
}
let text = record.path.text;
let end = text
.iter()
.position(|&c| c == b'?' || c == b'#')
.unwrap_or(text.len());
let path = &text[..end];

Check warning on line 6208 in src/js_printer/lib.rs

View check run for this annotation

Claude / Claude Code Review

Leading '#' truncation misses subpath-import .json specifiers

For a Node subpath-import specifier like `#cfg/data.json`, `.position(|&c| c == b'?' || c == b'#')` returns 0, so `path` becomes `b""` and this function returns `false` — no `with { type: "json" }` is emitted, and the identity fork this PR fixes remains for static `#`-prefixed subpath imports resolving to JSON. On the printer side `record.path.text` is the unresolved specifier, where a leading `#` is a subpath marker (resolver.rs:2569), not a URL fragment; Bun's own `normalize_specifier_for_load
Comment thread
robobun marked this conversation as resolved.
Outdated
if !strings::has_suffix_comptime(path, b".json") {
return false;
}
let filename = bun_paths::basename(path);
!(filename == b"package.json"
|| strings::has_prefix_comptime(filename, b"tsconfig.")
|| strings::has_prefix_comptime(filename, b"jsconfig."))
}

Check failure on line 6216 in src/js_printer/lib.rs

View check run for this annotation

Claude / Claude Code Review

Synthesized type:"json" defeats ?raw on .json specifiers

`record_implies_json_type()` strips `?`/`#` before the `.json` suffix check, so `import a from "./cfg.json?raw"` now gets a synthesized `with { type: "json" }` — and at fetch time the type attribute unconditionally overrides the `?raw` loader (jsc_hooks.rs:4034-4041), so the default export flips from the raw string to the parsed object. `normalizeFetchParametersForResolvedPath` has the same issue for dynamic `import("./cfg.json?raw")`. Skip the synthesis when the stripped query is `?raw` in both
Comment thread
claude[bot] marked this conversation as resolved.

pub fn print_import_record_path(&mut self, import_record: &ImportRecord) {
if IS_JSON {
unreachable!();
Expand Down
27 changes: 27 additions & 0 deletions src/jsc/bindings/ZigGlobalObject.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3650,6 +3650,31 @@ JSC::Identifier GlobalObject::moduleLoaderResolve(JSGlobalObject* jsGlobalObject
}
}

// Attribute-less dynamic import() of a `.json`: supply Type::JSON so JSC's
// (specifier, Type) module-map key matches the `with { type: "json" }` form.
// Skips filenames `loader_for_path` routes to jsonc, where a synthesized
// attribute would force strict JSON at fetch time.
Comment thread
robobun marked this conversation as resolved.
Outdated
static ALWAYS_INLINE void normalizeFetchParametersForResolvedPath(RefPtr<JSC::ScriptFetchParameters>& parameters, const JSC::Identifier& resolved)
{
if (parameters)
return;
auto* impl = resolved.impl();
if (!impl || impl->isSymbol())
return;
StringView path(*impl);
if (size_t q = path.find([](char16_t c) { return c == '?' || c == '#'; }); q != notFound)
path = path.left(q);
if (!path.endsWith(".json"_s))
return;
size_t slash = path.reverseFind('/');
size_t backslash = path.reverseFind('\\');
size_t sep = slash == notFound ? backslash : (backslash == notFound ? slash : std::max(slash, backslash));
StringView filename = sep == notFound ? path : path.substring(sep + 1);
if (filename == "package.json"_s || filename.startsWith("tsconfig."_s) || filename.startsWith("jsconfig."_s))
return;
parameters = JSC::ScriptFetchParameters::create(JSC::ScriptFetchParameters::Type::JSON);
}

JSC::JSPromise* GlobalObject::moduleLoaderImportModule(JSGlobalObject* jsGlobalObject,
JSModuleLoader*,
JSString* moduleNameValue,
Expand Down Expand Up @@ -3698,6 +3723,7 @@ JSC::JSPromise* GlobalObject::moduleLoaderImportModule(JSGlobalObject* jsGlobalO
if (globalObject->onLoadPlugins.hasVirtualModules()) {
if (auto resolution = globalObject->onLoadPlugins.resolveVirtualModule(moduleName, sourceURL.protocolIsFile() ? sourceOriginStringHolder : String())) {
resolvedIdentifier = JSC::Identifier::fromString(vm, resolution.value());
normalizeFetchParametersForResolvedPath(parameters, resolvedIdentifier);

auto result = JSC::importModule(globalObject, resolvedIdentifier, JSC::Identifier(), parameters, nullptr, /* deferred */ false, referrerAsyncOrder);
if (scope.exception()) [[unlikely]] {
Expand Down Expand Up @@ -3758,6 +3784,7 @@ JSC::JSPromise* GlobalObject::moduleLoaderImportModule(JSGlobalObject* jsGlobalO
// The C++ module loader now extracts `with.type` into a
// ScriptFetchParameters before calling this hook, so `parameters` is
// already the parsed RefPtr (or null). Just forward it.
normalizeFetchParametersForResolvedPath(parameters, resolvedIdentifier);
auto result = JSC::importModule(globalObject, resolvedIdentifier,
JSC::Identifier(), WTF::move(parameters), nullptr, /* deferred */ false, referrerAsyncOrder);
if (scope.exception()) [[unlikely]] {
Expand Down
231 changes: 231 additions & 0 deletions test/js/bun/resolve/json-import-identity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, normalizeBunSnapshot, tempDir } from "harness";

// Bun accepts `import "./x.json"` without `with { type: "json" }`. Both forms
// load the same file with the same loader, so they must resolve to the same
// module record in JSC's registry. Before this was fixed the attribute-less
// form keyed on ScriptFetchParameters::Type::JavaScript and the attributed
// form on Type::JSON, so two module instances were created and a mutation via
// one was invisible via the other.

async function run(files: Record<string, string>) {
using dir = tempDir("json-import-identity", files);
await using proc = Bun.spawn({
cmd: [bunExe(), "index.mjs"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout: normalizeBunSnapshot(stdout, dir), stderr, exitCode };
}

test("static .json imports with and without the type attribute share one module across files", async () => {
Comment thread
robobun marked this conversation as resolved.
Outdated
const { stdout, exitCode } = await run({
"cfg.json": `{"n":1}`,
"plain.mjs": `import a from "./cfg.json"; export default a;`,
"attr.mjs": `import b from "./cfg.json" with { type: "json" }; export default b;`,
"index.mjs": `
const plain = (await import("./plain.mjs")).default;
const attr = (await import("./attr.mjs")).default;
console.log("same:", plain === attr);
plain.n = 42;
console.log("mutation:", attr.n);
`,
});
expect(stdout).toMatchInlineSnapshot(`
"same: true
mutation: 42"
`);
expect(exitCode).toBe(0);
Comment thread
robobun marked this conversation as resolved.
});

test("static .json imports share one module regardless of load order", async () => {
const { stdout, exitCode } = await run({
"cfg.json": `{"n":1}`,
"plain.mjs": `import a from "./cfg.json"; export default a;`,
"attr.mjs": `import b from "./cfg.json" with { type: "json" }; export default b;`,
"index.mjs": `
const attr = (await import("./attr.mjs")).default;
const plain = (await import("./plain.mjs")).default;
console.log("same:", plain === attr);
`,
});
expect(stdout).toMatchInlineSnapshot(`"same: true"`);
expect(exitCode).toBe(0);
});

test("dynamic import() of a .json with and without the type attribute returns one module", async () => {
const { stdout, exitCode } = await run({
"cfg.json": `{"n":1}`,
"index.mjs": `
const plain = await import("./cfg.json");
const attr = await import("./cfg.json", { with: { type: "json" } });
console.log("ns:", plain === attr);
console.log("default:", plain.default === attr.default);
plain.default.n = 99;
console.log("mutation:", attr.default.n);
`,
});
expect(stdout).toMatchInlineSnapshot(`
"ns: true
default: true
mutation: 99"
`);
expect(exitCode).toBe(0);
});

test("dynamic import() of a .json shares one module regardless of order", async () => {
const { stdout, exitCode } = await run({
"cfg.json": `{"n":1}`,
"index.mjs": `
const attr = await import("./cfg.json", { with: { type: "json" } });
const plain = await import("./cfg.json");
console.log("ns:", plain === attr);
`,
});
expect(stdout).toMatchInlineSnapshot(`"ns: true"`);
expect(exitCode).toBe(0);
});

test("a static attribute-less .json import and a dynamic attributed one share one module", async () => {
const { stdout, exitCode } = await run({
"cfg.json": `{"n":1}`,
"plain.mjs": `import a from "./cfg.json"; export default a;`,
"index.mjs": `
const plain = (await import("./plain.mjs")).default;
const attr = (await import("./cfg.json", { with: { type: "json" } })).default;
console.log("same:", plain === attr);
`,
});
expect(stdout).toMatchInlineSnapshot(`"same: true"`);
expect(exitCode).toBe(0);
});

test("export-from of a .json shares one module with an attributed import", async () => {
const { stdout, exitCode } = await run({
"cfg.json": `{"n":1}`,
"reex.mjs": `export { default as cfg } from "./cfg.json";`,
"imp.mjs": `import b from "./cfg.json" with { type: "json" }; export default b;`,
"index.mjs": `
const a = (await import("./reex.mjs")).cfg;
const b = (await import("./imp.mjs")).default;
console.log("same:", a === b);
`,
});
expect(stdout).toMatchInlineSnapshot(`"same: true"`);
expect(exitCode).toBe(0);
});

test("export * as of a .json shares one module with an attributed import", async () => {
const { stdout, exitCode } = await run({
"cfg.json": `{"n":1}`,
"reex.mjs": `export * as cfg from "./cfg.json";`,
"imp.mjs": `import b from "./cfg.json" with { type: "json" }; export default b;`,
"index.mjs": `
const a = (await import("./reex.mjs")).cfg;
const b = (await import("./imp.mjs")).default;
console.log("same:", a.default === b);
`,
});
expect(stdout).toMatchInlineSnapshot(`"same: true"`);
expect(exitCode).toBe(0);
});

test('the bun-target transpiler emits `with { type: "json" }` for attribute-less .json specifiers', () => {
// Static child imports go through `hostLoadImportedModule`, not the
// dynamic-import hook, so the printer change is load bearing on its own.
const t = new Bun.Transpiler({ target: "bun" });
const out = t.transformSync(
[
`import a from "./cfg.json";`,
`import b from "./package.json";`,
`import c from "./data.json" with { type: "text" };`,
`export { default as d } from "./other.json";`,
`export * as e from "./more.json";`,
].join("\n"),
);
expect(out).toMatchInlineSnapshot(`
"import a from "./cfg.json" with { type: "json" };
import b from "./package.json";
import c from "./data.json" with { type: "text" };
export { default as d } from "./other.json" with { type: "json" };
export * as e from "./more.json" with { type: "json" };
"
`);
// Other targets are untouched.
for (const target of ["browser", "node"] as const) {
expect(new Bun.Transpiler({ target }).transformSync(`import a from "./cfg.json";`)).toBe(
`import a from "./cfg.json";\n`,
);
}
});

test("an explicit non-json type attribute still produces a distinct module", async () => {
const { stdout, exitCode } = await run({
"cfg.json": `{"n":1}`,
"index.mjs": `
const asJson = (await import("./cfg.json", { with: { type: "json" } })).default;
const asText = (await import("./cfg.json", { with: { type: "text" } })).default;
console.log("json:", JSON.stringify(asJson));
console.log("text:", asText);
console.log("distinct:", asJson !== asText);
`,
});
expect(stdout).toMatchInlineSnapshot(`
"json: {"n":1}
text: {"n":1}
distinct: true"
`);
expect(exitCode).toBe(0);
});

test("a .json specifier with a query string still normalizes to one module", async () => {
const { stdout, exitCode } = await run({
"cfg.json": `{"n":1}`,
"index.mjs": `
const plain = await import("./cfg.json?v=1");
const attr = await import("./cfg.json?v=1", { with: { type: "json" } });
console.log("same:", plain.default === attr.default);
`,
});
expect(stdout).toMatchInlineSnapshot(`"same: true"`);
expect(exitCode).toBe(0);
});

describe("jsonc-loaded filenames are left alone", () => {
// package.json / tsconfig.json / jsconfig.json use Bun's jsonc loader even
// though the extension is `.json`. The normalization must not synthesize
// `with { type: "json" }` for them: that would reach the fetch hook as an
// explicit `type` override and force strict JSON, breaking Bun's lenient
// handling of empty / commented config files.
for (const name of ["package.json", "tsconfig.json", "jsconfig.json"]) {
test(`static import of an empty ${name} still works`, async () => {
const { stdout, stderr, exitCode } = await run({
[name]: ``,
"plain.mjs": `import a from "./${name}"; export default a;`,
"index.mjs": `
const a = (await import("./plain.mjs")).default;
console.log(JSON.stringify(a));
`,
});
expect(stderr).not.toContain("JSON Parse error");
expect(stdout).toMatchInlineSnapshot(`"{}"`);
expect(exitCode).toBe(0);
});

test(`dynamic import of an empty ${name} still works`, async () => {
const { stdout, stderr, exitCode } = await run({
[name]: ``,
"index.mjs": `
const a = (await import("./${name}")).default;
console.log(JSON.stringify(a));
`,
});
expect(stderr).not.toContain("JSON Parse error");
expect(stdout).toMatchInlineSnapshot(`"{}"`);
expect(exitCode).toBe(0);
});
}
});
Loading