From ad38d24726865e32b17002fa653f8e335f540120 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Thu, 3 Sep 2026 14:45:07 +0800 Subject: [PATCH 1/4] feat(compiler): add an optimize option for constant folding and DCE --- .changeset/compiler-optimize-option.md | 17 + packages/compiler/README.md | 26 + packages/compiler/__tests__/optimize.test.js | 134 +++ packages/compiler/index.js | 8 + packages/compiler/src/compiler.rs | 15 + packages/compiler/src/config.rs | 5 + packages/compiler/src/lib.rs | 1 + packages/compiler/src/node_adapter.rs | 1 + packages/compiler/src/optimize/env.rs | 173 +++ packages/compiler/src/optimize/mod.rs | 1041 ++++++++++++++++++ packages/compiler/src/optimize/value.rs | 444 ++++++++ packages/compiler/src/shared/ast_builder.rs | 30 + packages/compiler/types.d.ts | 17 + 13 files changed, 1912 insertions(+) create mode 100644 .changeset/compiler-optimize-option.md create mode 100644 packages/compiler/__tests__/optimize.test.js create mode 100644 packages/compiler/src/optimize/env.rs create mode 100644 packages/compiler/src/optimize/mod.rs create mode 100644 packages/compiler/src/optimize/value.rs diff --git a/.changeset/compiler-optimize-option.md b/.changeset/compiler-optimize-option.md new file mode 100644 index 000000000..80647beae --- /dev/null +++ b/.changeset/compiler-optimize-option.md @@ -0,0 +1,17 @@ +--- +"@solidjs/compiler": minor +--- + +Add an `optimize` option (default `false`) that constant-folds the program, removes the code a constant condition makes unreachable, and resolves Solid's control-flow components when their props decide the outcome. + +The pass runs before JSX is lowered, so a resolved element never reaches the generate: it pays for no component call, memo, or insert hole, and its markup joins the surrounding template. + +- `` becomes its children or its `fallback`. +- `` becomes its `fallback` for an empty array literal or a statically falsy list. +- `` becomes its `fallback` for a count of zero or less. +- `` drops statically false `` branches and collapses to a statically true one. +- `` with a static intrinsic tag name becomes that element. + +A built-in tag only folds when it resolves to Solid's own component: either nothing declares the name, or it is imported from `moduleName` or `solid-js`. The exported name decides the identity, so an alias folds as what it renamed. Elements with a spread attribute or function children are left alone, and ``, ``, ``, and `` never fold. + +Folding changes the rendered tree shape and therefore hydration ids, so a server build and its client build must pass the same value. diff --git a/packages/compiler/README.md b/packages/compiler/README.md index 6c1541481..92154bc50 100644 --- a/packages/compiler/README.md +++ b/packages/compiler/README.md @@ -127,11 +127,37 @@ Pass `sourceMap: true` to receive a JSON source map string in `result.map`. For - `validate` - `omitNestedClosingTags` - `omitLastClosingTag` +- `optimize` (default `false` — see [Optimize](#optimize)) - `builtIns` (default `["For", "Show", "Switch", "Match", "Loading", "Reveal", "Portal", "Repeat", "Dynamic", "Errored"]`) - `requireImportSource` - `serverComponents` - `renderers` +### Optimize + +`optimize: true` adds a constant-folding and dead-code-elimination pass that runs before JSX is lowered, so whatever it resolves never reaches the generate at all. + +It folds constant expressions, substitutes module-level `const` bindings the program declares exactly once and never writes to, and removes branches a constant condition makes unreachable (`if`/`else`, `while (false)`, and statements after a `return`, `throw`, `break`, or `continue`). + +It also resolves Solid's control-flow components when their props decide the outcome: + +- `` becomes its children or its `fallback`. +- `` becomes its `fallback` when `each` is an empty array literal or statically falsy. +- `` becomes its `fallback` when `count` is zero or less. +- `` drops every statically false ``, and collapses to a match that is statically true (or to its `fallback` when every match is false). +- `` with a static intrinsic tag name becomes that element, so it can be templated. + +A folded element pays for no component call, memo, or insert hole, and its markup joins the surrounding template. + +Four rules keep a fold from changing behavior: + +- A built-in tag folds only when it resolves to Solid's own component: either nothing declares the name (the compiler auto-imports it) or it is imported from `moduleName` or `"solid-js"`. The exported name decides the identity, so an alias folds as what it renamed and `` from `import { Show as Cond } from "solid-js"` folds as ``. A local `Show`, or one imported from another module, is a different component and is left alone. +- A control-flow element with a spread attribute never folds, since the spread can supply or override the prop the fold reads. +- Function children never fold, since the runtime decides from their arity whether to call them. +- ``, ``, ``, and `` never fold: each exists for a runtime condition no static analysis can decide. + +Folding changes the shape of the rendered tree, and with it hydration ids. Compile a server build and its client build with the same `optimize` value. + ### Server function directives (experimental) `transformDirectives(code, options)` is a second pass for `"use server"`. It accepts ordinary JavaScript/TypeScript, including JSX/TSX. For a `.tsrx` module, run `transform()` first, then pass its generated code to `transformDirectives()` with the same original `.tsrx` filename so function IDs use the manifest path. `transformDirectives()` does not parse raw TSRX syntax itself. diff --git a/packages/compiler/__tests__/optimize.test.js b/packages/compiler/__tests__/optimize.test.js new file mode 100644 index 000000000..16ab32f02 --- /dev/null +++ b/packages/compiler/__tests__/optimize.test.js @@ -0,0 +1,134 @@ +// The `optimize` option: constant folding, dead-code elimination, and static +// resolution of Solid's control-flow components. The Rust unit tests cover +// the folding rules in depth; these cover the option surface and the shape of +// the generated code a consumer sees. + +const { transform } = require("../index"); + +function compile(code, options = {}) { + return transform(code, { + filename: "optimize.jsx", + moduleName: "r-dom", + ...options + }).code; +} + +function optimized(code, options = {}) { + return compile(code, { ...options, optimize: true }); +} + +describe("optimize option", () => { + it("is off by default", () => { + const code = compile("const view =
;"); + expect(code).toContain("_$createComponent"); + expect(code).toContain("Show as _$Show"); + }); + + it("rejects a non-boolean value", () => { + expect(() => compile("const view =
;", { optimize: "yes" })).toThrow( + /`optimize` option must be boolean/ + ); + }); + + it("resolves like an if", () => { + const taken = optimized("const view =
on
;"); + expect(taken).toContain("_$template(`
on`)"); + expect(taken).not.toContain("_$createComponent"); + + const dropped = optimized("const view =
on
;"); + expect(dropped).toContain("const view = null"); + + const fallback = optimized( + "const view = off}>
;" + ); + expect(fallback).toContain("off"); + expect(fallback).not.toContain(" over an empty list", () => { + const empty = optimized( + "const view = none}>{i =>
  • };" + ); + expect(empty).toContain("none"); + expect(empty).not.toContain("_$createComponent"); + + const dynamic = optimized("const view = {i =>
  • };"); + expect(dynamic).toContain("_$createComponent"); + }); + + it("resolves , , and ", () => { + const repeat = optimized( + "const view = none}>{i =>
  • };" + ); + expect(repeat).toContain("none"); + + const branch = optimized( + "const view = }>;" + ); + expect(branch).toContain(";'); + expect(dynamic).toContain("_$template(`
    { + const code = optimized( + "const DEBUG = false;\nexport const view =
    panel
    ;" + ); + expect(code).not.toContain("panel"); + expect(code).not.toContain("_$createComponent"); + }); + + it("folds constant expressions and drops unreachable statements", () => { + const attributes = optimized('const view =
    ;'); + expect(attributes).toContain("id=ab"); + expect(attributes).toContain("tabindex=3"); + + const dead = optimized("function App() {\n if (false) missing();\n return
    ;\n}"); + expect(dead).not.toContain("missing"); + }); + + it("only folds a built-in tag that resolves to Solid's component", () => { + const auto = optimized("const view =
    ;"); + expect(auto).not.toContain("_$createComponent"); + + const fromSolid = optimized( + 'import { Show } from "solid-js";\nconst view =
    ;' + ); + expect(fromSolid).not.toContain("_$createComponent"); + + const fromModuleName = optimized( + 'import { Show } from "r-dom";\nconst view =
    ;' + ); + expect(fromModuleName).not.toContain("_$createComponent"); + + const aliased = optimized( + 'import { Show as Cond } from "solid-js";\nconst view =
    ;' + ); + expect(aliased).not.toContain("_$createComponent"); + + const foreign = optimized( + 'import { Show } from "./my-show";\nconst view =
    ;' + ); + expect(foreign).toContain("_$createComponent"); + + const aliasedForeign = optimized( + 'import { Show as Cond } from "./my-show";\nconst view =
    ;' + ); + expect(aliasedForeign).toContain("_$createComponent"); + + const local = optimized( + "function App() {\n const Show = props => props.children;\n return
    ;\n}" + ); + expect(local).toContain("_$createComponent"); + }); + + it("folds the same way in SSR so hydration ids stay aligned", () => { + const source = "const view =
    ;"; + const ssr = optimized(source, { generate: "ssr", hydratable: true }); + expect(ssr).not.toContain(", pub renderers: Vec, } @@ -128,6 +133,7 @@ impl Default for CompileOptions { validate: true, omit_nested_closing_tags: false, omit_last_closing_tag: true, + optimize: false, built_ins: default_built_ins(), renderers: Vec::new(), } @@ -248,6 +254,15 @@ fn compile_inner(source: &str, options: &CompileOptions) -> Result { let mut transform = AstDomTransform::new( diff --git a/packages/compiler/src/config.rs b/packages/compiler/src/config.rs index b9c7d7151..8b7c8d425 100644 --- a/packages/compiler/src/config.rs +++ b/packages/compiler/src/config.rs @@ -50,6 +50,11 @@ pub struct TransformOptions { pub validate: Option, pub omit_nested_closing_tags: Option, pub omit_last_closing_tag: Option, + /// Constant-fold the program, drop the code that folding proves + /// unreachable, and resolve control-flow components whose props are + /// statically decidable (``, ``). + /// Default `false`. Server and client builds must use the same value. + pub optimize: Option, /// Babel's `serverComponents`: SSR-only. `ref`/`on*` positions on /// intrinsic elements compile to a guarded `_$ssrClaim` hole (the /// `_bnd` behavior-claim marker) instead of dropping. diff --git a/packages/compiler/src/lib.rs b/packages/compiler/src/lib.rs index cb47d8479..4857d80f4 100644 --- a/packages/compiler/src/lib.rs +++ b/packages/compiler/src/lib.rs @@ -25,6 +25,7 @@ mod error; mod lazy; #[cfg(feature = "node")] mod node_adapter; +mod optimize; #[cfg(feature = "node")] mod refresh; mod shared; diff --git a/packages/compiler/src/node_adapter.rs b/packages/compiler/src/node_adapter.rs index 22d79fe7f..75a0a8c42 100644 --- a/packages/compiler/src/node_adapter.rs +++ b/packages/compiler/src/node_adapter.rs @@ -261,6 +261,7 @@ fn core_options(options: TransformOptions) -> Result { validate: options.validate.unwrap_or(true), omit_nested_closing_tags: options.omit_nested_closing_tags.unwrap_or(false), omit_last_closing_tag: options.omit_last_closing_tag.unwrap_or(true), + optimize: options.optimize.unwrap_or(false), built_ins: options.built_ins.unwrap_or_else(default_built_ins), renderers: options .renderers diff --git a/packages/compiler/src/optimize/env.rs b/packages/compiler/src/optimize/env.rs new file mode 100644 index 000000000..b48c739f3 --- /dev/null +++ b/packages/compiler/src/optimize/env.rs @@ -0,0 +1,173 @@ +//! Program-wide facts the `optimize` pass needs before it may rewrite +//! anything: which names carry a known constant value, and which names +//! actually resolve to Solid's own control-flow components. +//! +//! Both answers rest on the same observation, which removes the need for a +//! scope model: a name the whole program declares exactly once cannot be +//! shadowed by anything but that one declaration, so every reference to it +//! resolves there. A name declared twice, or written to anywhere, is simply +//! not eligible. + +use std::collections::{HashMap, HashSet}; + +use oxc_ast::ast::{ + Declaration, ImportDeclarationSpecifier, ModuleExportName, Program, Statement, + VariableDeclarationKind, +}; +use oxc_ast_visit::{Visit, walk}; + +use super::value::{Const, ConstantEnv, evaluate}; + +/// The globals worth folding, admitted only when the program never declares +/// or writes the name itself. +const FOLDABLE_GLOBALS: [&str; 3] = ["undefined", "NaN", "Infinity"]; + +/// A named import binding at the top level of the module. +pub(crate) struct ImportBinding { + /// The name as exported by the source module, which is what identifies + /// the component (`import { Show as Cond }` imports `Show`). + pub(crate) imported: String, + pub(crate) source: String, +} + +pub(crate) struct ProgramFacts { + pub(crate) constants: ConstantEnv, + /// How many times each name is declared anywhere in the program. + declared: HashMap, + /// Top-level value imports, keyed by their local name. + imports: HashMap, +} + +impl ProgramFacts { + /// The exported name `local` was imported under, when it comes from one + /// of `sources`. The exported name is the component's identity, so an + /// alias resolves to what it renamed: `import { Show as Cond }` answers + /// `Show` for `Cond`. + /// + /// A name the program declares more than once could resolve to either + /// declaration, so it does not qualify. + pub(crate) fn solid_import(&self, local: &str, sources: &[&str]) -> Option<&str> { + if self.declared.get(local) != Some(&1) { + return None; + } + let import = self.imports.get(local)?; + sources + .iter() + .any(|source| *source == import.source) + .then_some(import.imported.as_str()) + } +} + +pub(crate) fn collect_facts(program: &Program<'_>) -> ProgramFacts { + let mut scan = Scan::default(); + scan.visit_program(program); + + let mut constants = ConstantEnv::new(); + for name in FOLDABLE_GLOBALS { + if !scan.declared.contains_key(name) && !scan.reassigned.contains(name) { + constants.insert( + name.to_string(), + match name { + "NaN" => Const::Number(f64::NAN), + "Infinity" => Const::Number(f64::INFINITY), + _ => Const::Undefined, + }, + ); + } + } + + let mut imports = HashMap::new(); + // Module-level `const`s execute top to bottom, so evaluating each + // initializer against the environment built so far lets a later constant + // be defined in terms of an earlier one. + for statement in &program.body { + if let Statement::ImportDeclaration(import) = statement { + if !import.import_kind.is_value() { + continue; + } + for specifier in import.specifiers.iter().flatten() { + let ImportDeclarationSpecifier::ImportSpecifier(specifier) = specifier else { + continue; + }; + if !specifier.import_kind.is_value() { + continue; + } + let imported = match &specifier.imported { + ModuleExportName::IdentifierName(name) => name.name.to_string(), + ModuleExportName::IdentifierReference(name) => name.name.to_string(), + ModuleExportName::StringLiteral(name) => name.value.to_string(), + }; + imports.insert( + specifier.local.name.to_string(), + ImportBinding { + imported, + source: import.source.value.to_string(), + }, + ); + } + continue; + } + let declaration = match statement { + Statement::VariableDeclaration(declaration) => &**declaration, + Statement::ExportDeclaration(export) => match &export.declaration { + Declaration::VariableDeclaration(declaration) => &**declaration, + _ => continue, + }, + _ => continue, + }; + if declaration.kind != VariableDeclarationKind::Const { + continue; + } + for declarator in &declaration.declarations { + let Some(name) = declarator.id.get_binding_identifier() else { + continue; + }; + let name = name.name.as_str(); + if scan.declared.get(name) != Some(&1) || scan.reassigned.contains(name) { + continue; + } + let Some(init) = &declarator.init else { + continue; + }; + if let Some(value) = evaluate(init, &constants) { + constants.insert(name.to_string(), value); + } + } + } + + ProgramFacts { + constants, + declared: scan.declared, + imports, + } +} + +/// Counts every binding of every name in the program and records every name +/// written to, at any depth. +#[derive(Default)] +struct Scan { + declared: HashMap, + reassigned: HashSet, +} + +impl<'a> Visit<'a> for Scan { + fn visit_binding_identifier(&mut self, it: &oxc_ast::ast::BindingIdentifier<'a>) { + *self.declared.entry(it.name.to_string()).or_default() += 1; + } + + fn visit_assignment_target(&mut self, it: &oxc_ast::ast::AssignmentTarget<'a>) { + if let oxc_ast::ast::AssignmentTarget::AssignmentTargetIdentifier(identifier) = it { + self.reassigned.insert(identifier.name.to_string()); + } + walk::walk_assignment_target(self, it); + } + + fn visit_update_expression(&mut self, it: &oxc_ast::ast::UpdateExpression<'a>) { + if let oxc_ast::ast::SimpleAssignmentTarget::AssignmentTargetIdentifier(identifier) = + &it.argument + { + self.reassigned.insert(identifier.name.to_string()); + } + walk::walk_update_expression(self, it); + } +} diff --git a/packages/compiler/src/optimize/mod.rs b/packages/compiler/src/optimize/mod.rs new file mode 100644 index 000000000..840bea9fb --- /dev/null +++ b/packages/compiler/src/optimize/mod.rs @@ -0,0 +1,1041 @@ +//! The `optimize` pass: constant folding, dead-code elimination, and static +//! resolution of Solid's control-flow components. +//! +//! It runs on the parsed program *before* any generate lowers JSX, so +//! whatever it resolves statically never reaches the DOM/SSR/universal +//! transforms at all: a `` whose condition is a compile-time constant +//! becomes its branch inline, and the branch is then templated like any other +//! markup instead of paying for a component call, a memo, and an insert hole. +//! +//! # What folds +//! +//! - Constant expressions: literals, template literals, and the unary, +//! binary, logical, and conditional operators over them. +//! - Module-level `const` bindings that the whole program declares exactly +//! once and never writes to (see [`env`]), so `const DEBUG = false` folds +//! at every use site. +//! - Statements a constant condition makes unreachable: `if`/`else` branches, +//! `while (false)` loops, and anything after a `return`, `throw`, `break`, +//! or `continue`. +//! - Solid's control-flow components, in [`fold_flow_element`]: +//! ``, ``, ``, ``/``, +//! and `` with a static intrinsic tag name. +//! +//! # What deliberately does not fold +//! +//! ``, ``, ``, and `` each exist for a +//! runtime condition (a mount target, a pending read, a thrown error, a +//! reveal order) that no static analysis can decide, so they have no +//! compile-time form. A control-flow component with a spread attribute is +//! left alone as well, since the spread can supply or override the very prop +//! the fold reads. Function children (`{v => …}`) also +//! stop a fold: the runtime decides whether to call them from their arity. +//! +//! # Consistency requirement +//! +//! Folding changes the shape of the rendered tree, and therefore hydration +//! ids. A server build and its client build must be compiled with the same +//! `optimize` setting. + +mod env; +mod value; + +use oxc_allocator::{Allocator, TakeIn}; +use oxc_ast::ast::{ + Expression, JSXAttribute, JSXAttributeItem, JSXAttributeName, JSXAttributeValue, JSXChild, + JSXElement, JSXElementName, JSXExpression, JSXFragment, ObjectProperty, Program, Statement, +}; +use oxc_ast_visit::{Visit, VisitMut, walk, walk_mut}; +use oxc_span::{GetSpan, Span}; + +use crate::shared::ast_builder::AstBuilder; +use crate::shared::bindings::BindingTable; +use crate::shared::classify::jsx_text_is_filtered; +use crate::shared::utils::decode_html_entities; +use env::ProgramFacts; +use value::{Const, array_literal_len, evaluate, truthiness}; + +/// Folding one node can expose the next one up (a `` that resolves to +/// an empty ``, say). The traversal is post-order, so a single pass +/// already handles nesting; the extra rounds only exist for the rarer case +/// where a parent's fold enables a sibling's, and they stop as soon as a pass +/// changes nothing. +const MAX_PASSES: usize = 3; + +/// The control-flow components this pass knows how to resolve statically. +/// `Match` is deliberately absent: it only has meaning inside a ``, +/// which folds it as part of folding itself. +const FOLDABLE_FLOW: [&str; 5] = ["Show", "For", "Repeat", "Switch", "Dynamic"]; + +/// Every built-in this pass recognizes by name, including the ones it only +/// folds as part of another (`Match`). +const KNOWN_FLOW: [&str; 6] = ["Show", "For", "Repeat", "Switch", "Match", "Dynamic"]; + +/// Solid's own runtime always re-exports the control-flow components, so an +/// explicit `import { Show } from "solid-js"` names the same component the +/// compiler would auto-import from `moduleName`. +const SOLID_MODULE_NAME: &str = "solid-js"; + +pub(crate) fn optimize_program<'a>( + allocator: &'a Allocator, + program: &mut Program<'a>, + built_ins: &[String], + module_name: &str, +) { + let mut bindings = BindingTable::default(); + bindings.scan_builtin_shadowing(program, built_ins); + let mut optimizer = Optimizer { + allocator, + ast: AstBuilder::new(allocator), + built_ins, + flow_sources: [module_name, SOLID_MODULE_NAME], + bindings, + facts: env::collect_facts(program), + changed: false, + }; + for _ in 0..MAX_PASSES { + optimizer.changed = false; + optimizer.visit_program(program); + if !optimizer.changed { + break; + } + } +} + +/// What a resolved control-flow element renders in its own place. +enum Fold<'a> { + /// The element's (or a winning branch's) JSX children, spliced in. + Children(oxc_allocator::Vec<'a, JSXChild<'a>>), + /// A single expression, such as a `fallback` prop's value. + Expression(Expression<'a>), + /// Nothing at all. + Empty, +} + +struct Optimizer<'a, 'o> { + allocator: &'a Allocator, + ast: AstBuilder<'a>, + built_ins: &'o [String], + /// Module specifiers an explicit built-in import may come from. + flow_sources: [&'o str; 2], + bindings: BindingTable, + facts: ProgramFacts, + changed: bool, +} + +impl<'a> VisitMut<'a> for Optimizer<'a, '_> { + fn visit_expression(&mut self, it: &mut Expression<'a>) { + walk_mut::walk_expression(self, it); + self.fold_expression(it); + } + + fn visit_jsx_element(&mut self, it: &mut JSXElement<'a>) { + walk_mut::walk_jsx_element(self, it); + self.fold_children(&mut it.children); + } + + fn visit_jsx_fragment(&mut self, it: &mut JSXFragment<'a>) { + walk_mut::walk_jsx_fragment(self, it); + self.fold_children(&mut it.children); + } + + fn visit_object_property(&mut self, it: &mut ObjectProperty<'a>) { + walk_mut::walk_object_property(self, it); + // `{ enabled }` cannot stay shorthand once its value folded to a + // literal; the printer would emit the key alone and lose the value. + if it.shorthand && !matches!(it.value, Expression::Identifier(_)) { + it.shorthand = false; + } + } + + fn visit_statements(&mut self, it: &mut oxc_allocator::Vec<'a, Statement<'a>>) { + walk_mut::walk_statements(self, it); + self.eliminate_dead_statements(it); + } +} + +// --------------------------------------------------------------------------- +// Expression folding +// --------------------------------------------------------------------------- + +impl<'a> Optimizer<'a, '_> { + fn fold_expression(&mut self, expression: &mut Expression<'a>) { + if let Expression::JSXElement(element) = expression + && let Some(fold) = self.fold_flow_element(element) + { + let span = element.span; + *expression = self.fold_into_expression(span, fold); + self.changed = true; + return; + } + + // Short-circuiting operators drop the unevaluated side outright: it + // never runs, so any effects in it are not observable. + match expression { + Expression::LogicalExpression(logical) => { + let Some(left) = truthiness(&logical.left, &self.facts.constants) else { + return; + }; + let takes_left = match logical.operator { + oxc_syntax::operator::LogicalOperator::And => !left, + oxc_syntax::operator::LogicalOperator::Or => left, + // `??` turns on nullishness, not truthiness, so it only + // folds against a known constant. + oxc_syntax::operator::LogicalOperator::Coalesce => { + match evaluate(&logical.left, &self.facts.constants) { + Some(value) => !matches!(value, Const::Null | Const::Undefined), + None => return, + } + } + }; + let kept = if takes_left { + logical.left.take_in(&self.allocator) + } else { + logical.right.take_in(&self.allocator) + }; + *expression = kept; + self.changed = true; + } + Expression::ConditionalExpression(conditional) => { + let Some(test) = truthiness(&conditional.test, &self.facts.constants) else { + return; + }; + let kept = if test { + conditional.consequent.take_in(&self.allocator) + } else { + conditional.alternate.take_in(&self.allocator) + }; + *expression = kept; + self.changed = true; + } + _ => { + if let Some(folded) = self.constant_expression(expression) { + *expression = folded; + self.changed = true; + } + } + } + } + + /// The literal spelling of a constant expression, or `None` when the + /// expression is not constant or is already at its shortest form. + fn constant_expression(&self, expression: &Expression<'a>) -> Option> { + if matches!( + expression, + Expression::NullLiteral(_) + | Expression::BooleanLiteral(_) + | Expression::NumericLiteral(_) + | Expression::StringLiteral(_) + ) { + return None; + } + let span = expression.span(); + match evaluate(expression, &self.facts.constants)? { + Const::Null => Some(self.ast.expression_null_literal(span)), + Const::Bool(value) => Some(self.ast.expression_boolean_literal(span, value)), + Const::Number(value) => Some(self.ast.expression_numeric_literal( + span, + value, + None, + oxc_syntax::number::NumberBase::Decimal, + )), + Const::String(value) => Some(self.ast.expression_string_literal( + span, + self.ast.str(&value), + None, + )), + // `undefined` has no literal spelling; leaving the expression as + // authored also keeps this pass from rewriting `void 0` forever. + Const::Undefined => None, + } + } +} + +// --------------------------------------------------------------------------- +// Control-flow components +// --------------------------------------------------------------------------- + +impl<'a> Optimizer<'a, '_> { + /// Replaces every statically resolvable control-flow child in `children` + /// with what it renders. + fn fold_children(&mut self, children: &mut oxc_allocator::Vec<'a, JSXChild<'a>>) { + let foldable = children.iter().any( + |child| matches!(child, JSXChild::Element(element) if self.flow_tag(element).is_some()), + ); + if !foldable { + return; + } + let taken = std::mem::replace(children, self.ast.vec()); + let mut folded = self.ast.vec_with_capacity(taken.len()); + for child in taken { + match child { + JSXChild::Element(mut element) => { + if let Some(fold) = self.fold_flow_element(&mut element) { + self.changed = true; + self.push_fold(&mut folded, element.span, fold); + } else { + folded.push(JSXChild::Element(element)); + } + } + other => folded.push(other), + } + } + *children = folded; + } + + fn push_fold( + &self, + out: &mut oxc_allocator::Vec<'a, JSXChild<'a>>, + span: Span, + fold: Fold<'a>, + ) { + match fold { + Fold::Children(children) => out.extend(children), + Fold::Expression(expression) => { + out.push(self.ast.jsx_child_expression(span, expression)) + } + Fold::Empty => {} + } + } + + fn fold_into_expression(&self, span: Span, fold: Fold<'a>) -> Expression<'a> { + match fold { + Fold::Expression(expression) => expression, + Fold::Empty => self.ast.expression_null_literal(span), + Fold::Children(mut children) => { + let significant = children + .iter() + .filter(|child| !filtered_text(child)) + .count(); + if significant == 0 { + return self.ast.expression_null_literal(span); + } + if significant == 1 + && let Some(position) = children.iter().position(|child| !filtered_text(child)) + && matches!( + children[position], + JSXChild::Element(_) | JSXChild::Fragment(_) + ) + { + return match children[position].take_in(&self.allocator) { + JSXChild::Element(element) => Expression::JSXElement(element), + JSXChild::Fragment(fragment) => Expression::JSXFragment(fragment), + // Unreachable given the match above. + _ => self.ast.expression_null_literal(span), + }; + } + self.ast.expression_jsx_fragment(span, children) + } + } + } + + /// The control-flow component `element` is, if it is one that this pass + /// can resolve and the tag is not shadowed by a local binding. + fn flow_tag(&self, element: &JSXElement<'a>) -> Option<&'static str> { + let name = self.built_in_tag(element)?; + FOLDABLE_FLOW.into_iter().find(|flow| *flow == name) + } + + /// Resolves a tag to the Solid built-in it actually refers to. + /// + /// Being in the configured `builtIns` list only makes a name a + /// candidate; a fold rewrites the component's semantics, so the tag has + /// to resolve to Solid's own component before one is safe: + /// + /// - a top-level value import from a Solid module decides the identity + /// outright, so `` from `import { Show as Cond }` is `Show`; + /// - with no such import, the name folds only when no binding is in + /// scope, which means the compiler auto-imports the built-in; + /// - anything else (a local `Show`, an import from an unrelated module) + /// is a different component and is left alone. + fn built_in_tag(&self, element: &JSXElement<'a>) -> Option<&'static str> { + let (name, span) = match &element.opening_element.name { + JSXElementName::IdentifierReference(identifier) => { + (identifier.name.as_str(), identifier.span) + } + JSXElementName::Identifier(identifier) => (identifier.name.as_str(), identifier.span), + _ => return None, + }; + if let Some(imported) = self.facts.solid_import(name, &self.flow_sources) { + return self.known_flow(imported); + } + if self.bindings.is_builtin_shadowed(span) { + return None; + } + self.known_flow(name) + } + + /// The built-in named `name`, when the configuration still treats that + /// name as one. An empty `builtIns` is an explicit opt-out, and it turns + /// the fold off as well as the auto-import. + fn known_flow(&self, name: &str) -> Option<&'static str> { + if !self.built_ins.iter().any(|built_in| built_in == name) { + return None; + } + // Return a `'static` spelling so callers can compare tags cheaply. + KNOWN_FLOW.into_iter().find(|known| *known == name) + } + + fn fold_flow_element(&mut self, element: &mut JSXElement<'a>) -> Option> { + let tag = self.flow_tag(element)?; + // A spread can supply or override the prop the fold reads, so no + // control-flow element with one is resolvable. + if has_spread_attribute(element) { + return None; + } + match tag { + "Show" => self.fold_show(element), + "For" => self.fold_for(element), + "Repeat" => self.fold_repeat(element), + "Switch" => self.fold_switch(element), + "Dynamic" => self.fold_dynamic(element), + _ => None, + } + } + + fn fold_show(&mut self, element: &mut JSXElement<'a>) -> Option> { + let when = self.attribute_truthiness(attribute(element, "when")?)?; + if !when { + return Some(self.take_fallback(element)); + } + if has_callback_child(&element.children) { + return None; + } + Some(Fold::Children(std::mem::replace( + &mut element.children, + self.ast.vec(), + ))) + } + + fn fold_for(&mut self, element: &mut JSXElement<'a>) -> Option> { + let each = attribute(element, "each")?; + let JSXAttributeValue::ExpressionContainer(container) = each.value.as_ref()? else { + return None; + }; + let expression = container.expression.as_expression()?; + // `each={[]}` renders the fallback, and so does any statically falsy + // `each` — `mapArray` treats `null`/`undefined`/`false` as an empty + // list, which is what the prop's own type allows. + let empty = array_literal_len(expression) == Some(0) + || truthiness(expression, &self.facts.constants) == Some(false); + if !empty { + return None; + } + Some(self.take_fallback(element)) + } + + fn fold_repeat(&mut self, element: &mut JSXElement<'a>) -> Option> { + let count = attribute(element, "count")?; + let JSXAttributeValue::ExpressionContainer(container) = count.value.as_ref()? else { + return None; + }; + let Const::Number(count) = + evaluate(container.expression.as_expression()?, &self.facts.constants)? + else { + return None; + }; + // Written this way so `NaN` counts as empty, like `repeat` does. + if count >= 1.0 { + return None; + } + Some(self.take_fallback(element)) + } + + /// Resolves a `` as far as its `` conditions allow: + /// statically false matches are dropped, a statically true match with no + /// undecided match before it wins outright, and a switch whose every + /// match is false renders its fallback. + fn fold_switch(&mut self, element: &mut JSXElement<'a>) -> Option> { + let mut matches = std::vec::Vec::new(); + for (index, child) in element.children.iter().enumerate() { + match child { + child if filtered_text(child) => {} + JSXChild::Element(candidate) + if self.built_in_tag(candidate) == Some("Match") + && !has_spread_attribute(candidate) => + { + let when = attribute(candidate, "when")?; + matches.push((index, self.attribute_truthiness(when))); + } + // Anything else in a `` is outside what this pass + // models, so the whole element is left alone. + _ => return None, + } + } + if matches.is_empty() { + return None; + } + + let winner = matches + .iter() + .position(|(_, when)| *when == Some(true)) + .filter(|position| { + matches[..*position] + .iter() + .all(|(_, when)| *when == Some(false)) + }); + if let Some(winner) = winner { + let index = matches[winner].0; + let JSXChild::Element(winning_match) = &mut element.children[index] else { + return None; + }; + if has_callback_child(&winning_match.children) { + return None; + } + return Some(Fold::Children(std::mem::replace( + &mut winning_match.children, + self.ast.vec(), + ))); + } + + if matches.iter().all(|(_, when)| *when == Some(false)) { + return Some(self.take_fallback(element)); + } + + // No outcome yet, but every statically false match is dead weight and + // the runtime would evaluate it on each pass. + let dead: std::collections::HashSet = matches + .iter() + .filter(|(_, when)| *when == Some(false)) + .map(|(index, _)| *index) + .collect(); + if !dead.is_empty() { + let taken = std::mem::replace(&mut element.children, self.ast.vec()); + let kept = self.ast.vec_from_iter( + taken + .into_iter() + .enumerate() + .filter_map(|(index, child)| (!dead.contains(&index)).then_some(child)), + ); + element.children = kept; + self.changed = true; + } + None + } + + /// `` is just `
    `, which the generates + /// can put in a template instead of creating an element at runtime. + /// Only intrinsic tag names fold: a capitalized string is not a component + /// reference, and a namespaced one is not something the transforms build + /// dynamically. + fn fold_dynamic(&mut self, element: &mut JSXElement<'a>) -> Option> { + let component = attribute(element, "component")?; + let tag = match component.value.as_ref()? { + JSXAttributeValue::StringLiteral(literal) => decode_html_entities(&literal.value), + JSXAttributeValue::ExpressionContainer(container) => { + match evaluate(container.expression.as_expression()?, &self.facts.constants)? { + Const::String(value) => value, + _ => return None, + } + } + _ => return None, + }; + if !is_intrinsic_tag(&tag) { + return None; + } + + let span = element.span; + let self_closing = element.closing_element.is_none(); + let mut attributes = self + .ast + .vec_with_capacity(element.opening_element.attributes.len()); + for item in element.opening_element.attributes.iter_mut() { + if attribute_named(item, "component") { + continue; + } + attributes.push(item.take_in(&self.allocator)); + } + let children = std::mem::replace(&mut element.children, self.ast.vec()); + Some(Fold::Expression(self.ast.expression_jsx_intrinsic_element( + span, + &tag, + attributes, + children, + self_closing, + ))) + } + + /// Whether an attribute's value is statically truthy or falsy. A bare + /// attribute (``) is `true`, matching JSX. + fn attribute_truthiness(&self, attribute: &JSXAttribute<'a>) -> Option { + match &attribute.value { + None => Some(true), + Some(JSXAttributeValue::StringLiteral(literal)) => Some(!literal.value.is_empty()), + Some(JSXAttributeValue::Element(_) | JSXAttributeValue::Fragment(_)) => Some(true), + Some(JSXAttributeValue::ExpressionContainer(container)) => container + .expression + .as_expression() + .and_then(|expression| truthiness(expression, &self.facts.constants)), + } + } + + /// What the element renders when its condition fails: its `fallback` + /// prop, or nothing. + fn take_fallback(&mut self, element: &mut JSXElement<'a>) -> Fold<'a> { + let Some(fallback) = element + .opening_element + .attributes + .iter_mut() + .find_map(|item| match item { + JSXAttributeItem::Attribute(attribute) + if attribute_name(&attribute.name) == "fallback" => + { + Some(&mut **attribute) + } + _ => None, + }) + else { + return Fold::Empty; + }; + let span = fallback.span; + match &mut fallback.value { + Some(JSXAttributeValue::ExpressionContainer(container)) => { + if container.expression.as_expression().is_none() { + return Fold::Empty; + } + match container.expression.take_in(&self.allocator) { + JSXExpression::EmptyExpression(_) => Fold::Empty, + expression => Fold::Expression(expression.into_expression()), + } + } + Some(JSXAttributeValue::StringLiteral(literal)) => { + let value = decode_html_entities(&literal.value); + Fold::Expression(self.ast.expression_string_literal( + span, + self.ast.str(&value), + None, + )) + } + Some(JSXAttributeValue::Element(element)) => { + Fold::Expression(Expression::JSXElement(element.take_in_box(&self.allocator))) + } + Some(JSXAttributeValue::Fragment(fragment)) => Fold::Expression( + Expression::JSXFragment(fragment.take_in_box(&self.allocator)), + ), + // A bare `fallback` is `fallback={true}`, which renders nothing. + None => Fold::Empty, + } + } +} + +// --------------------------------------------------------------------------- +// Dead statements +// --------------------------------------------------------------------------- + +impl<'a> Optimizer<'a, '_> { + fn eliminate_dead_statements( + &mut self, + statements: &mut oxc_allocator::Vec<'a, Statement<'a>>, + ) { + for statement in statements.iter_mut() { + if let Some(replacement) = self.resolve_statement(statement) { + *statement = replacement; + self.changed = true; + } + } + + // Everything after the first `return`/`throw`/`break`/`continue` is + // unreachable, but a `var` or function declaration in it is still + // hoisted, so a list carrying one is left intact. + let unreachable_from = statements + .iter() + .position(is_terminator) + .map(|position| position + 1) + .filter(|start| *start < statements.len()) + .filter(|start| { + !statements[*start..] + .iter() + .any(contains_hoisted_declaration) + }); + if let Some(start) = unreachable_from { + statements.truncate(start); + self.changed = true; + } + + if statements + .iter() + .any(|statement| matches!(statement, Statement::EmptyStatement(_))) + { + let taken = std::mem::replace(statements, self.ast.vec()); + *statements = self.ast.vec_from_iter( + taken + .into_iter() + .filter(|statement| !matches!(statement, Statement::EmptyStatement(_))), + ); + } + } + + /// The statement a constant condition leaves behind, if any. + fn resolve_statement(&mut self, statement: &mut Statement<'a>) -> Option> { + match statement { + Statement::IfStatement(branch) => { + let test = truthiness(&branch.test, &self.facts.constants)?; + let dropped_hoists = if test { + branch + .alternate + .as_ref() + .is_some_and(contains_hoisted_declaration) + } else { + contains_hoisted_declaration(&branch.consequent) + }; + if dropped_hoists { + return None; + } + let kept = if test { + Some(&mut branch.consequent) + } else { + branch.alternate.as_mut() + }; + let Some(kept) = kept else { + return Some(self.ast.statement_empty(branch.span)); + }; + // A bare function declaration as a branch body is + // Annex B web-compatibility semantics, not something to + // relocate into the enclosing list. + if matches!(kept, Statement::FunctionDeclaration(_)) { + return None; + } + Some(kept.take_in(&self.allocator)) + } + Statement::WhileStatement(loop_statement) => { + if truthiness(&loop_statement.test, &self.facts.constants)? { + return None; + } + if contains_hoisted_declaration(&loop_statement.body) { + return None; + } + Some(self.ast.statement_empty(loop_statement.span)) + } + _ => None, + } + } +} + +// --------------------------------------------------------------------------- +// Small AST predicates +// --------------------------------------------------------------------------- + +fn attribute_name(name: &JSXAttributeName<'_>) -> String { + match name { + JSXAttributeName::Identifier(identifier) => identifier.name.to_string(), + JSXAttributeName::NamespacedName(namespaced) => { + format!("{}:{}", namespaced.namespace.name, namespaced.name.name) + } + } +} + +fn attribute_named(item: &JSXAttributeItem<'_>, name: &str) -> bool { + matches!(item, JSXAttributeItem::Attribute(attribute) if attribute_name(&attribute.name) == name) +} + +fn attribute<'e, 'a>(element: &'e JSXElement<'a>, name: &str) -> Option<&'e JSXAttribute<'a>> { + element + .opening_element + .attributes + .iter() + .find_map(|item| match item { + JSXAttributeItem::Attribute(attribute) if attribute_name(&attribute.name) == name => { + Some(&**attribute) + } + _ => None, + }) +} + +fn has_spread_attribute(element: &JSXElement<'_>) -> bool { + element + .opening_element + .attributes + .iter() + .any(|item| matches!(item, JSXAttributeItem::SpreadAttribute(_))) +} + +/// Whether any child is a function, which the runtime may call with the +/// narrowed value. Folding one away would drop that call. +fn has_callback_child(children: &[JSXChild<'_>]) -> bool { + children.iter().any(|child| { + matches!( + child, + JSXChild::ExpressionContainer(container) + if matches!( + container.expression, + JSXExpression::ArrowFunctionExpression(_) + | JSXExpression::FunctionExpression(_) + ) + ) + }) +} + +/// JSX text that renders nothing, by the same rule the generates filter with. +fn filtered_text(child: &JSXChild<'_>) -> bool { + matches!(child, JSXChild::Text(text) if jsx_text_is_filtered(text.value.as_str())) +} + +/// An intrinsic element name: lowercase-initial and free of the `:` a +/// namespaced tag carries. +fn is_intrinsic_tag(tag: &str) -> bool { + tag.starts_with(|first: char| first.is_ascii_lowercase()) + && tag + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '-') +} + +fn is_terminator(statement: &Statement<'_>) -> bool { + matches!( + statement, + Statement::ReturnStatement(_) + | Statement::ThrowStatement(_) + | Statement::BreakStatement(_) + | Statement::ContinueStatement(_) + ) +} + +/// Whether removing `statement` would remove a `var` or function declaration +/// that is hoisted out of it. Nested functions are their own scope and do not +/// count. +fn contains_hoisted_declaration(statement: &Statement<'_>) -> bool { + struct Hoisted { + found: bool, + } + + impl<'a> Visit<'a> for Hoisted { + fn visit_variable_declaration(&mut self, it: &oxc_ast::ast::VariableDeclaration<'a>) { + if it.kind.is_var() { + self.found = true; + } + walk::walk_variable_declaration(self, it); + } + + fn visit_function( + &mut self, + it: &oxc_ast::ast::Function<'a>, + _flags: oxc_syntax::scope::ScopeFlags, + ) { + // A function *declaration* hoists its name; its body does not + // contribute anything to the enclosing scope. + if it.is_declaration() { + self.found = true; + } + } + + fn visit_arrow_function_expression( + &mut self, + _it: &oxc_ast::ast::ArrowFunctionExpression<'a>, + ) { + } + + fn visit_class(&mut self, _it: &oxc_ast::ast::Class<'a>) {} + } + + let mut hoisted = Hoisted { found: false }; + hoisted.visit_statement(statement); + hoisted.found +} + +#[cfg(test)] +mod tests { + use crate::{CompileOptions, Generate, compile}; + + fn compile_with(source: &str, optimize: bool) -> String { + compile( + source, + &CompileOptions { + filename: Some("optimize.jsx".into()), + module_name: "r-dom".into(), + generate: Generate::Dom, + optimize, + ..CompileOptions::default() + }, + ) + .expect("compile") + .code + } + + fn optimized(source: &str) -> String { + compile_with(source, true) + } + + #[test] + fn show_resolves_to_the_branch_a_constant_condition_selects() { + let taken = optimized("const view =
    on
    ;"); + assert!(taken.contains("
    on"), "{taken}"); + assert!(!taken.contains("createComponent"), "{taken}"); + assert!(!taken.contains("Show"), "{taken}"); + + let dropped = optimized("const view =
    on
    ;"); + assert!(dropped.contains("const view = null"), "{dropped}"); + + let fallback = + optimized("const view = off}>
    ;"); + assert!(fallback.contains("off"), "{fallback}"); + assert!(!fallback.contains("
    ;"; + let folded = optimized(source); + assert!(!folded.contains("
    ;", + ); + assert!(reassigned.contains("createComponent"), "{reassigned}"); + } + + #[test] + fn show_keeps_function_children_and_spreads() { + let callback = optimized("const view = {v =>
    {v()}
    }
    ;"); + assert!(callback.contains("createComponent"), "{callback}"); + + let spread = optimized("const view =
    ;"); + assert!(spread.contains("createComponent"), "{spread}"); + } + + #[test] + fn for_resolves_an_empty_list_to_its_fallback() { + let empty = optimized( + "const view = none}>{i =>
  • };", + ); + assert!(empty.contains("none"), "{empty}"); + assert!(!empty.contains("createComponent"), "{empty}"); + + let nullish = optimized("const view = {i =>
  • };"); + assert!(nullish.contains("const view = null"), "{nullish}"); + + let dynamic = optimized("const view = {i =>
  • };"); + assert!(dynamic.contains("createComponent"), "{dynamic}"); + } + + #[test] + fn repeat_resolves_a_zero_count_to_its_fallback() { + let empty = optimized( + "const view = none}>{i =>
  • };", + ); + assert!(empty.contains("none"), "{empty}"); + + let some = optimized("const view = {i =>
  • };"); + assert!(some.contains("createComponent"), "{some}"); + } + + #[test] + fn switch_picks_the_first_statically_true_match() { + let winner = optimized( + "const view = }>;", + ); + assert!(winner.contains("}>;", + ); + assert!(none.contains(";", + ); + assert!(pruned.contains("createComponent"), "{pruned}"); + assert!(!pruned.contains(";"); + assert!(folded.contains("_$template(`
    ;"); + assert!(component.contains("createComponent"), "{component}"); + } + + #[test] + fn constant_expressions_and_dead_statements_go() { + let attribute = optimized("const view =
    ;"); + assert!(attribute.contains("id=ab"), "{attribute}"); + assert!(attribute.contains("tabindex=3"), "{attribute}"); + + let branch = + optimized("function App() {\n if (false) { missing(); }\n return
    ;\n}"); + assert!(!branch.contains("missing"), "{branch}"); + + let unreachable = optimized("function App() {\n return
    ;\n unreachable();\n}"); + assert!(!unreachable.contains("unreachable"), "{unreachable}"); + + // A `var` in the dropped branch still hoists, so the branch stays. + let hoisted = + optimized("function App() {\n if (false) { var kept = 1; }\n return
    ;\n}"); + assert!(hoisted.contains("kept"), "{hoisted}"); + } + + #[test] + fn a_built_in_tag_only_folds_when_it_resolves_to_solids_component() { + let auto_imported = optimized("const view =
    ;"); + assert!( + !auto_imported.contains("createComponent"), + "{auto_imported}" + ); + + let from_solid = optimized( + "import { Show } from \"solid-js\";\nconst view =
    ;", + ); + assert!(!from_solid.contains("createComponent"), "{from_solid}"); + + let from_module_name = optimized( + "import { Show } from \"r-dom\";\nconst view =
    ;", + ); + assert!( + !from_module_name.contains("createComponent"), + "{from_module_name}" + ); + + let foreign = optimized( + "import { Show } from \"./my-show\";\nconst view =
    ;", + ); + assert!(foreign.contains("createComponent"), "{foreign}"); + + // The exported name decides the identity, so an alias folds as what + // it renamed — and a local `Show` bound to a different import does + // not become Solid's. + let aliased = optimized( + "import { Show as Cond } from \"solid-js\";\nconst view =
    ;", + ); + assert!(!aliased.contains("createComponent"), "{aliased}"); + + let aliased_list = optimized( + "import { For as Each } from \"solid-js\";\nconst view = }>{i =>
  • };", + ); + assert!(aliased_list.contains("
    ;", + ); + assert!( + aliased_foreign.contains("createComponent"), + "{aliased_foreign}" + ); + + let renamed = optimized( + "import { Reveal as Show } from \"solid-js\";\nconst view =
    ;", + ); + assert!(renamed.contains("createComponent"), "{renamed}"); + + let local = optimized( + "function App() {\n const Show = props => props.children;\n return
    ;\n}", + ); + assert!(local.contains("createComponent"), "{local}"); + } + + #[test] + fn the_pass_is_off_by_default() { + let source = "const view =
    ;"; + let untouched = compile_with(source, false); + assert!(untouched.contains("createComponent"), "{untouched}"); + assert!(untouched.contains("Show as _$Show"), "{untouched}"); + } +} diff --git a/packages/compiler/src/optimize/value.rs b/packages/compiler/src/optimize/value.rs new file mode 100644 index 000000000..ec444a96b --- /dev/null +++ b/packages/compiler/src/optimize/value.rs @@ -0,0 +1,444 @@ +//! The constant lattice the `optimize` pass folds against. +//! +//! Only values a JavaScript engine would produce with *no observable side +//! effect* live here: literals, and the operators over them whose ECMAScript +//! semantics this module reproduces exactly. Anything whose folded spelling +//! could differ from the engine's own (string/number coercions outside the +//! safe-integer range, non-ASCII relational comparison) deliberately returns +//! `None` and stays in the output untouched. A missed fold costs bytes; a +//! wrong fold costs correctness. + +use std::collections::HashMap; + +use oxc_ast::ast::Expression; +use oxc_syntax::operator::{BinaryOperator, LogicalOperator, UnaryOperator}; + +use crate::shared::utils::format_number; + +/// Program-wide constant bindings the pass may substitute (see +/// [`super::env::collect_facts`] for the safety rules that admit a name). +pub(crate) type ConstantEnv = HashMap; + +/// A JavaScript primitive known at compile time. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Const { + Undefined, + Null, + Bool(bool), + Number(f64), + String(String), +} + +impl Const { + /// ECMAScript `ToBoolean`. + pub(crate) fn truthy(&self) -> bool { + match self { + Const::Undefined | Const::Null => false, + Const::Bool(value) => *value, + Const::Number(value) => *value != 0.0 && !value.is_nan(), + Const::String(value) => !value.is_empty(), + } + } + + fn type_of(&self) -> &'static str { + match self { + Const::Undefined => "undefined", + // `typeof null` is the specified wart. + Const::Null => "object", + Const::Bool(_) => "boolean", + Const::Number(_) => "number", + Const::String(_) => "string", + } + } + + /// ECMAScript `ToString`, restricted to spellings this compiler can + /// reproduce byte-for-byte (see [`number_to_js_string`]). + fn to_js_string(&self) -> Option { + match self { + Const::Undefined => Some("undefined".to_string()), + Const::Null => Some("null".to_string()), + Const::Bool(value) => Some(if *value { "true" } else { "false" }.to_string()), + Const::Number(value) => number_to_js_string(*value), + Const::String(value) => Some(value.clone()), + } + } + + /// ECMAScript `ToNumber`. String coercion is not folded: the numeric + /// string grammar (leading/trailing whitespace, hex, `Infinity`, empty + /// string) is wide enough that a partial port would fold some inputs + /// wrongly. + fn to_number(&self) -> Option { + match self { + Const::Undefined => Some(f64::NAN), + Const::Null => Some(0.0), + Const::Bool(value) => Some(if *value { 1.0 } else { 0.0 }), + Const::Number(value) => Some(*value), + Const::String(_) => None, + } + } +} + +/// `String(number)` for the values whose JavaScript spelling this compiler +/// reproduces exactly: the non-finite names and integers up to +/// `Number.MAX_SAFE_INTEGER`. Fractional values and the exponent-notation +/// range are left unfolded rather than risk a Rust/JavaScript formatting +/// divergence. +fn number_to_js_string(value: f64) -> Option { + if value.is_nan() || value.is_infinite() { + return Some(format_number(value)); + } + if value.fract() == 0.0 && value.abs() <= 9_007_199_254_740_991.0 { + return Some(format_number(value)); + } + None +} + +/// ECMAScript `ToInt32`. +fn to_int32(value: f64) -> i32 { + if !value.is_finite() || value == 0.0 { + return 0; + } + let truncated = value.trunc(); + let wrapped = truncated.rem_euclid(4_294_967_296.0); + if wrapped >= 2_147_483_648.0 { + (wrapped - 4_294_967_296.0) as i32 + } else { + wrapped as i32 + } +} + +/// ECMAScript `ToUint32`. +fn to_uint32(value: f64) -> u32 { + to_int32(value) as u32 +} + +/// Evaluates `expression` to a primitive, or `None` when it is not a +/// side-effect-free constant this module can reproduce. +pub(crate) fn evaluate(expression: &Expression<'_>, env: &ConstantEnv) -> Option { + match expression { + Expression::NullLiteral(_) => Some(Const::Null), + Expression::BooleanLiteral(literal) => Some(Const::Bool(literal.value)), + Expression::NumericLiteral(literal) => Some(Const::Number(literal.value)), + Expression::StringLiteral(literal) => Some(Const::String(literal.value.to_string())), + Expression::Identifier(identifier) => env.get(identifier.name.as_str()).cloned(), + Expression::TemplateLiteral(template) => { + let mut out = String::new(); + for (index, quasi) in template.quasis.iter().enumerate() { + // A cooked value of `None` means an illegal escape sequence, + // which only a tagged template may observe. + out.push_str(quasi.value.cooked.as_ref()?.as_str()); + if let Some(expression) = template.expressions.get(index) { + out.push_str(&evaluate(expression, env)?.to_js_string()?); + } + } + Some(Const::String(out)) + } + Expression::UnaryExpression(unary) => { + if unary.operator == UnaryOperator::Void { + // `void` discards its operand's value but not its effects. + return evaluate(&unary.argument, env).map(|_| Const::Undefined); + } + let argument = evaluate(&unary.argument, env)?; + match unary.operator { + UnaryOperator::LogicalNot => Some(Const::Bool(!argument.truthy())), + UnaryOperator::Typeof => Some(Const::String(argument.type_of().to_string())), + UnaryOperator::UnaryPlus => Some(Const::Number(argument.to_number()?)), + UnaryOperator::UnaryNegation => Some(Const::Number(-argument.to_number()?)), + UnaryOperator::BitwiseNot => { + Some(Const::Number(f64::from(!to_int32(argument.to_number()?)))) + } + UnaryOperator::Void | UnaryOperator::Delete => None, + } + } + Expression::BinaryExpression(binary) => { + let left = evaluate(&binary.left, env)?; + let right = evaluate(&binary.right, env)?; + binary_value(&left, binary.operator, &right) + } + Expression::LogicalExpression(logical) => { + let left = evaluate(&logical.left, env)?; + let short_circuits = match logical.operator { + LogicalOperator::And => !left.truthy(), + LogicalOperator::Or => left.truthy(), + LogicalOperator::Coalesce => !matches!(left, Const::Null | Const::Undefined), + }; + if short_circuits { + Some(left) + } else { + evaluate(&logical.right, env) + } + } + Expression::ConditionalExpression(conditional) => { + let test = evaluate(&conditional.test, env)?; + if test.truthy() { + evaluate(&conditional.consequent, env) + } else { + evaluate(&conditional.alternate, env) + } + } + _ => None, + } +} + +fn binary_value(left: &Const, operator: BinaryOperator, right: &Const) -> Option { + match operator { + BinaryOperator::Addition => match (left, right) { + // String concatenation whenever either side is already a string; + // otherwise both sides coerce to numbers. + (Const::String(_), _) | (_, Const::String(_)) => Some(Const::String(format!( + "{}{}", + left.to_js_string()?, + right.to_js_string()? + ))), + _ => Some(Const::Number(left.to_number()? + right.to_number()?)), + }, + BinaryOperator::Subtraction => Some(Const::Number(left.to_number()? - right.to_number()?)), + BinaryOperator::Multiplication => { + Some(Const::Number(left.to_number()? * right.to_number()?)) + } + BinaryOperator::Division => Some(Const::Number(left.to_number()? / right.to_number()?)), + BinaryOperator::Remainder => Some(Const::Number(js_remainder( + left.to_number()?, + right.to_number()?, + ))), + BinaryOperator::Exponential => Some(Const::Number(js_exponent( + left.to_number()?, + right.to_number()?, + ))), + BinaryOperator::StrictEquality => Some(Const::Bool(strict_equals(left, right)?)), + BinaryOperator::StrictInequality => Some(Const::Bool(!strict_equals(left, right)?)), + BinaryOperator::Equality => Some(Const::Bool(loose_equals(left, right)?)), + BinaryOperator::Inequality => Some(Const::Bool(!loose_equals(left, right)?)), + BinaryOperator::LessThan + | BinaryOperator::LessEqualThan + | BinaryOperator::GreaterThan + | BinaryOperator::GreaterEqualThan => compare(left, operator, right), + BinaryOperator::BitwiseAnd => Some(Const::Number(f64::from( + to_int32(left.to_number()?) & to_int32(right.to_number()?), + ))), + BinaryOperator::BitwiseOR => Some(Const::Number(f64::from( + to_int32(left.to_number()?) | to_int32(right.to_number()?), + ))), + BinaryOperator::BitwiseXOR => Some(Const::Number(f64::from( + to_int32(left.to_number()?) ^ to_int32(right.to_number()?), + ))), + BinaryOperator::ShiftLeft => Some(Const::Number(f64::from( + to_int32(left.to_number()?) << (to_uint32(right.to_number()?) & 31), + ))), + BinaryOperator::ShiftRight => Some(Const::Number(f64::from( + to_int32(left.to_number()?) >> (to_uint32(right.to_number()?) & 31), + ))), + BinaryOperator::ShiftRightZeroFill => Some(Const::Number(f64::from( + to_uint32(left.to_number()?) >> (to_uint32(right.to_number()?) & 31), + ))), + BinaryOperator::In | BinaryOperator::Instanceof => None, + } +} + +/// `%` is a remainder that keeps the dividend's sign, unlike Rust's `%` on +/// floats only in the infinity cases, which are spelled out here. +fn js_remainder(left: f64, right: f64) -> f64 { + if left.is_nan() || right.is_nan() || left.is_infinite() || right == 0.0 { + return f64::NAN; + } + if right.is_infinite() { + return left; + } + left % right +} + +/// `**` differs from Rust's `powf` for a `NaN` exponent base case. +fn js_exponent(left: f64, right: f64) -> f64 { + if right.is_nan() { + return f64::NAN; + } + if right == 0.0 { + return 1.0; + } + // `(±1) ** ±Infinity` is NaN in JavaScript, but 1 in IEEE-754/Rust. + if right.is_infinite() && left.abs() == 1.0 { + return f64::NAN; + } + left.powf(right) +} + +fn strict_equals(left: &Const, right: &Const) -> Option { + Some(match (left, right) { + (Const::Undefined, Const::Undefined) | (Const::Null, Const::Null) => true, + (Const::Bool(left), Const::Bool(right)) => left == right, + // `NaN !== NaN` and `-0 === 0` both fall out of the float compare. + (Const::Number(left), Const::Number(right)) => left == right, + (Const::String(left), Const::String(right)) => left == right, + _ => false, + }) +} + +fn loose_equals(left: &Const, right: &Const) -> Option { + Some(match (left, right) { + (Const::Undefined | Const::Null, Const::Undefined | Const::Null) => true, + (Const::Undefined | Const::Null, _) | (_, Const::Undefined | Const::Null) => false, + (Const::String(left), Const::String(right)) => left == right, + // Every remaining pair coerces to numbers, and string coercion is + // outside what this module folds. + _ => left.to_number()? == right.to_number()?, + }) +} + +fn compare(left: &Const, operator: BinaryOperator, right: &Const) -> Option { + if let (Const::String(left), Const::String(right)) = (left, right) { + // JavaScript compares strings by UTF-16 code unit; Rust's `str` order + // is UTF-8 byte order. The two agree on ASCII, so only ASCII pairs + // fold. + if !left.is_ascii() || !right.is_ascii() { + return None; + } + return Some(Const::Bool(match operator { + BinaryOperator::LessThan => left < right, + BinaryOperator::LessEqualThan => left <= right, + BinaryOperator::GreaterThan => left > right, + _ => left >= right, + })); + } + let left = left.to_number()?; + let right = right.to_number()?; + if left.is_nan() || right.is_nan() { + // Every relational operator is false when either side is NaN. + return Some(Const::Bool(false)); + } + Some(Const::Bool(match operator { + BinaryOperator::LessThan => left < right, + BinaryOperator::LessEqualThan => left <= right, + BinaryOperator::GreaterThan => left > right, + _ => left >= right, + })) +} + +/// Whether `expression` is known truthy or falsy. Wider than [`evaluate`]: +/// an object, array, function, class, or JSX literal has no constant value +/// but is always truthy. +pub(crate) fn truthiness(expression: &Expression<'_>, env: &ConstantEnv) -> Option { + match expression { + Expression::ObjectExpression(_) + | Expression::ArrayExpression(_) + | Expression::ArrowFunctionExpression(_) + | Expression::FunctionExpression(_) + | Expression::ClassExpression(_) + | Expression::JSXElement(_) + | Expression::JSXFragment(_) => Some(true), + Expression::UnaryExpression(unary) if unary.operator == UnaryOperator::LogicalNot => { + truthiness(&unary.argument, env).map(|value| !value) + } + _ => evaluate(expression, env).map(|value| value.truthy()), + } +} + +/// The element count of an array literal with no spread elements, which is +/// what `` and friends need to recognize an empty list. +pub(crate) fn array_literal_len(expression: &Expression<'_>) -> Option { + let Expression::ArrayExpression(array) = expression else { + return None; + }; + if array.elements.iter().any(|element| { + matches!( + element, + oxc_ast::ast::ArrayExpressionElement::SpreadElement(_) + ) + }) { + return None; + } + Some(array.elements.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn number(value: f64) -> Const { + Const::Number(value) + } + + #[test] + fn to_int32_wraps_like_the_specification() { + assert_eq!(to_int32(4_294_967_296.0), 0); + assert_eq!(to_int32(4_294_967_297.0), 1); + assert_eq!(to_int32(-1.0), -1); + assert_eq!(to_int32(2_147_483_648.0), i32::MIN); + assert_eq!(to_int32(f64::NAN), 0); + assert_eq!(to_int32(f64::INFINITY), 0); + assert_eq!(to_uint32(-1.0), u32::MAX); + } + + #[test] + fn number_strings_fold_only_where_rust_and_javascript_agree() { + assert_eq!(number_to_js_string(42.0).as_deref(), Some("42")); + assert_eq!(number_to_js_string(f64::NAN).as_deref(), Some("NaN")); + assert_eq!(number_to_js_string(0.5), None); + assert_eq!(number_to_js_string(1e21), None); + } + + #[test] + fn arithmetic_edge_cases_match_javascript() { + let Some(Const::Number(remainder)) = + binary_value(&number(5.0), BinaryOperator::Remainder, &number(0.0)) + else { + panic!("5 % 0 folds to a number"); + }; + assert!(remainder.is_nan(), "5 % 0 is NaN in JavaScript"); + let Some(Const::Number(power)) = binary_value( + &number(1.0), + BinaryOperator::Exponential, + &number(f64::INFINITY), + ) else { + panic!("1 ** Infinity folds to a number"); + }; + assert!(power.is_nan(), "1 ** Infinity is NaN in JavaScript"); + assert_eq!( + binary_value(&Const::Null, BinaryOperator::Addition, &number(1.0)), + Some(number(1.0)) + ); + assert_eq!( + binary_value( + &Const::String("a".into()), + BinaryOperator::Addition, + &number(1.0) + ), + Some(Const::String("a1".into())) + ); + } + + #[test] + fn equality_follows_the_specification_warts() { + assert_eq!( + strict_equals(&number(f64::NAN), &number(f64::NAN)), + Some(false) + ); + assert_eq!(strict_equals(&number(-0.0), &number(0.0)), Some(true)); + assert_eq!(loose_equals(&Const::Null, &Const::Undefined), Some(true)); + assert_eq!(loose_equals(&Const::Null, &number(0.0)), Some(false)); + assert_eq!(loose_equals(&Const::Bool(true), &number(1.0)), Some(true)); + } + + #[test] + fn relational_comparison_declines_non_ascii_strings() { + assert_eq!( + compare( + &Const::String("a".into()), + BinaryOperator::LessThan, + &Const::String("b".into()) + ), + Some(Const::Bool(true)) + ); + assert_eq!( + compare( + &Const::String("é".into()), + BinaryOperator::LessThan, + &Const::String("b".into()) + ), + None + ); + assert_eq!( + compare(&number(f64::NAN), BinaryOperator::LessThan, &number(1.0)), + Some(Const::Bool(false)) + ); + } +} diff --git a/packages/compiler/src/shared/ast_builder.rs b/packages/compiler/src/shared/ast_builder.rs index 986e7b204..4f4d64d4d 100644 --- a/packages/compiler/src/shared/ast_builder.rs +++ b/packages/compiler/src/shared/ast_builder.rs @@ -756,6 +756,36 @@ impl<'a> AstBuilder<'a> { )) } + /// An intrinsic element (`
    `), whose tag the parser spells as a + /// `JSXIdentifier` rather than the identifier *reference* a component tag + /// carries. + pub(crate) fn expression_jsx_intrinsic_element( + &self, + span: Span, + name: &str, + attributes: ArenaVec<'a, JSXAttributeItem<'a>>, + children: ArenaVec<'a, JSXChild<'a>>, + self_closing: bool, + ) -> Expression<'a> { + let opening_name = + JSXElementName::Identifier(JSXIdentifier::boxed(span, self.str(name), &self.inner())); + let closing = (!self_closing).then(|| { + let closing_name = JSXElementName::Identifier(JSXIdentifier::boxed( + span, + self.str(name), + &self.inner(), + )); + JSXClosingElement::boxed(span, closing_name, &self.inner()) + }); + Expression::JSXElement(JSXElement::boxed( + span, + JSXOpeningElement::boxed(span, opening_name, None, attributes, &self.inner()), + children, + closing, + &self.inner(), + )) + } + pub(crate) fn jsx_child_expression( &self, span: Span, diff --git a/packages/compiler/types.d.ts b/packages/compiler/types.d.ts index caa7bbb14..243131aba 100644 --- a/packages/compiler/types.d.ts +++ b/packages/compiler/types.d.ts @@ -26,6 +26,23 @@ export interface TransformOptions { validate?: boolean; omitNestedClosingTags?: boolean; omitLastClosingTag?: boolean; + /** + * Constant-fold the program, drop the code that folding proves + * unreachable, and resolve control-flow components whose props are + * statically decidable: ``, ``, + * ``, `` over constant ``s, and + * ``. Default `false`. + * + * A built-in tag only folds when it resolves to Solid's own component: + * either nothing declares the name (the compiler auto-imports it) or it is + * imported from `moduleName` or `"solid-js"`. The exported name decides + * the identity, so `` from `import { Show as Cond }` folds as + * ``. + * + * Folding changes the rendered tree shape and therefore hydration ids, so + * a server build and its client build must pass the same value. + */ + optimize?: boolean; serverComponents?: boolean; /** Default `["For", "Show", "Switch", "Match", "Loading", "Reveal", "Portal", "Repeat", "Dynamic", "Errored"]`. */ builtIns?: string[]; From 767d1e6a3085dcafc6d86c433cf257de9f3c3d10 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Thu, 3 Sep 2026 15:09:43 +0800 Subject: [PATCH 2/4] fix: local folding --- .changeset/compiler-optimize-option.md | 2 + packages/compiler/README.md | 4 +- packages/compiler/__tests__/optimize.test.js | 19 +- packages/compiler/src/optimize/env.rs | 271 +++++++++++-------- packages/compiler/src/optimize/mod.rs | 96 +++++-- packages/compiler/src/optimize/value.rs | 11 +- 6 files changed, 256 insertions(+), 147 deletions(-) diff --git a/.changeset/compiler-optimize-option.md b/.changeset/compiler-optimize-option.md index 80647beae..8f0b3215e 100644 --- a/.changeset/compiler-optimize-option.md +++ b/.changeset/compiler-optimize-option.md @@ -4,6 +4,8 @@ Add an `optimize` option (default `false`) that constant-folds the program, removes the code a constant condition makes unreachable, and resolves Solid's control-flow components when their props decide the outcome. +Constant bindings resolve through `oxc_semantic`, so a `const` (or an unwritten `let`) folds at any scope and a same-named binding elsewhere is correctly left alone. + The pass runs before JSX is lowered, so a resolved element never reaches the generate: it pays for no component call, memo, or insert hole, and its markup joins the surrounding template. - `` becomes its children or its `fallback`. diff --git a/packages/compiler/README.md b/packages/compiler/README.md index 92154bc50..f4c02127b 100644 --- a/packages/compiler/README.md +++ b/packages/compiler/README.md @@ -137,7 +137,9 @@ Pass `sourceMap: true` to receive a JSON source map string in `result.map`. For `optimize: true` adds a constant-folding and dead-code-elimination pass that runs before JSX is lowered, so whatever it resolves never reaches the generate at all. -It folds constant expressions, substitutes module-level `const` bindings the program declares exactly once and never writes to, and removes branches a constant condition makes unreachable (`if`/`else`, `while (false)`, and statements after a `return`, `throw`, `break`, or `continue`). +It folds constant expressions, substitutes `const` bindings (and `let` bindings nothing writes to) at any scope, and removes branches a constant condition makes unreachable (`if`/`else`, `while (false)`, and statements after a `return`, `throw`, `break`, or `continue`). + +Binding resolution runs through `oxc_semantic`, so it is exact: a `const` declared inside a component folds at its use sites, while a same-named binding in another scope is a different symbol and is left alone. `var` is excluded, since a read before its declaration sees `undefined` rather than throwing. It also resolves Solid's control-flow components when their props decide the outcome: diff --git a/packages/compiler/__tests__/optimize.test.js b/packages/compiler/__tests__/optimize.test.js index 16ab32f02..2a45655ea 100644 --- a/packages/compiler/__tests__/optimize.test.js +++ b/packages/compiler/__tests__/optimize.test.js @@ -73,12 +73,23 @@ describe("optimize option", () => { expect(dynamic).not.toContain("_$createComponent"); }); - it("folds module-level constants into conditions", () => { - const code = optimized( + it("folds constants into conditions at any scope", () => { + const moduleLevel = optimized( "const DEBUG = false;\nexport const view =
    panel
    ;" ); - expect(code).not.toContain("panel"); - expect(code).not.toContain("_$createComponent"); + expect(moduleLevel).not.toContain("panel"); + expect(moduleLevel).not.toContain("_$createComponent"); + + const local = optimized( + "export function App() {\n const DEBUG = false;\n return
    panel
    ;\n}" + ); + expect(local).not.toContain("panel"); + expect(local).not.toContain("_$createComponent"); + + const shadowed = optimized( + "const DEBUG = false;\nexport function App(DEBUG) {\n return ;\n}" + ); + expect(shadowed).toContain("_$createComponent"); }); it("folds constant expressions and drops unreachable statements", () => { diff --git a/packages/compiler/src/optimize/env.rs b/packages/compiler/src/optimize/env.rs index b48c739f3..5c5e40135 100644 --- a/packages/compiler/src/optimize/env.rs +++ b/packages/compiler/src/optimize/env.rs @@ -1,94 +1,136 @@ //! Program-wide facts the `optimize` pass needs before it may rewrite -//! anything: which names carry a known constant value, and which names -//! actually resolve to Solid's own control-flow components. +//! anything: which identifier references carry a known constant value, and +//! which JSX tags actually resolve to Solid's own control-flow components. //! -//! Both answers rest on the same observation, which removes the need for a -//! scope model: a name the whole program declares exactly once cannot be -//! shadowed by anything but that one declaration, so every reference to it -//! resolves there. A name declared twice, or written to anywhere, is simply -//! not eligible. +//! Both answers come from `oxc_semantic`, so they are exact at any scope. A +//! reference is resolved to the symbol it binds to, and only that symbol's +//! declaration decides the answer: a `const` in a function body folds the +//! same way a module-level one does, and a same-named binding elsewhere in +//! the program is simply a different symbol and does not interfere. +//! +//! Facts are keyed by the source span of the reference rather than by name, +//! so the folding pass can look one up without carrying a scope stack of its +//! own. Spans are stable across the rewrite: the pass never renumbers a node +//! it keeps, and the nodes it creates are literals and intrinsic tags that no +//! lookup asks about. -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use oxc_ast::ast::{ - Declaration, ImportDeclarationSpecifier, ModuleExportName, Program, Statement, - VariableDeclarationKind, + ImportDeclarationSpecifier, ModuleExportName, Program, VariableDeclarationKind, }; use oxc_ast_visit::{Visit, walk}; +use oxc_semantic::{Scoping, SemanticBuilder}; +use oxc_syntax::symbol::SymbolId; use super::value::{Const, ConstantEnv, evaluate}; -/// The globals worth folding, admitted only when the program never declares -/// or writes the name itself. -const FOLDABLE_GLOBALS: [&str; 3] = ["undefined", "NaN", "Infinity"]; - -/// A named import binding at the top level of the module. -pub(crate) struct ImportBinding { - /// The name as exported by the source module, which is what identifies - /// the component (`import { Show as Cond }` imports `Show`). - pub(crate) imported: String, - pub(crate) source: String, +/// What a JSX tag identifier resolves to. +enum TagBinding { + /// Nothing in the program declares the name, so the compiler's own + /// auto-import supplies it. + Unbound, + /// A named import, carrying the name the source module exports. + Import { imported: String, source: String }, + /// Some other binding: a local, a parameter, an unrelated import form. + Local, } pub(crate) struct ProgramFacts { + /// The constant each identifier reference resolves to, keyed by the + /// reference's span start. pub(crate) constants: ConstantEnv, - /// How many times each name is declared anywhere in the program. - declared: HashMap, - /// Top-level value imports, keyed by their local name. - imports: HashMap, + /// What each identifier reference binds to, keyed by span start. + tags: HashMap, } impl ProgramFacts { - /// The exported name `local` was imported under, when it comes from one - /// of `sources`. The exported name is the component's identity, so an - /// alias resolves to what it renamed: `import { Show as Cond }` answers - /// `Show` for `Cond`. + /// The built-in a JSX tag names, given the tag's own spelling and the + /// module specifiers a Solid import may come from. /// - /// A name the program declares more than once could resolve to either - /// declaration, so it does not qualify. - pub(crate) fn solid_import(&self, local: &str, sources: &[&str]) -> Option<&str> { - if self.declared.get(local) != Some(&1) { - return None; + /// An import decides the identity by its *exported* name, so + /// `import { Show as Cond }` answers `Show` for a `` tag. With no + /// binding at all the tag is the auto-imported built-in of the same + /// name. Every other binding is somebody else's component. + pub(crate) fn tag_identity<'f>( + &'f self, + span_start: u32, + name: &'f str, + sources: &[&str], + ) -> Option<&'f str> { + match self.tags.get(&span_start)? { + TagBinding::Unbound => Some(name), + TagBinding::Import { imported, source } => sources + .iter() + .any(|candidate| *candidate == source) + .then_some(imported.as_str()), + TagBinding::Local => None, } - let import = self.imports.get(local)?; - sources - .iter() - .any(|source| *source == import.source) - .then_some(import.imported.as_str()) } } pub(crate) fn collect_facts(program: &Program<'_>) -> ProgramFacts { - let mut scan = Scan::default(); - scan.visit_program(program); + // Populates the node, symbol, and reference ids the AST carries in its + // own cells; nothing downstream reads them, so this is additive. + let semantic = SemanticBuilder::new().build(program).semantic; + let scoping = semantic.scoping(); - let mut constants = ConstantEnv::new(); - for name in FOLDABLE_GLOBALS { - if !scan.declared.contains_key(name) && !scan.reassigned.contains(name) { - constants.insert( - name.to_string(), - match name { - "NaN" => Const::Number(f64::NAN), - "Infinity" => Const::Number(f64::INFINITY), - _ => Const::Undefined, - }, - ); - } + // Pass one walks in source order and records the value of every constant + // declaration, evaluating each initializer against the constants already + // seen. Source order is what keeps `const A = B; const B = 1;` from + // folding `A` to a value the runtime would never reach. + let mut collector = Collector { + scoping, + constants: ConstantEnv::new(), + values: HashMap::new(), + imports: HashMap::new(), + tags: HashMap::new(), + resolve_tags: false, + }; + collector.visit_program(program); + + // Pass two resolves every remaining reference against the finished set, + // so a use site earlier in the file than its declaration still folds, + // and records what each tag binds to. + collector.resolve_tags = true; + collector.visit_program(program); + + ProgramFacts { + constants: collector.constants, + tags: collector.tags, } +} - let mut imports = HashMap::new(); - // Module-level `const`s execute top to bottom, so evaluating each - // initializer against the environment built so far lets a later constant - // be defined in terms of an earlier one. - for statement in &program.body { - if let Statement::ImportDeclaration(import) = statement { - if !import.import_kind.is_value() { - continue; - } - for specifier in import.specifiers.iter().flatten() { +/// The globals worth folding. They only apply to a reference that resolves to +/// no symbol, which is exactly the case where the global is what runs. +fn global_value(name: &str) -> Option { + match name { + "undefined" => Some(Const::Undefined), + "NaN" => Some(Const::Number(f64::NAN)), + "Infinity" => Some(Const::Number(f64::INFINITY)), + _ => None, + } +} + +struct Collector<'s> { + scoping: &'s Scoping, + constants: ConstantEnv, + values: HashMap, + imports: HashMap, + tags: HashMap, + resolve_tags: bool, +} + +impl<'a> Visit<'a> for Collector<'_> { + fn visit_import_declaration(&mut self, it: &oxc_ast::ast::ImportDeclaration<'a>) { + if it.import_kind.is_value() { + for specifier in it.specifiers.iter().flatten() { let ImportDeclarationSpecifier::ImportSpecifier(specifier) = specifier else { continue; }; + let Some(symbol) = specifier.local.symbol_id.get() else { + continue; + }; if !specifier.import_kind.is_value() { continue; } @@ -97,77 +139,68 @@ pub(crate) fn collect_facts(program: &Program<'_>) -> ProgramFacts { ModuleExportName::IdentifierReference(name) => name.name.to_string(), ModuleExportName::StringLiteral(name) => name.value.to_string(), }; - imports.insert( - specifier.local.name.to_string(), - ImportBinding { - imported, - source: import.source.value.to_string(), - }, - ); + self.imports + .insert(symbol, (imported, it.source.value.to_string())); } - continue; } - let declaration = match statement { - Statement::VariableDeclaration(declaration) => &**declaration, - Statement::ExportDeclaration(export) => match &export.declaration { - Declaration::VariableDeclaration(declaration) => &**declaration, - _ => continue, - }, - _ => continue, - }; - if declaration.kind != VariableDeclarationKind::Const { - continue; + walk::walk_import_declaration(self, it); + } + + fn visit_variable_declaration(&mut self, it: &oxc_ast::ast::VariableDeclaration<'a>) { + walk::walk_variable_declaration(self, it); + // `var` is excluded: it is readable before its declaration runs, and + // such a read sees `undefined` rather than throwing, so folding it to + // the initializer would silently change the result. `let` qualifies + // when nothing ever writes to it. + if !matches!( + it.kind, + VariableDeclarationKind::Const | VariableDeclarationKind::Let + ) { + return; } - for declarator in &declaration.declarations { - let Some(name) = declarator.id.get_binding_identifier() else { + for declarator in &it.declarations { + let Some(binding) = declarator.id.get_binding_identifier() else { continue; }; - let name = name.name.as_str(); - if scan.declared.get(name) != Some(&1) || scan.reassigned.contains(name) { + let Some(symbol) = binding.symbol_id.get() else { continue; - } + }; let Some(init) = &declarator.init else { continue; }; - if let Some(value) = evaluate(init, &constants) { - constants.insert(name.to_string(), value); + if self.scoping.symbol_is_mutated(symbol) { + continue; + } + if let Some(value) = evaluate(init, &self.constants) { + self.values.insert(symbol, value); } } } - ProgramFacts { - constants, - declared: scan.declared, - imports, - } -} - -/// Counts every binding of every name in the program and records every name -/// written to, at any depth. -#[derive(Default)] -struct Scan { - declared: HashMap, - reassigned: HashSet, -} - -impl<'a> Visit<'a> for Scan { - fn visit_binding_identifier(&mut self, it: &oxc_ast::ast::BindingIdentifier<'a>) { - *self.declared.entry(it.name.to_string()).or_default() += 1; - } - - fn visit_assignment_target(&mut self, it: &oxc_ast::ast::AssignmentTarget<'a>) { - if let oxc_ast::ast::AssignmentTarget::AssignmentTargetIdentifier(identifier) = it { - self.reassigned.insert(identifier.name.to_string()); + fn visit_identifier_reference(&mut self, it: &oxc_ast::ast::IdentifierReference<'a>) { + let symbol = it + .reference_id + .get() + .and_then(|reference| self.scoping.get_reference(reference).symbol_id()); + let value = match symbol { + Some(symbol) => self.values.get(&symbol).cloned(), + None => global_value(it.name.as_str()), + }; + if let Some(value) = value { + self.constants.insert(it.span.start, value); } - walk::walk_assignment_target(self, it); - } - - fn visit_update_expression(&mut self, it: &oxc_ast::ast::UpdateExpression<'a>) { - if let oxc_ast::ast::SimpleAssignmentTarget::AssignmentTargetIdentifier(identifier) = - &it.argument - { - self.reassigned.insert(identifier.name.to_string()); + if self.resolve_tags { + let binding = match symbol { + None => TagBinding::Unbound, + Some(symbol) => match self.imports.get(&symbol) { + Some((imported, source)) => TagBinding::Import { + imported: imported.clone(), + source: source.clone(), + }, + None => TagBinding::Local, + }, + }; + self.tags.insert(it.span.start, binding); } - walk::walk_update_expression(self, it); } } diff --git a/packages/compiler/src/optimize/mod.rs b/packages/compiler/src/optimize/mod.rs index 840bea9fb..600d5ffd1 100644 --- a/packages/compiler/src/optimize/mod.rs +++ b/packages/compiler/src/optimize/mod.rs @@ -11,9 +11,10 @@ //! //! - Constant expressions: literals, template literals, and the unary, //! binary, logical, and conditional operators over them. -//! - Module-level `const` bindings that the whole program declares exactly -//! once and never writes to (see [`env`]), so `const DEBUG = false` folds -//! at every use site. +//! - `const` bindings, and `let` bindings nothing ever writes to, at any +//! scope. References are resolved through `oxc_semantic` (see [`env`]), so +//! a `const DEBUG = false` inside a component folds at its use sites while +//! an unrelated binding of the same name elsewhere is untouched. //! - Statements a constant condition makes unreachable: `if`/`else` branches, //! `while (false)` loops, and anything after a `return`, `throw`, `break`, //! or `continue`. @@ -49,7 +50,6 @@ use oxc_ast_visit::{Visit, VisitMut, walk, walk_mut}; use oxc_span::{GetSpan, Span}; use crate::shared::ast_builder::AstBuilder; -use crate::shared::bindings::BindingTable; use crate::shared::classify::jsx_text_is_filtered; use crate::shared::utils::decode_html_entities; use env::ProgramFacts; @@ -82,14 +82,11 @@ pub(crate) fn optimize_program<'a>( built_ins: &[String], module_name: &str, ) { - let mut bindings = BindingTable::default(); - bindings.scan_builtin_shadowing(program, built_ins); let mut optimizer = Optimizer { allocator, ast: AstBuilder::new(allocator), built_ins, flow_sources: [module_name, SOLID_MODULE_NAME], - bindings, facts: env::collect_facts(program), changed: false, }; @@ -118,7 +115,6 @@ struct Optimizer<'a, 'o> { built_ins: &'o [String], /// Module specifiers an explicit built-in import may come from. flow_sources: [&'o str; 2], - bindings: BindingTable, facts: ProgramFacts, changed: bool, } @@ -342,10 +338,11 @@ impl<'a> Optimizer<'a, '_> { /// candidate; a fold rewrites the component's semantics, so the tag has /// to resolve to Solid's own component before one is safe: /// - /// - a top-level value import from a Solid module decides the identity - /// outright, so `` from `import { Show as Cond }` is `Show`; - /// - with no such import, the name folds only when no binding is in - /// scope, which means the compiler auto-imports the built-in; + /// - a value import from a Solid module decides the identity outright, + /// by the name that module exports, so `` from + /// `import { Show as Cond }` is `Show`; + /// - a tag that binds to nothing is the built-in the compiler + /// auto-imports; /// - anything else (a local `Show`, an import from an unrelated module) /// is a different component and is left alone. fn built_in_tag(&self, element: &JSXElement<'a>) -> Option<&'static str> { @@ -356,13 +353,10 @@ impl<'a> Optimizer<'a, '_> { JSXElementName::Identifier(identifier) => (identifier.name.as_str(), identifier.span), _ => return None, }; - if let Some(imported) = self.facts.solid_import(name, &self.flow_sources) { - return self.known_flow(imported); - } - if self.bindings.is_builtin_shadowed(span) { - return None; - } - self.known_flow(name) + let identity = self + .facts + .tag_identity(span.start, name, &self.flow_sources)?; + self.known_flow(identity) } /// The built-in named `name`, when the configuration still treats that @@ -972,6 +966,70 @@ mod tests { assert!(hoisted.contains("kept"), "{hoisted}"); } + #[test] + fn constants_fold_in_any_scope() { + let local = optimized( + "export function App() {\n const DEBUG = false;\n return
    panel
    ;\n}", + ); + assert!(!local.contains("panel"), "{local}"); + assert!(!local.contains("createComponent"), "{local}"); + + let nested = optimized( + "export function App() {\n const N = 2;\n const render = () =>
    1}>panel
    ;\n return render();\n}", + ); + assert!(nested.contains("panel"), "{nested}"); + assert!(!nested.contains("createComponent"), "{nested}"); + + // An unwritten `let` is a constant too; a written one is not. + let unwritten = optimized( + "export function App() {\n let DEBUG = false;\n return ;\n}", + ); + assert!(!unwritten.contains("createComponent"), "{unwritten}"); + + let written = optimized( + "export function App() {\n let DEBUG = false;\n DEBUG = flag();\n return ;\n}", + ); + assert!(written.contains("createComponent"), "{written}"); + + // A parameter shadows the outer constant, so the inner tag is not + // decided by it. + let shadowed = optimized( + "const DEBUG = false;\nexport function App(DEBUG) {\n return ;\n}", + ); + assert!(shadowed.contains("createComponent"), "{shadowed}"); + + // Two unrelated locals of the same name resolve independently. + let independent = optimized( + "export function A() {\n const FLAG = true;\n return ;\n}\nexport function B(FLAG) {\n return ;\n}", + ); + assert!(independent.contains("
    ;", + ); + assert!(out_of_scope.contains("createComponent"), "{out_of_scope}"); + + // A use site above its declaration still folds, since the function + // body runs after the module finishes evaluating. + let later = optimized( + "export function App() {\n return ;\n}\nconst DEBUG = false;", + ); + assert!(!later.contains("createComponent"), "{later}"); + } + + #[test] + fn a_local_binding_shadows_a_solid_import_for_its_own_scope() { + let source = "import { Show } from \"solid-js\";\nexport function App() {\n const Show = props => props.children;\n return ;\n}\nexport const view = ;"; + let output = optimized(source); + // The inner tag binds to the local component and keeps its call... + assert!(output.contains("createComponent"), "{output}"); + // ...while the outer one still resolves to the import and folds. + assert!(output.contains("
    ;"); diff --git a/packages/compiler/src/optimize/value.rs b/packages/compiler/src/optimize/value.rs index ec444a96b..91aa63b42 100644 --- a/packages/compiler/src/optimize/value.rs +++ b/packages/compiler/src/optimize/value.rs @@ -15,9 +15,12 @@ use oxc_syntax::operator::{BinaryOperator, LogicalOperator, UnaryOperator}; use crate::shared::utils::format_number; -/// Program-wide constant bindings the pass may substitute (see -/// [`super::env::collect_facts`] for the safety rules that admit a name). -pub(crate) type ConstantEnv = HashMap; +/// The constant each identifier *reference* resolves to, keyed by the +/// reference's span start. Keying by span rather than by name is what lets +/// the pass fold a `const` declared in any scope: resolution already +/// happened in [`super::env::collect_facts`], so a lookup here cannot +/// confuse two same-named bindings. +pub(crate) type ConstantEnv = HashMap; /// A JavaScript primitive known at compile time. #[derive(Clone, Debug, PartialEq)] @@ -120,7 +123,7 @@ pub(crate) fn evaluate(expression: &Expression<'_>, env: &ConstantEnv) -> Option Expression::BooleanLiteral(literal) => Some(Const::Bool(literal.value)), Expression::NumericLiteral(literal) => Some(Const::Number(literal.value)), Expression::StringLiteral(literal) => Some(Const::String(literal.value.to_string())), - Expression::Identifier(identifier) => env.get(identifier.name.as_str()).cloned(), + Expression::Identifier(identifier) => env.get(&identifier.span.start).cloned(), Expression::TemplateLiteral(template) => { let mut out = String::new(); for (index, quasi) in template.quasis.iter().enumerate() { From c5c1ac9fe9b73c4a8cc714db3e6f8050a1494ce9 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Thu, 3 Sep 2026 15:23:46 +0800 Subject: [PATCH 3/4] fix: side-effect scanning --- packages/compiler/src/optimize/mod.rs | 76 ++++++++++++++++++++++-- packages/compiler/src/optimize/value.rs | 77 ++++++++++++++++++++++++- 2 files changed, 147 insertions(+), 6 deletions(-) diff --git a/packages/compiler/src/optimize/mod.rs b/packages/compiler/src/optimize/mod.rs index 600d5ffd1..acc32e03b 100644 --- a/packages/compiler/src/optimize/mod.rs +++ b/packages/compiler/src/optimize/mod.rs @@ -53,7 +53,7 @@ use crate::shared::ast_builder::AstBuilder; use crate::shared::classify::jsx_text_is_filtered; use crate::shared::utils::decode_html_entities; use env::ProgramFacts; -use value::{Const, array_literal_len, evaluate, truthiness}; +use value::{Const, array_literal_len, evaluate, is_side_effect_free, truthiness}; /// Folding one node can expose the next one up (a `` that resolves to /// an empty ``, say). The traversal is post-order, so a single pass @@ -165,8 +165,9 @@ impl<'a> Optimizer<'a, '_> { return; } - // Short-circuiting operators drop the unevaluated side outright: it - // never runs, so any effects in it are not observable. + // The side a short-circuit does not reach never runs, so dropping + // it is free. The side that *does* run has to be skippable before + // the other one can replace the whole expression. match expression { Expression::LogicalExpression(logical) => { let Some(left) = truthiness(&logical.left, &self.facts.constants) else { @@ -187,6 +188,9 @@ impl<'a> Optimizer<'a, '_> { let kept = if takes_left { logical.left.take_in(&self.allocator) } else { + if !is_side_effect_free(&logical.left) { + return; + } logical.right.take_in(&self.allocator) }; *expression = kept; @@ -196,6 +200,9 @@ impl<'a> Optimizer<'a, '_> { let Some(test) = truthiness(&conditional.test, &self.facts.constants) else { return; }; + if !is_side_effect_free(&conditional.test) { + return; + } let kept = if test { conditional.consequent.take_in(&self.allocator) } else { @@ -408,10 +415,12 @@ impl<'a> Optimizer<'a, '_> { }; let expression = container.expression.as_expression()?; // `each={[]}` renders the fallback, and so does any statically falsy - // `each` — `mapArray` treats `null`/`undefined`/`false` as an empty - // list, which is what the prop's own type allows. + // `each`. `mapArray` treats `null`/`undefined`/`false` as an empty + // list, which is what the prop's own type allows. Either way the + // expression itself is dropped, so it has to be skippable. let empty = array_literal_len(expression) == Some(0) || truthiness(expression, &self.facts.constants) == Some(false); + let empty = empty && is_side_effect_free(expression); if !empty { return None; } @@ -559,6 +568,7 @@ impl<'a> Optimizer<'a, '_> { Some(JSXAttributeValue::ExpressionContainer(container)) => container .expression .as_expression() + .filter(|expression| is_side_effect_free(expression)) .and_then(|expression| truthiness(expression, &self.facts.constants)), } } @@ -664,6 +674,9 @@ impl<'a> Optimizer<'a, '_> { match statement { Statement::IfStatement(branch) => { let test = truthiness(&branch.test, &self.facts.constants)?; + if !is_side_effect_free(&branch.test) { + return None; + } let dropped_hoists = if test { branch .alternate @@ -695,6 +708,9 @@ impl<'a> Optimizer<'a, '_> { if truthiness(&loop_statement.test, &self.facts.constants)? { return None; } + if !is_side_effect_free(&loop_statement.test) { + return None; + } if contains_hoisted_declaration(&loop_statement.body) { return None; } @@ -966,6 +982,56 @@ mod tests { assert!(hoisted.contains("kept"), "{hoisted}"); } + /// A condition is discarded along with the component or branch that read + /// it, so a truthy-but-effectful condition must stop the fold. Composite + /// literals are always truthy yet can run arbitrary code. + #[test] + fn a_discarded_condition_keeps_its_side_effects() { + let logical = optimized("export const x = [effect()] && other;"); + assert!(logical.contains("effect()"), "{logical}"); + + let ternary = optimized("export const y = { k: effect() } ? a : b;"); + assert!(ternary.contains("effect()"), "{ternary}"); + + let spread = optimized("export const s = [...iterable()] ? a : b;"); + assert!(spread.contains("iterable()"), "{spread}"); + + let computed_key = optimized("export const c = { [key()]: 1 } ? a : b;"); + assert!(computed_key.contains("key()"), "{computed_key}"); + + // Evaluating a class runs its static blocks and heritage expression. + let static_block = optimized("export const z = (class { static { effect(); } }) ? a : b;"); + assert!(static_block.contains("effect()"), "{static_block}"); + + let heritage = optimized("export const w = (class extends base() {}) ? a : b;"); + assert!(heritage.contains("base()"), "{heritage}"); + + let when = optimized("export const v = ;"); + assert!(when.contains("effect()"), "{when}"); + + let each = optimized("export const l = ;"); + assert!(each.contains("effects()"), "{each}"); + + let branch = optimized( + "export function App() {\n if ({ k: effect() }) { taken(); }\n return
    ;\n}", + ); + assert!(branch.contains("effect()"), "{branch}"); + + let loop_test = optimized( + "export function App() {\n while ([effect()] && false) { body(); }\n return
    ;\n}", + ); + assert!(loop_test.contains("effect()"), "{loop_test}"); + + // The unreached side of a short-circuit never runs, so dropping it + // stays correct. + let unreached = optimized("export const k = false && effect();"); + assert!(!unreached.contains("effect()"), "{unreached}"); + + let untaken = optimized("export const j = true ? kept() : effect();"); + assert!(!untaken.contains("effect()"), "{untaken}"); + assert!(untaken.contains("kept()"), "{untaken}"); + } + #[test] fn constants_fold_in_any_scope() { let local = optimized( diff --git a/packages/compiler/src/optimize/value.rs b/packages/compiler/src/optimize/value.rs index 91aa63b42..95909cff6 100644 --- a/packages/compiler/src/optimize/value.rs +++ b/packages/compiler/src/optimize/value.rs @@ -10,7 +10,7 @@ use std::collections::HashMap; -use oxc_ast::ast::Expression; +use oxc_ast::ast::{ArrayExpressionElement, Expression, ObjectPropertyKind, PropertyKey}; use oxc_syntax::operator::{BinaryOperator, LogicalOperator, UnaryOperator}; use crate::shared::utils::format_number; @@ -335,6 +335,81 @@ pub(crate) fn truthiness(expression: &Expression<'_>, env: &ConstantEnv) -> Opti } } +/// Whether skipping `expression` entirely loses nothing observable. +/// +/// Every fold that reads a condition also *discards* it: `` +/// keeps neither `C` nor the component that read it, and a resolved `if` or +/// ternary keeps neither its test. [`truthiness`] answers what a value is +/// worth, which is a different question from whether producing it can be +/// skipped, so the two are asked separately at each of those sites. +/// +/// Composite literals are the reason this cannot be folded into +/// `truthiness`: `[effect()]` and `{ k: effect() }` are always truthy, yet +/// evaluating them runs a call. A class is never skippable, since evaluating +/// one runs its heritage expression, computed keys, static field +/// initializers, and static blocks. +pub(crate) fn is_side_effect_free(expression: &Expression<'_>) -> bool { + match expression { + Expression::NullLiteral(_) + | Expression::BooleanLiteral(_) + | Expression::NumericLiteral(_) + | Expression::BigIntLiteral(_) + | Expression::RegExpLiteral(_) + | Expression::StringLiteral(_) + | Expression::Identifier(_) + | Expression::ThisExpression(_) + // Creating a function does not run its body. + | Expression::ArrowFunctionExpression(_) + | Expression::FunctionExpression(_) => true, + Expression::TemplateLiteral(template) => { + template.expressions.iter().all(is_side_effect_free) + } + Expression::UnaryExpression(unary) => { + // `delete` mutates its target. + unary.operator != UnaryOperator::Delete && is_side_effect_free(&unary.argument) + } + Expression::BinaryExpression(binary) => { + // `in` and `instanceof` both consult the right operand's + // prototype chain, which a proxy or `Symbol.hasInstance` observes. + !matches!( + binary.operator, + BinaryOperator::In | BinaryOperator::Instanceof + ) && is_side_effect_free(&binary.left) + && is_side_effect_free(&binary.right) + } + Expression::LogicalExpression(logical) => { + is_side_effect_free(&logical.left) && is_side_effect_free(&logical.right) + } + Expression::ConditionalExpression(conditional) => { + is_side_effect_free(&conditional.test) + && is_side_effect_free(&conditional.consequent) + && is_side_effect_free(&conditional.alternate) + } + Expression::ArrayExpression(array) => array.elements.iter().all(|element| match element { + // A spread iterates its argument, which is observable. + ArrayExpressionElement::SpreadElement(_) => false, + ArrayExpressionElement::Elision(_) => true, + element => element.as_expression().is_some_and(is_side_effect_free), + }), + Expression::ObjectExpression(object) => object.properties.iter().all(|property| { + // A spread reads the source's own enumerable properties, which a + // getter observes. + let ObjectPropertyKind::ObjectProperty(property) = property else { + return false; + }; + property_key_is_side_effect_free(&property.key) && is_side_effect_free(&property.value) + }), + _ => false, + } +} + +fn property_key_is_side_effect_free(key: &PropertyKey<'_>) -> bool { + match key { + PropertyKey::StaticIdentifier(_) | PropertyKey::PrivateIdentifier(_) => true, + key => key.as_expression().is_some_and(is_side_effect_free), + } +} + /// The element count of an array literal with no spread elements, which is /// what `` and friends need to recognize an empty list. pub(crate) fn array_literal_len(expression: &Expression<'_>) -> Option { From 0ce6be75d549f189b28c793a2c8aba005854bec4 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Thu, 3 Sep 2026 15:42:40 +0800 Subject: [PATCH 4/4] feat(compiler): fold past an effectful condition with a sequence --- packages/compiler/src/optimize/mod.rs | 157 ++++++++++++++++++------ packages/compiler/src/optimize/value.rs | 33 +++++ 2 files changed, 155 insertions(+), 35 deletions(-) diff --git a/packages/compiler/src/optimize/mod.rs b/packages/compiler/src/optimize/mod.rs index acc32e03b..3d0be39e6 100644 --- a/packages/compiler/src/optimize/mod.rs +++ b/packages/compiler/src/optimize/mod.rs @@ -53,7 +53,7 @@ use crate::shared::ast_builder::AstBuilder; use crate::shared::classify::jsx_text_is_filtered; use crate::shared::utils::decode_html_entities; use env::ProgramFacts; -use value::{Const, array_literal_len, evaluate, is_side_effect_free, truthiness}; +use value::{Const, array_literal_len, effectful_parts, evaluate, is_side_effect_free, truthiness}; /// Folding one node can expose the next one up (a `` that resolves to /// an empty ``, say). The traversal is post-order, so a single pass @@ -185,13 +185,14 @@ impl<'a> Optimizer<'a, '_> { } } }; + let span = logical.span; let kept = if takes_left { logical.left.take_in(&self.allocator) } else { - if !is_side_effect_free(&logical.left) { - return; - } - logical.right.take_in(&self.allocator) + let effectful = !is_side_effect_free(&logical.left); + let discarded = effectful.then(|| logical.left.take_in(&self.allocator)); + let right = logical.right.take_in(&self.allocator); + self.keep_after(span, discarded, right) }; *expression = kept; self.changed = true; @@ -200,15 +201,15 @@ impl<'a> Optimizer<'a, '_> { let Some(test) = truthiness(&conditional.test, &self.facts.constants) else { return; }; - if !is_side_effect_free(&conditional.test) { - return; - } + let span = conditional.span; + let effectful = !is_side_effect_free(&conditional.test); + let discarded = effectful.then(|| conditional.test.take_in(&self.allocator)); let kept = if test { conditional.consequent.take_in(&self.allocator) } else { conditional.alternate.take_in(&self.allocator) }; - *expression = kept; + *expression = self.keep_after(span, discarded, kept); self.changed = true; } _ => { @@ -220,6 +221,32 @@ impl<'a> Optimizer<'a, '_> { } } + /// Keeps `value`, evaluating `discarded` first when the fold could not + /// simply skip it. + /// + /// A condition whose truthiness is known but whose evaluation is + /// observable still has to run. `(discarded, value)` runs it in the same + /// order the original did and yields the same result, so the branch goes + /// even though the test stays. + fn keep_after( + &self, + span: Span, + discarded: Option>, + value: Expression<'a>, + ) -> Expression<'a> { + let Some(discarded) = discarded else { + return value; + }; + let mut parts = std::vec::Vec::new(); + effectful_parts(discarded, &mut parts); + if parts.is_empty() { + return value; + } + parts.push(value); + self.ast + .expression_sequence(span, self.ast.vec_from_iter(parts)) + } + /// The literal spelling of a constant expression, or `None` when the /// expression is not constant or is already at its shortest form. fn constant_expression(&self, expression: &Expression<'a>) -> Option> { @@ -631,11 +658,31 @@ impl<'a> Optimizer<'a, '_> { &mut self, statements: &mut oxc_allocator::Vec<'a, Statement<'a>>, ) { + // A resolution can leave nothing, one statement, or an effectful + // test followed by the taken branch, so the list is rebuilt rather + // than patched in place. + let mut resolutions = std::vec::Vec::with_capacity(statements.len()); + let mut resolved_any = false; for statement in statements.iter_mut() { - if let Some(replacement) = self.resolve_statement(statement) { - *statement = replacement; - self.changed = true; + let resolution = self.resolve_statement(statement); + resolved_any |= resolution.is_some(); + resolutions.push(resolution); + } + if resolved_any { + let taken = std::mem::replace(statements, self.ast.vec()); + let mut rebuilt = self.ast.vec_with_capacity(taken.len()); + for (statement, resolution) in taken.into_iter().zip(resolutions) { + match resolution { + Some(replacements) => { + for replacement in replacements { + rebuilt.push(replacement); + } + } + None => rebuilt.push(statement), + } } + *statements = rebuilt; + self.changed = true; } // Everything after the first `return`/`throw`/`break`/`continue` is @@ -669,14 +716,15 @@ impl<'a> Optimizer<'a, '_> { } } - /// The statement a constant condition leaves behind, if any. - fn resolve_statement(&mut self, statement: &mut Statement<'a>) -> Option> { + /// The statements a constant condition leaves behind, or `None` when the + /// statement stands as written. An empty list removes it outright. + fn resolve_statement( + &mut self, + statement: &mut Statement<'a>, + ) -> Option>> { match statement { Statement::IfStatement(branch) => { let test = truthiness(&branch.test, &self.facts.constants)?; - if !is_side_effect_free(&branch.test) { - return None; - } let dropped_hoists = if test { branch .alternate @@ -693,32 +741,57 @@ impl<'a> Optimizer<'a, '_> { } else { branch.alternate.as_mut() }; - let Some(kept) = kept else { - return Some(self.ast.statement_empty(branch.span)); - }; - // A bare function declaration as a branch body is - // Annex B web-compatibility semantics, not something to - // relocate into the enclosing list. - if matches!(kept, Statement::FunctionDeclaration(_)) { + // A bare function declaration as a branch body is Annex B + // web-compatibility semantics, not something to relocate + // into the enclosing list. + if matches!(kept, Some(Statement::FunctionDeclaration(_))) { return None; } - Some(kept.take_in(&self.allocator)) + let kept = kept.map(|kept| kept.take_in(&self.allocator)); + let mut resolved = std::vec::Vec::with_capacity(2); + if let Some(effect) = self.discarded_test(&mut branch.test) { + resolved.push(effect); + } + resolved.extend(kept); + Some(resolved) } Statement::WhileStatement(loop_statement) => { if truthiness(&loop_statement.test, &self.facts.constants)? { return None; } - if !is_side_effect_free(&loop_statement.test) { - return None; - } if contains_hoisted_declaration(&loop_statement.body) { return None; } - Some(self.ast.statement_empty(loop_statement.span)) + // A `while` whose test is falsy evaluates that test exactly + // once and never enters the body. + Some( + self.discarded_test(&mut loop_statement.test) + .into_iter() + .collect(), + ) } _ => None, } } + + /// The statement that preserves a discarded test's evaluation, or `None` + /// when skipping it changes nothing. + fn discarded_test(&self, test: &mut Expression<'a>) -> Option> { + if is_side_effect_free(test) { + return None; + } + let span = test.span(); + let mut parts = std::vec::Vec::new(); + effectful_parts(test.take_in(&self.allocator), &mut parts); + let kept = match parts.len() { + 0 => return None, + 1 => parts.pop().expect("one part"), + _ => self + .ast + .expression_sequence(span, self.ast.vec_from_iter(parts)), + }; + Some(self.ast.statement_expression(span, kept)) + } } // --------------------------------------------------------------------------- @@ -982,16 +1055,22 @@ mod tests { assert!(hoisted.contains("kept"), "{hoisted}"); } - /// A condition is discarded along with the component or branch that read - /// it, so a truthy-but-effectful condition must stop the fold. Composite - /// literals are always truthy yet can run arbitrary code. + /// A condition is discarded along with the branch that read it, so an + /// effectful condition keeps running in front of whatever the branch + /// resolved to. Composite literals are always truthy yet can run + /// arbitrary code, so they are the interesting case. #[test] fn a_discarded_condition_keeps_its_side_effects() { let logical = optimized("export const x = [effect()] && other;"); - assert!(logical.contains("effect()"), "{logical}"); + assert!(logical.contains("([effect()], other)"), "{logical}"); let ternary = optimized("export const y = { k: effect() } ? a : b;"); - assert!(ternary.contains("effect()"), "{ternary}"); + assert!(ternary.contains("({ k: effect() }, a)"), "{ternary}"); + + // The sequence a previous fold left behind still decides the next + // one, and only its effectful parts survive. + let chained = optimized("export const n = ([effect()] && false) ? a : b;"); + assert!(chained.contains("([effect()], b)"), "{chained}"); let spread = optimized("export const s = [...iterable()] ? a : b;"); assert!(spread.contains("iterable()"), "{spread}"); @@ -1006,21 +1085,29 @@ mod tests { let heritage = optimized("export const w = (class extends base() {}) ? a : b;"); assert!(heritage.contains("base()"), "{heritage}"); + // A component prop is not hoistable: Solid reads `when` inside a + // memo, so evaluating it eagerly here would change when and how + // often it runs. These stay unfolded rather than resequenced. let when = optimized("export const v = ;"); assert!(when.contains("effect()"), "{when}"); + assert!(when.contains("createComponent"), "{when}"); let each = optimized("export const l = ;"); assert!(each.contains("effects()"), "{each}"); let branch = optimized( - "export function App() {\n if ({ k: effect() }) { taken(); }\n return
    ;\n}", + "export function App() {\n if ({ k: effect() }) { taken(); }\n else { gone(); }\n return
    ;\n}", ); assert!(branch.contains("effect()"), "{branch}"); + assert!(branch.contains("taken()"), "{branch}"); + assert!(!branch.contains("gone()"), "{branch}"); let loop_test = optimized( "export function App() {\n while ([effect()] && false) { body(); }\n return
    ;\n}", ); assert!(loop_test.contains("effect()"), "{loop_test}"); + assert!(!loop_test.contains("body()"), "{loop_test}"); + assert!(!loop_test.contains("while"), "{loop_test}"); // The unreached side of a short-circuit never runs, so dropping it // stays correct. diff --git a/packages/compiler/src/optimize/value.rs b/packages/compiler/src/optimize/value.rs index 95909cff6..440717ea9 100644 --- a/packages/compiler/src/optimize/value.rs +++ b/packages/compiler/src/optimize/value.rs @@ -331,6 +331,12 @@ pub(crate) fn truthiness(expression: &Expression<'_>, env: &ConstantEnv) -> Opti Expression::UnaryExpression(unary) if unary.operator == UnaryOperator::LogicalNot => { truthiness(&unary.argument, env).map(|value| !value) } + // A sequence evaluates to its last element, which is what a fold + // built from an earlier one leaves in test position. + Expression::SequenceExpression(sequence) => sequence + .expressions + .last() + .and_then(|last| truthiness(last, env)), _ => evaluate(expression, env).map(|value| value.truthy()), } } @@ -380,6 +386,9 @@ pub(crate) fn is_side_effect_free(expression: &Expression<'_>) -> bool { Expression::LogicalExpression(logical) => { is_side_effect_free(&logical.left) && is_side_effect_free(&logical.right) } + Expression::SequenceExpression(sequence) => { + sequence.expressions.iter().all(is_side_effect_free) + } Expression::ConditionalExpression(conditional) => { is_side_effect_free(&conditional.test) && is_side_effect_free(&conditional.consequent) @@ -410,6 +419,30 @@ fn property_key_is_side_effect_free(key: &PropertyKey<'_>) -> bool { } } +/// Splits `expression` into the parts that must still run, flattening any +/// sequence and dropping the parts that can be skipped. +/// +/// A fold that discards a condition can leave a sequence behind, and that +/// sequence often ends in the very constant that decided the fold. Keeping +/// only the parts with effects stops those leftovers from accumulating as +/// folds chain. +pub(crate) fn effectful_parts<'a>( + expression: Expression<'a>, + parts: &mut std::vec::Vec>, +) { + if is_side_effect_free(&expression) { + return; + } + match expression { + Expression::SequenceExpression(sequence) => { + for part in sequence.unbox().expressions { + effectful_parts(part, parts); + } + } + expression => parts.push(expression), + } +} + /// The element count of an array literal with no spread elements, which is /// what `` and friends need to recognize an empty list. pub(crate) fn array_literal_len(expression: &Expression<'_>) -> Option {