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 ──▶ JavaScriptThe 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@nextpnpm add -D solid-optimizer@nextyarn 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.
defineentries andimport.meta.envvalues fold in both modes, when every build environment gives them the same primitive value.import.meta.env.SSRnever folds, since the server and client builds differ on it.- Dependencies are not read, and nothing is read while serving.
// 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>
);
}// 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.
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 HomeEach 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
| Reason | Meaning |
|---|---|
props-useview-use | The 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-conflict | A spread of the props would repeat an attribute the element already has. |
component-paramscomponent-bodycomponent-returncomponent-asynccomponent-recursivecomponent-this | The component’s own shape is not supported. |
component-write | The 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-namespace | The call site passes a spread or a namespaced attribute. |
call-refref-use | The 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-host | The 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. |
size | The copies would be larger than the calls. See Only where it is smaller. |
lazyimportednestednot-a-function | The tag is not a component declared at the top of the same module or chunk. |
copy-packagecopy-cyclecopy-virtualcopy-wrappercopy-size | Copying 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
| Reason | Meaning |
|---|---|
open-componentopen-callopen-newopen-spreadopen-outer | Provider pass: code under the provider that it cannot see into. |
provider-valueprovider-undefinedprovider-shape | Provider pass: a value or a provider it cannot handle. |
memo-readsmemo-read-placememo-constant | Memo 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
babeloption is set orcompileris'babel', since those passes need each module. - An export named
__so_local$nameis 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_modulesare only copied from the packagesoptimizer.packageslists. .tsrxmodules 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 setThe 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
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.
function Title(props) {
return <h1 class="title">{props.label}</h1>;
}
export function App() {
return (
<main>
<Title label="Hello" />
</main>
);
}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.namebecomes 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
constfirst. {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 torender. - 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'sconst Dialog = Object.assign(DialogRoot, { Trigger: DialogTrigger }):<Dialog>callsDialogRootand<Dialog.Trigger>callsDialogTrigger, 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 tomerge()andomit(). - Its body ends in the only
return, and thatreturnreturns JSX. - It does not use
this,arguments,super, ornew.target. - The call site has no spread attribute and no namespaced attribute.
- A
refat the call site moves to the element the component givesprops.refto, when its value is a name, a function, or an array of them. In an array,void 0anduntrack(() => ...)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, orfalse. 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 anothermerge()oromit(). - 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 asthis, 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.
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>
);
}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
constonce, 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.
const style = createMemo(() => ({ color: color() }));
return <p style={style()} />;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.
const size = createMemo(() => 1 + 2);
return <p>{size() * 2}</p>;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
constbindings andletbindings nothing writes to, at any scope. - Branches that a constant condition never takes are removed. So are statements after a
return,throw,break, orcontinue. <Show when>becomes its children or itsfallback.<For each>becomes itsfallbackwheneachis an empty array literal or a falsy constant.<Repeat count>becomes itsfallbackwhencountis 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>whenCardis 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.
const LINKS = [
{ href: '/', label: 'Home' },
{ href: '/docs', label: 'Docs' },
];
<nav>
<For each={LINKS}>
{(link) => <a href={link.href}>{link.label}</a>}
</For>
</nav><nav>
<a href={'/'}>{'Home'}</a>
<a href={'/docs'}>{'Docs'}</a>
</nav>- The list is an array literal, or a
constof 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
refor a spread, and reads the item asitem.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).