Componentsv2.189.0
Iconsv1.19.0
MCPv0.8.61
Tokensv0.34.3

Button variant rename codemod

Migrates Pluma Button (and ButtonGroup) variant props for the upcoming Button overhaul. The current secondary variant is being removed, and the remaining variants are shifting down a tier:

table
BeforeAfter
variant="primary"variant="primary" (no-op)
variant="secondary"variant="secondary" (removed in a later release — see below)
variant="tertiary"variant="secondary"
variant="subtle"variant="tertiary"

Buttons with no variant prop are left alone — the default has always been (and remains) secondary.

There are two codemods — one for React (.tsx/.ts/.jsx/.js) and one for Ember (.gts/.gjs/.hbs). Run whichever apply to your app.

Prerequisites

Before running the codemod, set the unsafe_withMigratedButtonVariants flag on PlumaProvider (or per-button) so the renamed variants render the same as they did before:

<PlumaProvider
  componentConfig={{
    PlumaButton: {
      unsafe_withMigratedButtonVariants: true,
    },
  }}
>
  {/* … */}
</PlumaProvider>

The flag makes Pluma render variants as if the upcoming major release (which removes the current secondary variant) had already shipped:

  • prop secondary renders with the current tertiary styles
  • prop tertiary renders with the current subtle styles

This also applies to <Button> invocations with no variant prop — they default to secondary and therefore start rendering with tertiary styles the moment the flag is turned on. That's intentional: the default-variant look post-major is today's tertiary. Enabling the flag is the one visible change; dropping it again at the major release is a no-op.

Run the codemod with this flag enabled, and your app looks identical before and after the rename. When the breaking-change major eventually ships, drop the flag — your migrated source already uses the new names.

If you'd previously enabled unsafe_withSoftDeprecatedSecondaryVariant (the earlier, narrower flag from #1531), you can replace it with unsafe_withMigratedButtonVariants. The new flag supersedes the old one (it includes the secondary → tertiary mapping).

  1. Preview the new look. Turn on unsafe_withSoftDeprecatedSecondaryVariant so variant="secondary" renders with tertiary styles. Verify the app still looks right.
  2. Switch to the full simulation. Replace that flag with unsafe_withMigratedButtonVariants. Visually the app is unchanged so far (you haven't migrated tertiary/subtle source yet).
  3. Run the codemod (see below). Visually the app should still be unchanged, because the flag remaps the now-renamed variants to their original looks.
  4. Commit. Don't re-run the codemod.
  5. When the major release ships (secondary removed, tertiary→secondary rename, subtle→tertiary rename), update Pluma and drop the flag from your PlumaProvider. Rendering doesn't change.

Running it

Run it through the Pluma CLI from inside your consuming app. Always start with a dry run to review the diff and the warning summary before applying:

# from the root of your consuming app

# Dry run first (no writes; per-file diff is printed inline)
pnx @customerio/pluma-cli@latest upgrade --codemod button-variant-rename react --dry ./src
pnx @customerio/pluma-cli@latest upgrade --codemod button-variant-rename ember --dry ./app

# Then apply for real
pnx @customerio/pluma-cli@latest upgrade --codemod button-variant-rename react ./src
pnx @customerio/pluma-cli@latest upgrade --codemod button-variant-rename ember ./app ./addon

Both frameworks accept --dry (no writes; per-file diff is printed inline) and --force (bypass the re-run safety check — see below). The React path also forwards standard jscodeshift flags, so -p prints transformed output.

What gets matched

React. Only JSX elements whose tag resolves (via the import in the same file) to Button or ButtonGroup from @customerio/pluma-components/react (or the bare @customerio/pluma-components entry). Aliased imports (import { Button as MyButton } from …) are handled correctly. Any local component called Button that wasn't imported from Pluma is left alone.

Inline string literals are always rewritten (variant="tertiary", variant={'tertiary'}, variant={'tertiary' as const}). The codemod also follows two additional shapes:

Result-position expressions — branches of a ?: ternary, the right-hand side of ?? / || / &&, and recursive combinations:

<Button variant={cond ? 'tertiary' : 'subtle'} />
<Button variant={x ?? 'tertiary'} />

Each tertiary / subtle literal that flows into a value position of the expression is rewritten under the fixed map; non-literal branches (identifiers, function calls, etc.) are left alone.

Local consts — a binding like

function Foo() {
  const v = 'tertiary';
  return <Button variant={v} />;
}

gets its initializer rewritten — provided the binding is a const, isn't exported, and every reference in the file is a Pluma <Button|ButtonGroup variant={…}> value. If the const flows somewhere else (a prop on a non-Pluma component, a log call, etc.), the codemod leaves it alone and emits a warning (see below). let bindings, exported consts, and bindings in other files (imports, props) are likewise skipped with a warning.

Ember. Only <PlumaButton …> and <PlumaButtonGroup …> element nodes are touched. Inline literals — @variant="tertiary", @variant='tertiary', @variant={{"tertiary"}} — are always rewritten.

In .gts / .gjs files the codemod additionally follows two kinds of binding back into the surrounding JavaScript:

  • Class members referenced as @variant={{this.X}}. The codemod rewrites a matching get X() { return '…'; } getter or X = '…' class field (including @tracked X = '…') provided every return expression in the body / the field initializer reduces to a result- position string literal under the same rules as the React side (ternary branches, ?? / || / && sides, if/else returns), and this.X is not read anywhere else in the file's JavaScript.
  • Module-level free vars referenced as @variant={{X}}. A matching non-exported const X = '…' declaration is rewritten provided X isn't read anywhere else in JavaScript. The initializer may itself be a ternary / ?? / || fallback — literal branches are rewritten.
  • if / unless / or / and helpers in templates@variant={{if cond 'tertiary' 'subtle'}}, @variant={{or @x 'tertiary'}}. Result-position literals (the consequent/alternate of if/unless, every positional arg of or/and) are rewritten under the same fixed-map rule. Other helpers ({{my-helper 'tertiary'}}) are left alone with a warning.

Anything that doesn't fit those shapes — @variant={{@arg}}, unknown helper calls, class members shared with non-Pluma JS readers, deep this.X.Y paths — is left untouched and surfaces as a warning. .hbs files don't have a JS context to consult, so template-only references ({{this.x}}, {{x}}) are always warned; those need to be migrated by hand in the corresponding .js/.ts component file.

Internally, the Ember codemod uses @codemod-utils/ast-template

  • @codemod-utils/ast-template-tag for the Glimmer side, and jscodeshift for parsing the JavaScript portion of .gts/.gjs files. Rewrites are applied as byte-level splices on the original source, so formatting is preserved byte-for-byte outside the literal value itself — indentation, comments, quote style, and whitespace are untouched.

Workers (Ember only)

For larger codebases, the Ember script spreads files across node:worker_threads (up to 8 workers, scaled to your CPU count). Runs of fewer than 30 files stay in the main thread to skip the spawn overhead. You'll see a Processing N files across K workers… line at the start of the run.

Re-run guard

Both subcommands refuse to run if they find unsafe_withMigratedButtonVariants anywhere in the input — that's the signal that the codemod has already been applied to this codebase. A second run would cascade the renames (formerly-subtle buttons, now tertiary, would silently shift to secondary), which is the wrong style.

If you've set the flag for preview only and haven't actually applied the rename yet (uncommon but possible), pass --force to bypass the check. The dispatcher prints a Found … running anyway (--force) notice when it does.

Warnings — variant usages that need manual review

Both codemods print a summary at the end of the run listing every variant usage they recognized but chose not to migrate, with a file + line and a short explanation. Examples of what shows up here:

  • variant={getVariant()} — dynamic expression, can't determine value
  • variant={importedFromElsewhere} — identifier resolves outside this file; check the source module
  • const v = 'tertiary'; … log(v); <Button variant={v} /> — binding is shared between Pluma and non-Pluma uses; rename only the Pluma path
  • @variant={{@buttonVariant}} — caller-provided argument
  • @variant={{my-helper 'tertiary'}} — unknown helper invocation
  • @variant={{this.x}} (in .hbs) — class member lives in a sibling .js/.ts file the codemod can't see

There's also a partial-migration category for cases where some of an expression's branches were rewritten but others were opaque:

  • <Button variant={override ?? 'tertiary'} />'tertiary' migrates to 'secondary', but override could itself hold a variant the codemod can't see
  • {{if @cond @maybeVariant 'subtle'}}'subtle' migrates, but @maybeVariant is opaque
  • get v() { return this.x ?? 'subtle'; } — same idea on a class getter; the literal migrates, this.x is opaque

When partial migration happens, the codemod rewrites what it can and emits a warning so you know to inspect the opaque branches. Silent no-ops only happen when every branch is either a clean rewrite or a literal that doesn't need migration (e.g. cond ? 'primary' : 'secondary').

Run through this list before committing — it's the codemod's way of saying "I noticed these, you need to look."

After running

  1. Diff the result.
  2. Triage the warning summary (above) — rename anything that needs a manual touch.
  3. Run your formatter. Both codemods preserve formatting byte-for-byte except at the rewritten literal itself, so most files won't need anything; Prettier will normalize the rewritten literal's quote style on its next run if it doesn't match your config.
  4. Run your test suite.
  5. Commit. Do not re-run the codemod.
  6. After the next major Pluma release that drops secondary, any remaining variant="secondary" usages in your codebase will trigger TypeScript / ember-template-lint errors and can be removed/replaced manually.

Working on the codemod itself

There's a test suite under tests/ covering the React and Ember transforms (renames, formatting preservation, dynamic-value skip, alias handling, etc.). It ships as part of the @customerio/pluma-cli package. After running pnpm install at the Pluma repo root, run the CLI's transform tests from anywhere in the repo:

pnpm --filter @customerio/pluma-cli run test:transforms

It uses Node's built-in test runner (node --test), so no extra dependencies beyond what the CLI already needs.

Known limitations

These cases are recognized and reported in the warning summary, but not auto-rewritten:

  • Prop-spread patterns<Button {...{ variant: 'tertiary' }} />. The inner object isn't inspected.
  • Method / function calls in result position<Button variant={getX()}> (and any other expression that isn't a literal, ternary, or logical-fallback). The codemod can't determine what the call resolves to.
  • Cross-file variable references<Button variant={importedVar}> where importedVar is from another module. The codemod won't follow the import.
  • Ember class members read in JavaScript — if this.X is used as a Pluma variant and read elsewhere (an event handler, a derived getter, …), the codemod can't safely rewrite the declaration.
  • Ember angle-bracket wrappers around PlumaButton — if your app re-exports PlumaButton as <MyButton …>, the codemod won't match the wrapper invocation. Rename to PlumaButton first, or migrate those uses manually.
  • .hbs files referencing this.X — the class definition lives in a sibling .js/.ts file the codemod doesn't read. Use the warning's file/line to find each one.

After running the codemod, you can also grep for any leftover 'tertiary' / 'subtle' / "tertiary" / "subtle" to catch anything subtle the warnings might have missed.