diff --git a/src/jsc/RuntimeTranspilerStore.rs b/src/jsc/RuntimeTranspilerStore.rs index 016b9bd524a2..e6975d434276 100644 --- a/src/jsc/RuntimeTranspilerStore.rs +++ b/src/jsc/RuntimeTranspilerStore.rs @@ -994,7 +994,7 @@ impl TranspilerJob { _ => (ptr::null_mut(), 0), }; self.resolved_source = OwnedResolvedSource::from(ResolvedSource { - source_code: String::clone_latin1(&parse_result.source.contents), + source_code: String::clone_utf8(&parse_result.source.contents), already_bundled: true, bytecode_cache, bytecode_cache_size, diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 538ccf7e3715..9ad53c6f0af5 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -2787,8 +2787,15 @@ fn transpile_source_code_inner( } _ => (core::ptr::null_mut(), 0), }; + if is_main { + // Same as the transpiler-cache-hit return below: leaving + // `has_loaded` false sends unknown-extension imports to + // the `Loader::Tsx` fallback instead of `Loader::File`. + // SAFETY: per fn contract — `jsc_vm` is the live per-thread VM. + unsafe { (*jsc_vm).has_loaded = true }; + } return Ok(OwnedResolvedSource::from(ResolvedSource { - source_code: bun_core::String::clone_latin1(&source.contents), + source_code: bun_core::String::clone_utf8(&source.contents), specifier: input_specifier.dupe_ref(), source_url: create_if_different(input_specifier, path.text), already_bundled: true, @@ -2931,6 +2938,14 @@ fn transpile_source_code_inner( }; let (bytecode_cache, bytecode_cache_size) = node_compile_cache_blob.unwrap_or((core::ptr::null_mut(), 0)); + if is_main { + // Without this, a cache hit for the entry point leaves + // `has_loaded` false and later imports with unknown + // extensions fall back to `Loader::Tsx` instead of + // `Loader::File`. + // SAFETY: per fn contract — `jsc_vm` is the live per-thread VM. + unsafe { (*jsc_vm).has_loaded = true }; + } return Ok(OwnedResolvedSource::from(ResolvedSource { source_code, specifier: input_specifier.dupe_ref(), diff --git a/test/bundler/transpiler/runtime-transpiler.test.ts b/test/bundler/transpiler/runtime-transpiler.test.ts index 5c05e408c92a..ce6302c0a064 100644 --- a/test/bundler/transpiler/runtime-transpiler.test.ts +++ b/test/bundler/transpiler/runtime-transpiler.test.ts @@ -48,6 +48,57 @@ describe("// @bun", () => { expect(stdout.toString()).toBe("Hello world!\n"); expect(exitCode).toBe(0); }); + + // https://github.com/oven-sh/bun/issues/37161 + const nonAscii = "\u2014\u00b7\u2713 K\u00e4ufer"; + test("raw utf-8 decodes as utf-8, not latin-1 (import)", async () => { + using dir = tempDir("bun-pragma-utf8-import", { + "pragma.js": `// @bun\nexport const text = "${nonAscii}";\n`, + }); + const { text } = await import(`${dir}/pragma.js`); + expect(text).toBe(nonAscii); + }); + + test("raw utf-8 decodes as utf-8, not latin-1 (require)", async () => { + using dir = tempDir("bun-pragma-utf8-require", { + "pragma.js": `// @bun\nexport const text = "${nonAscii}";\n`, + }); + const { text } = require(`${dir}/pragma.js`); + expect(text).toBe(nonAscii); + }); + + test("entry point with pragma keeps unknown-extension imports on the file loader", async () => { + using dir = tempDir("bun-pragma-file-loader", { + "asset.hbs": "\n\n", + "entry.js": `// @bun\nimport asset from "./asset.hbs";\nconsole.log(typeof asset);\n`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toBe("string\n"); + expect(exitCode).toBe(0); + }); + + test("raw utf-8 decodes as utf-8, not latin-1 (entry point)", async () => { + using dir = tempDir("bun-pragma-utf8-entry", { + "pragma.js": `// @bun\nconst DASH = "\u2014";\nconsole.log([...DASH].map(c => c.codePointAt(0).toString(16)).join(" "));\n`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "pragma.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toBe("2014\n"); + expect(exitCode).toBe(0); + }); }); describe("json imports", () => { diff --git a/test/cli/run/transpiler-cache.test.ts b/test/cli/run/transpiler-cache.test.ts index 2b6561b37bb2..9ddb70793310 100644 --- a/test/cli/run/transpiler-cache.test.ts +++ b/test/cli/run/transpiler-cache.test.ts @@ -81,6 +81,17 @@ describe("transpiler cache", () => { expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("a"); expect(!existsSync(cache_dir)).toBeTrue(); }); + test("cache hit on the entry point keeps unknown-extension imports on the file loader", async () => { + writeFileSync(join(temp_dir, "asset.hbs"), "\n\n"); + const padding = "// " + Buffer.alloc(8 * 1024, "x").toString() + "\n"; + writeFileSync(join(temp_dir, "a.js"), `import asset from "./asset.hbs";\n${padding}console.log(typeof asset);\n`); + expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("string"); + expect(newCacheCount()).toBe(1); + // The second run loads the entry point from the transpiler cache; the + // unknown extension must still get the file loader, not the tsx fallback. + expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("string"); + expect(newCacheCount()).toBe(0); + }); test("it is indeed content addressable", async () => { writeFileSync(join(temp_dir, "a.js"), dummyFile(50 * 1024, "1", "b")); expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("b");