Introduction

Documentation

Introduction

solid-optimizer rewrites JSX before Solid’s JSX transform lowers it. It inlines components, removes context providers, inlines memos, and resolves control flow whose outcome is known at build time. More markup lands in fewer templates, so the browser clones fewer of them, the server sends fewer hydration keys, and fewer component calls run.

your JSX ──▶ solid-optimizer ──▶ JSX ──▶ @solidjs/compiler ──▶ JavaScript

The input and the output are both JSX. The optimizer never lowers JSX itself, so whatever Solid’s JSX transform does with templates, events, and hydration stays the same.

It is built for SolidJS 2.0 and @solidjs/compiler. Try it on your own components in the REPL.

Install

The Solid 2.0 version is published under the next tag. The latest version is for Solid 1.x.

npm i -D solid-optimizer@next
pnpm add -D solid-optimizer@next
yarn add -D solid-optimizer@next

@solidjs/vite-plugin and vite are peer dependencies of the Vite plugin.

Set up Vite

solid-optimizer/vite replaces @solidjs/vite-plugin. It takes the same options and wraps the official plugin, so SSR, server functions, and HMR keep working. Change the import and keep the options.

// vite.config.ts
import { defineConfig } from 'vite';
import solid from 'solid-optimizer/vite';

export default defineConfig({
  // Replaces @solidjs/vite-plugin, with the same options.
  plugins: [solid({ ssr: true })],
});

The optimizer runs on builds. It is off while serving, unless optimizer.dev is set. Pass optimizer to turn passes off or to print a report:

import { defineConfig } from 'vite';
import solid from 'solid-optimizer/vite';

export default defineConfig({
  plugins: [
    solid({
      ssr: true,
      optimizer: {
        // Leave context providers in place.
        contexts: false,
        // Print what stays as written in each build, and why.
        report: true,
      },
    }),
  ],
});

Hydration

The Vite plugin does this for you: a hydrating build runs in module mode, where every decision depends only on the modules, never on how the bundler splits chunks. See Chunk and module mode.

Vite plugin

Chunk and module mode

The plugin optimizes in one of two modes.

Chunk mode

For client builds that do not hydrate. Constants fold and memos inline in each module first, so a branch that folds away takes its imports and lazy() chunks out of the bundle. Components imported from other modules are inlined there too, so the bundler drops them and the runtime code only they used. JSX is then kept through bundling. Each chunk is optimized as a whole and then lowered by Solid’s JSX transform, so a component inlines anywhere in its chunk. Each chunk is also built with every component inlined that can be, and the smaller one is kept.

Module mode

For everything else. Each module is optimized before the official plugin lowers it, with the components it imports from other modules copied in first. A server build and its client build split chunks differently, and hydration needs both to render the same tree, so every decision depends only on the modules. Two modules that import each other do not copy each other’s components.

Set optimizer.mode to 'module' to use module mode everywhere.

Constants across modules

Both modes fold constants imported from other modules in the project.

  • Each import is resolved and loaded by the bundler, so aliases, virtual modules, and plugins that rewrite the module are respected.
  • A module with JSX is read from this plugin’s own output for it. It is skipped when it imports the importer, directly or through other modules of the project, since the two transforms would wait for each other.
  • define entries and import.meta.env values fold in both modes, when every build environment gives them the same primitive value. import.meta.env.SSR never folds, since the server and client builds differ on it.
  • Dependencies are not read, and nothing is read while serving.
Input
// config.ts
export const SHOW_BANNER = false;

// App.tsx
import { Show } from 'solid-js';
import { SHOW_BANNER } from './config';
import { Banner } from './Banner';

export function App() {
  return (
    <main>
      <Show when={SHOW_BANNER}>
        <Banner />
      </Show>
      <h1>Home</h1>
    </main>
  );
}
Output
// App.tsx
import { Show } from 'solid-js';
import { SHOW_BANNER } from './config';
import { Banner } from './Banner';

export function App() {
  return (
    <main>
      <h1>Home</h1>
    </main>
  );
}

// Nothing uses Banner now, so the
// bundler leaves it out.

Options

The plugin takes every option of @solidjs/vite-plugin, plus optimizer.

optimizerOptimizerOptions | false

false uses @solidjs/vite-plugin as it is. Every other option of the plugin is passed to @solidjs/vite-plugin.

optimizer.mode'auto' | 'module'default 'auto'

'auto' uses chunk mode for client builds that do not hydrate, and module mode for everything else. 'module' never keeps JSX through bundling.

optimizer.devbooleandefault false

Also optimizes while serving, in module mode.

optimizer.reportboolean | (report: OptimizerReport) => voiddefault false

Reports what stays as written in each build, and why. true prints it. A function receives it instead, once per build environment. See Report.

optimizer.packagesstring[]default []

Packages whose components are copied into the modules that use them, like ['@kobalte/core']. A package has to publish its JSX, as most Solid libraries do through the solid export condition. Their copies inline wherever they can be, so the bundler drops the originals, even when the package lands in another chunk. A listed package has to be bundled in every build, or the server and client builds copy different components.

optimizer.inlinebooleandefault true

Component inlining. Works like the compile option.

optimizer.alwaysInlinebooleandefault false

Inlines every component that can be, even where the copies are larger than the calls. Works like the compile option.

optimizer.contextsbooleandefault true

Context provider removal.

optimizer.memosbooleandefault true

Memo inlining.

optimizer.foldbooleandefault true

Constant folding and control-flow resolution.

optimizer.maxPassesnumberdefault 10

The most rounds to run. Compilation stops early once a round changes nothing.

Report

optimizer.report lists each component call, provider, and memo the optimizer left alone, grouped by reason. Turn it on when a component you expected to inline stays a component.

solid-optimizer: what stays as written in client
  inline   lazy              4  About, Chart, Dashboard, Settings
  inline   copy-no-host      1  Home (./pages/Home)
  inline   no-host           1  Home

Each line is a pass, a reason, how many times it happened, and the names it happened to. A function receives the report instead of printing it:

solid({
  optimizer: {
    report(report) {
      // report.environment is the Vite environment, like 'client' or 'ssr'.
      for (const skip of report.skips) {
        console.log(skip.pass, skip.reason, skip.name);
      }
    },
  },
});

A package’s own modules are compiled too, but what they leave alone is not reported, since the app cannot change it. The app’s calls of a package’s components are. In chunk mode, a chunk is reported as a whole, so it also lists the package code bundled into it.

Inline pass

ReasonMeaning
props-useview-useThe component uses its props in a way other than props.name, a spread onto an element, or a merge() or omit() view, such as passing them to a function.
view-conflictA spread of the props would repeat an attribute the element already has.
component-paramscomponent-bodycomponent-returncomponent-asynccomponent-recursivecomponent-thisThe component’s own shape is not supported.
component-writeThe component’s statements assign to something declared outside it, like a counter at the top of the module. A copy’s statements run before the JSX around the call, so that JSX would see the write too early.
call-spreadcall-namespaceThe call site passes a spread or a namespaced attribute.
call-refref-useThe call’s ref is an expression other than a name, a function, or an array of them, or the component uses props.ref other than as a ref.
no-hostThe component has statements, and the call is not in JSX that a component or one of Solid’s callbacks returns. A <Show> child or a fallback is one such place.
sizeThe copies would be larger than the calls. See Only where it is smaller.
lazyimportednestednot-a-functionThe tag is not a component declared at the top of the same module or chunk.
copy-packagecopy-cyclecopy-virtualcopy-wrappercopy-sizeCopying a component from another module. It comes from a package that optimizer.packages does not list, its module imports the importer back, the copy would import a virtual module its specifier does not reach from the importer, the component only returns a call of another component in its module, or the copy was larger.

Provider and memo passes

ReasonMeaning
open-componentopen-callopen-newopen-spreadopen-outerProvider pass: code under the provider that it cannot see into.
provider-valueprovider-undefinedprovider-shapeProvider pass: a value or a provider it cannot handle.
memo-readsmemo-read-placememo-constantMemo pass: a memo it keeps, because of how often or where it is read.

The memo pass reports a few more reasons. See strategy.md for the exact rules of each pass.

Limits

  • Chunk mode is skipped when the babel option is set or compiler is 'babel', since those passes need each module.
  • An export named __so_local$name is added to a module for each local binding its exported components need, so a copy in another module can import it. In chunk mode, entry modules get none.
  • A component used in several chunks is copied into each of them, where each copy is no larger than its call.
  • Components from dependencies in node_modules are only copied from the packages optimizer.packages lists.
  • .tsrx modules are lowered by the official plugin and are not optimized.

API

compile

compile(code, options) optimizes one module, or a bundled chunk that kept its JSX. A chunk holds more components in one scope, so more of them inline.

import { compile } from 'solid-optimizer';

const result = compile(code, { filename: 'app.tsx' });

result.code;    // JSX, ready for Solid's JSX transform
result.map;     // a source map, or null when nothing changed
result.skipped; // what was left alone, when `report` is set

The output is still JSX. Run Solid’s JSX transform after it:

import { transform } from '@solidjs/compiler';
import { compile } from 'solid-optimizer';

const optimized = compile(code, { filename: 'app.tsx' });
const output = transform(optimized.code, {
  filename: 'app.tsx',
  moduleName: '@solidjs/web',
  generate: 'dom',
});

compile options

filenamestringdefault 'input.jsx'

Picks the parser and names the source in the source map. .ts and .tsx parse as TypeScript. Anything else parses as JSX.

inlinebooleandefault true

Replaces component calls with the component’s JSX. See Component inlining.

alwaysInlinebooleandefault false

Inlines every component that can be, even where the copies are larger than the calls they replace. See Only where it is smaller.

alwaysInlineComponentsstring[]default []

Top-level components to inline wherever they can be, like alwaysInline does for every component. The Vite plugin lists the copies from optimizer.packages here.

contextsbooleandefault true

Removes context providers whose readers are all visible. See Context inlining.

memosbooleandefault true

Replaces a memo that always makes a new value and is read once with its computation. See Memo inlining.

foldbooleandefault true

Folds constants, removes unreachable code, and resolves control flow whose props are constants. See Folding and control flow.

builtInsstring[]default ['For', 'Show', 'Switch', 'Match', …]

The names of Solid’s built-in components. A tag only folds when it is one of them. An empty list turns control-flow folding off.

moduleSourcesstring[]default ['solid-js', '@solidjs/web']

The modules Solid’s built-ins are imported from.

importedConstantsRecord<string, Record<string, Primitive>>

The constants of imported modules, keyed by the import specifier as the code writes it, then by export name. An import of one of them folds like a local const. See readModuleConstants.

globalConstantsRecord<string, Primitive>

Values of global names and paths, such as { __DEV__: false, 'import.meta.env.MODE': 'production' }. A path starts at import.meta or at a name the module does not declare, and folds like a constant.

sharedComponentsRecord<string, number>

Top-level components that other modules use too, by the number of modules that use them, this one included. Inlining one only counts its share of the declaration. 0 keeps the declaration whatever is inlined.

maxPassesnumberdefault 10

The most rounds to run. One round can expose work for the next, such as an inlined component whose props now fold. Compilation stops early once a round changes nothing.

sourceMapbooleandefault true

Generates a source map.

reportbooleandefault false

Records what the inline, provider, and memo passes leave alone, and why. The result lists them in skipped, each with its pass, a reason code, and a name. See Report.

readModuleConstants

readModuleConstants reads the constants a module exports, and the modules it imports from. Pass them to compile as importedConstants.

import { compile, readModuleConstants } from 'solid-optimizer';

const config = readModuleConstants(configCode, { filename: 'config.ts' });
config.exports; // { SHOW_BANNER: true, THEME: 'light' }
config.imports; // the modules it imports from

compile(appCode, {
  filename: 'app.tsx',
  importedConstants: { './config': config.exports },
});

It keeps export const bindings whose value folds, a let the module never writes to, export default of a constant, and names exported from them. import * as config also works, as config.NAME. A module that re-exports constants from another module takes that module’s constants in its own importedConstants.

The Vite plugin does all of this for you.

Passes

Component inlining

A component call is replaced with the component’s JSX.

Input
function Title(props) {
  return <h1 class="title">{props.label}</h1>;
}

export function App() {
  return (
    <main>
      <Title label="Hello" />
    </main>
  );
}
Output
export function App() {
  return (
    <main>
      <h1 class="title">{'Hello'}</h1>
    </main>
  );
}

Solid then creates one template for <main> instead of two.

  • A read of props.name becomes the attribute’s expression. Solid passes dynamic props as getters, so the expression runs at every read, as before.
  • A static prop that must be evaluated once, like a function read twice, is stored in a const first.
  • {props.children}, and a prop whose value is JSX, splice their JSX in place so it joins the template.
  • Statements in the component body move into the function whose returned JSX holds the call: a component, the child callback of <For> or <Repeat>, the child callback of <Show> or <Match> when it takes the value, or the callback given to render.
  • Bindings declared in the component get fresh names in every copy.
  • A component nothing else uses is removed, with any alias of it that nothing uses.

When a component inlines

  • It is declared once at the top level, with a capitalized name. A call through an alias, like const Separator = SeparatorRoot, is a call of the component it names. So is a call through a compound component, like Kobalte's const Dialog = Object.assign(DialogRoot, { Trigger: DialogTrigger }): <Dialog> calls DialogRoot and <Dialog.Trigger> calls DialogTrigger, as long as nothing writes to a member of either.
  • Its parameter is one identifier that is only read as props.name, spread onto an element as {...props}, or passed to merge() and omit().
  • Its body ends in the only return, and that return returns JSX.
  • It does not use this, arguments, super, or new.target.
  • The call site has no spread attribute and no namespaced attribute.
  • A ref at the call site moves to the element the component gives props.ref to, when its value is a name, a function, or an array of them. In an array, void 0 and untrack(() => ...) around a name or a function count too.

Props with defaults

The props can go through merge() and omit() first, declared as a const in the component:

function Button(props) {
  const merged = merge({ type: 'button' }, props);
  const rest = omit(merged, 'label');
  return <button {...rest}>{props.label}</button>;
}
  • A default has to be a literal, like 'button', 0, or false.
  • omit() has to name its keys as string literals.
  • A view can only be read as view.name, spread onto an element, or passed to another merge() or omit().
  • A spread becomes the attributes the call site passes, and it cannot repeat an attribute the element already has.

Only where it is smaller

Every copy repeats the component’s own code, so inlining a component at many calls can make the output larger. Before inlining, each component’s copies are compared with what they replace, as Solid’s JSX transform would lower them. A copy saves the component call and the getters of its props. It costs the component’s statements, its dynamic attributes and children, and the elements that cannot join a template.

A small component, or one used once, inlines at every call. A wrapper used many times, whose root is another component, stays a component. Set alwaysInline to inline every component that can be.

The estimate weighs each component on its own, so it cannot see runtime code that only goes once every call that needs it is inlined, like <Dynamic>, merge(), and omit() behind a headless library’s components. In chunk mode, each chunk is also built with every component inlined that can be, and the smaller one, minified and gzipped, is kept.

Accepted differences

  • A prop called as props.name() runs with its own receiver as this, not the props object.
  • Moved statements run before the host’s JSX is created. The order of effect registration between siblings can change.

Context inlining

A context provider whose readers are all visible is removed, and each read gets the provider’s value.

Input
const Theme = createContext();

function Label() {
  const theme = useContext(Theme);
  return <span class={theme}>label</span>;
}

export function App() {
  return (
    <main>
      <Theme value="dark">
        <Label />
      </Theme>
    </main>
  );
}
Output
export function App() {
  const theme$1 = 'dark';
  return (
    <main>
      <span class={'dark'}>label</span>
    </main>
  );
}
  • Everything under the provider has to be visible: intrinsic elements, Solid’s built-ins, and components that inline. A call to an unknown function, or a read of a component’s props, keeps the provider.
  • An object value is stored in a const once, so every reader gets the same object.
  • The value must never be undefined.
  • Removing the provider removes its owner, so the hydration keys change. See Hydration.

Memo inlining

A createMemo whose result is new on every run, such as an object, array, or JSX, never stops an update. When it is also read once, as the whole of a tracked JSX expression, the read runs the computation itself.

Input
const style = createMemo(() => ({ color: color() }));
return <p style={style()} />;
Output
return <p style={{ color: color() }} />;

A memo whose computation folds to a literal never changes. Every call of it becomes the literal, when each call comes after the declaration and nothing else uses the memo.

Input
const size = createMemo(() => 1 + 2);
return <p>{size() * 2}</p>;
Output
return <p>{6}</p>;

Solid’s children helper over JSX, read once as an element’s child, becomes that JSX. This is what a component that calls children(() => props.children) leaves once it is inlined with JSX children.

Folding and control flow

This follows the optimize option of @solidjs/compiler.

  • Constant expressions fold, including const bindings and let bindings nothing writes to, at any scope.
  • Branches that a constant condition never takes are removed. So are statements after a return, throw, break, or continue.
  • <Show when> becomes its children or its fallback.
  • <For each> becomes its fallback when each is an empty array literal or a falsy constant.
  • <Repeat count> becomes its fallback when count is below one.
  • <Switch> drops each <Match> that is always false, and becomes the first <Match> that is always true.
  • <Dynamic component="div"> becomes <div>. <Dynamic component={Card}> becomes <Card> when Card is a capitalized component declared at the top level that nothing reassigns.
  • untrack(() => value) becomes the value when the value is a constant.
  • A call of an arrow function whose body is JSX, such as a render prop left by inlining, becomes the JSX when it is an element’s child and each argument is a constant or a binding nothing reassigns.

Constant lists

<For> over a constant list becomes one copy of its row per item, when the copies are estimated to be no larger than the loop.

Input
const LINKS = [
  { href: '/', label: 'Home' },
  { href: '/docs', label: 'Docs' },
];

<nav>
  <For each={LINKS}>
    {(link) => <a href={link.href}>{link.label}</a>}
  </For>
</nav>
Output
<nav>
  <a href={'/'}>{'Home'}</a>
  <a href={'/docs'}>{'Docs'}</a>
</nav>
  • The list is an array literal, or a const of one that only <For> reads and that is not exported.
  • Each item is a primitive literal, or an object literal of them.
  • The row is an arrow function whose body is JSX without a ref or a spread, and reads the item as item.key, or as itself when it is a primitive.
  • Each row had its own owner, so this shifts the hydration keys after it.

What never folds

A built-in only folds when the tag resolves to Solid’s component: it binds to nothing, or is imported from one of moduleSources. A tag with a spread attribute or a function child never folds. <Portal>, <Loading>, <Errored>, and <Reveal> never fold.

A call to Solid’s lazy is marked /* @__PURE__ */, so the bundler drops one that a folded branch left unused, along with its chunk. A condition that is discarded but has side effects still runs: [effect()] && value becomes ([effect()], value).