Skip to content

Pass the exports object as this to exported functions that read sibling exports - #1610

Merged
ctate merged 8 commits into
opt/npm-cjs-var-exportfrom
opt/npm-exports-this
Oct 21, 2026
Merged

ctate merged 8 commits into
opt/npm-cjs-var-exportfrom
opt/npm-exports-this

Conversation

@cramforce

@cramforce cramforce commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Pass the exports object as this to exported functions that read sibling exports

Stacked on #1558.

Summary

Some CommonJS modules export plain functions that reach their sibling exports through this:

exports.operation = function (options) {
  var timeouts = this.timeouts(options);
  …
};
exports.timeouts = function (options) {
  …
  timeouts.push(this.createTimeout(i, opts));
};

Node runs these with the exports object as this, because callers write retry.operation(...). scriptc compiled the function bodies without a receiver, so the first this.createTimeout failed. retry 0.13.1 is the most common example.

A new package-file stage, npm-static-exports-this.ts, runs before the interop-helper stage. Inside a top-level exports.f = function () {…}, it respells this.X as exports.X. It does so only when all of these hold:

  • every this in the function, including inside arrows, is a read of this.<name> where <name> is one of the module's exports;
  • the module never replaces its exports object (no exports = and no module.exports =);
  • inside the module, exports.f is only stored or called as exports.f(...), never passed around as a value.

Under those rules, this and exports are the same object for every call a consumer can make through the namespace, so the respelling preserves behavior. Any other shape keeps its text and its current diagnostics.

Evidence

  • Unit tests in npm-static-exports-this.test.ts run rewritten modules against their originals under Node. They also pin the shapes that must keep their text: a this value escaping, a non-export member, a replaced exports object, and exports.f used as a value.
  • New npm-static case 2597-exports-this: a generic package whose exported functions call each other through this, including from an arrow function. Its output is byte-identical to Node's.
  • New npm-static case 2598-retry-timeouts: the unmodified retry 0.13.1 package (MIT) as a fixture. It calls retry.timeouts(...) and retry.operation(...). Its output is byte-identical to Node's; before this change it failed at load.
  • Zero-cost gate against Load bundled CommonJS dependencies: settled exports, merged requires, semver's tokens, fs.realpath, whole-export members #1558 with --corpus=all --timing-runs=3: 3433 of 3434 programs are identical. The one difference, fs-extra-package, is only the fs-extra shim's absolute checkout path.
  • pnpm lint, pnpm format:check and test:ts7 --baselines-only pass. The only baseline changes are new entries for the new cases.
  • The npm, npm-static and package-shims suites, plus the frontend unit tests, pass 1153 of 1154. The one failure, node24-url-authority, is caused by the sandbox's Node 24.15 and also fails on the base.
  • The self-hosted compiler builds, and self-hosting-native-driver.test.ts passes (1092 s).

🤖 Generated with Claude Code

cramforce and others added 7 commits October 20, 2026 17:00
…ling exports

retry 0.13.1's `exports.timeouts` reads `this.createTimeout`, and
`exports.operation` calls it through the exports object; async-retry wraps
every request in an operation. A call through the exports object passes
that object as `this`, which compiles to a static table, so the call was
fenced ("calling 'exports.timeouts' through the module's exports object
when the function may read 'this'").

When every `this` in an exported function expression names another
export the module writes (`this.X` with `exports.X = …`), the exports
object is never replaced, and the module calls the function only through
`exports.f(…)`, a package-file stage now respells `this.X` as `exports.X`:
the member Node reads for those calls. Only a caller that detaches the
function or rebinds its `this` would observe a difference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	tests/harness/npm-cases.ts
#	tests/harness/npm-static.test.ts
@vercel

vercel Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
scriptc Ready Ready Preview, v0 Oct 21, 2026 2:18am UTC

Comment thread packages/compiler/src/frontend/npm-static-exports-this.ts Outdated
Comment thread packages/compiler/src/frontend/npm-static-exports-this.ts
…t shadow exports

- A sibling read as a value through `this.f` inside the module (`var g =
  this.b; g()`) now counts as a detached use, as `exports.f` already did,
  so `b` keeps its `this`.
- An exported function that declares its own `exports` binding (a
  parameter, a `var`, an arrow parameter or its own name) keeps its text,
  and so does a module that declares `exports` at module scope: the
  respelled reads would name that binding instead of the exports object.
@ctate
ctate merged commit fdcc97e into opt/npm-cjs-var-export Oct 21, 2026
61 of 65 checks passed
@ctate
ctate deleted the opt/npm-exports-this branch October 21, 2026 02:22

This branch was successfully deployed

1 active deployment
Preview — 9ddbd94f Deployed Oct 21, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants