Skip to content

fix: do not rename nested "exports" bindings that do not conflict#6360

Merged
lukastaegert merged 2 commits into
rollup:masterfrom
tariqrafique:fix/nested-exports-deconflict-6357
May 3, 2026
Merged

fix: do not rename nested "exports" bindings that do not conflict#6360
lukastaegert merged 2 commits into
rollup:masterfrom
tariqrafique:fix/nested-exports-deconflict-6357

Conversation

@tariqrafique

Copy link
Copy Markdown
Contributor

This PR contains:

  • bugfix
  • feature
  • refactor
  • documentation
  • other

Are tests included?

  • yes (bugfixes and features will not be merged without tests)
  • no

Breaking Changes?

  • yes (breaking changes will not be merged unless absolutely necessary)
  • no

List any relevant issue numbers:

Description

Note: I went through many iterations of fixes to this issue. Used cursor with opus 4.7 max thinking to help design and evaluate fixes.
This fix seemed contained and resonable to me. It goes back to the way things mostly were before #5947 for avoiding un-necessary rewrite of exports variables.
Additionaly it doesn't impact performance either

Since #5947 was merged for 4.53.0, Rollup renames every nested exports binding — even ones that cannot conflict with the output format's runtime exports identifier. The rename silently breaks code where an exports name appears inside a string, for example inside eval, because Rollup doesn't rewrite string contents.

Reproducer

var modules = {
  foo: (unused, exports) => {
    console.log(exports.bar);
    eval('exports.bar = 1');
  }
};
export default modules;

4.52.5 (expected):

var modules = {
  foo: (unused, exports) => {
    console.log(exports.bar);
    eval('exports.bar = 1');
  }
};
export { modules as default };

4.53.0 – 4.60.x (current, broken):

var modules = {
  foo: (unused, exports$1) => {
    console.log(exports$1.bar);
    eval('exports.bar = 1'); // still references outer `exports` at runtime!
  }
};
export { modules as default };

The parameter was renamed to exports$1, but the string argument to eval was not, so the two stop referring to the same binding. The reporter ran into this while running webpack-produced development bundles through Rollup, where webpack's runtime uses eval to execute module factories whose parameters are literally named exports.

As the reporter notes in #6357, the rename is conceptually wrong even without the eval: it's an over-deconfliction of a legal nested binding that never needed to change.

Root cause

#5947 did two things:

  1. Added the output-format runtime names (module, exports, require, __filename, __dirname, interop helpers) to a new chunk-level RESERVED_USED_NAMES seed in Chunk.ts, so the top-level deconflict step sees them as already-used.
  2. Added 'exports' to the universal RESERVED_NAMES set in src/utils/RESERVED_NAMES.ts.

The first change is fine — it's how the top-level const exports = 1 case has always been protected in CJS/AMD/UMD/IIFE/SYSTEM. The second change is the regression: RESERVED_NAMES is consulted by getSafeName for every scope. ChildScope.deconflict runs it on each nested scope's variables, so any nested binding or parameter named exports is forced into exports$1, regardless of whether the surrounding format even has a runtime exports (ES doesn't), or whether the scope references anything that actually needs it.

#5947 also dropped a pre-existing conditional in ChildScope.addUsedOutsideNames that added 'exports' to a scope's used names only when that scope accessed an outer binding that was being re-assigned through exports("name", value) in SystemJS output. With 'exports' now universally reserved, that conditional was no longer strictly needed — but removing it meant that the "I actually do need to avoid exports" decision stopped being driven by the scope's own behavior and started being driven by a global flag.

Fix

  1. Remove 'exports' from src/utils/RESERVED_NAMES.ts. exports isn't a JavaScript reserved word; it's only a runtime identifier in certain output formats.
  2. Restore the SystemJS-specific check in ChildScope.addUsedOutsideNames so that nested scopes in SystemJS output continue to reserve exports exactly when they reference an outer binding that will be assigned via exports("name", value) at render time.
  3. Thread format and exportNamesByVariable through the one extra call site in deconflictChunk.

For CJS/AMD/UMD/IIFE the existing machinery still does the right thing without any extra work: re-assigned exports already carry renderBaseName === 'exports', and ChildScope.addUsedOutsideNames already pulls that name into the scope's usedNames via getBaseVariableName(). So a nested exports binding in a scope that references such an outer variable still gets renamed to exports$1 — and one that doesn't (the reporter's case) stays as exports.

The top-level protection in Chunk.ts (RESERVED_USED_NAMES) and the module.info.safeVariableNames cache added by #5947 are both untouched.

Tests

  • New test/form/samples/no-rename-unrelated-exports-parameter/ covers the reporter's exact reproducer across all six formats (amd, cjs, es, iife, system, umd).
  • Existing tests whose expected output was changed by refactor: store safe variable names in cache for subsequent usage #5947 to match the buggy behaviour are reverted to match the correct (pre-refactor: store safe variable names in cache for subsequent usage #5947) behaviour for nested scopes:
    • deconflict-format-specific-exports — this test is literally titled "only deconflict 'exports' for formats where it is necessary"; the expected outputs had drifted away from that invariant for nested scopes in every format. Restored.
    • deconflict-format-specific-globals — same fix for the nested-scope case.
    • supports-es5-shim, supports-es6-shim — the wrapper function of the bundled shim ((function (module, exports) { … }(…))) no longer has its exports parameter spuriously renamed.
    • chunking-form/chunk-assigment-in-dynamic — the IIFE (function (exports) { exports.preFaPrint = … }(c)) generated by @rollup/plugin-commonjs for the wrapped module no longer has its exports parameter renamed.

Top-level rename behaviour that was intentionally unified by #5947 (e.g. const exports = 1 at module top-level being renamed in ES output for caching consistency) is left unchanged.

Performance

Because #5947's explicit goal was performance, I validated that this change doesn't regress it. I built three versions of Rollup and ran 60 iterations (2 runs × 30, alternated to control for drift) of a 500-module synthetic fixture that exercises the deconflict hot path, generating all six output formats per iteration:

The benchmark is demonstrably sensitive enough to detect the original #5947 speedup (|t| ≈ 30–37 on generate-time metrics — well above the 95% confidence threshold of 2). Times in ms, mean ± standard error:

Metric pre-PR master this PR #5947 Δ This PR vs master
Total wall time 531.7 ± 1.4 451.8 ± 2.4 441.5 ± 1.2 −15.0% (t=28.5) −2.3% (t=3.8)
Parse 312.8 ± 1.1 276.8 ± 1.6 273.4 ± 0.9 −11.5% (t=18.3) −1.2% (t=1.8)
Generate (deconflict path) 218.9 ± 0.7 175.0 ± 0.9 168.1 ± 0.5 −20.0% (t=37.2) −3.9% (t=6.4)

Per-format generate time:

Format pre-PR master this PR #5947 Δ This PR vs master
es 38.8 ± 0.3 40.7 ± 0.4 39.0 ± 0.2 +4.7% (t=−3.4) −4.2% (t=3.7)
cjs 33.5 ± 0.2 24.8 ± 0.2 23.7 ± 0.1 −25.9% (t=34.5) −4.2% (t=4.6)
amd 36.1 ± 0.1 27.2 ± 0.3 25.9 ± 0.1 −24.6% (t=27.9) −5.0% (t=4.3)
system 36.8 ± 0.2 27.6 ± 0.2 26.6 ± 0.1 −25.1% (t=34.4) −3.5% (t=4.3)
iife 37.1 ± 0.4 27.2 ± 0.2 26.2 ± 0.3 −26.9% (t=22.3) −3.6% (t=2.8)
umd 36.6 ± 0.3 27.6 ± 0.3 26.7 ± 0.3 −24.4% (t=22.3) −3.2% (t=2.1)

Negative = faster. #5947's speedup (−20% generate, −24% to −27% per non-ES format) is fully preserved. This PR is additionally 3.9% faster on the generate phase (t=6.4), because (a) RESERVED_NAMES.has() has one fewer element to check on every getSafeName call, and (b) nested bindings that used to be spuriously renamed no longer go through getSafeName + MagicString rewrite.

On the ES format, #5947 actually made generate time slightly slower (+4.7%) by forcing ES builds to treat exports/module/require/__filename/__dirname as reserved at the top level even though ES has no runtime for them; this PR brings ES back to essentially pre-#5947 levels (40.7ms → 39.0ms) by lifting the unnecessary nested-scope reservation.

PR rollup#5947 added "exports" to the universal RESERVED_NAMES set, which
caused every variable or parameter named "exports" to be renamed to
"exports$1" in any scope, even when no conflict exists with the output
format's runtime "exports" identifier. Because eval strings are not
rewritten, this silently broke code like:

    (unused, exports) => {
      console.log(exports.bar);
      eval('exports.bar = 1');
    }

where the parameter was renamed but the eval argument was not. The same
over-aggressive rename also applied to unrelated nested bindings in ES
output, which has no runtime "exports" at all.

This change removes "exports" from RESERVED_NAMES and restores the
system-format-specific check in ChildScope.addUsedOutsideNames so that
nested scopes in SystemJS output still treat "exports" as used when
they reference an outer exported binding. Top-level protection for
CJS/AMD/UMD/IIFE/SYSTEM output is unchanged: RESERVED_USED_NAMES in
Chunk.ts still seeds the chunk-level usedNames with "exports", and
reassigned exports still carry renderBaseName === "exports" which
propagates into nested scopes via getBaseVariableName().

The deconflictTopLevelVariables cache from PR rollup#5947 is untouched, so
its performance benefits are preserved; a benchmark that reliably sees
the PR's generate-time speedup (~20% vs pre-PR) shows no regression
from this change.

Fixes rollup#6357

Made-with: Cursor
@vercel

vercel Bot commented Apr 17, 2026

Copy link
Copy Markdown

@tariqrafique is attempting to deploy a commit to the rollup-js Team on Vercel.

A member of the Team first needs to authorize it.

Comment thread src/ast/scopes/ChildScope.ts
@lukastaegert

Copy link
Copy Markdown
Member

The reason this was done is because with the new caching logic for variable names implemented, you could otherwise have bugs when generating e.g. SystemJS and ES output together from the same output because in SystemJS, it would need to rename exports and for ES it would not.
With your change, you will be breaking this again just to support eval. But as I just wrote in the other issue, eval is inherently broken and unfixable in Rollup, and even though you put much work in this fix, I would carefully decline to merge it as keeping the caching working reliably is more important to me.

@tariqrafique

tariqrafique commented Apr 20, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for taking the time to review and for the context on the caching design — that helps a lot.

I dug deeper into this based on your feedback and wanted to share what I found, in case it changes the picture.

The safeVariableNames cache assigns renderName to top-level variables in deconflictTopLevelVariables. Those names do flow into child scopes indirectly — ChildScope.addUsedOutsideNames pulls them in via variable.getBaseVariableName(). So I understand the concern about cache consistency affecting nested scopes.

However, for exports specifically, the top-level name is consistent across all formats: RESERVED_USED_NAMES in Chunk.ts always includes 'exports', so a top-level variable named exports is always renamed to exports$1 regardless of format. The cache stores this same value for all formats. This PR doesn't change that path.

The change here restores the format-conditional logic that was in ChildScope.addUsedOutsideNames prior to #5947 — it adds 'exports' to a child scope's usedNames only when format === 'system' AND the scope accesses an outer exported variable (meaning the SystemJS exports() function will appear in that scope). Since deconflictChunk runs fresh per generate() call, each format gets the correct child-scope behavior independently.

To verify, I tested generating ES + SystemJS from the same bundle, in both orders, with and without cache reuse — all outputs are correct and deterministic, matching 4.52.5 behavior.

Would it be helpful if I added a multi-format generation test to the suite to explicitly cover this invariant going forward?

@lukastaegert

Copy link
Copy Markdown
Member

so a top-level variable named exports is always renamed to exports$1 regardless of format

Ah, you are right. Nested names are not cached, and top-level exports is protected. Then your change is fine. We might consider adding a multi-format test for this scenario, but it would serve mostly for documentation, I do not think we will add caching for nested variables any time soon.

@vercel

vercel Bot commented May 1, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
rollup Ready Ready Preview, Comment May 1, 2026 6:04am

Request Review

@codecov

codecov Bot commented May 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.78%. Comparing base (20af1c4) to head (eff6762).
⚠️ Report is 6 commits behind head on master.

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #6360   +/-   ##
=======================================
  Coverage   98.78%   98.78%           
=======================================
  Files         274      274           
  Lines       10791    10793    +2     
  Branches     2881     2882    +1     
=======================================
+ Hits        10660    10662    +2     
  Misses         89       89           
  Partials       42       42           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@tariqrafique

Copy link
Copy Markdown
Contributor Author

Ah, you are right. Nested names are not cached, and top-level exports is protected. Then your change is fine. We might consider adding a multi-format test for this scenario, but it would serve mostly for documentation, I do not think we will add caching for nested variables any time soon.

@lukastaegert thank you for reviewing this. What would it take to meet the criteria for merging this in?

@lukastaegert lukastaegert added this pull request to the merge queue May 3, 2026
@lukastaegert

Copy link
Copy Markdown
Member

This looks fine form my side, will merge this now

Merged via the queue into rollup:master with commit 12195dc May 3, 2026
47 checks passed
@tariqrafique tariqrafique deleted the fix/nested-exports-deconflict-6357 branch May 8, 2026 23:20
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.

Regression: AMD output renames shadowed 'exports' to 'exports$1' in eval-containing scope

2 participants