Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/compiler-optimize-option.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@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.

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.

- `<Show when>` becomes its children or its `fallback`.
- `<For each>` becomes its `fallback` for an empty array literal or a statically falsy list.
- `<Repeat count>` becomes its `fallback` for a count of zero or less.
- `<Switch>` drops statically false `<Match when>` branches and collapses to a statically true one.
- `<Dynamic component>` 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 `<Portal>`, `<Loading>`, `<Errored>`, and `<Reveal>` 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.
28 changes: 28 additions & 0 deletions packages/compiler/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,39 @@ 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 `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:

- `<Show when>` becomes its children or its `fallback`.
- `<For each>` becomes its `fallback` when `each` is an empty array literal or statically falsy.
- `<Repeat count>` becomes its `fallback` when `count` is zero or less.
- `<Switch>` drops every statically false `<Match when>`, and collapses to a match that is statically true (or to its `fallback` when every match is false).
- `<Dynamic component>` 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 `<Cond>` from `import { Show as Cond } from "solid-js"` folds as `<Show>`. 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.
- `<Portal>`, `<Loading>`, `<Errored>`, and `<Reveal>` 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.
Expand Down
145 changes: 145 additions & 0 deletions packages/compiler/__tests__/optimize.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// 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 = <Show when={false}><div /></Show>;");
expect(code).toContain("_$createComponent");
expect(code).toContain("Show as _$Show");
});

it("rejects a non-boolean value", () => {
expect(() => compile("const view = <div />;", { optimize: "yes" })).toThrow(
/`optimize` option must be boolean/
);
});

it("resolves <Show> like an if", () => {
const taken = optimized("const view = <Show when={true}><div>on</div></Show>;");
expect(taken).toContain("_$template(`<div>on`)");
expect(taken).not.toContain("_$createComponent");

const dropped = optimized("const view = <Show when={false}><div>on</div></Show>;");
expect(dropped).toContain("const view = null");

const fallback = optimized(
"const view = <Show when={0} fallback={<span>off</span>}><div /></Show>;"
);
expect(fallback).toContain("<span>off");
expect(fallback).not.toContain("<div");
});

it("resolves <For> over an empty list", () => {
const empty = optimized(
"const view = <For each={[]} fallback={<span>none</span>}>{i => <li />}</For>;"
);
expect(empty).toContain("<span>none");
expect(empty).not.toContain("_$createComponent");

const dynamic = optimized("const view = <For each={items()}>{i => <li />}</For>;");
expect(dynamic).toContain("_$createComponent");
});

it("resolves <Repeat>, <Switch>, and <Dynamic>", () => {
const repeat = optimized(
"const view = <Repeat count={0} fallback={<span>none</span>}>{i => <li />}</Repeat>;"
);
expect(repeat).toContain("<span>none");

const branch = optimized(
"const view = <Switch fallback={<a />}><Match when={false}><b /></Match><Match when={true}><i /></Match></Switch>;"
);
expect(branch).toContain("<i");
expect(branch).not.toContain("<b");

const dynamic = optimized('const view = <Dynamic component="div" id="main" />;');
expect(dynamic).toContain("_$template(`<div id=main");
expect(dynamic).not.toContain("_$createComponent");
});

it("folds constants into conditions at any scope", () => {
const moduleLevel = optimized(
"const DEBUG = false;\nexport const view = <div><Show when={DEBUG}><b>panel</b></Show></div>;"
);
expect(moduleLevel).not.toContain("panel");
expect(moduleLevel).not.toContain("_$createComponent");

const local = optimized(
"export function App() {\n const DEBUG = false;\n return <div><Show when={DEBUG}><b>panel</b></Show></div>;\n}"
);
expect(local).not.toContain("panel");
expect(local).not.toContain("_$createComponent");

const shadowed = optimized(
"const DEBUG = false;\nexport function App(DEBUG) {\n return <Show when={DEBUG}><b /></Show>;\n}"
);
expect(shadowed).toContain("_$createComponent");
});

it("folds constant expressions and drops unreachable statements", () => {
const attributes = optimized('const view = <div id={"a" + "b"} tabindex={1 + 2} />;');
expect(attributes).toContain("id=ab");
expect(attributes).toContain("tabindex=3");

const dead = optimized("function App() {\n if (false) missing();\n return <div />;\n}");
expect(dead).not.toContain("missing");
});

it("only folds a built-in tag that resolves to Solid's component", () => {
const auto = optimized("const view = <Show when={true}><div /></Show>;");
expect(auto).not.toContain("_$createComponent");

const fromSolid = optimized(
'import { Show } from "solid-js";\nconst view = <Show when={true}><div /></Show>;'
);
expect(fromSolid).not.toContain("_$createComponent");

const fromModuleName = optimized(
'import { Show } from "r-dom";\nconst view = <Show when={true}><div /></Show>;'
);
expect(fromModuleName).not.toContain("_$createComponent");

const aliased = optimized(
'import { Show as Cond } from "solid-js";\nconst view = <Cond when={true}><div /></Cond>;'
);
expect(aliased).not.toContain("_$createComponent");

const foreign = optimized(
'import { Show } from "./my-show";\nconst view = <Show when={true}><div /></Show>;'
);
expect(foreign).toContain("_$createComponent");

const aliasedForeign = optimized(
'import { Show as Cond } from "./my-show";\nconst view = <Cond when={true}><div /></Cond>;'
);
expect(aliasedForeign).toContain("_$createComponent");

const local = optimized(
"function App() {\n const Show = props => props.children;\n return <Show when={true}><div /></Show>;\n}"
);
expect(local).toContain("_$createComponent");
});

it("folds the same way in SSR so hydration ids stay aligned", () => {
const source = "const view = <div><Show when={false}><b /></Show><i /></div>;";
const ssr = optimized(source, { generate: "ssr", hydratable: true });
expect(ssr).not.toContain("<b");
expect(ssr).toContain("<i");
});
});
8 changes: 8 additions & 0 deletions packages/compiler/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ const nativeOptionKeys = new Set([
"staticMarker",
"omitNestedClosingTags",
"omitLastClosingTag",
"optimize",
"serverComponents",
"builtIns",
"renderers"
Expand Down Expand Up @@ -314,6 +315,13 @@ function validateOptions(code, options) {
nativeOptions.wrapConditionals = value;
continue;
}
if (key === "optimize") {
if (typeof value !== "boolean") {
throw new TypeError("@solidjs/compiler `optimize` option must be boolean");
}
nativeOptions.optimize = value;
continue;
}
if (key === "validate") {
if (typeof value !== "boolean") {
throw new TypeError("@solidjs/compiler `validate` option must be boolean");
Expand Down
15 changes: 15 additions & 0 deletions packages/compiler/src/compiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ pub struct CompileOptions {
pub validate: bool,
pub omit_nested_closing_tags: bool,
pub omit_last_closing_tag: bool,
/// Constant-fold the program and eliminate the code and control-flow
/// components that folding proves unreachable. A server build and its
/// client build must agree on this: folding changes the rendered tree
/// shape, and with it hydration ids.
pub optimize: bool,
pub built_ins: Vec<String>,
pub renderers: Vec<Renderer>,
}
Expand Down Expand Up @@ -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(),
}
Expand Down Expand Up @@ -248,6 +254,15 @@ fn compile_inner(source: &str, options: &CompileOptions) -> Result<CompileOutput
)?;
}

if options.optimize {
crate::optimize::optimize_program(
&allocator,
&mut program,
&options.built_ins,
&options.module_name,
);
}

match options.generate {
Generate::Dom => {
let mut transform = AstDomTransform::new(
Expand Down
5 changes: 5 additions & 0 deletions packages/compiler/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ pub struct TransformOptions {
pub validate: Option<bool>,
pub omit_nested_closing_tags: Option<bool>,
pub omit_last_closing_tag: Option<bool>,
/// Constant-fold the program, drop the code that folding proves
/// unreachable, and resolve control-flow components whose props are
/// statically decidable (`<Show when={false}>`, `<For each={[]}>`).
/// Default `false`. Server and client builds must use the same value.
pub optimize: Option<bool>,
/// 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.
Expand Down
1 change: 1 addition & 0 deletions packages/compiler/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ mod error;
mod lazy;
#[cfg(feature = "node")]
mod node_adapter;
mod optimize;
#[cfg(feature = "node")]
mod refresh;
mod shared;
Expand Down
1 change: 1 addition & 0 deletions packages/compiler/src/node_adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ fn core_options(options: TransformOptions) -> Result<CompileOptions> {
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
Expand Down
Loading
Loading