Plumeria 19.2
Plumeria v19.2 lets a component take a style through a prop named the same as the one elements take.
That is the spelling the StyleProps, WithoutProperties and StaticStyles references show, and until now it did not work: the style was resolved at the call site and handed over under a renamed attribute the component never read, so it arrived as nothing. 19.1.7 attempted the same thing and broke three releases doing it. This is the second attempt, and what changed is not the mechanism but where the decision comes from.
A unit the declaration writes itself
A dynamic style can spell its own unit, and that is the natural way to write one:
const styles = css.create({
fill: (pct: number = 0) => ({ width: `${pct}%` }),
});It compiled to width: var(--v)%, and the % never reached the number. CSS substitutes a custom property as tokens rather than as text, so the declaration was read as a variable followed by a stray %, found invalid, and dropped. The element kept its unstyled width — no error, no warning, and nothing in the sheet that looks wrong. A rating bar written this way rendered at full width.
It now compiles to width: var(--v), and the element sets --v: 20%. The unit moves out of the declaration and into the value, which is the only place a variable can carry one. A unit written inside calc() moves the same way: calc(100% - ${gap}px) compiles to calc(100% - var(--v)).
One parameter can land in a declaration that writes the unit and in one that does not, and 4 and 4px cannot both be the value. Each rule gets its own variable — the split a parameter crossing the unitless rule already got, now made once per unit rather than once per pair:
const styles = css.create({
box: (n: number) => ({ width: `${n}px`, margin: `${n}rem`, opacity: n }),
});Three units, three variables, one argument read at the call site.
19.2.3 finishes the unit list. vb and vi were read, and so were svw, svh, svmin and svmax, but the block and inline axes of the small, large and dynamic viewport were missing, so a declaration writing dvb was still emitted after the variable and dropped. No CSS unit is left out now.
An installed plugin no longer follows @plumeria/utils forward
19.2.1 changes one thing, and none of it is in the compiler. Every Plumeria package that depends on another one now names an exact version instead of a caret range.
@plumeria/utils is not a public surface. It is the seam the bundler packages share — since 19.1.1 the plugin and the Turbopack loader compile a file through the same implementation, and the scan tables travel across that seam. A caret range let a plugin you had already installed resolve @plumeria/utils to a version published long after it.
18.5.3 removed the variant tables from the scan result. Every @plumeria/unplugin, @plumeria/compiler and @plumeria/turbopack-loader below that version reads one of them off the result directly, and its caret range resolves @plumeria/utils to 18.5.4. Installing one of them today fails before the first file compiles:
TypeError: Cannot convert undefined or null to objectThe table it asked for is not there. Nothing in the lockfile changed to cause it; publishing a patch of a different package was enough.
18.0.0 through 18.5.2 of those three packages are deprecated on npm for this. An install that already works is not affected — the resolution it recorded is the one it keeps — but a fresh install of any of them cannot succeed. 18.5.3 and 18.5.4 stopped reading the tables and are unaffected.
Pinning cannot be applied backwards, so the versions published before 19.2.1 keep the ranges they were published with. What it does is end the class of failure going forward: from 19.2.1 on, a Plumeria package can only ever be paired with the @plumeria/utils it was released with.
Two other deprecations
@plumeria/utils 19.1.7, 19.1.8 and 19.1.9 are deprecated. They carry the classStyle regression described in the 19.1 release notes, which is reverted in 19.1.10 and solved properly in 19.2.0. The regression is in the transform, so the warning appears on @plumeria/utils rather than on the plugin that pulled it in.
There is also a fix worth pointing at that carries no warning at all, because no version of it is deprecated. 19.1.6 gave a bracket group's key a cell in the combination table even when it sets none of the colliding properties; before it, selecting such a key missed the lookup and dropped every other group's styles. That is not a 19.1 regression — the combination table has behaved this way since it was introduced on July 30, 2026, so every release before 19.1.6 is affected. It fails silently, which is why it went unnoticed for six weeks. If you write bracket groups and have seen styles disappear for one combination and not another, this is the one.
The half that already worked
A component that applies a style it received is compiled into a lookup. The key is the style; the value is the classes it resolves to, merged under whatever base the component applies:
const styles = css.create({
base: { fontSize: '14px' },
tinted: { color: 'teal' },
});
export function Text({ classStyle }: { classStyle?: css.Style }) {
return <span classStyle={[styles.base, classStyle]} />;
}Compiled:
export function Text({ classStyle }) {
return <span className={"xbief8v4" + " " + ({"x672n4nd":"xxcejlqg"}[classStyle] || "")} />;
}That table is built correctly, and has been all along. A prop of any other name is handed the key it is waiting for:
<Renamed styleArray={"x672n4nd"} />Only the prop named the same as the element's was not. It was read as the element's own, resolved on the spot, and renamed:
<Text className={"xxcejlqg"} /> // before 19.2Text reads classStyle, was given className, and saw undefined. The lookup missed, and the span rendered with its base and nothing else.
Why the call site cannot decide alone
Two components, written identically at the call site, need opposite treatment:
<Link classStyle={styles.tinted} /> // forwards className to its own element
<Text classStyle={styles.tinted} /> // reads classStyle and resolves itLink needs the classes. Text needs the key. Nothing in either line says which.
19.1.7 decided it from a table of which components had been passed a style. That table is filled by call sites — the very thing being classified — so Link appeared in it the moment someone wrote classStyle on it, and was handed a key it could not read. Every <Link classStyle={...}> in this documentation broke, which is what took three releases to surface and revert.
Decided by what the component declares
19.2 reads the other side. A component that declares a prop is recorded as a receiver for it, from its own parameter list:
Text.tsx-Text ["classStyle"]Link comes from a dependency, is never scanned, and is therefore absent — so it keeps the element's reading and gets the classes, which is what it needs. No list of exceptions, and nothing to keep up to date.
Compiled:
<Link href="/blog" className={"xxcejlqg"} />
<Text classStyle={"x672n4nd"} />Where a received style is applied does not have to be read. A style taken through a prop is already rejected unless it reaches the style prop or css.use(), so a component that declares one and compiles has applied it to one of the two. Both are the same receiver:
export function Chip({ classStyle }: { classStyle?: css.StaticStyles }) {
return <span className={css.use(styles.base, classStyle)} />;
}A dynamic style travels the same way. The key carries the custom properties with it, and the component spreads them onto the element it renders:
<Panel classStyle={{ key: "xjbuabpu", vars: { "--x4laf45v-w": "4px" } }} />A call site the scan has not seen
The decision rests on the scan having read the component's file. Where it has not, there is no key to hand over, and the call site is read as the element's prop — which is what it compiled to before this release. The styles do not reach the component, exactly as they did not in 19.1, and nothing malformed is emitted. 19.1.7 wrote a raw object into the attribute in that case.
Upgrading
From 19.1. Nothing to do, and nothing to configure. A component taking a style through a prop of its own name is untouched, an element is untouched, and a component the scan does not know keeps the reading it had. What changes is that a component declaring the element's prop name now receives the style instead of losing it, so such an element gains declarations rather than losing any.
If you renamed a prop to work around the loss, the rename is still the clearer spelling and nothing asks you to undo it. If you moved a style to the caller's own element instead, it can move back.
Read the release notes of 19.1 if you are on 19.1.7, 19.1.8 or 19.1.9, which carried the first attempt at this and are superseded by 19.1.10.
Release notes
19.2.15 (Sep 23, 2026)
- Fix: fail the build on a style value that cannot be known at build time, instead of resolving it away. A value the compiler could not read produced no declaration at all —
letterSpacing: read(), a method chain, a constructor, a tagged template, a ternary, an array literal, the same written behind a localconst, and an identifier or member expression that resolves to nothing. The build succeeded, nothing was printed, the rule was absent from the sheet and the element came out with an empty class, so the only way to notice was to look at the screen.css.create,css.createStatic,css.createTheme,css.keyframesandcss.viewTransitionnow compare the keys they were handed against the keys they resolved and fail on any that went missing, naming the property, the expression behind it, and the function form as the way to pass a value that is only known at runtime. Literals, locally resolvable constants, template literals built from them, spreads, computed keys,nulland style functions still compile as they did. A property goes unchecked in one situation only: something later in the same object replaced what it was given — a second use of its key, or a spread that carries that key — where comparing the two would reject a value that resolved perfectly well. A property is only rejected where the style still uses it: a later property or a later spread writing the same key replaces it, and the value it replaced is never looked at. That rule holds wherever the value was written — at the call site, behind a localconst, inside aconstan object spreads in, or in the object a style function returns, where a value that does not come from the parameter is rejected the same way whilewidth: wcompiles to a variable as it always did.
19.2.14 (Sep 22, 2026)
- Fix: order the transform ahead of the framework plugins. The plugin declared
enforce: 'pre', which only wins against plugins that do not, so a framework plugin that also enforces pre and was listed first compiled the JSX away before Plumeria ever saw the style prop; the transform hook now declares its ownorder: 'pre', and the Vite adapter keeps that order when it rewraps the hook. Solid was the visible case — the style prop produced no class and no CSS at all - Fix: write the class attribute a non-React renderer reads. The style prop rewrite emitted
className, which only reaches the DOM on renderers that alias it, so on Solid the class silently never landed and the CSS never applied; the Vite adapter now recognises a Solid build from its resolved plugins and writesclassinstead, under the same name it reads an existing attribute to merge with. Dynamic function keys keep their companionstyleattribute either way, and a React build is unchanged
19.2.13 (Sep 22, 2026)
- Perf: collect a module's imports from its body. Building the alias and import maps ran a full AST walk to reach declarations that can only appear at the top level; the transform now iterates the module body
- Perf: reuse the source buffer when splicing the replacements. Writing the transformed file encoded the source into a second buffer that the parse step had already built. On a 17 KB module with 26 imports and 240 top-level declarations the two together save about 170 µs, roughly 9% of the SWC parse of the same file; modules the size of the benchmark's fixtures are unchanged
19.2.12 (Sep 22, 2026)
- Perf: take a file's own table aliases from the scan's contribution index. Building the keyframes, view transition, create, theme and static-create alias maps walked every key of the project-wide table and matched it against the file's path, once per table; the scan already records which keys each file contributed, and the transform and the compiler now read that index
- Perf: hand back the scanned tables that are only read by key as overlays. Every snapshot copied all of them entry by entry; only the tables a caller enumerates are copied now, and the rest sit on a prototype chain over the scanned table
- Perf: overlay a file's local values on the scanned static table. Resolving a dynamic style's parameters, a member expression's root identifier and a file's local constants each rebuilt the project-wide static table to add a handful of entries
- Perf: read a compile pass's tables through one overlay. Each transform took its own copy of the scanned tables before layering the file's entries on top; a compile now layers over a base that is rebuilt only when the scan changes. Transforming 200 components across a 601-file project drops about 20%, and a single edited module about 8%; a hub that reparses 200 files on every edit is unchanged, since the parsing dominates it
19.2.11 (Sep 21, 2026)
- Perf: index a JSX attribute by its owning element. Resolving the component behind an attribute walked every opening element in the file and re-filtered its attribute list, twice per attribute; the opening-element pass now records each attribute as it goes
- Perf: index a component's prop entries by call site. Every style expression filtered the project-wide entry list for the component and the prop it was written on; the entries belonging to the current file are now bucketed by position once per list
- Perf: order the replacements once before resolving deferred sources. Each deferred expression copied and re-sorted the whole replacement list, and the final splice sorted it a second time
- Perf: overlay the file-local aliases on the scanned tables. Transforming one file copied every keyframes, view transition, create, theme and static-create entry in the project before adding its own aliases; the aliases now sit on top of the scanned table instead
19.2.10 (Sep 21, 2026)
- Perf: collect a file's extracted stylesheets in a set. Every sheet was matched against the growing array, and the on-demand pass rebuilt its own lookup from that array on each call, so a file's own sheets were scanned once more for every style it compiled. Both now write through one insertion-ordered set, and
extractOndemandStylestakes the collector rather than the array it was expected to stay in step with - Perf: read a misordered property's reported position from the index
sort-propertiesalready builds. The rule maps every property to its sorted position to decide what is out of order, then scanned the sorted list again once per report
Both contributed by @Anionix.
19.2.9 (Sep 18, 2026)
- Fix: scan a file again once an import that resolved to nothing names a real file. Deleting a module a component imports its styles from leaves the importer holding a specifier that resolves nowhere, and restoring the file only scanned the file itself: the importer was never visited again, so a style handed to a child through a prop stayed missing and the child kept rendering
className="". A scan that finds files it has not seen now revisits whatever was left holding an unresolved specifier
19.2.8 (Sep 18, 2026)
- Perf: share a file's modification time across one scan while resolving exports. Resolving an export checks on disk whether the file it names has moved on, and a barrel re-exported by every consumer was checked once per consumer. The result now lives for the length of the scan and is dropped when it ends: about 5% off a rescan that reparses 201 files, and 2% off a cold scan
- Perf: match a component's prop entries through a key set rather than a scan of the ones already collected. Each entry was compared against every entry already held, serializing both sides' conditions on the way, so the work grew with the square of the call sites a component is used from. A component used from 1600 call sites scans about 6% faster; below a few hundred call sites the difference is not measurable
19.2.7 (Sep 18, 2026)
- Perf: read a file's exports once per scan rather than twice. Every parsed file had its imports and exports walked before the scan and again at the end of the first pass, resolving each import specifier a second time on the way. A rescan that reparses 201 files drops about 3%, and 400 file system lookups go with it
- Perf: order the entries a component's prop collects by code unit rather than by locale.
localeComparebuilds the collator the first time it runs, and that landed inside the first scan of every process: about 7 ms, paid once per worker by a build that scans from several. Paths are internal keys and now sort the way the scanned file list already does
19.2.6 (Sep 18, 2026)
- Perf: remove a changed file's entries from the aggregated scan tables by name, instead of searching every table for them. Each invalidated file searched all of those tables, so a rescan cost the number of changed files times the size of the project: editing a module that 200 others import spent more than half of the rescan inside that search. The keys a file writes are now recorded as it writes them, including the ones it published before it threw, and the same edit rescans in about half the time
19.2.5 (Sep 18, 2026)
- Fix: read a relative import written for
NodeNextas the file it names. UndermoduleResolutionset tonode16ornodenexta relative specifier has to carry the.jsextension TypeScript will emit, so./styles.jswas looked for on disk exactly as written and never reachedstyles.ts: acreateor akeyframesdeclared there was left out of the sheet and the element lost the class, while the build passed. A.jsspecifier now falls back to.tsand.tsx, and.jsxto.tsx, only where the file it names is not itself written
19.2.4 (Sep 18, 2026)
- Fix: resolve a
pathsalias from thetsconfig.jsonas it is actually written. The file was read as strict JSON, from the root config alone, so a config carrying comments or a trailing comma, one saved with a byte order mark, one inheritingpathsthroughextends, and the scaffolded pair that keeps its real config underreferenceseach left the alias resolving to nothing. Nothing reported it: akeyframesor acreateimported under such an alias was left out of the sheet and the element lost the class, while the build passed. An inherited substitution is anchored to the config that declares it, the waytscanchors it - Fix: match the longest alias prefix rather than the first one written. Where
@/*and@/lib/*are both declared,@/lib/buttonwas taken under@/*, so a file that happened to sit at that path was compiled in place of the real one - Fix: keep reading a substitution that is neither relative nor written under a
baseUrl.tscrejects one, but the resolver has always taken it, so a project that compiles its styles without ever runningtscresolves as it did.baseUrlitself stays unread: it is deprecated in TypeScript 6 and stops functioning in 7, and a substitution written against one has never resolved here
19.2.3 (Sep 17, 2026)
- Fix: read
svb,svi,lvb,lvi,dvbanddvias a unit the declaration writes itself, the way the rest of their family already was. The block and inline axes of the small, large and dynamic viewport were missing from the unit list, so a declaration writing one of them was still emitted after the variable and dropped as invalid
19.2.2 (Sep 17, 2026)
- Fix: move a unit the declaration writes itself into the value the element sets, instead of leaving it after the variable.
width: ${pct}%compiled towidth: var(--v)%, and CSS substitutes a variable as tokens rather than text, so the%never joined the number: the declaration was invalid, was dropped, and the element fell back to its unstyled width with nothing reported. The same applies to a unit written insidecalc() - Fix: split the variable when one parameter lands both in a declaration that writes the unit and in one that does not, the way a parameter crossing the unitless rule was already split
19.2.1 (Sep 16, 2026)
- Fix: name an exact version for every dependency on another Plumeria package, instead of a caret range.
@plumeria/utilscarries the scan tables between the bundler packages and is not a stable surface, and a caret range let an already installed plugin resolve it to a version whose tables no longer had the shape that plugin reads - Deprecate:
@plumeria/unplugin,@plumeria/compilerand@plumeria/turbopack-loader18.0.0 through 18.5.2 on npm. Each reads a scan table that 18.5.3 removed, and its caret range resolves@plumeria/utilsto 18.5.4, so a fresh install throws before it compiles anything. 18.5.3 and 18.5.4 are unaffected - Deprecate:
@plumeria/utils19.1.7, 19.1.8 and 19.1.9 on npm, which carry theclassStyleregression reverted in 19.1.10
19.2.0 (Sep 15, 2026)
- Feat: a component can take a style through a prop named the same as the one elements take. The call site is handed the key the component's lookup table is built to resolve, instead of resolving the classes and renaming the attribute to one the component never reads
- Feat: decide that from the component's own declaration rather than from the call sites that pass to it. A component outside the project is never scanned, so it is absent from the table and keeps the element's reading, which is what one forwarding
classNameto its own element needs - Fix: fall back to the element's reading for a call site the scan has no entry for, rather than emitting an attribute nothing resolves