• Getting Started
    • Overview
    • Why Without JSX?
    • Installation
    • Usage
    • Styling
    • Theming
    • Portal System
    • Rules & Patterns
    • Framework Integration
    • Compiler
    • FAQ
    • Release Notes
  • MUI Integration
  • Components
  • Hooks

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 mostly an optimization, with one exception worth knowing about: compiled call sites also get a stronger memoization key. See Memoization keys.

Why it exists

Every factory call — Div({...}), P('text', {...}), Node('div', {...}) — normally does three things at runtime, on every single render:

  1. Classifies each prop as a CSS property vs. a DOM attribute (a lookup against 689 known CSS property names).
  2. Hashes the prop signature to produce the stable key that drives element caching.
  3. Resolves theme.* tokens to var(--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:

KeyMeaning
__meo$Marker schema version. Tells the runtime this call site is pre-partitioned.
__meo$cProps already known to be CSS properties.
__meo$dProps already known to be DOM attributes / handlers.
__meo$kThe call site's stable key, derived from its source position.
__meo$dynNames of props whose value can differ between renders, so the runtime knows which ones still need hashing. 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 and hashing 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

Three numbers, because they answer different questions:

BenchmarkResult
Node construction in isolation~4x faster
Client render — mount plus re-renders~1.6x faster
End-to-end SSR render, production build~29% faster

The first figure covers only prop classification and stable-key hashing. 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 other two are page-level. The SSR figure is measured on a 156-node tree under renderToPipeableStream; the client figure on an 85-node tree through mount and 40 re-renders.

The client number matters on its own, because @meonode/ui computes stable keys only on the client — it skips them entirely during SSR. So __meo$k, the call-site key the plugin emits, does nothing on the server and everything in the browser. A compiler benchmark that only measured SSR would miss it completely.

All figures come from production React. Development builds spend so much time in their own validation that they hide the difference — measured against a development build, the client gain reads as 1.03x instead of 1.6x.

Ratios also move with machine load, and not evenly: the compiled path is much shorter, so fixed overhead and garbage collection cost it proportionally more. Across repeated runs on one machine, construction ranged 4.0–6.1x and client render 1.4–1.8x. The figures above are the conservative end of that.

That end-to-end number depends on your @meonode/ui version, because two releases made the runtime cheaper specifically for the shape compiled output has:

AgainstEnd-to-end SSR
@meonode/ui 1.7.015.2%
@meonode/ui 1.7.125.5%
@meonode/ui 1.7.2 and later~29%
  • 1.7.1 made theme-token conversion allocation-free when there is nothing to convert. Compiled props are already token-free, since the tokens were rewritten at build time.
  • 1.7.2 stopped the prop processor allocating per node on the compiled path — no copy of every prop, and no classification work when every key is already partitioned.

The gap stopped widening after 1.7.2. Later releases kept improving both paths — 1.7.3 made theme resolution allocation-free when there is nothing to resolve, which helps compiled and uncompiled input alike — so absolute render times fell while the ratio held. Do not read the trend as "keep upgrading and compiling keeps paying more"; upgrade for the absolute win.

Your mileage depends heavily on how token-dense your styles are. This site averages about 0.8 theme.* strings per compiled call site; a codebase using far more or far fewer will see a different split.

Inline event handlers

Compiler 0.5.0 roughly doubled the construction figure by leaving inline function literals out of __meo$dyn.

dyn names the props whose values the runtime must hash into the stable key because they can change. An inline onClick: () => {} looks like it qualifies and does not: the runtime hashes a function by its source text, which is fixed by the call site, so it always produces the same value — one __meo$k already encodes.

Listing it was worse than redundant. The runtime memoizes that hash in a WeakMap keyed by function identity, and an inline arrow is a new object on every render, so the memo never hit: every render paid a toString() and a hash, for every handler, on every node.

A handler that is referenced rather than written inline — onClick: handler, onClick: cond ? a : b, onClick: makeHandler(id) — can genuinely differ between renders and stays in dyn.

Memoization keys

This is the one place where compiling changes behaviour rather than just speed.

@meonode/ui caches rendered elements for nodes given a deps array, the same way useMemo does. Cache entries are looked up by a key, and uncompiled call sites derive that key from the node's props and its position in the tree. Two memoized subtrees that are structurally identical therefore compute the same key — and one can be served the other's rendered output:

// Two components, identical shape, different content.
const Sidebar = () => Div({ children: [Div({ padding: '8px', children: 'AAA' }, [])] }).render()
const Footer  = () => Div({ children: [Div({ padding: '8px', children: 'BBB' }, [])] }).render()
// Uncompiled, both render "AAA".

Compiled call sites don't have this problem. __meo$k is derived from the call site's source position, so two different places in your code produce two different keys no matter how similar their props are.

The key can't simply include the children instead. deps means "my output depends only on these values", so a memoized node deliberately keeps its previous render when its content changes but its deps don't. A key that moved with content would break exactly that guarantee.

This only affects nodes that pass deps. Nodes without deps are never cached, so they can't be involved. If you use deps on uncompiled call sites, give structurally identical nodes distinct key props — key is folded into the cache key and disambiguates them:

Div({ key: 'sidebar-card', padding: '8px', children: 'AAA' }, [])
Div({ key: 'footer-card', padding: '8px', children: 'BBB' }, [])

@meonode/ui 1.7.4 fixed the reachable variants of this that did not need the compiler: colliding siblings under one root, and separate roots mounted through render() from @meonode/ui/client, which now namespaces each container.

Setup

Install it as a dev dependency — it only runs at build time:

npm install --save-dev @meonode/compiler

It requires @meonode/ui 1.7.0 or later, which is the first version whose runtime understands the current marker contract. On older versions the markers are simply ignored and you get the normal runtime path.

1.7.4 or later is recommended. 1.7.1 and 1.7.2 each removed per-node allocation work that only compiled output can skip, taking the end-to-end gain from 15% to roughly 29%. 1.7.4 additionally fixes an element-cache key collision; see Memoization keys.

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, like key: item.id inside a .map() or a single computed backgroundColor.
  • Two or more effectful values whose relative order survives bucketing.
  • Leading spreads ({ ...props, padding: '8px' }), with a caveat below.

Bails, and is left exactly as written:

  • The callee isn't really a @meonode/ui factory — shadowed locals, namespace imports (import * as M).
  • The props argument isn't a plain object literal.
  • 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.

Special keys — css, props, ref, key, children, as, theme, disableEmotion — are never bucketed. They stay at the top level in their original relative order. key and children in particular keep their exact runtime semantics: compiling never changes how either is evaluated.

Spread-bearing call sites

A spread's contents aren't known until runtime, so a call site containing one gets prop partitioning but no stable key. The marker and buckets are still emitted, so the classification speedup is retained, but __meo$k and __meo$dyn are omitted entirely and the runtime falls back to its normal signature path.

The reason is subtle and worth knowing: the stable key is a function of source position, so it would be identical across evaluations no matter what the spread contained that time. Emitting it would let two evaluations with different props share a cache entry.

Theme tokens

Only prop values are rewritten, never keys:

Div({
  // Rewritten -- direct, bucketed, static string values
  padding: 'theme.spacing.md',
  border: '1px solid theme.base.deep',

  // NOT rewritten -- `css` is a special key, left to the runtime
  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)': { 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. Tokens inside css blocks still work exactly as before — they're just resolved at render time rather than build time.

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.

On this page