From 0e61e645feab1c2a12e4dc37bca077d5ffd10d8c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:46:36 +0000 Subject: [PATCH 1/2] Substitute top-level this with undefined in ES modules In a file with ES module syntax, value_for_this substituted E::Null for a top-level this, so typeof this was "object" and this === undefined was false. The spec, Node, and esbuild give undefined. Substitute E::Undefined instead. The CommonJS branch (this is exports) is unchanged. Bump the runtime transpiler cache version so cached output with the old substitution is not served, and hash module_type into the features hash: exports_kind for a file with no module syntax is decided by module_type, so byte-identical sources in an ESM package and a CommonJS package must not share a cache entry. Fixes #32167 --- src/js_parser/p.rs | 6 +-- src/js_parser/parse/parse_entry.rs | 5 +++ src/jsc/RuntimeTranspilerCache.rs | 4 +- test/bundler/esbuild/default.test.ts | 2 +- .../transpiler/runtime-transpiler.test.ts | 45 +++++++++++++++++++ test/bundler/transpiler/transpiler.test.js | 16 +++++++ test/cli/run/transpiler-cache.test.ts | 21 +++++++++ 7 files changed, 92 insertions(+), 7 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 7aba6f7d8d38..f08a7a21cc60 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -6032,7 +6032,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // compile time using expression substitution here. return Some(Expr { loc, - data: null_value_expr(), + data: js_ast::ExprData::EUndefined(E::Undefined {}), }); } else { // In a CommonJS module, "this" is supposed to be the same as "exports". @@ -10275,10 +10275,6 @@ pub(crate) fn null_expr_data() -> js_ast::ExprData { pub(crate) fn null_stmt_data() -> js_ast::StmtData { js_ast::StmtData::SEmpty(S::Empty {}) } -#[inline] -pub(crate) fn null_value_expr() -> js_ast::ExprData { - js_ast::ExprData::ENull(E::Null {}) -} /// `require()` of an ES module returns a copy of the namespace with /// `__esModule` set, and reads `default` off `module.exports`: neither name is diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index 743cc7c322c6..ee8992245058 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -265,6 +265,11 @@ impl<'a> Options<'a> { hasher.update(b"udfcf=0"); } + // `exports_kind` for a file with no module syntax is decided by + // `module_type`, so byte-identical sources in an ESM package and a + // CommonJS package are different cache entries. + hasher.update(&[self.module_type as u8]); + self.features.hash_for_runtime_transpiler(hasher); } diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index a37b8714fbba..7b7440a2d02d 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -57,7 +57,9 @@ bun_core::declare_scope!(cache, visible); /// Version 27: ModuleInfo string table holds Latin-1 / UTF-16 bodies, not WTF-8. /// Version 28: the define table and `--drop` entries participate in the features hash. /// Version 29: `new Array(x, ...spread)` is no longer folded into an array literal. -const EXPECTED_VERSION: u32 = 29; +/// Version 30: Top-level `this` in an ES module is `undefined`, not `null` (#32167), +/// and `module_type` participates in the features hash. +const EXPECTED_VERSION: u32 = 30; /// 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/test/bundler/esbuild/default.test.ts b/test/bundler/esbuild/default.test.ts index f035bb593c26..588827f8058e 100644 --- a/test/bundler/esbuild/default.test.ts +++ b/test/bundler/esbuild/default.test.ts @@ -5313,7 +5313,7 @@ describe.concurrent("bundler", () => { "/node_modules/pkg/file2.js": `export default [this, this]`, }, run: { - stdout: "[ null, null ] [ null, null ]", + stdout: "[ undefined, undefined ] [ undefined, undefined ]", }, }); itBundled("default/QuotedProperty", { diff --git a/test/bundler/transpiler/runtime-transpiler.test.ts b/test/bundler/transpiler/runtime-transpiler.test.ts index 5c05e408c92a..50023ce198d0 100644 --- a/test/bundler/transpiler/runtime-transpiler.test.ts +++ b/test/bundler/transpiler/runtime-transpiler.test.ts @@ -252,3 +252,48 @@ describe("unterminated string literals in large files", () => { expect(exitCode).toBe(1); }); }); + +// https://github.com/oven-sh/bun/issues/32167 +describe.concurrent("top-level this", () => { + const files = { + // the issue's repro: an import-only entry point, `this` captured by an arrow + "import-only.js": `import { EventEmitter } from "node:events"; +const emitter = new EventEmitter(); +emitter.on("event", () => { + console.log(typeof this, this === undefined); +}); +emitter.emit("event"); +`, + "export-only.mjs": `export {};\nconsole.log(typeof this, this === undefined);\n`, + // an imported (not entry point) ES module goes through the async transpiler path + "imports-esm.js": `import { kind, isUndefined } from "./dep.mjs";\nconsole.log(kind, isUndefined);\n`, + "dep.mjs": `export const kind = typeof this;\nexport const isUndefined = this === undefined;\n`, + // CommonJS keeps `this === module.exports`, also when imported from an ES module + // in a "type": "module" package + "package.json": `{ "type": "module" }`, + "cjs.cjs": `console.log(typeof this, this === module.exports);\n`, + "imports-cjs.js": `import dep from "./dep.cjs";\nconsole.log(dep.kind, dep.isExports);\n`, + "dep.cjs": `var self = this;\nmodule.exports = { kind: typeof self, isExports: self === exports };\n`, + }; + + for (const [file, expected] of [ + ["import-only.js", "undefined true\n"], + ["export-only.mjs", "undefined true\n"], + ["imports-esm.js", "undefined true\n"], + ["cjs.cjs", "object true\n"], + ["imports-cjs.js", "object true\n"], + ]) { + test(file, async () => { + using dir = tempDir("top-level-this", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), file], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr }).toEqual({ stdout: expected, stderr: "" }); + expect(exitCode).toBe(0); + }); + } +}); diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 6ef742958718..925d6420a91d 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -3722,6 +3722,22 @@ class Foo { ); }); + // https://github.com/oven-sh/bun/issues/32167 + it("top-level this is undefined in an ES module and exports in CommonJS", () => { + expectPrinted_("export {}; console.log(this)", "export {};\nconsole.log(undefined)"); + expectPrinted_('import "foo"; console.log(this)', 'import"foo";\nconsole.log(undefined)'); + expectPrinted_( + "export {}; console.log(typeof this, this === undefined)", + 'export {};\nconsole.log("undefined", true)', + ); + expectPrinted_("export {}; const f = () => [this]", "export {};\nconst f = () => [undefined]"); + // bare `delete undefined` is a SyntaxError in strict mode code + expectPrinted_("export {}; delete this", "export {};\ndelete (0, undefined)"); + expectPrinted_("export {}; function f() { return this }", "export {};\nfunction f() {\n return this;\n}"); + + expectPrinted_("console.log(this)", "console.log(exports)"); + }); + it("declarations named eval or arguments, and reserved words, in strict mode", () => { expectParseError( '"use strict"; var arguments = 1', diff --git a/test/cli/run/transpiler-cache.test.ts b/test/cli/run/transpiler-cache.test.ts index 606582c1f2d3..396966fb426f 100644 --- a/test/cli/run/transpiler-cache.test.ts +++ b/test/cli/run/transpiler-cache.test.ts @@ -123,6 +123,27 @@ describe("transpiler cache", () => { expect(await bunRun(join(temp_dir, "main.js"), env)).toSpawn(expected); expect(!existsSync(cache_dir)).toBeTrue(); }); + test("byte-identical files with different package.json types do not share entries", async () => { + // A file with no import/export and no module/exports references is an ES + // module (strict) under "type": "module" and CommonJS (sloppy) under + // "type": "commonjs", so the package.json "type" must be part of the cache key. + const data = dummyFile(50 * 1024, "1", { + code: `(() => { try { undeclaredVariable = 1; return "sloppy"; } catch { return "strict"; } })()`, + }); + mkdirSync(join(temp_dir, "esm")); + mkdirSync(join(temp_dir, "cjs")); + writeFileSync(join(temp_dir, "esm", "package.json"), '{ "type": "module" }'); + writeFileSync(join(temp_dir, "cjs", "package.json"), '{ "type": "commonjs" }'); + writeFileSync(join(temp_dir, "esm", "a.js"), data); + writeFileSync(join(temp_dir, "cjs", "a.js"), data); + + expect(await bunRun(join(temp_dir, "esm", "a.js"), env)).toSpawn("strict"); + expect(newCacheCount()).toBe(1); + // Same bytes in a CommonJS package must not be served the cached ES module entry. + expect(await bunRun(join(temp_dir, "cjs", "a.js"), env)).toSpawn("sloppy"); + // And the reverse direction, now that the CommonJS entry was written last. + expect(await bunRun(join(temp_dir, "esm", "a.js"), env)).toSpawn("strict"); + }); 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"); From d798cdd61644a0bc68657b6cfc067a09360d7f5c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 03:06:25 +0000 Subject: [PATCH 2/2] Shorten the two new comments --- src/js_parser/parse/parse_entry.rs | 4 +--- src/jsc/RuntimeTranspilerCache.rs | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index ee8992245058..a2afa2c690d6 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -265,9 +265,7 @@ impl<'a> Options<'a> { hasher.update(b"udfcf=0"); } - // `exports_kind` for a file with no module syntax is decided by - // `module_type`, so byte-identical sources in an ESM package and a - // CommonJS package are different cache entries. + // `exports_kind` falls back to `module_type` when the content does not decide. hasher.update(&[self.module_type as u8]); self.features.hash_for_runtime_transpiler(hasher); diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 7b7440a2d02d..56bfad7d00b8 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -57,8 +57,7 @@ bun_core::declare_scope!(cache, visible); /// Version 27: ModuleInfo string table holds Latin-1 / UTF-16 bodies, not WTF-8. /// Version 28: the define table and `--drop` entries participate in the features hash. /// Version 29: `new Array(x, ...spread)` is no longer folded into an array literal. -/// Version 30: Top-level `this` in an ES module is `undefined`, not `null` (#32167), -/// and `module_type` participates in the features hash. +/// Version 30: ESM top-level `this` is `undefined`, not `null`; `module_type` is in the features hash. const EXPECTED_VERSION: u32 = 30; /// Source files smaller than this are not written to / read from the on-disk