• 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 does two things: it moves work @meonode/ui would otherwise do on every render into your build step, and it records one fact about your source that the runtime cannot recover on its own.

Rendering is identical either way. Every call site the plugin can't prove is safe is left completely untouched, and your app renders correctly whether the plugin is installed, misconfigured, or absent entirely.

What is not identical either way is the missing-key report. The plugin marks call sites whose children came from an expression — a .map(), a spread, a helper call — and only those children are handed to React as a list it will check. Without the plugin, React is told every set of siblings was written out by hand, so it never asks for keys on a list that needs them. Removing the plugin therefore removes those reports silently; it changes nothing about what renders. See Generated children.

Why it exists

Every factory call — Div({...}), P('text', {...}), Node('div', {...}) — does two 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. 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 key, derived from its source position. Emitted for older runtimes; the current one ignores it.
__meo$dynNames 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.
__meo$listPresent, as 1, only when this call site's children expression was generated rather than written out. Absent otherwise — there is no 0. See Generated children.
__meo$locWhere the call site was written, as file:line:column. Only emitted if you turn it on — see Call-site locations.

The example above carries no __meo$list, because children: [A, B] is an array the author typed. A call site built from an expression does:

// You write:
Div({ children: items.map(item => Row({ key: item.id, children: item.label })) })

// The plugin emits:
Div({
  __meo$: 2,
  __meo$k: 'm4d5e6f',
  __meo$list: 1,
  children: items.map(item => Row({ key: item.id, children: item.label })),
})

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:

BenchmarkResult
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 unread, so neither key costs you anything on a current runtime.

That does not mean any compiled bundle runs cleanly on any runtime. Each key the plugin has added since has its own floor, and a runtime below it forwards the key instead of reading it. See Runtime compatibility.

Generated children

An unkeyed list reconciles by position, so deleting or reordering a row hands the next one the row before it — its state, its focus, whatever the user had typed. React reports that. @meonode/ui could not, for any list, because render() passes children to createElement as separate arguments, which is React's signal that a human wrote the siblings out.

The distinction cannot be recovered at runtime. Arguments are evaluated before the function they are passed to runs, so by the time Div({ children }) is entered, items.map(fn) has already become an ordinary array — byte for byte the same thing as [A, B, C]. Only the source still knows which one was written, which is why the answer has to be recorded at build time. React's own JSX transform records the same fact at the same point, as isStaticChildren.

The test is deliberately inverted: an array literal whose elements are written out is authored, and everything else is generated.

Authored — no marker, React stays quiet:

Div({ children: [A(), B(), C()] })       // an array you typed
Div({ children: [A(), cond && B()] })    // a conditional slot is still a slot you chose
Div({ children: Span({}) })              // a single factory call returns one node
Div({ children: 'hello' })               // text

Generated — marked, React asks for keys:

Div({ children: items.map(fn) })
Div({ children: [Header(), ...rows] })   // a spread has a runtime length
Div({ children: rows })                  // an identifier: built somewhere else
Div({ children: buildRows() })           // any call that isn't a factory

The ways to generate children have no end — .map, .reduce, .flatMap, Array.from, a loop pushing into an array, an immediately-invoked arrow. A list of patterns to catch would never be finished, and every gap in it is a report you silently never get. The ways to write children out by hand are few, so the plugin matches those instead.

The cost of that choice is one false positive: children: buildRows() — a helper returning hand-written children — is called generated, and you are asked for keys you do not need. React's transform has the identical false positive for the identical reason. Adding a key is cheap; a missing report is not.

Call-site locations

React's report tells you a key is missing. It cannot tell you where the list is, and in a MeoNode app the line it names identifies nothing: Div({...}) only builds a node, and createElement fires later inside .render(), so every element in the tree belongs to that one call. Every missing key in a file reports at the same line.

Turn on callSiteLocations and the plugin records where each generated list was written, so the console names it:

[MeoNode] A generated list at src/app/page.ts:124:6 has children without a `key`.

That line sits beside React's own report, not in place of it — React still says what is wrong, in its own words. It appears only when a row is actually missing a key, so a list you have already keyed stays quiet. Like every other MeoNode diagnostic it is on in development and off in production — and like them, setDebugMode(true) turns it back on in a production build when you need it.

It is off unless you ask for it. The plugin cannot tell a development build from a production one, so rather than guess it emits nothing until you opt in — see Enabling call-site locations for the config.

What you are opting into is source paths in your output. A marked call site in a client component carries its own file:line:column into the browser chunk — app/ClientList.js:7:10, project-relative, readable by anyone who opens the bundle. Call sites in server components stay server-side. On a real application of 55 files with 17 containing a list that came to 759 bytes gzipped, but the size is not the part to weigh.

Generated-children marking needs @meonode/compiler 0.8.0 or later to emit the marker, and @meonode/ui 2.1.0 or later to read it. Call-site locations need @meonode/compiler 0.9.0 and @meonode/ui 2.2.0. Both halves are required in each case: an older plugin emits nothing, and an older runtime forwards the key instead of acting on it, which is why upgrading only one of them changes no behaviour. See Runtime compatibility.

Children-first factories

Factories like P, Span and H1 take children as their first argument, not as a children prop, so that is where the plugin reads them:

Span(items.map(fn), { padding: 8 })   // generated — argument 0 is the list
Span(['a', 'b'], { padding: 8 })      // authored

A children prop passed to one of these is dead at runtime — the factory merges { ...props, children } with argument 0 last, so argument 0 always wins, and the types declare children?: never. The plugin reads argument 0 for the same reason.

A children-first call with no props object at all still gets marked. Span(items.map(fn)) has nowhere to put the marker, so the plugin appends a props argument holding nothing but the marker:

// You write:
Span(items.map(fn))

// The plugin emits:
Span(items.map(fn), { __meo$: 3, __meo$list: 1 })

This is the one case where the plugin changes a call's shape rather than only its props. It is inert: two number literals, evaluated after the children, contributing nothing to what renders. An authored call like Span(['a', 'b']) keeps the arity you wrote — the argument is only added when there is something to report.

Runtime compatibility

The plugin gained emissions over time, and a runtime strips only the marker keys its own version knows about, matched by exact name. A key it does not recognise is not ignored: it is forwarded as an ordinary prop, and React rejects the attribute name. Nothing reaches the DOM — React refuses to write the attribute rather than writing it — so the cost is a console line per render per call site, on the same channel the missing-key reports use.

Four different floors, because they are four different numbers:

Compiled outputNeeds @meonode/uiBelow that
Schema 2 marker and the c/d/k/dyn buckets1.7.01.6.2 and earlier have no marker support at all, so all five keys are forwarded
Schema 3 marker — call sites that get a key but no bucketing1.8.01.7.0–1.7.8 accept only schemas 1 and 2, so the whole marker object is forwarded
__meo$list2.1.0Every earlier version, 1.x and 2.0.x alike, forwards it
__meo$loc — only if you enable call-site locations2.2.02.1.0 forwards it while stripping __meo$list beside it cleanly

The schema 3 floor has been 1.8.0 since @meonode/compiler 0.2.0; it is not new in this release, and it was previously documented as 1.7.0.

There is no programmatic way to feature-detect this — @meonode/ui does not export its schema table — so the check is observable. If your console shows

Invalid attribute name: `%s` __meo$list

then your runtime is below 2.1.0. The same line naming __meo$loc means it is below 2.2.0, and that one only appears if you enabled call-site locations. Either way the runtime is forwarding the key rather than reading it: upgrade @meonode/ui, or drop the plugin if you cannot yet. Nothing else is affected.

Below any of these floors the plugin still works. You lose that key's benefit and gain console noise, not correctness.

Setup

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

npm install --save-dev @meonode/compiler

The current plugin needs @meonode/ui 2.1.0 or later to get everything it emits by default, and 2.2.0 if you enable call-site locations. It works against anything from 1.7.0, with the caveats in Runtime compatibility.

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.

Enabling call-site locations

Off by default. Turning it on makes the plugin record where each generated list was written, so a missing-key report names the call site instead of the one .render() every element in the tree shares — see Call-site locations for what you get.

// next.config.ts
const nextConfig: NextConfig = {
  experimental: {
    swcPlugins: [['@meonode/compiler', { callSiteLocations: true }]],
  },
}
// vite.config.ts
react({
  plugins: [['@meonode/compiler', { callSiteLocations: true }]],
})

It costs a short string per marked call site — 759 bytes gzipped across a 55-file application — and nothing at all in a build that leaves it off. The strings are source paths, so weigh them as disclosure rather than as size.

The option is a build-time switch, so those strings are in the output whenever it is on, production included, and a call site in a client component puts its path in a browser chunk. What is runtime-gated is only whether the line is printed: silent in production by default, and back with setDebugMode(true) if you are chasing something in a deployed build. Turning the flag off does not remove the paths.

This is the same kind of information a sourcemap carries, but Next does not serve browser sourcemaps unless you ask for them, so switching this on in a production build publishes file names and line numbers that were not otherwise public. Plenty of projects are content with that; it is a decision rather than a defect. If you would rather not make it, enable the option in your development config only — that is what keeps the paths out of a production bundle, and turning the runtime flag off will not.

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. (The one thing it does beyond rewriting props is append a props argument to a props-less children-first call — see Children-first factories.)

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.

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/ui factory — 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 a key.
  • No props argument at all — Div(), Node(El). Nothing to mark, and nothing worth marking. The single exception is a children-first call whose children are generated, which gets a props argument appended rather than being left alone; see Children-first factories.

A props argument that was written but isn't an object literal is never replaced — that would discard a value you wrote.

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, or when, either is evaluated. It can change how children are handed to React — a marked call site passes them as one array rather than as separate arguments, which is what lets React check them for keys — but the values, their order, and when they are computed are identical either way.

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 css were left entirely to the runtime before @meonode/compiler 0.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.


What does MeoNode warn about, and how do I enable debug logging?

The one warning that concerns compiled output fires when a marker's c/d bucket holds a reserved key that would shadow a top-level value — the quickest way to catch a stale or malformed compiled call site. It is gated behind setDebugMode(true), called at module scope in your entry file before anything renders.

With call-site locations enabled there is a second one: a [MeoNode] line naming where a generated list without keys was written. That is not behind setDebugMode — it appears in an ordinary development run, like the theme diagnostics. And like them, in a production build only if you set the flag.

The missing-key reports this page describes are React's own, not MeoNode's, and are not gated behind anything.

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

On this page