Skip to content

Substitute top-level this with undefined in ES modules - #32173

Open
robobun wants to merge 2 commits into
mainfrom
farm/bdb20eae/esm-top-level-this-undefined
Open

robobun wants to merge 2 commits into
mainfrom
farm/bdb20eae/esm-top-level-this-undefined

Conversation

@robobun

@robobun robobun commented Jun 12, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #32167

Problem

  • In an ES module, a top-level this evaluates to null in bun: typeof this is "object" and this === undefined is false. Node and the spec give undefined. An arrow at module top level captures the same value, which is how the issue's EventEmitter repro shows it.
  • The cause is value_for_this in src/js_parser/p.rs:6029. The parser substitutes a top-level this at compile time. For a file with ES module syntax it built E::Null. The comment above it says undefined, and so does the esbuild code it was ported from.

Fix

  • Substitute E::Undefined. The CommonJS branch (this is exports), the REPL path, and this nested in functions and class bodies are unchanged. The unused null_value_expr helper is removed.
  • Bump EXPECTED_VERSION in src/jsc/RuntimeTranspilerCache.rs (29 to 30). The cache key does not include the bun version, so without the bump a cached module keeps the null output.
  • Hash module_type into the transpiler cache features hash. exports_kind for a file with no module syntax is decided by module_type, so byte-identical files in a "type": "module" package and a "type": "commonjs" package shared one entry, and the first to run decided strict ESM or sloppy CommonJS for both.
  • Verified: test/bundler/transpiler/transpiler.test.js, runtime-transpiler.test.ts, test/cli/run/transpiler-cache.test.ts (the new tests fail on 1.4.3), esbuild/default.test.ts (ThisUndefinedWarningESM now expects esbuild's output). Also ran bundler_cjs, bundler_edgecase, esbuild/{dce,importstar,packagejson}. Self-reviewed: 2 concerns raised, 2 addressed (see Notes).

Background

  • The visit pass rewrites every E::This outside a function. In an ES module it becomes a literal, so typeof this and this === undefined constant-fold from it. That is why the wrong literal also showed in folded output.
  • The runtime transpiler cache stores transpiled output on disk for files of 4 KiB or more. An entry is keyed on the source hash plus a features hash of parser options. src/js_parser/parser.rs asks for a version bump on any parser change that affects output.
  • module_type is Esm, Cjs, or Unknown, from the file extension or the package.json "type". The parser reads it only to pick exports_kind when the file content does not decide.
Notes
  • This PR supersedes js_parser: substitute undefined, not null, for top-level this in an ES module #41515, which made the same E::Null to E::Undefined change without the cache version bump.
  • An earlier revision of this branch also took the ESM branch when module_type == Esm, so that a .mjs or "type": "module" file with no import/export syntax gets undefined instead of exports (Node prints undefined there, bun prints {}). The self-review found that the runtime loader does not report module_type consistently yet: the async import path derives it from package.json only (src/jsc/RuntimeTranspilerStore.rs:335), so a .cjs file imported under "type": "module" reports Esm, and a nameless nested {"type":"commonjs"} package.json is only honored for files in its own directory (read_dir_info_package_json in src/runtime/jsc_hooks.rs). With the widening, import dep from "./dep.cjs" in a "type": "module" package gave this === undefined inside dep.cjs (verified), where Node and current bun give module.exports. That case is dropped from this PR. parser: .mjs/"type":"module" is authoritative over module/exports identifier refs #33807 makes module_type authoritative at runtime and carries the same value_for_this change together with the loader fixes it needs. The new imports-cjs.js runtime test locks in the current .cjs behavior.
  • The earlier revision also added EUndefined to the printer's delete-operand wrap (delete this would print delete undefined, a strict-mode SyntaxError). That has since landed on main in is_identifier_or_numeric_constant_or_property_access. The transpiler test still covers delete (0, undefined).
  • The earlier revision synced a RUNTIME_TRANSPILER_CACHE_VERSION mirror constant in src/bundler/cache.rs. That constant no longer exists on main.
  • Cache poisoning repro on 1.4.3 with a 50 KiB file whose only code is try { undeclaredVariable = 1; "sloppy" } catch { "strict" }: run under "type": "module" then "type": "commonjs" prints strict twice. In the other order it prints sloppy twice. Node prints strict then sloppy. With module_type in the features hash bun matches Node in both orders.
  • On 1.4.3, export {}; console.log(typeof this, this === undefined, this === null) prints object false true. Node prints undefined true false.

@robobun

robobun commented Jun 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:26 AM PT - Sep 8th, 2026

❌ @robobun, your commit d798cdd has 1 failures in Build #112520 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32173

That installs a local version of the PR into your bun-32173 executable, so you can run:

bun-32173 --bun

@coderabbitai

coderabbitai Bot commented Jun 12, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Rewrites top-level this to undefined for ES modules in the parser/printer, updates runtime transpiler cache hashing and versioning, and adds/updates unit and runtime tests plus a bundler expectation change.

Changes

Top-level this undefined substitution tests and compiler updates

Layer / File(s) Summary
Transpiler unit tests for top-level this rewriting
test/bundler/transpiler/transpiler.test.js
Added unit tests covering ES module top-level this → undefined for export/import, top-level arrow substitution, CommonJS this → exports, delete this rewrite, and non-substitution inside functions.
Runtime fixture tests verifying actual this at runtime
test/bundler/transpiler/runtime-transpiler.test.ts
Added runtime tests that create temporary ESM (.mjs/type: module) and CommonJS fixtures, spawn them, assert exact stdout showing undefined for ESM and module.exports for CJS, and verify exit codes; updated harness import to include tempDir.
Bundler test expectation and bundler cache bump
test/bundler/esbuild/default.test.ts, src/bundler/cache.rs
Updated default/ThisUndefinedWarningESM expected output from [ null, null ] to [ undefined, undefined ]; incremented RUNTIME_TRANSPILER_CACHE_VERSION.
Parser: treat module-type files as ESM and substitute undefined
src/js_parser/p.rs
value_for_this now treats files with options.module_type == Esm (when no commonjs named exports) as ES modules and substitutes top-level this with an undefined expression at compile time.
Remove public null helper
src/js_parser/p.rs
Removed the exported null_value_expr() helper.
Printer: treat undefined as identifier-like operand
src/js_printer/lib.rs
is_identifier_or_numeric_constant_or_property_access now returns true for ExprData::EUndefined so unary-expression printing treats substituted undefined appropriately and documents wrapping rules for delete undefined.
JSC runtime transpiler cache version bump
src/jsc/RuntimeTranspilerCache.rs
Bumped expected runtime transpiler cache format version and documented that ES-module top-level this is undefined for cache validity checks.
Include module_type in runtime transpiler hasher
src/js_parser/parse/parse_entry.rs
hash_for_runtime_transpiler now incorporates self.module_type (as a u8) into the hasher so ESM vs CJS module-type contexts produce distinct cache keys.
CLI transpiler-cache integration test
test/cli/run/transpiler-cache.test.ts
Adds an integration test that writes identical JS into ESM/CJS dirs with different package.json "type" values and asserts cache isolation and correct runtime semantics per module type.

Possibly related PRs

  • oven-sh/bun#31226: Both PRs modify src/js_parser/p.rs’s P::value_for_this logic for top-level this substitution—main PR changes when this becomes undefined (ESM/module_type) and retrieved PR adds a REPL-mode exception to avoid rewriting this to exports.

Suggested reviewers

  • Jarred-Sumner
  • dylan-conway
  • RiskyMH
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: substituting top-level this with undefined in ES modules.
Linked Issues check ✅ Passed The PR fully addresses issue #32167 by changing top-level this substitution from null to undefined in ES modules, matching ECMA-262 and Node behavior, with cache invalidation and printer safety improv…
Out of Scope Changes check ✅ Passed All changes directly support the primary objective of fixing top-level this substitution; cache version bumps, parser logic, printer safety, and test additions are all necessary and scoped to the issu…
Description check ✅ Passed The description clearly explains the problem, root cause, implementation, cache changes, compatibility considerations, and verification steps. It does not use the template headings exactly, but it pro…

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/js_parser/p.rs (1)

5616-5623: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle module_type == Esm in this branch too.

Line 5616 still keys only on has_es_module_syntax. prepare_for_visit_pass() separately marks files as ESM when self.options.module_type == options::ModuleType::Esm, so a .mjs/type: "module" file with no explicit import/export tokens will still fall through to the CommonJS exports rewrite instead of getting undefined.

💡 Suggested fix
-            if self.has_es_module_syntax && self.commonjs_named_exports.count() == 0 {
+            if (self.has_es_module_syntax
+                || self.options.module_type == options::ModuleType::Esm)
+                && self.commonjs_named_exports.count() == 0
+            {
                 // In an ES6 module, "this" is supposed to be undefined. Instead of
                 // doing this at runtime using "fn.call(undefined)", we do it at
                 // compile time using expression substitution here.
                 return Some(Expr {
                     loc,
                     data: js_ast::ExprData::EUndefined(E::Undefined),
                 });
             } else {

As per coding guidelines, "Fix the whole bug class in the same PR - grep for every sibling site sharing the pattern."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js_parser/p.rs` around lines 5616 - 5623, The branch that substitutes
"this" for undefined only checks self.has_es_module_syntax and misses files
marked ESM via options; update the condition to also consider
self.options.module_type == options::ModuleType::Esm (or an equivalent
module_type == Esm flag) so that when has_es_module_syntax OR the module_type is
Esm and commonjs_named_exports.count() == 0 you return the EUndefined Expr;
search for other sibling sites that check has_es_module_syntax (e.g., other
CommonJS rewrites called after prepare_for_visit_pass()) and apply the same
module_type check to fix the whole bug class.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/bundler/transpiler/runtime-transpiler.test.ts`:
- Around line 236-239: The test captures stderr but never asserts it; update the
subprocess tests that read Promise.all([proc.stdout.text(), proc.stderr.text(),
proc.exited]) to assert stderr before checking exitCode—either add
expect(stderr).toBe("") (or the expected stderr string) immediately after the
stdout assertion or include stderr in the same object assertion (e.g., expect({
file, stdout, stderr }).toEqual({ file, stdout: "undefined true\n", stderr: ""
})), then keep expect(exitCode).toBe(0); apply the same change for the other
occurrence around the variables stdout, stderr, exitCode.

---

Outside diff comments:
In `@src/js_parser/p.rs`:
- Around line 5616-5623: The branch that substitutes "this" for undefined only
checks self.has_es_module_syntax and misses files marked ESM via options; update
the condition to also consider self.options.module_type ==
options::ModuleType::Esm (or an equivalent module_type == Esm flag) so that when
has_es_module_syntax OR the module_type is Esm and
commonjs_named_exports.count() == 0 you return the EUndefined Expr; search for
other sibling sites that check has_es_module_syntax (e.g., other CommonJS
rewrites called after prepare_for_visit_pass()) and apply the same module_type
check to fix the whole bug class.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2aa73306-b322-4419-9f80-9e0f1ad174c4

📥 Commits

Reviewing files that changed from the base of the PR and between 885c44f and 72653f0.

📒 Files selected for processing (4)
  • src/js_parser/p.rs
  • test/bundler/esbuild/default.test.ts
  • test/bundler/transpiler/runtime-transpiler.test.ts
  • test/bundler/transpiler/transpiler.test.js

Comment thread test/bundler/transpiler/runtime-transpiler.test.ts Outdated
Comment thread src/js_parser/p.rs Outdated
Comment thread src/js_parser/p.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js_parser/p.rs`:
- Around line 5616-5621: The strict-mode check in P::prepare_for_visit_pass
should use the same forced-ESM predicate as P::value_for_this: replace the
current branch that only checks
esm_import_keyword/esm_export_keyword/top_level_await_keyword with a predicate
that uses self.has_es_module_syntax || self.options.module_type ==
options::ModuleType::Esm (and still ensure commonjs_named_exports.count() == 0
if that was part of the original module detection), so files forced to ESM (e.g.
.mjs or package.json "type":"module") enable implicit strict mode and match the
behavior of value_for_this; update the branch in prepare_for_visit_pass
accordingly, referencing prepare_for_visit_pass, value_for_this,
has_es_module_syntax, and options::ModuleType::Esm.

In `@src/jsc/RuntimeTranspilerCache.rs`:
- Line 46: Update the runtime-transpiler cache version constant so both modules
agree: change the RUNTIME_TRANSPILER_CACHE_VERSION constant in the bundler cache
to match EXPECTED_VERSION (23) defined in RuntimeTranspilerCache.rs; locate the
RUNTIME_TRANSPILER_CACHE_VERSION symbol in src/bundler/cache.rs and set its
value to 23 to prevent existing cache entries from being treated as stale.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d0e6225c-9432-42eb-b1d3-c5f940a4c837

📥 Commits

Reviewing files that changed from the base of the PR and between 72653f0 and 84e22c4.

📒 Files selected for processing (5)
  • src/js_parser/p.rs
  • src/js_printer/lib.rs
  • src/jsc/RuntimeTranspilerCache.rs
  • test/bundler/transpiler/runtime-transpiler.test.ts
  • test/bundler/transpiler/transpiler.test.js

Comment thread src/js_parser/p.rs Outdated
Comment thread src/jsc/RuntimeTranspilerCache.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
src/js_parser/p.rs (1)

5616-5620: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep forced-ESM detection in sync with implicit strict-mode setup.

Lines 5619-5620 now classify syntax-less .mjs / "type":"module" files as ESM for top-level this, but prepare_for_visit_pass() still only enables implicit strict mode from import/export/top-level-await. That leaves the same file with module this semantics and sloppy-mode parsing rules, which is an observable correctness break for forced-ESM files. Please drive both sites from the same forced-ESM predicate/helper.

As per coding guidelines, "Rust code: fix the whole bug class in the same PR - grep for every sibling site sharing the pattern."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js_parser/p.rs` around lines 5616 - 5620, The code currently treats files
as forced-ESM in the TLA/`this` check using the condition
(self.has_es_module_syntax || self.options.module_type ==
options::ModuleType::Esm) && self.commonjs_named_exports.count() == 0 but
`prepare_for_visit_pass()` still enables implicit strict mode only when
import/export/top-level-await are present, causing mismatched semantics; extract
that forced-ESM logic into a single helper (e.g., is_forced_esm(&self) or
ModuleType::is_forced_esm) and replace both the top-level `this`/TLA check and
the implicit strict-mode decision in `prepare_for_visit_pass()` to call this
helper so forced `.mjs`/package-"type":"module" files get both module `this`
semantics and implicit strict parsing consistently (ensure to reference and
remove the duplicated conditionals: has_es_module_syntax, options.module_type ==
options::ModuleType::Esm, and commonjs_named_exports.count()).

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@src/js_parser/p.rs`:
- Around line 5616-5620: The code currently treats files as forced-ESM in the
TLA/`this` check using the condition (self.has_es_module_syntax ||
self.options.module_type == options::ModuleType::Esm) &&
self.commonjs_named_exports.count() == 0 but `prepare_for_visit_pass()` still
enables implicit strict mode only when import/export/top-level-await are
present, causing mismatched semantics; extract that forced-ESM logic into a
single helper (e.g., is_forced_esm(&self) or ModuleType::is_forced_esm) and
replace both the top-level `this`/TLA check and the implicit strict-mode
decision in `prepare_for_visit_pass()` to call this helper so forced
`.mjs`/package-"type":"module" files get both module `this` semantics and
implicit strict parsing consistently (ensure to reference and remove the
duplicated conditionals: has_es_module_syntax, options.module_type ==
options::ModuleType::Esm, and commonjs_named_exports.count()).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 49a8f0a4-b2af-4de9-85fc-49152ae63652

📥 Commits

Reviewing files that changed from the base of the PR and between 9f8b044 and 63de048.

📒 Files selected for processing (3)
  • src/js_parser/p.rs
  • src/js_printer/lib.rs
  • src/jsc/RuntimeTranspilerCache.rs

Comment thread src/js_parser/p.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All my earlier feedback has been addressed and I didn't find anything new, but since this changes transpiler semantics (the module_type == Esm expansion of value_for_this, the printer's delete-operand wrap, and the runtime cache key derivation) it's worth a human maintainer's sign-off.

Extended reasoning...

Overview

This PR fixes #32167 by changing top-level this substitution in ES modules from null to undefined, matching ECMA-262 and Node. It touches five source files in the parser/printer/cache layer plus four test files:

  • src/js_parser/p.rs — value_for_this returns EUndefined instead of ENull, and now also treats options.module_type == Esm as ESM (so .mjs / "type": "module" files with no import/export syntax get undefined instead of falling through to exports). The now-unused null_value_expr helper is removed (verified no remaining references).
  • src/js_printer/lib.rs — EUndefined added to the delete-operand wrap predicate so substituted delete this prints as delete (0, undefined) instead of the strict-mode-invalid delete undefined.
  • src/jsc/RuntimeTranspilerCache.rs — EXPECTED_VERSION bumped 22→23 with a changelog line.
  • src/bundler/cache.rs — RUNTIME_TRANSPILER_CACHE_VERSION mirror synced 20→23.
  • src/js_parser/parse/parse_entry.rs — module_type (which is #[repr(u8)] with stable discriminants) folded into the runtime-transpiler features hash, closing a pre-existing cache-aliasing gap where byte-identical files in ESM vs CJS packages shared a cache entry.

All three of my earlier inline comments (cache version bump, delete undefined printer wrap, module_type in features hash) and CodeRabbit's comments have been addressed and resolved across commits 84e22c4, 9f8b044, 63de048, and 1a6c6fa. The bug-hunting pass on the current revision found no new issues.

Security risks

None identified. No auth, crypto, permissions, or untrusted-input parsing surface is touched. The cache-key change strictly narrows which entries are considered hits, and the version bump invalidates all prior on-disk entries.

Level of scrutiny

This is core transpiler/parser infrastructure — value_for_this runs on every top-level this in every transpiled file, and the features-hash change affects cache keying for every cached module. The primary fix (ENull→EUndefined) is a one-liner that restores the upstream esbuild behavior, but the PR also includes a deliberate behavioral expansion: the || module_type == Esm clause changes what bare .mjs / "type": "module" files (with no import/export syntax) see for top-level this from {} (via the exports substitution) to undefined. That is the spec-correct outcome and is well-tested here, but it is a user-visible semantic change beyond the original bug report.

Other factors

  • None of the changed files are CODEOWNER-protected.
  • Test coverage is thorough: transform-output unit tests, runtime subprocess tests across import-only/export-only/bare-.mjs/type:module/CJS, an updated bundler expectation, and a transpiler-cache integration test that exercises the ESM/CJS cache-poisoning scenario in both orders.
  • The author appropriately scoped out the related implicit-strict-mode question to a separate issue (#32175).

Given the transpiler-semantics scope and the multi-commit evolution across review rounds, I'm deferring to a human maintainer rather than auto-approving.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All my earlier feedback is addressed and I found nothing new on this revision — deferring only because this is a transpiler-semantics change (widened ESM predicate, printer delete-operand wrap, cache-key shape) that's worth a maintainer glance before merge.

Extended reasoning...

Overview

This PR fixes #32167 by changing the compile-time substitution of top-level this in ES modules from null to undefined (matching ECMA-262 and Node). What started as a one-line ENull → EUndefined swap in value_for_this (src/js_parser/p.rs) grew through review into five source-file changes:

  • src/js_parser/p.rs — return EUndefined instead of ENull; widen the ESM predicate to include module_type == Esm (so syntax-less .mjs / "type": "module" files also get undefined); remove the now-unused null_value_expr helper (verified no remaining references).
  • src/js_printer/lib.rs — add EUndefined to is_identifier_or_numeric_constant_or_property_access so delete this in a module prints delete (0, undefined) instead of the strict-mode-invalid delete undefined.
  • src/jsc/RuntimeTranspilerCache.rs — bump EXPECTED_VERSION 22 → 23.
  • src/bundler/cache.rs — sync the mirror constant 20 → 23.
  • src/js_parser/parse/parse_entry.rs — fold module_type into the runtime-transpiler features hash so byte-identical files in ESM vs CJS package contexts no longer alias one cache entry.

Plus four test files: bundler expectation update, new runtime subprocess tests, new transform-output tests, and a new transpiler-cache integration test.

Security risks

None. No auth, crypto, network, or filesystem-permission surface is touched. The cache-key change only adds a discriminator; it cannot cause stale entries to be accepted that weren't before.

Level of scrutiny

Moderate-to-high. The parser, printer, and runtime transpiler cache are on the hot path for every JS/TS file Bun executes. Each individual change is small and well-reasoned, but together they alter (a) ESM classification for this-substitution purposes, (b) printer output for an edge-case delete operand, and (c) the shape of the on-disk cache key. None of these are mechanical; they're semantic decisions about transpiler behavior. The widened ESM predicate in particular (treating module_type == Esm as ESM even with no import/export syntax) is a deliberate behavior change beyond the original bug report — it's correct per spec, but a maintainer should confirm it doesn't conflict with any intentional Bun-specific interop heuristic.

Other factors

  • All three of my earlier inline comments (cache version bump, delete undefined wrap, module_type in features hash) were addressed in commits 84e22c4 and 1a6c6fa, with tests covering each.
  • The bug-hunting system found nothing on the latest revision.
  • No CODEOWNERS paths are touched.
  • The author appropriately scoped out the related implicit-strict-mode question to a follow-up issue (#32175) rather than expanding this PR further.
  • Test coverage is thorough: transform-output assertions, runtime subprocess checks across four ESM-forcing variants plus a CJS negative control, and a cache-aliasing integration test that fails on the pre-fix build.

This is good work and I'd expect it to merge cleanly — I'm deferring purely because parser/cache semantics changes of this scope benefit from a maintainer sign-off rather than bot-only approval.

@robobun

robobun commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the PR's own tests (transpiler, runtime-transpiler, transpiler-cache, esbuild suites) pass on all lanes in both runs. The remaining red lanes are unrelated to this diff and differ between the two runs:

No failures overlap between runs and none touch the parser, printer, or transpiler cache. Ready for review.

@robobun
robobun force-pushed the farm/bdb20eae/esm-top-level-this-undefined branch from 24c6b77 to 4b66440 Compare June 20, 2026 02:08

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All prior review feedback has been addressed and the change looks correct to me, but since this alters transpiler output semantics for every ES module (and broadens the ESM classification for this-substitution via module_type), it's worth a human sign-off.

Extended reasoning...

Overview

This PR fixes #32167 by changing the compile-time substitution of top-level this in ES modules from null to undefined (per ECMA-262 §16.2.1.6 and matching Node/esbuild). It touches:

  • src/js_parser/p.rs — value_for_this returns EUndefined instead of ENull; the ESM predicate is broadened to include options.module_type == Esm (so .mjs / "type": "module" files without import/export syntax also get undefined instead of falling through to the CJS exports substitution). The now-unused null_value_expr helper is removed.
  • src/js_printer/lib.rs — adds EUndefined to the delete-operand wrap helper so delete this → delete (0, undefined) instead of strict-mode-invalid delete undefined.
  • src/jsc/RuntimeTranspilerCache.rs — bumps EXPECTED_VERSION 22→23.
  • src/bundler/cache.rs — syncs the mirror constant 20→23.
  • src/js_parser/parse/parse_entry.rs — folds module_type into the features hash so byte-identical files in ESM vs CJS contexts no longer alias one cache entry.
  • Tests: transform-output assertions, runtime subprocess tests across import-only/export-only/bare-.mjs/type:module fixtures, a CJS negative control, and a transpiler-cache test for the module_type aliasing.

All three issues I raised on earlier revisions (cache version bump, delete undefined strict-mode SyntaxError, module_type missing from the features hash) have been addressed and resolved, as has CodeRabbit's feedback. The bug-hunting pass on the current revision found nothing.

Security risks

None. No auth, crypto, filesystem permissions, or untrusted-input parsing surface is changed. The transpiler-cache changes only add a discriminant byte to an existing hash and bump a version constant.

Level of scrutiny

Moderate-to-high. The diff is small and each piece is well-reasoned, but the JS parser/printer is on the hot path for every module Bun executes, and this is a runtime-observable semantics change. Two aspects in particular warrant a maintainer's eye:

  1. Broadening the ESM predicate (|| module_type == Esm) changes what .mjs/"type":"module" files with no import/export syntax see for top-level this — from exports ({}) to undefined. That's spec-correct and matches Node, but it's a behavior change for a class of files beyond the original bug report, and it interacts with Bun's intentional CJS-interop leniency for forced-ESM files (the author explicitly scoped out the related strict-mode question into #32175).
  2. Cache-key change — adding module_type to features_hash is correct, but it's a structural change to how cache entries are keyed.

Other factors

  • Test coverage is thorough (transform output, runtime behavior across all four ESM-detection paths, CJS control, delete-operand edge case, cache-aliasing regression test).
  • CI is green on the PR's own tests across all lanes; remaining failures are documented as unrelated flakes.
  • The default/ThisUndefinedWarningESM expectation update aligns with esbuild's original fixture.
  • No outstanding unresolved review threads.

I'm not approving solely because transpiler-semantics changes are the kind of thing a Bun maintainer should sign off on, not because I see anything wrong.

@robobun

robobun commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator Author

Post-rebase CI (build 63529): the PR's own tests pass on all lanes. The two red lanes are unrelated to this diff and, again, differ from the previous runs' failures:

  • test/cli/hot/hot.test.ts (Windows x64-baseline): CI-tagged flaky, temp-file ENOENT race during hot reload, passed on retry
  • test/js/node/test/parallel/test-tls-client-destroy-soon.js (macOS aarch64): TLS byte-count assertion (read 2097152 vs expected 2048000), a streaming/destroy timing test

Neither touches the parser, printer, or transpiler cache. Across three CI runs (62071, 62073, 63529) no failure has overlapped and none has involved this change. The branch is rebased onto current main and mergeable; ready for a maintainer.

@robobun
robobun force-pushed the farm/bdb20eae/esm-top-level-this-undefined branch from 4b66440 to e13bbdf Compare July 5, 2026 01:42
@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased head CI (build 68404): the PR's own tests pass on all lanes. The only annotation is CI's "flaky" bucket (all lanes retried), none touching this diff:

  • test/js/bun/shell/leak.test.ts (Windows x64, shell leak)
  • test/cli/install/bun-install-security-provider.test.ts (aarch64)
  • test/js/bun/s3/s3.test.ts (Windows x64-baseline, S3 network)
  • test/package.json (x64-asan)

Across four CI runs (62071, 62073, 63529, 68404) no failure has overlapped and none has involved the parser, printer, or transpiler cache. The branch is rebased onto current main and mergeable; ready for a maintainer.

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
@robobun
robobun force-pushed the farm/bdb20eae/esm-top-level-this-undefined branch from e13bbdf to 0e61e64 Compare September 8, 2026 02:48
Comment thread src/js_parser/parse/parse_entry.rs Outdated
Comment thread src/jsc/RuntimeTranspilerCache.rs Outdated
@robobun

robobun commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main as one commit (0e61e64) and narrowed. #41515 was a duplicate of this PR and is closed.

Reproduced on 1.4.3: export {}; console.log(typeof this, this === undefined) prints object false, Node prints undefined true. The issue's EventEmitter repro prints null.

What changed since the last revision:

  • The printer change (delete (0, undefined)) and the src/bundler/cache.rs mirror constant are gone from the diff: the first landed on main separately, the second no longer exists.
  • EXPECTED_VERSION goes 29 to 30 (main moved).
  • The module_type == Esm widening for .mjs / "type": "module" files with no import/export syntax is dropped. The runtime loader's async import path derives module_type from package.json only, so with the widening a .cjs file imported under "type": "module" got this === undefined instead of module.exports. parser: .mjs/"type":"module" is authoritative over module/exports identifier refs #33807 carries that change together with the loader fixes it depends on.
  • module_type stays in the features hash. The new transpiler-cache test shows the poisoning it fixes without relying on this.

Verified with a debug build: test/bundler/transpiler/transpiler.test.js, test/bundler/transpiler/runtime-transpiler.test.ts, test/cli/run/transpiler-cache.test.ts, test/bundler/esbuild/default.test.ts, test/bundler/bundler_cjs.test.ts, test/bundler/bundler_edgecase.test.ts. The new tests fail with USE_SYSTEM_BUN=1.

@robobun

robobun commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

CI on the rebased head (build 112509): the PR's own tests pass on all lanes. The red lanes are unrelated to this diff:

  • test/js/node/test/parallel/test-crypto-dh-leak.js (x64-asan): Diffie-Hellman RSS-growth threshold (24 MB delta vs 20 MB limit). An RSS assertion inflated by ASAN quarantine; test: skip ASAN-gated Node tests on Bun's ASAN builds #41518 covers skipping this class of Node memory test on ASAN builds.
  • test/js/third_party/jsonwebtoken/async_sign.test.js (aarch64), test/cli/install/bun-lock.test.ts, test/cli/install/bun-install-registry.test.ts (x2), test/bake/deinitialization.test.ts: all in CI's flaky bucket, each passed on retry.

None involve the parser, printer, or transpiler cache. Mergeable with no open review threads; ready for a maintainer.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — all three earlier concerns are addressed in the current diff: EXPECTED_VERSION is bumped to 30, module_type is folded into the features hash (with a test proving byte-identical ESM/CJS files no longer alias), and the delete this → delete (0, undefined) case is covered by the printer and locked in by a transpiler test.

Checked that null_value_expr has no remaining call sites, that options::ModuleType is #[repr(u8)] so the as u8 hash write is well-defined, and that the runtime test matrix covers both the sync entry-point and async imported-dep transpiler paths plus .cjs-under-"type":"module" staying exports.

Extended reasoning...

Overview

The PR fixes #32167: top-level this in an ES module was being substituted with null instead of undefined, contrary to the ECMAScript spec, Node, and the esbuild code this was ported from. The core change is one line in src/js_parser/p.rs (ENull → EUndefined), plus removal of the now-dead null_value_expr() helper. Supporting changes: EXPECTED_VERSION bumped 29→30 in RuntimeTranspilerCache.rs to invalidate stale on-disk entries, and module_type added to the features hash in parse_entry.rs so byte-identical .js files under "type": "module" vs "type": "commonjs" no longer share a cache entry. Four test files are extended: bundler snapshot updated to match esbuild, transpiler printed-output tests (including constant folding, arrow capture, delete this, and nested-function this left alone), a describe.concurrent runtime matrix over ESM/CJS entry and dep paths, and a transpiler-cache test proving the ESM/CJS cache separation via strict-vs-sloppy observable behavior.

Security risks

None. This is a spec-correctness fix to a compile-time literal substitution in the transpiler. No user input parsing, no auth, no crypto, no filesystem or network surface. The module_type as u8 hash write is safe — options::ModuleType is #[repr(u8)] with three variants.

Level of scrutiny

Moderate. The parser is a critical path, but the semantic change is a one-token substitution that aligns with the spec and the reference implementation (the comment above the changed line already said "undefined"). The remaining changes are the cache-invalidation infrastructure I asked for in earlier review rounds. I verified null_value_expr has no remaining callers repo-wide, the ModuleType enum has a stable #[repr(u8)], and the new test file already imports tempDir/bunExe/bunEnv from harness.

Other factors

All three of my prior inline concerns (cache version bump, delete undefined strict-mode SyntaxError, module_type missing from the cache key) are demonstrably fixed in the current diff — verified from code, not from the author's self-resolution. All coderabbitai threads were resolved by non-authors. Test coverage is thorough and follows the CLAUDE.md/REVIEW.md conventions: describe.concurrent for independent subprocess tests, Promise.all draining stdout/stderr/exited, combined-object assertion before exit code, tempDir from harness, real issue number cited, and the transpiler-cache test reuses that file's existing temp_dir/dummyFile fixture pattern. The PR description transparently documents scope that was tried and backed out (widening module_type == Esm into the branch condition) with a clear reason and a locking test for the current .cjs behavior.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — all three concerns from earlier rounds are addressed: EXPECTED_VERSION is bumped to 30, module_type is now folded into the features hash, and the printer already wraps EUndefined for delete (verified at src/js_printer/lib.rs:1544, covered by the new delete (0, undefined) assertion).

Also checked: null_value_expr has no remaining callers, the module_type as u8 cast is sound (options_types::bundle_enums::ModuleType is #[repr(u8)]), and the earlier module_type == Esm widening is indeed dropped from value_for_this — the imports-cjs.js test locks in .cjs-under-"type":"module" keeping this === module.exports.

Extended reasoning...

Overview

The PR swaps a single literal in value_for_this (ENull → EUndefined) so top-level this in ES modules matches Node and the spec, deletes the now-orphaned null_value_expr() helper, bumps the runtime transpiler cache EXPECTED_VERSION from 29 to 30, and adds module_type to the features hash so byte-identical .js files under different package.json "type" settings no longer alias one cache entry. Test coverage spans the Bun.Transpiler printed output (including typeof/equality folding, arrow capture, delete this, and function-body this left alone), runtime spawns for entry-point and imported ESM plus CJS-under-ESM, an updated esbuild bundler snapshot, and a transpiler-cache test proving the ESM/CJS split.

Security risks

None. This is a compile-time expression substitution in the parser and a hash-input addition; no untrusted-input parsing, no auth/crypto/permissions, no I/O surface.

Level of scrutiny

Low-to-moderate. The core change is a one-token literal fix that brings Bun in line with the esbuild reference (js_parser.go emits EUndefined here) and Node. The two knock-on cache concerns — stale on-disk entries and the module_type key gap — were the substantive review risk, and both are handled with the version bump plus the hash addition (backed by a targeted test). Tests are added alongside existing cases in the right files, use tempDir/bunExe/bunEnv, drain pipes concurrently, assert output before exit code, and run under describe.concurrent.

Other factors

This is a follow-up push after my earlier inline comments; every point raised is now visible in the diff. I confirmed null_value_expr has zero remaining references, is_identifier_or_numeric_constant_or_property_access already includes EUndefined on main (so delete this prints delete (0, undefined)), and bundle_enums::ModuleType is #[repr(u8)] so the as u8 hash write is well-defined. The value_for_this condition is unchanged from base (has_es_module_syntax && commonjs_named_exports.count() == 0), so the dropped module_type == Esm widening described in the PR notes is not present — and the imports-cjs.js runtime test guards against a future regression there. No outstanding third-party CHANGES_REQUESTED reviews; the coderabbit threads are non-author-resolved. Exit reason is dry_streak.

@robobun

robobun commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

CI on the current head d798cdd (build 112520): the PR's own tests pass on all lanes. Same picture as the previous run:

  • test/js/node/test/parallel/test-crypto-dh-leak.js (x64-asan): fails on main too. test: skip ASAN-gated Node tests on Bun's ASAN builds #41518 cites main builds 110810 and 110828 with the identical assertion (RSS delta about 22 to 24 MB vs the 20 MB limit). The growth is ASAN quarantine across 50k DH key-set calls, not a leak; Node skips this test under isASan but Bun reports process.config.variables.asan as 0, so the skip never fires. test: skip ASAN-gated Node tests on Bun's ASAN builds #41518 fixes the skip.
  • test/cli/install/bun-lock.test.ts, test/js/sql/postgres-listen-notify.test.ts, test/cli/install/bun-install-registry.test.ts: CI's flaky bucket, each passed on retry.

Nothing here involves the parser or transpiler cache. Mergeable, no open review threads, ready for a maintainer.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Different output when logging this

1 participant