From a44055cc0de7bcbc3cbc958561d9d402c26a447c Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 10 Jun 2026 20:19:33 -0400 Subject: [PATCH 1/2] feat(ui): make cva variants optional and add cx sugar Variants on the Mosaic cva config are now optional, and a new cx helper covers the base-only case. --- .changeset/cva-optional-variants.md | 2 ++ packages/ui/src/mosaic/__tests__/cva.test.ts | 38 +++++++++++++++++++- packages/ui/src/mosaic/cva.ts | 32 ++++++++++++++--- 3 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 .changeset/cva-optional-variants.md diff --git a/.changeset/cva-optional-variants.md b/.changeset/cva-optional-variants.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/cva-optional-variants.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/ui/src/mosaic/__tests__/cva.test.ts b/packages/ui/src/mosaic/__tests__/cva.test.ts index 6e37f7b5a93..0af82f65db0 100644 --- a/packages/ui/src/mosaic/__tests__/cva.test.ts +++ b/packages/ui/src/mosaic/__tests__/cva.test.ts @@ -1,6 +1,6 @@ import { describe, expect, expectTypeOf, it } from 'vitest'; -import { cva } from '../cva'; +import { cva, cx } from '../cva'; import type { SxProp, VariantProps } from '../cva'; import { defaultMosaicVariables, resolveVariables } from '../variables'; import type { MosaicTheme } from '../variables'; @@ -719,3 +719,39 @@ describe('type safety', () => { }); }); }); + +describe('cva without variants', () => { + it('applies base styles and merges sx', () => { + const styles = cva({ base: { color: 'red' } }); + expect(styles({ sx: { opacity: 0.5 } })(mockTheme)).toEqual({ color: 'red', opacity: 0.5 }); + }); + + it('exposes empty variant metadata for tooling', () => { + const styles = cva({ base: { color: 'red' } }); + expect(styles._variants).toEqual({}); + expect(styles._defaultVariants).toEqual({}); + }); +}); + +describe('cx', () => { + it('resolves a static style object', () => { + const styles = cx({ color: 'red' }); + expect(styles()(mockTheme)).toEqual({ color: 'red' }); + }); + + it('resolves a theme function', () => { + const styles = cx(theme => ({ color: theme.color.primary })); + expect(styles()(mockTheme)).toEqual({ color: mockTheme.color.primary }); + }); + + it('merges sx over base styles', () => { + const styles = cx({ color: 'red', opacity: 1 }); + expect(styles({ sx: { opacity: 0.5 } })(mockTheme)).toEqual({ color: 'red', opacity: 0.5 }); + }); + + it('exposes empty variant metadata for tooling', () => { + const styles = cx({ color: 'red' }); + expect(styles._variants).toEqual({}); + expect(styles._defaultVariants).toEqual({}); + }); +}); diff --git a/packages/ui/src/mosaic/cva.ts b/packages/ui/src/mosaic/cva.ts index 0c95c514189..640837ff8cd 100644 --- a/packages/ui/src/mosaic/cva.ts +++ b/packages/ui/src/mosaic/cva.ts @@ -37,7 +37,7 @@ type CompoundVariant = VariantPropsOf & { css: StyleRule type CvaConfig = { base?: StyleRule; - variants: V; + variants?: V; compoundVariants?: Array>; defaultVariants?: VariantPropsOf; }; @@ -78,9 +78,13 @@ type CvaFn = { * // In a component: * css={buttonStyles({ size: 'sm', sx: { opacity: 0.8 } })(theme)} */ -export function cva(config: CvaConfig): CvaFn; -export function cva(configFn: (theme: MosaicTheme) => CvaConfig): CvaFn; -export function cva(configOrFn: CvaConfig | ((theme: MosaicTheme) => CvaConfig)): CvaFn { +export function cva>(config: CvaConfig): CvaFn; +export function cva>( + configFn: (theme: MosaicTheme) => CvaConfig, +): CvaFn; +export function cva>( + configOrFn: CvaConfig | ((theme: MosaicTheme) => CvaConfig), +): CvaFn { const configCache = typeof configOrFn === 'function' ? new WeakMap>() : null; const fn = ((props: VariantPropsOf & { sx?: SxProp } = {} as VariantPropsOf) => (theme: MosaicTheme): StyleRule => { @@ -115,12 +119,30 @@ export function cva(configOrFn: CvaConfig | ((theme: Mosa const resolvedConfig = typeof configOrFn === 'function' ? configOrFn(resolveVariables(defaultMosaicVariables)) : configOrFn; - fn._variants = resolvedConfig.variants; + fn._variants = (resolvedConfig.variants ?? {}) as V; fn._defaultVariants = (resolvedConfig.defaultVariants ?? {}) as VariantPropsOf; return fn; } +// ─── cx ─────────────────────────────────────────────────────────────────────-- + +/** + * Sugar over `cva` for styles with no variants — just base rules plus `sx`. + * + * Equivalent to `cva({ base })` but skips the `base:` wrapper. The result is a + * full `cva` function (carries empty `_variants`/`_defaultVariants`), so it stays + * compatible with tooling that reads variant metadata. + * + * @example + * const boxStyles = cx(theme => ({ color: theme.color.primary })); + * // In a component: + * css={boxStyles({ sx: { opacity: 0.8 } })(theme)} + */ +export function cx(styles: StyleRule | ((theme: MosaicTheme) => StyleRule)): CvaFn> { + return typeof styles === 'function' ? cva(theme => ({ base: styles(theme) })) : cva({ base: styles }); +} + // ─── Internal ───────────────────────────────────────────────────────────────── /** Resolves each variant axis to a string key, preferring explicit props over defaults. Booleans are stringified to match variant map keys. */ From 155ce161a1ad9c1242bc81d80b921e38a41f626a Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 10 Jun 2026 20:28:27 -0400 Subject: [PATCH 2/2] perf(ui): resolve cva variants in a single pass Fold variant resolution into the merge loop, building the compound-match map lazily so components without compound variants skip the allocation and an extra pass. --- packages/ui/src/mosaic/cva.ts | 42 +++++++++++++++++------------------ 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/ui/src/mosaic/cva.ts b/packages/ui/src/mosaic/cva.ts index 640837ff8cd..a314b17c1d5 100644 --- a/packages/ui/src/mosaic/cva.ts +++ b/packages/ui/src/mosaic/cva.ts @@ -99,17 +99,30 @@ export function cva>( } else { config = configOrFn as CvaConfig; } - const { base, variants = {} as V, compoundVariants = [], defaultVariants = {} } = config; - const resolved = resolveVariants(variants, props, defaultVariants); + const { base, variants, compoundVariants, defaultVariants = EMPTY } = config; const computedStyles: StyleRule = {}; if (base) fastDeepMergeAndReplace(base, computedStyles); - for (const key in resolved) { - const rule = variants[key]?.[resolved[key]]; + + // Resolve and merge each variant axis in a single pass. The `resolved` map is only + // needed to match compound variants, so it's built lazily — components without + // compound variants (the common case) skip the allocation entirely. + const hasCompounds = !!compoundVariants && compoundVariants.length > 0; + const resolved: Record | null = hasCompounds ? {} : null; + for (const key in variants) { + const propValue = (props as Record)[key]; + const raw = propValue !== undefined ? propValue : defaultVariants[key]; + if (raw === undefined) continue; + const value = typeof raw === 'boolean' ? String(raw) : raw; + const rule = variants[key][value]; if (rule) fastDeepMergeAndReplace(rule, computedStyles); + if (resolved) resolved[key] = value; } - for (const cv of compoundVariants) { - if (compoundMatches(cv, resolved)) fastDeepMergeAndReplace(cv.css, computedStyles); + if (resolved) { + for (const cv of compoundVariants!) { + if (compoundMatches(cv, resolved)) fastDeepMergeAndReplace(cv.css, computedStyles); + } } + if (props.sx) { const sxStyles = typeof props.sx === 'function' ? props.sx(theme) : props.sx; fastDeepMergeAndReplace(sxStyles, computedStyles); @@ -145,21 +158,8 @@ export function cx(styles: StyleRule | ((theme: MosaicTheme) => StyleRule)): Cva // ─── Internal ───────────────────────────────────────────────────────────────── -/** Resolves each variant axis to a string key, preferring explicit props over defaults. Booleans are stringified to match variant map keys. */ -function resolveVariants( - variants: Variants, - props: Record, - defaults: Record, -): Record { - const resolved: Record = {}; - for (const key in variants) { - const value = props[key] !== undefined ? props[key] : defaults[key]; - if (value !== undefined) { - resolved[key] = typeof value === 'boolean' ? String(value) : value; - } - } - return resolved; -} +/** Shared empty defaults object — avoids allocating one per resolve when a config omits `defaultVariants`. */ +const EMPTY: Record = {}; /** Returns true when all conditions in a compound variant entry match the resolved variant set. */ function compoundMatches(cv: Record, resolved: Record): boolean {