Skip to content

fix: handle JSON default mutation bailouts#9972

Merged
IWANABETHATGUY merged 2 commits into
rolldown:mainfrom
TheAlexLichter:cjs-json-require-treeshake
Jun 27, 2026
Merged

fix: handle JSON default mutation bailouts#9972
IWANABETHATGUY merged 2 commits into
rolldown:mainfrom
TheAlexLichter:cjs-json-require-treeshake

Conversation

@TheAlexLichter

Copy link
Copy Markdown
Collaborator

This PR hardens the existing JSON default-import tree-shaking optimization. It prevents Rolldown from replacing json.a with the static split JSON export when the shared JSON default object may have been mutated elsewhere.

It also catches the less obvious case where that mutation happens through a named re-export.

Example

REPL

Right now, Rolldown treats config.mode like a static JSON named export and emit something equivalent to:

var config_default = { mode };
config_default.mode = "dev";
console.log(mode); // "prod" - wrong

After:

var mode = "prod";
var config_default = { mode };

config_default.mode = "dev";

console.log(config_default.mode); // "dev" - correct

@netlify

netlify Bot commented Jun 25, 2026

Copy link
Copy Markdown

Deploy Preview for rolldown-rs ready!

Name Link
🔨 Latest commit 685294b
🔍 Latest deploy log https://app.netlify.com/projects/rolldown-rs/deploys/6a3f7d59b0502c0008277b23
😎 Deploy Preview https://deploy-preview-9972--rolldown-rs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@TheAlexLichter TheAlexLichter marked this pull request as draft June 25, 2026 15:55
@codspeed-hq

codspeed-hq Bot commented Jun 25, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 7 untouched benchmarks
⏩ 10 skipped benchmarks1


Comparing TheAlexLichter:cjs-json-require-treeshake (685294b) with main (1d77c9c)2

Open in CodSpeed

Footnotes

  1. 10 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on main (d3303b7) during the generation of this report, so 1d77c9c was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@TheAlexLichter TheAlexLichter force-pushed the cjs-json-require-treeshake branch from bc82da1 to 4973d36 Compare June 25, 2026 16:02
@TheAlexLichter TheAlexLichter marked this pull request as ready for review June 25, 2026 16:28
@IWANABETHATGUY

Copy link
Copy Markdown
Member

I’ll make a few small adjustments directly on this branch to better align it with the project style and reduce back-and-forth.

TheAlexLichter and others added 2 commits June 27, 2026 15:35
…e scan

Collapse the `#[cfg(target_family = "wasm")]` / `not(wasm)` split into a single
path. The branches existed only because rayon's two-arg `reduce(identity, op)`
has no equivalent on the wasm `par_iter` shim; collecting into a `Vec` and using
std `flatten` compiles on both targets and matches the adjacent
`resolved_meta_data` collection.
@IWANABETHATGUY IWANABETHATGUY force-pushed the cjs-json-require-treeshake branch from d312291 to 685294b Compare June 27, 2026 07:35
@IWANABETHATGUY IWANABETHATGUY enabled auto-merge (squash) June 27, 2026 07:35
@IWANABETHATGUY IWANABETHATGUY merged commit 05031e2 into rolldown:main Jun 27, 2026
33 of 34 checks passed
@TheAlexLichter TheAlexLichter deleted the cjs-json-require-treeshake branch June 27, 2026 07:53
IWANABETHATGUY added a commit that referenced this pull request Jun 28, 2026
#9972 keeps a JSON default object materialized once it observes a member
write (`data.foo = ...`), including through a named re-export. But the
object can also diverge from its split per-key exports by *escaping*
without a write we can see: aliasing (`const o = data`), being passed to
a function (`f(data)`), or dynamic indexing (`data[k]`). A later
`data.key` read then resolves to the stale split export.

Treat any bare-symbol reference to the default -- anything other than a
static `data.key` access, which is recorded as a `MemberExpr` -- as
non-splittable, in addition to the existing member writes. Fold the
per-reference classification into one whitelist helper
(`collect_non_splittable_json_defaults` / `json_default_made_non_splittable_by`
/ `json_default_canonical_ref`), replacing the write-only
`collect_json_default_imports_with_member_write`.

Add `json_default_mutation_bailout` covering alias, call-argument,
computed-write, named-re-export, and cross-module mutation escapes, plus
a still-splittable control. The `json_default_import` snapshot also drops
the redundant split for the whole-object-use `bailout.json` case.
IWANABETHATGUY added a commit that referenced this pull request Jun 28, 2026
#9972 keeps a JSON default object materialized once it observes a member
write (`data.foo = ...`), including through a named re-export. But the
object can also diverge from its split per-key exports by *escaping*
without a write we can see: aliasing (`const o = data`), being passed to
a function (`f(data)`), or dynamic indexing (`data[k]`). A later
`data.key` read then resolves to the stale split export.

Treat any bare-symbol reference to the default -- anything other than a
static `data.key` access, which is recorded as a `MemberExpr` -- as
non-splittable, in addition to the existing member writes. Fold the
per-reference classification into one whitelist helper
(`collect_non_splittable_json_defaults` / `json_default_made_non_splittable_by`
/ `json_default_canonical_ref`), replacing the write-only
`collect_json_default_imports_with_member_write`.

Add `json_default_mutation_bailout` covering alias, call-argument,
computed-write, named-re-export, and cross-module mutation escapes, plus
a still-splittable control. The `json_default_import` snapshot also drops
the redundant split for the whole-object-use `bailout.json` case.
graphite-app Bot pushed a commit that referenced this pull request Jun 28, 2026
…9996)

## What this solves

[#9972](#9972) keeps a JSON default object materialized (instead of splitting `data.key` reads into per-key exports) once it observes a member **write** — `data.foo = ...`, including through a named re-export. But the split optimization is equally unsound when the default object **escapes** without a write rolldown can see, and `data.key` reads then resolve to the stale split export instead of the live object:

- aliasing — `const o = data; o.foo = ...`
- call argument — `f(data)` where `f` mutates it
- dynamic index — `data[k] = ...`

Minimal reproduction (alias case) — **[live REPL](https://repl.rolldown.rs/#eNptk8Fu2zAMhl/lHy9OUE+9u8hhHYYBK7Acht10qGzTiTqFCiS5WRYY2EPsCfckg+QmTYFdBJDC/4n8SZ3omRpyJnFMVNNAzYl6k4x6il5yINRcJWrqqKETND0bN7KmBppaHnxgTZi00FTTzlhRT/GsPoez1u72PiRkJIbgd6jU7YVf3WnRcnuLx5x6xCJtGV++rb+i58GMLsG3T9ylJTh2Zs8R7REtW9nAOGsi9zXSliUfc6bQdmPKDZZs3JrA/QsHVrB3pmOlpfMS0yzCqtR3p6WEqvSKFSozJA6XIj/kqEDLA9ZLPRc+Cx6xG2NCy2edwlpgBGYYuEvcox2t6zPgteezNLDp0bLzBxxMRMz8zjh3ROBDsCnlJn15fM/h/Q8+QlPcO5s0FRr/LD4vhuB/scAkVPOcqmWN3j770HE/T+DSwasvf3//Qcx4OzuYtsEfIqzElAvzA5zfbLLv6welxQ5YvFaPd6uLV0uctGDWQ/iATyH4sKjuv39ucCVZocJNGbWKKVjZ2OF4hVziBlWdm5qd01TwmqrlnZYpDyTPzztWzm8W1frhLb2qr8IsoZqYmhRGnmoK3rneH0R1Xga7Uemyu/+5ebPGp7yYVvhjucb0stJnWZULe5nEeYOvBYtijiYr+zHlzzRj1Y6TUXNSpmX+VdP0D4saQXo=)**:

```js
// data.json — { "value": "before" }
import data from './data.json';
const alias = data;            // `data` escapes
alias.value = 'after';         // mutate the shared object
data.value;                    // main: "before"  ❌   (should be "after")
```

On `main` the read is compiled to a bare `value` split const (`"before"`), divorced from the mutated object.

## The fix

Flip the JSON-default scan from a **blacklist** ("optimize unless we spot a write") to a **whitelist** ("only split when every reference to the default is a plain `data.key` read"). The enabling signal: a static `data.key` access is recorded as a `MemberExpr`, so **any bare-symbol reference** to the default binding is an escape we can't reason about — mark it non-splittable, in addition to the member writes #9972 already handles. The three ad-hoc helpers are folded into one classifier (`collect_non_splittable_json_defaults` / `json_default_made_non_splittable_by` / `json_default_canonical_ref`), replacing the write-only `collect_json_default_imports_with_member_write`.

This is free in practice: if the object escapes, the whole thing is already materialized, so no per-key tree-shaking was possible to begin with — bailing only changes *which* form is emitted, never what gets dropped.

## Alternatives explored

Continuing to extend the blacklist with more mutation patterns (the #9972 direction). Rejected — it is unsound by construction: every escape pattern not yet enumerated is a latent miscompile. The whitelist is sound by construction and strictly supersedes the write-only checks.

## Tests

`tree_shaking/json_default_escape_bailout` — an **executed** fixture (real runtime asserts) covering the two escape forms `main` still mis-optimizes: **aliasing** (`const o = data`) and **passing as a call argument** (`f(data)`). Both fail on `main` and pass here.

The other non-splittable categories are intentionally **not** duplicated — each already has dedicated coverage that passes on `main`:

- computed-write → `issues/9484_computed_write`
- named-re-export / cross-module write → `tree_shaking/json_default_import` (+ `issues/9484`)
- read-only split + tree-shake (no over-bail) → `tree_shaking/json_default_import` (`foo.json`)

The `json_default_import` snapshot also drops a now-redundant split for the whole-object-use `bailout.json` case.

Full `rolldown` suite: **1771 passed, 0 failed**; `clippy --deny warnings` clean.

## Reviewer attention

- The soundness of the *"bare `Symbol` = escape"* signal — i.e. that the scanner records static `x.key` accesses as `MemberExpr` and everything else (alias, arg, dynamic index, return) falls through to a bare `Symbol`.
- No over-bail: the read-only coverage above + full green suite confirm read-only JSON defaults still split and tree-shake unused keys.
@rolldown-guard rolldown-guard Bot mentioned this pull request Jul 1, 2026
shulaoda added a commit that referenced this pull request Jul 1, 2026
## [1.1.4] - 2026-07-01

### 🚀 Features

- disable `experimental.lazyBarrel` by default (#10071) by @shulaoda

### 🐛 Bug Fixes

- dev: disable lazy barrel in dev mode (#10060) by @shulaoda
- generate: keep full JSON interface under preserveModules namespa… (#10056) by @IWANABETHATGUY
- check finalize_other_specifiers in its own Debug attribute (#10032) by @shulaoda
- serialize the KeepAssign unused minify option as "keep_assign" (#10031) by @shulaoda
- keep fragments after the newline fragment in MagicString::last_line (#10023) by @shulaoda
- generate: undeclared JSON named exports under preserveModules (#10020) (#10027) by @IWANABETHATGUY
- deconflict: rename CJS-wrapped locals that shadow chunk-root bindings (#9921) by @IWANABETHATGUY
- rolldown: keep entry facade when a shared chunk holds another entry's module (#9997) by @hyf0
- treeshake: also bail JSON default split when the object escapes (#9996) by @IWANABETHATGUY
- don't classify await in a strict-mode function as top-level await (#9987) by @shulaoda
- avoid spurious leading newline in addon hooks (banner/footer/intro/outro) (#9989) by @shulaoda
- handle JSON default mutation bailouts (#9972) by @TheAlexLichter
- plugin: make lazy hook metadata enumerable (#9991) by @TheAlexLichter
- dev: make init errors in lazy-compiled modules catchable (#9981) by @h-a-n-a
- treeshake: keep computed-key side effects on namespace member access (#9986) by @shulaoda
- binding: validate replace plugin delimiters length instead of panicking (#9984) by @shulaoda
- reconstruct nested rest patterns in into_expression (#9980) by @IWANABETHATGUY
- reconstruct rest patterns as spread in into_expression (#9976) by @shulaoda
- preserve export keyword on multi-declarator exports under keepNames (#9974) by @shulaoda
- deterministically keep the shortest name for deduplicated assets (#9948) by @x1024
- treeshake: apply @__NO_SIDE_EFFECTS__ to cross-chunk namespace calls (#9960) by @IWANABETHATGUY

### 🚜 Refactor

- drop redundant program scope enter/leave in finalizer (#10049) by @shulaoda
- deconflict: extract collect_chunk_scope_captured_names (#10006) by @IWANABETHATGUY
- unify pre-scan multi-declarator split into one decision site (#9982) by @IWANABETHATGUY
- common: return bool from SymbolRef::is_not_reassigned (#9962) by @IWANABETHATGUY

### 📚 Documentation

- rolldown: remove outdated comment for removing parenthesized expression (#10062) by @Dunqing
- use GitHub-flavored alert for Etiquette note in contribution guide (#10012) by @IWANABETHATGUY
- replace: explain the delimiters left and right boundaries (#9985) by @shulaoda
- ast-mutation: remove stale Address Use section after pre-scan refactor (#9983) by @IWANABETHATGUY
- remove fathom (#9968) by @mdong1909
- contribution-guide: code-format main branch references (#9966) by @IWANABETHATGUY
- contribution-guide: fix stale REPL note and tidy wording (#9957) by @hyf0
- contribution-guide: clarify when to discuss before opening a PR (#9955) by @hyf0

### ⚡ Performance

- disable preserve_parens across all parse paths (#10057) by @Dunqing
- common: inline declared_symbols with SmallVec (#9920) by @IWANABETHATGUY
- common: pack TaggedSymbolRef into 8 bytes (#9919) by @IWANABETHATGUY
- sourcemap: skip newline scan on the no-sourcemap join fast path (#9936) by @Boshen

### 🧪 Testing

- dev: error in lazy module should be catchable (#9975) by @sapphi-red
- dev: reject unknown lazy compile modules (#9969) by @sapphi-red

### ⚙️ Miscellaneous Tasks

- deps: update actions/cache action to v6 (#10001) by @renovate[bot]
- trigger vite ecosystem-ci from PR comments (#10058) by @shulaoda
- deps: update napi to v3.10.0 (#10063) by @renovate[bot]
- remove unused From impl for RolldownLabelSpan (#10055) by @shulaoda
- remove dead Diagnostic::with_kind method (#10054) by @shulaoda
- remove unused StatementExt methods (#10053) by @shulaoda
- remove unused ExpressionExt methods (#10052) by @shulaoda
- remove commented-out re_export_all_names field (#10051) by @shulaoda
- deps: update pnpm to v11.9.0 (#10047) by @renovate[bot]
- remove the unused BindingGenerateHmrPatchReturn napi type (#10034) by @shulaoda
- remove the dead inline_entry_chunk_wrapping scaffolding (#10037) by @shulaoda
- deps: bump oxc_resolver to 11.22.0 (#10045) by @Boshen
- remove never-constructed MatchImportKind::_Ignore variant (#10041) by @shulaoda
- remove the unused ScheduledBuild napi struct (#10033) by @shulaoda
- remove dead compute_hmr_update_single method (#10040) by @shulaoda
- drop the redundant visited.insert in manual code splitting (#10038) by @shulaoda
- remove the dead output_assets vector in render_chunk_to_assets (#10036) by @shulaoda
- remove the unused From<String>/Display impls for BindingLogLevel (#10035) by @shulaoda
- deps: upgrade oxc to 0.138.0 and migrate to per-type AST construction (#10018) by @shulaoda
- deps: update rust crates (#9911) by @renovate[bot]
- deps: update test262 submodule for tests (#10016) by @rolldown-guard[bot]
- deps: update github actions (#9999) by @renovate[bot]
- deps: update npm packages (#10000) by @renovate[bot]

### ◀️ Revert

- "fix(plugin): make lazy hook metadata enumerable (#9991)" (#10005) by @shulaoda

### ❤️ New Contributors

* @x1024 made their first contribution in [#9948](#9948)

Co-authored-by: shulaoda <165626830+shulaoda@users.noreply.github.com>
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