diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index a0c4584f3afe..c1957f9b2229 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -5244,18 +5244,22 @@ pub mod __gated_printer { 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()); @@ -5481,7 +5485,11 @@ pub mod __gated_printer { 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() { @@ -5490,7 +5498,8 @@ pub mod __gated_printer { 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() { @@ -5999,6 +6008,8 @@ pub mod __gated_printer { self.print_whitespacer(ws!(b" with { type: \"md\" }")) } } + } else if Self::record_implies_json_type(record) { + self.print_whitespacer(ws!(b" with { type: \"json\" }")); } } self.print_semicolon_after_statement(); @@ -6039,6 +6050,8 @@ pub mod __gated_printer { 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 } @@ -6179,6 +6192,31 @@ pub mod __gated_printer { 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. Must agree with `specifierImpliesJsonType` in + /// `ZigGlobalObject.cpp` (both inspect the as-written specifier). + 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'?').unwrap_or(text.len()); + // `?raw` selects the text loader; a synthesized attribute would override it. + if &text[end..] == b"?raw" { + return false; + } + let path = &text[..end]; + if !strings::has_suffix_comptime(path, b".json") { + return false; + } + // `loader_for_path` routes these to jsonc; a synthesized attribute would force strict JSON. + let filename = bun_paths::basename(path); + !(filename == b"package.json" + || strings::has_prefix_comptime(filename, b"tsconfig.") + || strings::has_prefix_comptime(filename, b"jsconfig.")) + } + pub fn print_import_record_path(&mut self, import_record: &ImportRecord) { if IS_JSON { unreachable!(); diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 1f2447a9ac9e..cafb970a3385 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -43,7 +43,9 @@ bun_core::declare_scope!(cache, visible); /// path reinstates the bug for any previously-cached TLA module (#30887). /// Version 23: `jsx.runtime`/`jsx.development` participate in the features hash, /// and tsconfig `"jsx": "react-jsx"` now emits the production runtime (#4227). -const EXPECTED_VERSION: u32 = 23; +/// Version 24: attribute-less `.json` imports emit `with { type: "json" }` and +/// record `FetchParameters::Json` for the Bun target (#35914). +const EXPECTED_VERSION: u32 = 24; /// Source files smaller than this are not written to / read from the on-disk /// transpiler cache. Originally 50 KiB, which excluded almost every file in a diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index a45cf0016dfe..00783ba9ca91 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -3650,6 +3650,31 @@ JSC::Identifier GlobalObject::moduleLoaderResolve(JSGlobalObject* jsGlobalObject } } +// Attribute-less dynamic import() whose specifier ends in `.json`: supply +// Type::JSON so JSC's (specifier, Type) module-map key matches the +// `with { type: "json" }` form and the printer's static-side normalization. +// Must inspect the as-written specifier, not the resolved path, so a specifier +// that resolves to a `.json` file but doesn't literally end in one stays +// consistent with the printer (which never resolves). +static ALWAYS_INLINE bool specifierImpliesJsonType(StringView specifier) +{ + size_t q = specifier.find('?'); + if (q != notFound) { + // `?raw` selects the text loader; a synthesized `type: "json"` would override it. + if (specifier.substring(q) == "?raw"_s) + return false; + specifier = specifier.left(q); + } + if (!specifier.endsWith(".json"_s)) + return false; + size_t slash = specifier.reverseFind('/'); + size_t backslash = specifier.reverseFind('\\'); + size_t sep = slash == notFound ? backslash : (backslash == notFound ? slash : std::max(slash, backslash)); + StringView filename = sep == notFound ? specifier : specifier.substring(sep + 1); + // `loader_for_path` routes these to jsonc; a synthesized `type: "json"` would force strict JSON. + return filename != "package.json"_s && !filename.startsWith("tsconfig."_s) && !filename.startsWith("jsconfig."_s); +} + JSC::JSPromise* GlobalObject::moduleLoaderImportModule(JSGlobalObject* jsGlobalObject, JSModuleLoader*, JSString* moduleNameValue, @@ -3676,6 +3701,9 @@ JSC::JSPromise* GlobalObject::moduleLoaderImportModule(JSGlobalObject* jsGlobalO auto moduleName = moduleNameValue->value(globalObject); RETURN_IF_EXCEPTION(scope, nullptr); + if (!parameters && specifierImpliesJsonType(moduleName)) + parameters = JSC::ScriptFetchParameters::create(JSC::ScriptFetchParameters::Type::JSON); + auto sourceURL = sourceOrigin.url(); String sourceOriginStringHolder; int64_t referrerAsyncOrder = -1; diff --git a/test/js/bun/resolve/json-import-identity.test.ts b/test/js/bun/resolve/json-import-identity.test.ts new file mode 100644 index 000000000000..944ce0f8f492 --- /dev/null +++ b/test/js/bun/resolve/json-import-identity.test.ts @@ -0,0 +1,321 @@ +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) { + 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]); + expect(stderr).toBe(""); + return { stdout: normalizeBunSnapshot(stdout, dir), exitCode }; +} + +test.concurrent("static .json imports with and without the type attribute share one module across files", 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 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); +}); + +test.concurrent("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.concurrent("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.concurrent("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.concurrent("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.concurrent("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.concurrent("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" };`, + `import d from "./cfg.json?raw";`, + `import e from "pkg/data";`, + `import f from "#cfg";`, + `import g from "#cfg/data.json";`, + `export { default as h } from "./other.json";`, + `export * as i 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" }; +import d from "./cfg.json?raw"; +import e from "pkg/data"; +import f from "#cfg"; +import g from "#cfg/data.json" with { type: "json" }; +export { default as h } from "./other.json" with { type: "json" }; +export * as i 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.concurrent("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.concurrent("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("specifiers that resolve to a .json but don't end in one keep a shared module", () => { + // The normalization keys on the as-written specifier in both the printer and + // `moduleLoaderImportModule`; keying on the resolved path on only one side + // would fork static vs dynamic for these. + test.concurrent("package exports: `pkg/data` -> data.json, static vs dynamic", async () => { + const { stdout, exitCode } = await run({ + "node_modules/pkg/package.json": `{"name":"pkg","exports":{"./data":"./data.json"}}`, + "node_modules/pkg/data.json": `{"n":1}`, + "a.mjs": `import a from "pkg/data"; export default a;`, + "index.mjs": ` + const s = (await import("./a.mjs")).default; + const d = (await import("pkg/data")).default; + console.log("same:", s === d); + `, + }); + expect(stdout).toMatchInlineSnapshot(`"same: true"`); + expect(exitCode).toBe(0); + }); + + test.concurrent("subpath import: `#cfg` -> cfg.json, static vs dynamic", async () => { + const { stdout, exitCode } = await run({ + "package.json": `{"imports":{"#cfg":"./cfg.json"}}`, + "cfg.json": `{"n":1}`, + "a.mjs": `import a from "#cfg"; export default a;`, + "index.mjs": ` + const s = (await import("./a.mjs")).default; + const d = (await import("#cfg")).default; + console.log("same:", s === d); + `, + }); + expect(stdout).toMatchInlineSnapshot(`"same: true"`); + expect(exitCode).toBe(0); + }); +}); + +test.concurrent("a #subpath specifier that ends in .json shares one module with the attributed form", async () => { + const { stdout, exitCode } = await run({ + "package.json": `{"imports":{"#cfg/*":"./src/*"}}`, + "src/data.json": `{"n":1}`, + "plain.mjs": `import a from "#cfg/data.json"; export default a;`, + "attr.mjs": `import b from "#cfg/data.json" with { type: "json" }; export default b;`, + "index.mjs": ` + const plain = (await import("./plain.mjs")).default; + const attr = (await import("./attr.mjs")).default; + const dyn = (await import("#cfg/data.json")).default; + console.log("plain === attr:", plain === attr); + console.log("plain === dyn:", plain === dyn); + `, + }); + expect(stdout).toMatchInlineSnapshot(` +"plain === attr: true +plain === dyn: true" +`); + expect(exitCode).toBe(0); +}); + +describe("the ?raw query on a .json specifier still selects the text loader", () => { + test.concurrent("static", async () => { + const { stdout, exitCode } = await run({ + "cfg.json": `{"n":1}`, + "a.mjs": `import a from "./cfg.json?raw"; export default a;`, + "index.mjs": ` + const a = (await import("./a.mjs")).default; + console.log(typeof a, a.trimEnd()); + `, + }); + expect(stdout).toMatchInlineSnapshot(`"string {"n":1}"`); + expect(exitCode).toBe(0); + }); + + test.concurrent("dynamic", async () => { + const { stdout, exitCode } = await run({ + "cfg.json": `{"n":1}`, + "index.mjs": ` + const a = (await import("./cfg.json?raw")).default; + console.log(typeof a, a.trimEnd()); + `, + }); + expect(stdout).toMatchInlineSnapshot(`"string {"n":1}"`); + 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.concurrent(`static import of an empty ${name} still works`, async () => { + const { stdout, 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(stdout).toMatchInlineSnapshot(`"{}"`); + expect(exitCode).toBe(0); + }); + + test.concurrent(`dynamic import of an empty ${name} still works`, async () => { + const { stdout, exitCode } = await run({ + [name]: ``, + "index.mjs": ` + const a = (await import("./${name}")).default; + console.log(JSON.stringify(a)); + `, + }); + expect(stdout).toMatchInlineSnapshot(`"{}"`); + expect(exitCode).toBe(0); + }); + } +});