perf(oxc): conditionally rebuild semantic in compiler pipeline (#23153)

## What

Track scoping/AST sync state with a single `scoping_dirty` flag in `Compiler::compile` so each downstream step (inject/define plugins, DCE, compress) only rebuilds semantic when it's actually needed.

## Details

- `InjectGlobalVariables` and `ReplaceGlobalDefines` already return a `changed: bool`; this was previously ignored. It's now OR-ed into `scoping_dirty`.
- `scoping_dirty` starts `true` only when the transformer runs — the parser, React Compiler (rebuilds scoping on success, returns the untouched scoping on bail-out), and any fresh `SemanticBuilder` all leave scoping in sync.
- The pre-plugin rebuild is now gated on `scoping_dirty`, skipping a needless rebuild when no transform ran.
- DCE and Compress are now a single mutually-exclusive `if/else`, both rebuilding semantic only when `scoping_dirty`.
- `Compressor::build` (which always rebuilds semantic internally) is replaced with `build_with_scoping`, so when scoping is already in sync the compressor skips its own `SemanticBuilder` pass.
- The three rebuild sites are extracted into a `rebuild_scoping` helper, which enables enum eval when a transform is configured so const-enum resolution matches the original semantic build.

## Note

The `CompilerInterface::compress` trait method gains a `scoping: Scoping` parameter — a breaking change for external implementors that override it.
This commit is contained in:
Boshen committed 2026-06-09 14:25:42 +00:00
1 parent 93326e7475
commit 534f9c6cf4
1 file changed
+50 -16
+50 -16
View File
@@ -9,7 +9,7 @@ use oxc_mangler::{MangleOptions, Mangler, ManglerReturn};
use oxc_minifier::{CompressOptions, Compressor};
use oxc_parser::{ParseOptions, Parser, ParserReturn};
use oxc_react_compiler::{self, PluginOptions as ReactCompilerOptions};
use oxc_semantic::{Scoping, SemanticBuilder, SemanticBuilderReturn};
use oxc_semantic::{Scoping, SemanticBuilder, SemanticBuilderReturn, Stats};
use oxc_span::SourceType;
use oxc_transformer::{TransformOptions, Transformer, TransformerReturn};
use oxc_transformer_plugins::{
@@ -205,37 +205,54 @@ pub trait CompilerInterface {
self.handle_errors(errors);
}
// The transformer leaves symbols and scopes out of sync with the AST;
// the parser, React Compiler, and any fresh semantic build leave them in
// sync. Track the state so each consumer below only rebuilds when needed.
let transform = self.transform_options().is_some();
let mut scoping_dirty = transform;
let inject_options = self.inject_options();
let define_options = self.define_options();
let has_define = define_options.is_some();
// Symbols and scopes are out of sync.
if inject_options.is_some() || define_options.is_some() {
scoping =
SemanticBuilder::new().with_stats(stats).build(&program).semantic.into_scoping();
// The inject/define plugins require in-sync scoping as input.
if scoping_dirty && (inject_options.is_some() || has_define) {
scoping = rebuild_scoping(&program, stats, transform);
scoping_dirty = false;
}
if let Some(options) = inject_options {
let ret = InjectGlobalVariables::new(&allocator, options).build(scoping, &mut program);
scoping = ret.scoping;
scoping_dirty |= ret.changed;
}
if let Some(options) = define_options {
let ret = ReplaceGlobalDefines::new(&allocator, options).build(scoping, &mut program);
scoping = ret.scoping;
// Run DCE if minification is disabled.
if self.compress_options().is_none() {
Compressor::new(&allocator).dead_code_elimination_with_scoping(
&mut program,
scoping,
CompressOptions::dce(),
);
}
scoping_dirty |= ret.changed;
}
/* Compress */
/* Compress / DCE */
// Both consumers need in-sync scoping; only rebuild when a preceding step
// (transform or a plugin that mutated the AST) left it dirty. The branches
// are mutually exclusive, so `scoping` is moved into exactly one of them.
if let Some(options) = self.compress_options() {
self.compress(&allocator, &mut program, options);
if scoping_dirty {
scoping = rebuild_scoping(&program, stats, transform);
}
self.compress(&allocator, &mut program, scoping, options);
} else if has_define {
// Run DCE if minification is disabled.
if scoping_dirty {
scoping = rebuild_scoping(&program, stats, transform);
}
Compressor::new(&allocator).dead_code_elimination_with_scoping(
&mut program,
scoping,
CompressOptions::dce(),
);
}
/* Mangler */
@@ -306,9 +323,10 @@ pub trait CompilerInterface {
&self,
allocator: &'a Allocator,
program: &mut Program<'a>,
scoping: Scoping,
options: CompressOptions,
) {
Compressor::new(allocator).build(program, options);
Compressor::new(allocator).build_with_scoping(program, scoping, options);
}
fn mangle(&self, program: &mut Program<'_>, options: MangleOptions) -> ManglerReturn {
@@ -336,3 +354,19 @@ pub trait CompilerInterface {
.build(program)
}
}
/// Rebuild [`Scoping`] from scratch, reusing prior `stats` to size allocations.
///
/// Used to re-sync symbols and scopes with the AST after a step that left them
/// out of date (the transformer, or an inject/define plugin that mutated nodes).
///
/// `enum_eval` must match the original semantic build so downstream transforms
/// see the same const-enum resolution.
fn rebuild_scoping(program: &Program<'_>, stats: Stats, enum_eval: bool) -> Scoping {
SemanticBuilder::new()
.with_stats(stats)
.with_enum_eval(enum_eval)
.build(program)
.semantic
.into_scoping()
}