Visitar URL original
fix(es/minifier): drop spans of cached `globals` values by upupming · Pull Request #12129 · swc-project/swc · GitHub
Skip to content

fix(es/minifier): drop spans of cached globals values - #12129

Merged
Donny/강동윤 (kdy1) merged 3 commits into
swc-project:mainfrom
upupming:fix/inline-globals-span
Aug 24, 2026
Merged

Donny/강동윤 (kdy1) merged 3 commits into
swc-project:mainfrom
upupming:fix/inline-globals-span

Conversation

@upupming

@upupming Yiming Li (upupming) commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor

Related issue: none filed yet — happy to open one if you would like the report separately.

Rewritten after your review: the fix moved from inline_globals to the cache that produces the values, and the PR now leads with a failing test.

Description

GlobalPassOption::build parses each jsc.transform.optimizer.globals.vars value into an anonymous file of the SourceMap it is handed, and memoizes the parsed Expr in a process-wide static CACHE. A span is only meaningful to the SourceMap that produced it, so the cache outlives what its contents point at.

For a host that builds a fresh SourceMap per file — which is what swc::Compiler does, and what a bundler does — the emitted source map depends on whether the call hit the cache:

  • on the call that parses the value, the mappings point into the anonymous file. With source_file_name set, map_file_name_to_source reports that file under the name of the file being compiled, so the position is silently attributed to the user's source;
  • on every call that hits the cache, the position belongs to a SourceMap that no longer exists: it is dropped when it is past the end of the file being compiled, and points at an unrelated line when that file is longer.

Three things that cannot affect the semantics of globals currently affect its output:

  1. the order the host passes the defines in — the cache key is a Vec<(Atom, Atom)> built from vars, so a host that holds the defines in a std::collections::HashMap (whose iteration order differs per instance) produces a different key per call, and therefore a different hit/miss pattern;
  2. whether the call hits the process-wide cache — a cache should be semantically a no-op;
  3. which file was compiled first in the process — the cached position is derived from the size of that file, so in a parallel build it depends on scheduling.

That is how this surfaced: in a Rspack build the source map is part of the module hash, so the chunk hash moved between builds of unchanged sources, and with minification on every mangled name in the chunk moved with it.

Fix

Drop the spans before the value is cached.

Parsing still happens in the caller's SourceMap, so a syntax error in a define value is still reported with a position — but nothing SourceMap-dependent survives into the cache. Substituted values then carry DUMMY_SP, which srcmap! skips, so no mapping is emitted for them on any call, whether it parsed the value or got it from the cache.

The previous revision of this PR overwrote the spans at the four substitution sites in inline_globals instead. It left the cache poisoned for any other consumer, added a subtree visit per substitution on a hot path, and gave synthesized nodes the span of real source. This one is a single drop_span at the point the value is created, which is also what inline_globals' own tests already assume — they build their globals map with DropSpan.

If you would rather keep a mapping for substituted values (__DEV__ mapping back to __DEV__ is nicer than no mapping at all), that can be layered on top cheaply now: with the cached children already dummy, the substitution site only needs value.set_span(ident.span), no visitor. I left it out because it is a separate change.

Tests

crates/swc/tests/source_map.rs gets a test that compiles the same input twice, each in a fresh SourceMap, and compares the emitted maps. It is committed before the fix and fails there:

assertion failed: `(left == right)`: the same input produced two different source maps
< {"version":3,"sources":["input.js","<anon>"],"sourcesContent":[...,"true"],...}
> {"version":3,"sources":["input.js"],"sourcesContent":[...],...}

The first compilation leaks the define value itself into the map as a source file; the second, which hits the cache, does not.

  • cargo test -p swc_ecma_transforms_optimization — pass
  • cargo test -p swc --test projects — 889 pass
  • cargo test -p swc --test rust_api — pass
  • cargo test -p swc --test source_map — pass except issue_622 and the stacktrace fixtures, which fail on main on my machine too (Node version)

The end-to-end numbers in the original description (400 transforms → 1 distinct source map, 40 builds → 1 distinct chunk hash) were measured with the previous revision. I have not rebuilt the downstream binding against this revision; the invariant is the one the new test asserts, and this revision removes the mappings entirely rather than rewriting them.

@CLAassistant

CLAassistant commented Aug 19, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@changeset-bot

changeset-bot Bot commented Aug 19, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9808b4d

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@codspeed

codspeed Bot commented Aug 19, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 200 untouched benchmarks
⏩ 61 skipped benchmarks1


Comparing upupming:fix/inline-globals-span (9808b4d) with main (3802924)

Open in CodSpeed

Footnotes

  1. 61 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. ↩

@kdy1 Donny/강동윤 (kdy1) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The approach needs to be changed

Comment thread crates/swc_ecma_transforms_optimization/src/inline_globals.rs Outdated
@upupming Yiming Li (upupming) changed the title fix(es/optimization): give substituted globals the span they replace fix(swc): drop spans of cached globals values Aug 20, 2026
`GlobalPassOption::build` parses each `jsc.transform.optimizer.globals.vars`
value into an anonymous file of the `SourceMap` it is handed, and memoizes the
parsed expression in a process-wide cache. Compiling the same input twice with
a fresh `SourceMap` each time - what a bundler does - therefore gives two
different source maps: the first compilation emits mappings into the anonymous
file, the second gets the cached expression whose positions belong to a
`SourceMap` that no longer exists.

The test fails on this commit:

    < sources: ["input.js", "<anon>"], sourcesContent: [..., "true"]
    > sources: ["input.js"]
`GlobalPassOption::build` parses each `jsc.transform.optimizer.globals.vars`
value into an anonymous file of the `SourceMap` it is handed, and memoizes the
parsed expression in a process-wide cache. A span is only meaningful to the
`SourceMap` that produced it, so the cache outlives what its contents point at,
and the emitted source map ends up depending on three things that should not
affect it: whether the call hit the cache, the order the host passed the
defines in (the cache key is order sensitive), and which file was compiled
first in the process (the cached position is derived from its size).

Drop the spans before the value is cached. Parsing still happens in the
caller's `SourceMap`, so a syntax error in a define value keeps its position,
but nothing `SourceMap` dependent survives into the cache. Substituted values
now carry `DUMMY_SP`, which codegen skips, so no mapping is emitted for them on
any call.

match expr {
Ok(v) => v,
Ok(v) => drop_span(v),

@upupming Yiming Li (upupming) Aug 20, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Donny/강동윤 (@kdy1) I just dropped the span since it is useless and can cause unstable source map output, what do you think?

If we do not drop, the test below will fail like this:

Image

Or shall we always create the "<anon>" file on the second compile?

@upupming

Copy link
Copy Markdown
Contributor Author

Reworked per your review, thanks.

The fix moved out of inline_globals and into GlobalPassOption::build: the parsed value gets drop_span before it goes into the process-wide cache, so nothing SourceMap-dependent is memoized and the four substitution sites are untouched. That is also what inline_globals' own tests already assume — they build their globals map with DropSpan.

Two commits: the first adds a test that compiles the same input twice, each with a fresh SourceMap, and compares the emitted maps (it fails there — the first compilation reports the define text itself as a source); the second is the one-line fix.

Three checks are red, none of them from this PR as far as I can tell:

  • Check license of dependencies — the advisories section, also red on feat(es/parser): add opt-in parser-only TSRX lowering #12120
  • Test - swc_node_bundler - windows-latest and Test with @swc/cli — both died fetching crates.io (503 first byte timeout, Timeout was reached). I do not have rights to re-run them.

If you would rather have the cache keep the source text and re-parse per SourceMap, or have the cache key normalized so define order stops producing distinct entries, I am happy to switch.

Yiming Li (upupming) added a commit to lynx-family/lynx-stack that referenced this pull request Aug 20, 2026
Part of the series that moves the Lynx build engine out of
`@lynx-js/rspeedy` and into `pluginLynx`.

The point of the series is that an Rsbuild build with only
`pluginReactLynx()` produces the same bundle as an Rspeedy build. This
locks that in: the same fixture is built both ways, each in its own
process, in production and in development, and the two bundles are
compared.

The comparison ignores identifiers minted per build rather than derived
from the source. `chunk.hash` is not reproducible across builds, and
both the content hash in a filename and the debug metadata release are
keyed on it, so the filename hash is turned off and the release is
stripped.

That reproducibility problem is not ours: swc's `inline_globals` keeps
the span of the value it substitutes for a define, and those values come
from a process-wide cache, so their spans belong to a `SourceMap` from
an earlier call. The module's source map therefore moves between builds,
which moves `build_info.hash`, `chunk.hash` and the content hash. Fix
proposed upstream in swc-project/swc#12129; with it, 40 Rspeedy builds
of the same source produce one bundle instead of ten. Once that lands
the two allowances in this test can go away.

Stacked on #3596.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Tests**
* Added coverage to verify that development and production bundles
generated by Rsbuild and Rspeedy remain equivalent.
* Normalized build-specific metadata during comparisons for reliable
results.
  * Isolated builds and cleaned up temporary output after testing.

* **Chores**
* Added a parity build utility using consistent React, environment,
output, and deterministic build settings.
  * Documented the parity coverage in the upcoming patch release notes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
@kdy1 Donny/강동윤 (kdy1) changed the title fix(swc): drop spans of cached globals values fix(es/minifier): drop spans of cached globals values Aug 24, 2026
@kdy1
Donny/강동윤 (kdy1) enabled auto-merge (squash) August 24, 2026 05:36
@kdy1
Donny/강동윤 (kdy1) enabled auto-merge (squash) August 24, 2026 05:37
@kdy1
Donny/강동윤 (kdy1) merged commit 9a306b8 into swc-project:main Aug 24, 2026
59 checks passed
@github-actions github-actions Bot added this to the Planned milestone Aug 24, 2026
Donny/강동윤 (kdy1) pushed a commit that referenced this pull request Sep 1, 2026
**Description:**

`GlobalPassOption::build` stores parsed
`jsc.transform.optimizer.globals.envs` maps in a process-wide cache, but
the cache key is currently built from `globals.vars`. Two compilations
with the same `vars` and different explicit `envs` maps therefore
collide, causing the later compilation to reuse stale environment
replacements from the earlier one.

Build the cache key from the configured environment map instead, and
sort its entries so equivalent maps produce the same key regardless of
iteration order.

This was discovered while investigating the cached-span source-map issue
fixed by #12129, but it is a separate cache-key bug.

The regression test compiles the same source twice in one process with
different explicit environment values. Before the fix, the second
compilation incorrectly emits the first value.

Tests:

- `cargo fmt --all -- --check`
- `cargo clippy -p swc --all-targets -- -D warnings`
- `cargo test -p swc --lib`
- `cargo test -p swc --test simple`
- `cargo test -p swc --test simple --features react-compiler`
- `cargo test -p swc --test rust_api`
- `cargo test -p swc --test source_map
define_source_map_is_stable_across_compilations -- --exact`

**Related issue (if exists):**

Follow-up to #12129.
@github-actions github-actions Bot modified the milestones: Planned, v1.16.2 Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants