Compiler
@meonode/compiler is an optional build-time plugin that moves work @meonode/ui would otherwise do on every render into your build step.
It is safe to add or remove at any time. Every call site it can't prove is safe is left completely untouched, and your app runs correctly whether the plugin is installed, misconfigured, or absent entirely.
It is purely an optimization: compiled and uncompiled call sites render identically, so the plugin is safe to add or remove at any time.
Why it exists
Every factory call — Div({...}), P('text', {...}), Node('div', {...}) — does two things at runtime, on every single render:
- Classifies each prop as a CSS property vs. a DOM attribute (a lookup against 689 known CSS property names).
- Resolves
theme.*tokens tovar(--meonode-theme-*)references.
None of that depends on runtime values. It depends only on which prop names appear at the call site and whether the object literal was written with a static shape — all of which is knowable from the source text. So the plugin computes the answers once, at build time, and writes them directly into the call site.
What compiled output looks like
// You write: Div({ padding: 'theme.spacing.md', width, onClick: handler, css: { color: 'red' }, children: [A, B], }) // The plugin emits: Div({ __meo$: 2, __meo$c: { padding: 'var(--meonode-theme-spacing-md)', width }, __meo$d: { onClick: handler }, __meo$k: 'm1a2b3c', __meo$dyn: ['width', 'onClick'], css: { color: 'red' }, children: [A, B], })
Reading that output:
| Key | Meaning |
|---|---|
__meo$ | Marker schema version. Tells the runtime this call site is pre-partitioned. |
__meo$c | Props already known to be CSS properties. |
__meo$d | Props already known to be DOM attributes / handlers. |
__meo$k | The call site's key, derived from its source position. Emitted for older runtimes; the current one ignores it. |
__meo$dyn | Names of props whose value can differ between renders. Emitted for older runtimes; the current one ignores it. Inline function literals are excluded — see Inline event handlers. |
The runtime sees __meo$ and reads the buckets directly instead of re-deriving them, skipping the whole classification pass.
onClick: handler appears in dyn because handler is a reference — it can point at a different function on a later render. An inline onClick: () => {} would not; see Inline event handlers.
Note padding: the theme.spacing.md token has already become a var() reference. @meonode/ui does this same conversion at runtime, but its memoization only helps objects declared outside a render body — an inline call site allocates a fresh props object every render, so the cache never hits and the walk repeats. Doing it at build time changes when the conversion happens, never what it produces.
What it measurably buys
Two numbers, because they answer different questions:
| Benchmark | Result |
|---|---|
| Node construction in isolation | ~1.7x faster |
| Client render — mount plus re-renders | ~1.06x faster |
The first covers prop classification and the theme rewrite only. It excludes React, Emotion and the DOM, so it is not how much faster a page renders — it is the ceiling on what compiling can remove. The second is page-level, measured on an 85-node tree through mount and 40 re-renders.
The gap is modest because the runtime itself is fast: classification and the theme rewrite are all that is left to hoist. Real gains therefore scale with how many theme.* tokens your call sites carry — a token-dense tree benefits most, a token-free one barely at all.
Both figures come from production React. Development builds spend so much time in their own validation that they hide the difference. Ratios also move with machine load, and not evenly: the compiled path is shorter, so fixed overhead and garbage collection cost it proportionally more.
Inline event handlers
dyn names the props whose values can change between renders. Inline function literals are deliberately left out of it: an onClick: () => {} is fixed by its call site, so it can never differ between evaluations of that site.
A handler that is referenced rather than written inline — onClick: handler, onClick: cond ? a : b, onClick: makeHandler(id) — can genuinely differ, and stays in dyn.
Memoization and the call-site key
Compiling changes speed, not behaviour. A memoized node renders inside a React fiber of its own, so its identity comes from React and nothing about it is derived from the compiled output.
__meo$k and __meo$dyn are emitted for runtimes that predate that, which read them to key a global element cache. The current runtime accepts both and strips them, so a bundle compiled here works on either.
Setup
Install it as a dev dependency — it only runs at build time:
npm install --save-dev @meonode/compiler
Next.js
// next.config.ts import type { NextConfig } from 'next' const nextConfig: NextConfig = { experimental: { swcPlugins: [['@meonode/compiler', {}]], }, } export default nextConfig
Pass the package name, not a resolved path. Turbopack resolves the plugin itself, and handing it an absolute path causes it to fail.
Vite
// vite.config.ts import { defineConfig } from 'vite' import react from '@vitejs/plugin-react-swc' export default defineConfig({ plugins: [ react({ plugins: [['@meonode/compiler', {}]], }), ], })
Wrapped factories
If you re-export or wrap factories in your own package, the plugin can't see through that — it only traces imports from @meonode/ui. Name those packages explicitly:
swcPlugins: [['@meonode/compiler', { factoryModules: ['@meonode/mui'] }]]
This site uses exactly that, so its @meonode/mui call sites compile too.
What compiles, and what doesn't
The plugin physically reorders props into buckets, so it only compiles a call site when that reorder provably can't be observed.
Compiles:
- Plain object literals whose values are literals, identifiers, arrow functions, or nested literal-only objects — the common case.
- Any call site with at most one effectful prop value (a call, member access,
await, assignment). This covers the dominant real-world shapes, likekey: item.idinside a.map()or a single computedbackgroundColor. - Two or more effectful values whose relative order survives bucketing.
- Leading spreads (
{ ...props, padding: '8px' }), with a caveat below.
Gets a call-site key but no bucketing (@meonode/compiler 0.6.0 and later):
- A spread appears after a static prop (
{ padding: 1, ...rest }), since the spread would need to win and the merge order can't express that. - Computed keys (
{ [k]: v }), numeric keys, getters/setters, shorthand methods. - Two or more effectful values that bucketing would reorder.
These props can't be partitioned, but the call-site key doesn't depend on them — it's a hash of filename and source position. So the plugin appends just the marker and __meo$k, and the runtime classifies the props exactly as it would uncompiled. No speedup on these call sites.
The marker is appended after any spread, so a spread can never shadow it, and two constant literals evaluated last reorder nothing — which is what makes this safe even on a call site refused for an ordering reason.
Bails, and is left exactly as written:
- The callee isn't really a
@meonode/uifactory — shadowed locals, namespace imports (import * as M). - The props argument isn't a plain object literal —
Div(cond ? {...} : {...}), an identifier, a call. There's no object literal to append the marker to, so these key off props like an uncompiled call site and can still collide. Give them akey.
Special keys — css, props, ref, key, children, as, theme, disableEmotion — are never bucketed. They stay at the top level in their original relative order, and their values are passed through as written, with one exception: theme tokens inside a css object are rewritten in place (see Theme tokens), which changes the CSS text but not where or when the prop is evaluated. key and children in particular keep their exact runtime semantics: compiling never changes how either is evaluated.
Spread-bearing call sites
A leading spread ({ ...props, padding: '8px' }) gets prop partitioning but no call-site key. The marker and buckets are still emitted, so the classification speedup is retained, while __meo$k and __meo$dyn are omitted — the key is a function of source position, so it would be identical across evaluations no matter what the spread carried.
Non-static props also stay flat rather than joining the c/d buckets, which is both what older runtimes require and the faster shape: bucketing them builds two extra objects for the runtime to merge, which costs more than the classification it saves once a spread carries more than a few props.
A trailing spread ({ padding: 1, ...rest }) can't be partitioned at all, and takes a key-only marker.
Theme tokens
Only values are rewritten, never keys — including values nested inside a css block:
Div({ // Rewritten -- direct, bucketed, static string values padding: 'theme.spacing.md', border: '1px solid theme.base.deep', css: { // NOT rewritten -- this is a KEY. `var()` is invalid inside a media // feature, so it must resolve to a concrete value at runtime. '@media (max-width: theme.breakpoint.md)': { // Rewritten. `padding` is back in scope one level down, so this gets // the same treatment it would have at the top level. padding: 'theme.spacing.sm', }, }, })
Media queries and selectors keep their raw tokens and resolve at runtime, where the live theme is available. Template literals (`theme.spacing.${size}`) aren't static, so they're left alone too.
Which CSS variable a token becomes depends on the property it's written against — a length property gets a form carrying the unit. Inside a css block that property is the nearest enclosing key, and selectors and at-rules contribute none, since they name no property:
css: { '&:hover': { padding: 'theme.spacing.sm', // property is `padding` }, }
Two nested shapes stay with the runtime, both because their property name isn't knowable at build time: arrays, and a token used as a non-selector key ({ 'theme.custom.prop': ... }, where the resolved key is the property name).
Values inside
csswere left entirely to the runtime before@meonode/compiler0.7.0.
Verifying it ran
Because bailing is silent and safe, a misconfigured plugin looks exactly like a working one. To confirm it's actually running, grep your build output for the marker:
grep -ro '__meo\$:' .next/server | wc -l
Zero means the plugin isn't loading. Check that you passed the package name rather than a path, and that your host can load SWC WASM plugins at all.
If it doesn't load
The plugin/host boundary is a versioned wire protocol, not a linked ABI, and hosts occasionally bump the plugin ABI generation they accept. A plugin built against one swc_core version can be rejected outright by a host expecting another, usually with an opaque error.
If a framework upgrade suddenly breaks your build, this is the first thing to check — consult the compatibility table in the @meonode/compiler README against your exact Next.js or @swc/core version.
Remember the fallback is total: if the plugin fails to load, @meonode/ui runs correctly without it. You lose the speedup, not the app.
Related FAQ
How do I enable debug logging?
Call setDebugMode(true) at module scope in your entry file, before anything renders. Among other things it warns when a compiled marker looks wrong — a dyn entry naming a prop that isn't present, or a c/d bucket holding a reserved key — which is the quickest way to catch a stale or malformed compiled call site.
How do I install and set up MeoNode UI?
Install @meonode/ui with your package manager of choice. @meonode/compiler is a separate, optional dev dependency registered through next.config.ts or vite.config.ts — see Setup. The runtime behaves identically without it.
How does the theming system work?
Design tokens are defined once and referenced as strings like theme.colors.primary. The compiler rewrites the static ones to CSS variables at build time — including those nested inside a css block — while media-query and selector keys, along with template literals, keep their raw tokens and resolve at render time against the live theme.
More details: /docs/getting-started/faq
Next Steps
- Rules & Patterns — Hooks, memoization, and common pitfalls
- Framework Integration — Next.js, Vite, Remix
- FAQ — Common patterns and edge cases
On this page
- Compiler