feat(cva): customizable class name concatenator via cva/core - #387
feat(cva): customizable class name concatenator via cva/core#387joe-bell wants to merge 5 commits into
Conversation
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
cva | 91c6c31 | Commit Preview URL Branch Preview URL |
Jul 15 2026, 02:07 PM |
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
BenchmarksComparing this PR's local benchmark run against the latest published npm versions. Aim for higher ops/s. Treat deltas within ±5% as noise.
|
| Task | This PR | 0.7.1 |
Δ |
|---|---|---|---|
cva (runtime)component call with defaults |
618,885 ops/s ±0.12% | 617,508 ops/s ±0.24% | +0.2% |
cva (runtime)component call with props |
99,595 ops/s ±0.24% | 99,427 ops/s ±0.25% | +0.2% |
cx (runtime)class join |
5,954,420 ops/s ±0.28% | 5,794,653 ops/s ±0.13% | +2.8% |
cva (static)component definition |
15,347,502 ops/s ±0.08% | 13,825,216 ops/s ±0.38% | 🟢 +11.0% |
compose (static + runtime)define, join and call |
521,811 ops/s ±0.23% | 514,759 ops/s ±0.28% | +1.4% |
cva
| Task | This PR | 1.0.0-beta.7 (beta) |
Δ |
|---|---|---|---|
cva (runtime)component call with defaults |
614,270 ops/s ±0.19% | 623,373 ops/s ±0.21% | -1.5% |
cva (runtime)component call with props |
95,580 ops/s ±0.20% | 97,678 ops/s ±0.22% | -2.1% |
cx (runtime)class join |
6,172,012 ops/s ±0.25% | 4,442,638 ops/s ±0.07% | 🟢 +38.9% |
cva (static)component definition |
1,852,413 ops/s ±0.20% | 1,776,100 ops/s ±0.24% | +4.3% |
composes property (static + runtime)define, join and call |
250,678 ops/s ±0.22% | 252,491 ops/s ±0.30% | -0.7% |
Commit 0af63ec · Node v24.18.0 · linux x64 · 2026-07-15T14:06:13.870Z
These benchmarks ran in CI on this PR's code. The comment checks that the report looks valid before posting it, but it cannot guarantee the numbers are correct. If you see a large change, re-run the benchmarks locally before treating it as a real improvement or regression.
Restructure around a new `cva/core` entry point whose `defineConfig`
requires a `cx` concatenator and never interprets class values: core
assembles the authored pieces (composed outputs, base, matched variant
and compound values, class/className) and passes them to the
concatenator verbatim, one argument each, with core-created arrays
spread flat. The concatenator owns the grammar entirely, so twMerge,
clsx/lite, cnfast's cn, and clsx all work within their own input
contracts — including clsx/lite for string-authored components.
The main `cva` package becomes the batteries preset: cva/cx/compose
wired up with real clsx for class-variance-authority parity ("cx is an
alias of clsx" again), plus a deprecated defineConfig re-export (cx
optional, defaulting to clsx) pointing migrators at cva/core.
Typing is handled by a new augmentable CVARegistry interface: the cx
option and the whole authoring surface default to ClassValue, and one
`declare module "cva/core"` line narrows both to a concatenator's own
input type (e.g. tailwind-merge's ClassNameValue), making bare
`cx: twMerge` typecheck while turning object-syntax authoring into a
compile error project-wide. Registry narrowing is verified in an
isolated compile (type-tests/ + check:registry) since module
augmentation is program-wide and would poison the main suite.
Adds a real-package concatenator matrix (concatenators.test.ts) with
tailwind-merge and cnfast as devDependencies alongside clsx/clsx/lite,
pinning type assignability and runtime behavior (conflict resolution,
grammar support, lite's string-only dropping) for each.
Rebuilt on top of the tsdown migration (0c6ca91), which carries the
multi-entry packaging natively: src/core.ts is a second tsdown entry,
the exports/publishConfig.exports maps are build-generated, rolldown
emits the cross-entry import with correct per-format extensions, and
./core's node10 fallback is a hand-maintained publishConfig
typesVersions block following the stable package's ./types pattern.
All build gates (attw strict, publint, unused) pass for both entries;
dist/index.cjs 1.25kB and dist/core.cjs 986B, within budget.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5Z2xhaZWz9bk24RjRu3GM
Replace the CVARegistry module-augmentation mechanism with automatic
inference: defineConfig is now generic over the passed concatenator,
and CXInput derives the accepted class value type from its parameters,
so the whole authoring surface (base, variant values, class/className)
adopts the concatenator's own input grammar with zero declarations.
`defineConfig({ cx: twMerge })` typechecks bare and turns object-syntax
authoring into a compile error, per config rather than per project;
clsx, clsx/lite, and cnfast's cn keep the full ClassValue grammar.
Inference edges, pinned by tests: unannotated inline concatenators
(contextually typed any[]) and wider-than-ClassValue signatures fall
back to ClassValue; Parameters picks an overloaded concatenator's last
signature (cnfast's variadic form); every new type parameter is
trailing and defaulted to ClassValue, so the exported portability pins
(CVAComponentShape, CVAVariantShape) and all 94 legacy tests are
untouched. The change is types-only: the built output is
byte-comparable and all build gates stay green.
The registry's isolated type-test compile (type-tests/,
tsconfig.type-tests.json, check:registry) is deleted — with no
program-wide augmentation, inference assertions live in the main suite.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5Z2xhaZWz9bk24RjRu3GM
Lock the bundle budgets to the branch's measured output plus a small margin so CI fails on any future byte drift: the preset entry (dist/index.cjs, bundling core and clsx — the `import from "cva"` cost) measures 1.26 kB and is capped at 1.3 kB, and the standalone core entry (dist/core.cjs, dependency-free) measures 985 B and is capped at 1 kB. For the record against main's single-entry baseline (measured 1.18 kB in a clean worktree): the preset entry carries +80 B of module-split glue and the deprecated defineConfig shim — the full cost of the cva/core split for preset consumers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K5Z2xhaZWz9bk24RjRu3GM
Add examples/beta/react-with-tailwind-merge, a React + Tailwind CSS example wiring shadcn/ui's `cn` pattern into cva: clsx resolves the authoring grammar and tailwind-merge dedupes conflicting utilities, composed as the `cx` concatenator via cva/core. The button's base carries bg/text defaults that each intent variant overrides, so the merge is visible in the rendered output (and a consumer className wins for the same reason). Surface it in the beta React with Tailwind CSS docs page under a new "Merging Tailwind classes" section. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K5Z2xhaZWz9bk24RjRu3GM
Rename the example's exported concatenator from `cx` to `cn` so it matches shadcn/ui's `cn` helper verbatim (same clsx + tailwind-merge composition), and link the shadcn manual-installation reference from both the config comment and the docs page. https://ui.shadcn.com/docs/installation/manual#add-a-cn-helper Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K5Z2xhaZWz9bk24RjRu3GM
e84d158 to
91c6c31
Compare
Description
cva's underlying class name concatenator is now swappable, without baking any Tailwind-specific dependency into the library.The package splits into two entry points:
cva/core— a dependency-free engine.defineConfigrequires acxconcatenator and never interprets class values: it assembles the authored pieces (composed component outputs,base, matched variant and compound variant values,class/className) and passes them to the concatenator verbatim, one argument each, with its own internal arrays spread flat. The concatenator owns the grammar entirely.cva— the batteries-included preset:cva/cx/composewired up withclsx, exactly likeclass-variance-authority(socxis an alias ofclsxagain). It also re-exports a deprecateddefineConfig(withcxoptional, defaulting toclsx) that points migrators atcva/core.The authoring surface infers from the concatenator automatically, no declarations or registry needed:
defineConfig({ cx: twMerge })typechecks bare and narrowsbase/variants/classto tailwind-merge's ownClassNameValue(object syntax becomes a compile error, per config).clsx,clsx/lite, andcnfast'scnall work out of the box and keep the fullClassValuegrammar.(...inputs) => twMerge(clsx(inputs))) falls back to the fullClassValuegrammar, so object/array authoring stays available.A new React example (
examples/beta/react-with-tailwind-merge) demonstrates the shadcn/uicnpattern (clsx+tailwind-merge) wired in viacva/core, with the merge visible in the rendered output.Beta-only.
class-variance-authority(stable) is untouched, and there's no version bump on this branch (releases are owner-cut).Additional context
Areas worth a close look:
packages/cva/src/core.ts:CXInput<TCX>derives the accepted class value type from the concatenator's parameters (Parameters<TCX>[number], with anany-detection fallback so inline functions keep the full grammar, and overload resolution taking the last signature socnfast's variadic form infers correctly). Every new type parameter is trailing and defaulted toClassValue, so the exported portability pins (CVAComponentShape,CVAVariantShape) and all pre-existing tests are unchanged. Declaration-emit portability is verified against a packed artifact.packages/cva/src/concatenators.test.tsexercisesclsx,clsx/lite,tailwind-merge, andcnfastthroughcva/cxwith their actual published packages, covering both inferred typing and runtime behavior (conflict resolution, grammar support,clsx/lite's documented string-only dropping).cva/coreentry:exports/publishConfig.exportsare build-generated, and./core's node10 fallback is a hand-maintainedpublishConfig.typesVersionsblock (mirroring the stable package's./types). All build gates (attw strict, publint, unused) pass for both entrypoints.size-limitbudgets are locked to measured:dist/index.cjs1.26 kB (vs main's 1.18 kB single-entry baseline; the +80 B is the module-split glue plus the deprecateddefineConfigshim),dist/core.cjs985 B.What is the purpose of this pull request?
Before submitting the PR, please make sure you do the following
fixes #123).🤖 Generated with Claude Code