Plumeria

Plumeria 19.1

Current API: plumerialint is now plumeria-lint (19.11); see @plumeria/eslint-plugin.

Sou Watanabe
Sou WatanabeCore team members

Plumeria v19.1 tightens the type constraints v19.0 introduced and adds a type for the prop that takes a single style.

The 18.5 patches spent four releases closing the paths where a style expression compiled to nothing. What they could not change is when you find out. A build error arrives after you have written the file; a type error arrives while you are writing it. That is the move 19.0 made, in four parts:

  1. Style accepts only created styles — an inline object is a type error, not a build error.
  2. StyleProps, WithoutProperties and StaticStyles — a component can state which properties it accepts through a style prop.
  3. StaticStyles rejects a dynamic style — the one thing css.use() cannot carry, said at the call site.
  4. validate-at-rules — a lint rule for the one key the type system cannot reach.

19.1 adds a fifth, and makes the first two hold in the cases 19.0 let through:

  1. AtomicStyle — a prop that takes a single style can say what has to be in it, and read it back.

19.1.1 adds a sixth, and it is not a type. The 18.5 patches kept fixing the same bug in two places because the plugin and the Turbopack loader each carried their own copy of the compiler:

  1. One transform — both bundler packages compile a file through the same implementation, in @plumeria/utils.

19.1.2 adds a seventh, and it is not in the library at all. Everything above is about the moment a mistake is caught; this one is about the moment before any of it, when a project has eight packages to choose between and has not written a style yet:

  1. npx @plumeria/init — one command reads the project and writes the setup, and one answer configures the compiler and the linter together.

19.1.3 adds an eighth, and it is about none of the above. Everything so far is about a mistake you make in a file; this one is about a file arriving somewhere it had never been asked to arrive — a build split into three, where a server component's styles reach the browser by a route the plugin had never taken:

  1. React Server Components — Waku and @vitejs/plugin-rsc are supported, and the whole app's atomic CSS ships as one sheet.

19.1.4 adds a ninth, with no runtime changes:

  1. Clearer created style types — hovering one shows its atomic class names again.

19.1.6 adds a tenth, and it is the first that is about the output rather than the input. Everything above decides what you are allowed to write, or when you are told; this one is about what styles[key] turns into once you have written it:

  1. A variant table pairs only what collides — combinations are built per group of colliding properties, and a key that sets none of them no longer drops the other groups.

19.1.7 adds an eleventh and a twelfth. Sections 1 to 5 give a component a type for the style it receives; the transform read the prop carrying that style by its name alone, and the name the reference writes was the one it read as the element's own:

  1. A component's style prop can take the element's name — the style reaches the component, instead of being resolved at the call site and lost on the way.

  2. An error names the file that failed — a style read through import * as is traced to the file that declared it, even through a file that only re-exports it.

19.1.8 adds a thirteenth, and it closes the last place the two compilers disagreed about what they would accept:

  1. The compiler refuses what the plugins refuse — a declaration it cannot register, and a dynamic function key handed to css.use(), are errors instead of missing rules.

Sections 1 to 4 describe what 19.0 shipped and 19.1 keeps; sections 5 through 13 and Upgrading are what is new.


1. Style accepts only created styles

- <div classStyle={{ color: 'red' }} />
+ <div classStyle={styles.text} />

Style used to be written in terms of CSSProperties, the shape of the objects you hand to css.create. That made an inline object structurally valid, so it type-checked, and the compiler rejected it a step later. The two layers disagreed, and the gap was covered by a lint rule you had to be running.

Style is now written in terms of what css.create returns. Each property of a created style is a class name carrying the property it was generated for, and a plain string is not one of those, so the object literal has nowhere to land:

type Style = StyleList<StyleNamespace>;

The type reaches further than the rule it overlaps with. no-inline-object sees the object handed directly to css.use(); it does not see the one nested inside a style list. The type sees both:

css.use({ color: 'red' });                          // rule and type
css.use([styles.text, { ':hover': { color: 'blue' } }]);  // type only

2. A prop can name the properties it accepts

A design system component owns a shape. Letting a caller override its padding is deliberate; letting one override its position is how a card escapes its grid. Until now the type had no way to say which is which, because a compiler can check that a style resolves — it cannot know which overrides you meant to allow.

StyleProps enumerates them:

type TextProps = {
  styleArray?: css.StyleProps<'color' | 'fontSize'>;
};

WithoutProperties is the inverse, for the common case of protecting a few properties and leaving the rest open:

type PanelProps = {
  styleArray?: css.WithoutProperties<'position' | 'top' | 'left'>;
};

Both reach into nested blocks. A property cannot slip past the constraint by being written inside a pseudo class or an at-rule:

// Rejected by WithoutProperties<'position'>
<Panel styleArray={styles.hoverPinned} />
const styles = css.create({
  hoverPinned: {
    ':hover': {
      position: 'fixed',
    },
  },
});

3. StaticStyles rejects a dynamic style

A dynamic style is a style defined as a function. Its values are computed by the caller, so they reach the element as custom properties — and a class name string has no room for a custom property. That is why 18.4.1 made css.use() report one instead of dropping it.

Reporting it is still a build error. A component that renders through css.use() can now say so in its own signature:

type ChipProps = {
  styleArray?: css.StaticStyles;
};

function Chip({ styleArray }: ChipProps) {
  return <span className={css.use(styles.base, styleArray)} />;
}
<Chip styleArray={styles.tinted} />     // ok
<Chip styleArray={styles.sized(120)} /> // Type error

Written without a type argument, StaticStyles accepts every property and rejects only the dynamic style, which is the shape most components want. A type argument narrows the properties as well, so StaticStyles<'color'> is StyleProps<'color'> without dynamic styles.

A component that applies its styles through classStyle has no such limit and should keep using StyleProps or WithoutProperties.

4. An unrecognised at-rule is reported

A key inside a style object is the one place the type system cannot help. css.create infers its argument through a generic constraint, and a constraint check performs no excess property check, so a key that matches nothing was accepted:

css.create({ box: { '@unknown foo': { position: 'absolute' } } });

The compiler emitted nothing for it and raised nothing either. validate-at-rules reports it:

Invalid at-rule: "@unknown foo".

It accepts @media, @container, @supports, @layer and @scope, each with a prelude, and reaches into pseudo classes, nested at-rules and dynamic styles. Given TypeScript project information it resolves computed keys as well, the same way no-invalid-selector and validate-pseudos do. It is error in recommended.

The rule is scoped to css.create because that is where the gap is. createTheme takes its selector as a plain parameter rather than a key inside an inferred object, so the type already rejects an unrecognised at-rule there.

5. A component can look inside one style

The four types above all describe a style list. A prop typed with one of them can be handed an array, a conditional or nothing at all, which is what a styling prop wants — and it is also why such a prop cannot be indexed. There is no single style there to read a property off.

AtomicStyle is the other shape. It names one created style by the rule object it was created from, so its type argument is a requirement rather than a permission:

function Glyph({ glyphStyle }: { glyphStyle: css.AtomicStyle<{ color: string }> }) {
  return <svg fill="currentColor" className={glyphStyle.color} />;
}
<Glyph glyphStyle={styles.tinted} />  // ok
<Glyph glyphStyle={styles.padded} />  // Type error, there is no color to read

Requiring the property is what makes it readable. glyphStyle.color is the atomic class name generated for that property, so a component can hand one property of a caller's style to something that takes a className and nothing else.

Because an atomic class name carries the value it was generated for, a literal in the type argument holds the style to it. css.AtomicStyle<{ display: 'flex' }> takes a style that sets display: flex and rejects one that sets display: grid. AtomicClassStyle is the same thing for a style defined as a function, once it has been called.

This is the reverse of what StyleProps and WithoutProperties do. Those say which properties a prop will accept; AtomicStyle says which ones have to be there. A styling prop still wants Style or one of its narrowed forms — AtomicStyle is for the prop a component looks inside.

The two names are also what TypeScript writes when it emits a declaration for an exported style, so a package that ships styles now compiles with declaration: true. Before 19.1 that emit failed: the symbol that tags a created style had no name outside the package.

6. One transform, not two

Nothing in 19.1.1 changes what you write. It changes how many copies of the compiler your code passes through.

@plumeria/unplugin and @plumeria/turbopack-loader each carried their own transform — two implementations of the same three thousand lines, walking the same AST to produce the same class names. They were written together and then drifted, and the 18.5 series spent four releases on that drift. The shape of the fixes says it plainly:

  • Fix: apply the same top-level style declaration checks in unplugin and the Turbopack loader
  • Fix: read a destructured props parameter the same way in the compiler, the loader, and the plugin
  • Fix: align style transformation, scope checks, preserved JSX references, and compilation errors with the Turbopack loader

Every one of those is the same bug: a guard that existed in one implementation and not the other. Fixing them one at a time keeps two copies in step by hand, which is a thing you have to keep doing.

19.1.1 removes the second copy. transformSource lives in @plumeria/utils, and both bundler packages call it with the same argument:

const { code, sheets } = await transformSource({
  source,
  moduleId: id,
  filePath: baseId,
  root,
  styleProp,
  propertyPolicy,
  isDev,
  collectOndemandSheets,
  addDependency,
});

What was 3,188 lines inside the plugin is now 13. The loader went from 3,150 to 14. Each package keeps what is genuinely its own — the plugin keeps its virtual .zero.css module and its dev-mode sheet accumulation, the loader keeps its file lock and production CSS file — and hands the compiling to one place.

Twenty regression suites moved with it. They were written against whichever bundler the bug was found in, and they now live in @plumeria/utils and run once against the one implementation instead of twice against two. "The plugin and the loader disagree" stops being a category of bug, rather than a category of bug that gets fixed.

The same release fixes a scoping bug in the shared code. A function declaration nested inside a block hoists to the enclosing function scope, and the reference collector was not treating it that way:

function Card() {
  render();
  if (ready) {
    function render() { /* ... */ }
  }
}

render is local to Card. Walking the body, the collector saw a function node and stopped there without first taking its name — the branch that should have taken it sat below a check that already returned for every function, so it had never run once. render never entered the scope's bindings, and the call above the declaration was recorded as a reference to something outside the module.

7. One command to set it up

Nothing in 19.1.2 changes what you write either. It changes what stands between installing Plumeria and writing the first style.

There are eight packages. Which ones a project needs is not a judgement — Next.js takes @plumeria/next-plugin, every other bundler takes @plumeria/unplugin with the factory that matches — but it is something you have to know, and someone meeting the library has no way to know it. The same is true of plumeria.d.ts, which has to name the styling prop before you have seen a style to put in it.

npx @plumeria/init reads the project rather than asking:

Terminal
npx @plumeria/init
Terminal
✔ detected  next  pnpm  typescript

  ➡︎ install  @plumeria/core @plumeria/next-plugin @plumeria/eslint-plugin oxlint
  + write    plumeria.d.ts     classStyle
  ~ patch    next.config.ts    withPlumeria(...)
  ~ patch    eslint.config.ts  plumeria.configs.recommended
  ~ patch    package.json      build: plumerialint -- next build

Apply? (Y/n)

The package manager comes from packageManager or the lockfile, both looked for upwards, so a package inside a workspace is installed with the manager the workspace root uses. The bundler comes from the dependencies, and from the config files when nothing is installed yet. The plan is printed before anything is written, and --dry-run stops at the plan.

One answer, two layers

The one thing detection cannot settle is a decision, and it is the decision this release is really about.

A property that carries both a logical and a physical name can be written twice under two spellings, and specificity cannot rank that pair. Two mechanisms already remove it: withoutLogicalProperties and withoutPhysicalProperties on the bundler plugin, and no-logical-properties and no-physical-properties in ESLint. They are one decision made at two levels — and because you set them separately, you could set them in opposite directions, and the build would then reject what the editor had just told you to write.

init asks once:

Terminal
Which spelling of a two-named property does this project write?
  1: logical   marginBlockStart, insetInlineStart
  2: physical  marginTop, left
  3: both      no policy

and writes both sides from the one answer — the plugin option into the bundler config, the matching rule into the flat config, { sizes: true } into both if you extend the policy to the size axis. Their directions cannot disagree, because there is only one direction to give.

That is section 1's problem standing somewhere else. There, a type and a compiler disagreed about an inline object, and the gap was covered by a lint rule you had to remember to run. Here, a linter and a compiler could be configured into contradiction. Both fixes have the same shape: stop letting two layers be told different things.

On an existing codebase, answer 3. The rule offers a rename on every occurrence, which is what a migration needs, and the build option belongs after that — Optional rules says which way round.

What counts as already set up

Running it a second time writes nothing. What it checks for is the plugin being called, not imported: a bare plumeria.version, and a call inside a comment, a string or a regex literal, all read as not set up, because none of them registers anything. An import nothing calls is therefore completed rather than skipped, and the binding the file already named is the one that gets called.

The plugins array it extends is the one the config object owns:

vite.config.ts
export default defineConfig({
  test: {
    plugins: [],                      // not this one
  },
  plugins: [react(), plumeria.vite()], // this one
});

A config whose own object has no plugins array is not guessed at. Nothing is touched, the snippet is printed, and the command exits with 1 — which is also what happens for a build script rather than a config, and for a legacy .eslintrc.

On Next.js it adds rimraf .next before dev and before build, since a version change read as a compile error is the first thing a cache outlives. A pre script is added only for a script the project has, and one you already wrote is left alone.

The output is marked by kind through node:util's styleText, which leaves the text alone when it is not writing to a terminal, so a piped or logged run carries no escape codes.

8. Styles that reach a server component

Nothing in 19.1.3 changes what you write either. It changes whether what you wrote arrives, once the framework renders on the server.

Waku and @vitejs/plugin-rsc — Waku is built on it — split a build into three Vite environments. rsc serialises the React tree, ssr turns that stream into HTML, client hydrates it. A module is transformed once per environment, and each environment keeps its own module graph. @plumeria/unplugin was written when a Vite build had one of each.

Four things followed from that. The first three are the same mistake at three depths.

The plugin emits a virtual .zero.css module per file and lets the importing module pull it in. A server component has no module on the client to pull anything with, so the RSC plugin renders a <link> for it instead — and Vite answers a stylesheet request by appending ?direct to the id. Resolving that id dropped the query, so the link received the JavaScript module wrapper. Every server component was unstyled, from the first render.

In dev the sheet then never changed. The CSS module lives in the client graph; the module that produces it is transformed in the rsc graph. Editing a server component rebuilt the CSS and invalidated nothing, so the browser kept the sheet it had fetched when the dev server started — across a full reload, until the server was restarted. The client side had the same problem from the other end: the rsc environment transformed a client component first and recorded that its CSS had changed, so when the client environment reached the same edit it read as unchanged and no update was sent.

A declaration written once

The fourth is not a wiring mistake. It is the one that decides whether any of this is still atomic CSS.

For a single-environment build the plugin sets cssCodeSplit: false, so every sheet merges into one file. Under RSC that setting removes the mapping the framework needs: the client build records which stylesheet belongs to which client reference, and with the CSS merged there is nothing to record. Every client component lost its styles in production, while the dev server showed them.

Leaving cssCodeSplit alone fixes that and costs the thing the library exists for. Code-split CSS is emitted per chunk, and a declaration two chunks both use is written into both:

/* _layout-*.css */   .x3brsar4{font-weight:700}
/* pages-*.css */     .x3brsar4{font-weight:700}
/* pages-*.css */     .x3brsar4{font-weight:700}

Thirty-nine rules for thirty-two declarations, in an app with two pages. The overlap is not a rounding error to be tolerated — it is proportional to how many routes share a style, which in atomic CSS is most of them.

19.1.3 stops handing the atoms to the chunker. They are collected across every environment, deduplicated once, and emitted as a single asset from the rsc environment, which every chunk of that environment resolves to. Whichever server component renders, the browser fetches the one sheet:

<link rel="stylesheet" href="/assets/plumeria-CAePt-BD.css" />

That the sheet rides the server path rather than the client one is deliberate. The RSC plugin injects a client reference's CSS through ReactDOM.preinit at the moment the reference resolves, so a component discovered mid-stream brings its own resources; collapsing that into one sheet would work against how it streams. The server path has no module to hang resources on and carries a bulk mapping instead, which is exactly the shape a global sheet needs.

The atom set is complete by the time the rsc build ends because the RSC plugin walks the client graph twice before it starts — once to find client references, once to find server ones — and a module imported only by a client component is transformed in that second pass. Nothing has to be persisted between builds to know all of it.

The result is smaller on every axis than letting the chunker do it:

JS rawCSS rawJS + CSS gzip
One sheet244,3071,32077,994
Code-split CSS244,4601,53878,109

Your own .css imports are untouched by all of this. They keep the asset Vite gives them and the cascade position you put them in — mixing them into the atom sheet would hand their ordering to the plugin, and their content hash would change every time any component's style did.

Two examples are in the repository: example-waku and example-vite-react-rsc. Neither needs a Plumeria option — plumeria.vite() is the same call it is everywhere else, and the RSC path turns itself on when an RSC plugin is present.

9. Clearer created style types

19.1.4 refines the type definitions for created styles. Editor hovers once again show the atomic class names a style contains, including styles returned by a function. AtomicStyle continues to accept the rule-object form used in prop declarations.

There are no runtime changes. AtomicDynamicStyle has been renamed to AtomicClassStyle, and the codemod recognizes the new type names.

10. A variant table pairs only what collides

styles[key] is resolved at build time. Every key of the group is reachable, so the compiler emits a lookup rather than a branch, and the key expression picks from it:

const tone = css.create({ ok: { color: 'green' }, ng: { color: 'red' } });

<div classStyle={tone[t]} />

Compiled:

({"ok":"x3git8yv","ng":"xq96bg3w"}[t] || "")

Two groups in one list is where a lookup stops being enough. If they set different properties, nothing has to be decided and each keeps its own map. If they set the same one, the later group wins, and the class the element ends up with depends on both keys at once — so the compiler builds a table over the combinations.

19.1 built that table over every group that touched a colliding property, whether or not those groups could collide with each other. Four groups — two fighting over color, two over fontSize — produced a single table of eighty-one entries for a decision that is two independent nine-entry ones.

19.1.6 partitions the groups first. Two groups belong to the same partition when they set a property in common, transitively, and each partition gets its own table:

classStyle19.119.1.6
two groups over color, 3 keys each9 entries9 entries
the above plus two groups over fontSize81 entries9 + 9
three such pairs, 2 keys each64 entries4 + 4 + 4

The partitions are transitive because overriding is: a group that sets both color and fontSize sits between the two pairs above and folds them back into one table, which is the answer that table has to give.

The same table had a hole in it. A group whose selected key sets none of the colliding properties contributed no cell, so the lookup missed and fell through to the empty string — taking every other group's styles with it:

const tone = css.create({ base: { color: 'black' }, danger: { color: 'red' } });
const state = css.create({ idle: { opacity: 1 }, muted: { color: 'gray', opacity: 0.5 } });

<div classStyle={[tone[t], state[s]]} />

state.idle sets no color, so t === 'base' && s === 'idle' had no entry and the element lost its color: black — while the same list written with literal keys, [tone.base, state.idle], kept it. Such a key now contributes a cell that changes nothing, and the two forms agree for every input.

Neither change reaches the stylesheet. A group's atoms are emitted once each however the combinations are counted; what shrinks is the lookup in the module.

11. A component's style prop can take the element's name

This shipped broken. The attempt described below decided from a table the call sites themselves filled, so a component forwarding className was handed a key it could not read. 19.1.7 through 19.1.9 are affected and 19.1.10 reverts it. 19.2 reaches the same behaviour by reading what the component declares instead.

A component that accepts a style through a prop applies it to the element it renders, usually under a base of its own. The reference for StyleProps writes that prop as classStyle — the same name an element takes:

const s = css.create({
  base: { color: 'green' },
  loud: { color: 'crimson', fontWeight: 700 },
});

const Card = ({ classStyle }: { classStyle?: css.Style }) => (
  <div classStyle={[s.base, classStyle]} />
);

export const A = () => <Card classStyle={s.loud} />;

The transform reads a style prop in two ways. On an element it resolves to class names, there and then. On a component it cannot — the styles have to be merged under whatever base that component applies, in the module that declares it — so it emits a key instead, and the component looks the key up in a table built from every style its callers pass.

Which way it went was decided by the attribute's name alone. Anything not named classStyle took the second path; classStyle took the first, whether it sat on a div or on a component.

Compiled before 19.1.7:

export const A = () => <Card className={"xhnqg42x x3brsar4"} />;

const Card = ({ classStyle }) => (
  <div className={({"xttveuad":"x3brsar4"}[classStyle] || "") + " " + ({"xttveuad":"xhnqg42x"}[classStyle] || "x3git8yv")} />
);

Both halves are correct on their own. Card was given a table keyed xttveuad, and the call site was handed the resolved class names under a renamed attribute. Card reads classStyle, received className, and saw undefined: the lookup missed, fell through to x3git8yv, and the element rendered with its base and nothing else. The two halves were built to meet and never did.

19.1.7 decides by the receiver rather than by the name. A component the scan registered as taking a style through that prop is handed the key:

Compiled:

export const A = () => <Card classStyle={"xttveuad"} />;

An element is unchanged — nothing registers div as taking a style prop, so it still resolves to class names where it stands. A conditional still compiles to a conditional key, and its false branch falls through to the component's base, which is what on && s.loud always meant.

12. An error names the file that failed

When a style cannot be read at build time, the message worth printing is rarely about the line that could not be read. It is about the file the style came from, which usually failed earlier and for a reason of its own. Both the compiler and the plugins look the origin up and report that instead:

The style key 1 is a number. Style keys have to be names — quote it as '1' or rename it. (styles.ts)

The lookup starts from the import that bound the name. A namespace import binds the module, not one of its exports, so the binding carries a placeholder where an export name would go. One resolver understood the placeholder; the one behind this message did not, and asked for an export literally named *.

Directly imported, that still worked — the file in the binding is the file that failed, so the error was found without following anything. Through a file that only re-exports the style, it did not:

import * as imported from './styles';   // a barrel: export { styles } from './card.styles'

<div classStyle={imported.styles.good} />

The lookup stopped at the barrel, which is sound, found no error there, and fell back:

Dynamic or unresolvable style object "imported.styles.good" is not supported. (app.tsx)

That names the file you are reading and tells you nothing about the one to open. 19.1.7 reads the export from the member that follows the namespace — styles — and hands that to the lookup, which then follows the re-export the same way a named import already did. All four combinations of named or namespace, direct or re-exported, now name card.styles.ts and the error it raised.

13. The compiler refuses what the plugins refuse

@plumeria/compiler extracts a project's CSS in one pass, and the Turbopack loader builds its production stylesheet with it. It is a different job from the transform — it decides which rules to emit, not which class names go on an element — but the two have to agree on what a source file is allowed to say.

They did not. Where the plugins raised a build error, the compiler skipped the declaration and carried on:

export default css.create({ a: { color: 'teal' } });

A default-exported call cannot be registered under a name, so nothing could reference it and the compiler emitted nothing for it — no rule, and no reason given. A destructured call behaved the same way, and a call written as a bare statement was reported for whatever was wrong with its selector rather than for not being assigned at all.

The worse one emitted something:

const s = css.create({ box: (w: number) => ({ width: w }) });

export const cls = css.use(s.box(4));

A dynamic style reaches the element as a class name and a custom property, which is why css.use() — which returns only the class name — has refused one since 18.4.1. The compiler resolved the call anyway and wrote the rule:

.x2thc9dg { width: var(--x4laf45v-w); }

Nothing on the page ever sets --x4laf45v-w, because the element that would have carried it was never told to. The rule is not merely unused; it is unusable, and it shipped.

19.1.8 gives the compiler the same three refusals, with the same wording the plugins use. The same key stays sound where it belongs — on an element, which does carry the property:

<div classStyle={s.box(4)} />   // unchanged

Upgrading

From 18.5. An inline object in a style prop is a type error. It was already a build error, so a codebase that built on 18.5 still builds — the change is that the editor says it first. Move the object into css.create and pass the created style. The prop types are additive: Style still accepts every property, and a component that does not need to constrain anything needs no change.

From 19.0. Three shapes that used to type-check no longer do, all of them things 19.0 set out to reject and missed: the object css.create returns passed whole, an object assembled by hand out of atomic class names, and an object with no style properties at all. Nothing that runs changes — the compiler already refused to resolve any of the three — so the work is in the editor, not in the output. AtomicStyle and AtomicClassStyle are additive on top.

@plumeria/codemod knows every one of these types, the two Atomic ones included. The export removes the @plumeria/core import, so a reference left behind has nothing to declare it — a component prop, a type alias, a variable annotation alike. Those modules are now left in place and reported:

component/Badge.tsx
  21:16  style-type-reference
        `css.StaticStyles` has no CSS Modules equivalent, and the import that
        declares it does not survive the export of Badge.tsx.

Before, the export rewrote such a component's call sites to className while its declaration still said classStyle, and left the file uncompilable. It now joins dynamic-value and the other reasons the export already leaves styles behind, so a migration that meets one is partial rather than broken. One shape is still outside it: a module that declares nothing but style prop types has no stylesheet to hold the report, and its consumer will not compile after the export. Keeping the prop type in the file that defines the styles avoids it, and the codemod reference says so where the other reports are listed.

From 19.1.0. Nothing to do. The transform moving into @plumeria/utils is internal to the packages — the plugin and the loader are configured exactly as before, and @plumeria/utils is already a dependency of both. If you build with the Turbopack loader, the one thing that changes is that you now get the behaviour the plugin had in the places the two still differed.

From 19.1.1. Nothing to do. @plumeria/init is a new package you run rather than depend on, and it writes the same configuration this documentation has always described. Running it on a project that is already set up is safe — every file it would touch reads as done, and it writes nothing.

From 19.1.2. Nothing to do, and nothing to configure. On a build with no RSC plugin the output is byte for byte what it was. On one with an RSC plugin, styles that were silently missing now arrive: a server component was unstyled in dev and in production, and a client component was unstyled in production. If you had worked around either — a hand-written <link>, a global stylesheet holding what the plugin dropped — remove it, or the same declarations ship twice.

From 19.1.3. Rename any reference to AtomicDynamicStyle to AtomicClassStyle. Nothing else changes at runtime.

From 19.1.4. Nothing to do. 19.1.5 updates dependencies and changes nothing the packages emit.

From 19.1.5. Nothing to do. Where a classStyle's groups do not collide the class names are unchanged; where they do, the element now gets what the same list written with literal keys always got. If you had kept two bracket groups apart because combining them dropped one of their styles, they can be combined.

From 19.1.6. Nothing to do, and nothing to configure. If a component of yours takes its style through a prop named the same as the one elements take, that style now arrives — it was being dropped before, so the element gains declarations rather than losing any. Any other prop name was already working and is untouched. If you had renamed such a prop to work around the loss, or moved the style to the caller's own element, you can put it back. The error messages are the second half: where one used to name the file you were compiling, it now names the file that failed.

From 19.1.7. A build that passed may now fail, and that is the point of the release. @plumeria/compiler refuses three things the bundler plugins have always refused: a css.create, css.createTheme or css.createStatic call that is not assigned to a named top-level variable, a dynamic function key handed to css.use(), and a call written as a bare statement. If only the plugins built your project, nothing changes — you were already being told. If you build with the Turbopack loader, a file the module graph never reached could carry one of these silently; it is now reported, with the same wording the plugins use. Nothing that already compiled emits different CSS.


Release notes

19.1.10 (Sep 15, 2026)

  • Fix: revert the 19.1.7 change that attempted to relay a style through a component prop named classStyle. This resolves the regression that broke the docs header, code blocks, and other components using classStyle

19.1.9 (Sep 15, 2026)

  • Fix: accept a length for outlineWidth in validate-values. The property was grouped with the border widths for its keywords but left out of the length group, so thin, medium and thick passed while 2px, calc() and every other length was reported as invalid

Regression notice: This release is affected by the classStyle regression introduced in 19.1.7. Please use ^19.1.10 or later.

19.1.8 (Sep 15, 2026)

  • Fix: refuse a css.create, css.createTheme or css.createStatic call that is not assigned to a named top-level variable, instead of skipping the declaration and emitting nothing for it
  • Fix: refuse a dynamic function key handed to css.use(), which used to emit a rule reading a custom property that only an element can set, so nothing on the page ever defined it
  • Fix: report a call that is only a statement as one that has to be assigned, ahead of anything wrong with the selector it was given, which is the order the bundler plugins already used

Regression notice: This release is affected by the classStyle regression introduced in 19.1.7. Please use ^19.1.10 or later.

19.1.7 (Sep 15, 2026)

  • Fix: hand a component the key for a style passed through a prop named the same as the one elements take, instead of resolving it to class names at the call site and renaming the attribute, which left the component with nothing and dropped the style with no error

    Regression notice: Attempting to support this pattern broke the docs header, code blocks, and other components using classStyle in versions 19.1.7 through 19.1.9. The change is reverted in 19.1.10. Please use ^19.1.10 or later. I will prevent this from happening again — sorry 🙏

    const Component = ({ classStyle }) => {
      return <div classStyle={classStyle} />;
    };
  • Fix: name the file that failed rather than the one being compiled, when a style the compiler cannot read came through a namespace import

  • Fix: read the export a namespace binding carries from the member that follows it, so a style imported with import * as is traced through a file that only re-exports it

19.1.6 (Sep 15, 2026)

  • Fix: give a bracket group's key a cell in the combination table even when it sets none of the colliding properties, so selecting that key no longer misses the lookup and drops every other group's styles
  • Perf: partition the groups by the properties they collide over and emit one combination table per partition, instead of one product over every group that touches a colliding property

Affects earlier releases: This is not a 19.1 regression. The combination table has built its cells this way since it was introduced on July 30, 2026, so every release before 19.1.6 drops the other groups' styles for a key that sets none of the colliding properties. It fails silently — no error, and no deprecation warning to run into.

19.1.5 (Sep 14, 2026)

  • Update dependencies

19.1.4 (Sep 13, 2026)

  • Fix: created style type definitions so editor hovers show their atomic class names, and rename AtomicDynamicStyle to AtomicClassStyle

19.1.3 (Sep 12, 2026)

  • Fix: keep the query when the Vite plugin resolves a virtual CSS id, so Vite's ?direct stylesheet request answers with CSS instead of the module wrapper. A <link rel="stylesheet"> rendered by an RSC framework used to load JavaScript, and the styles of every server component were dropped
  • Fix: invalidate the virtual CSS module in the client environment when the owning module was transformed in a server environment, and push a css-update so the rendered link refetches. A server component's styles used to stay at the value they held when the dev server started, even across a full reload
  • Fix: record the last pushed CSS per environment, so the client transform still reloads its own CSS module after a server environment has already seen the same edit. A client component's styles used to stop updating once the rsc environment ran first
  • Fix: emit the app's atomic CSS as one sheet in an RSC build, instead of letting it follow the chunks. build.cssCodeSplit is no longer forced off for the whole app, which used to leave the client references without CSS and strip every client component of its styles in production

19.1.2 (Sep 12, 2026)

  • Feat: add @plumeria/init, one command that sets Plumeria up in an existing project. It is run with npx @plumeria/init, and installed it is plumeria-init, alongside plumeria-codemod and plumerialint. It detects the package manager and the bundler, installs the packages that are missing, writes plumeria.d.ts and the bundler config, extends the ESLint flat config, and puts plumerialint -- in front of the build script
  • Feat: ask which spelling of a two-named property the project writes and set both levels from the one answer — withoutLogicalProperties or withoutPhysicalProperties on the bundler plugin, and no-logical-properties or no-physical-properties in ESLint — so their directions cannot disagree
  • Feat: ask whether to turn on expand-border-shorthands, and which JSX prop carries styles, writing a renamed prop into the plugin and into plumeria.d.ts at once
  • Feat: patch an existing config rather than replace it, and extend only the plugins array the config object owns, so a nested one such as Vitest's test.plugins is never written into. Where the plugin cannot be placed safely, print the snippet and touch nothing
  • Feat: treat a config as set up only once the plugin is called, not merely imported — a bare property read, and a call inside a comment, a string or a regex literal, all read as not set up — so an import nothing calls is completed rather than skipped, and call the binding the file already named. An import spanning several lines is read as one statement, so it is never duplicated
  • Feat: on Next.js add rimraf .next before dev and before build, so a version change is not read as a compile error. A pre script is added only for a script the project has, and one it already wrote is left alone
  • Feat: look for packageManager and the lockfile upwards from the directory init runs in, so a package inside a workspace is installed with the manager the workspace root uses
  • Feat: mark each planned action by kind — install, write, patch, manual, skip — with node:util's styleText, which leaves the text alone when the output is not a terminal
  • Feat: refuse a styling prop that is not an identifier, or that React already handles, from --style-prop as well as from the prompt

19.1.1 (Sep 11, 2026)

  • Refactor: share one transform between the plugin and the Turbopack loader, so a file compiles through the same implementation instead of two copies that could drift
  • Fix: hoist a function declaration nested in a block to the enclosing function scope, so a reference to it is no longer collected as a module reference
  • Refactor: drop the unreachable null paths from resolveDynamicStyle and the call sites that guarded them

19.1.0 (Sep 11, 2026)

  • Fix: a style type accepts only a namespace css.create returned, so the object that holds the styles, and an object that came from anywhere else, are type errors
  • Fix: key Marker by the pseudo class it emits, so a style built from css.marker() passes a constrained style prop type
  • Fix: accept an at-rule prelude that follows the keyword without a space, both in the type and in validate-at-rules
  • Feat: add AtomicStyle and AtomicDynamicStyle, which name one created style by the rule object it was created from, so a prop can require a property and pin its value
  • Fix: a declaration file can name the type of an exported style through those exports, instead of failing on the symbol that tags it
  • Fix: the export reports a reference to AtomicStyle or AtomicDynamicStyle as well, instead of migrating a module that still needs the import

19.0.0 (Sep 9, 2026)

  • Feat: add StyleProps and WithoutProperties for constraining the properties a style prop accepts
  • Feat: add StaticStyles, which rejects a dynamic style and narrows properties when given a type argument
  • Feat: add the validate-at-rules lint rule, which reports an unrecognised at-rule inside css.create()
  • Feat: @plumeria/codemod reports a reference to a Plumeria style type instead of removing the import it still needs
  • Fix: css.use() declared a rest parameter that was not an array type
  • Fix: the classStyle global augmentation was missing its declare modifier
  • Break: Style accepts only styles created by css.create; an inline object is a type error

Thanks for building with Plumeria! If you have any feedback or questions, please let us know on GitHub Discussions or GitHub Issues.