diff --git a/packages/cva/package.json b/packages/cva/package.json index dc20a9a..74097bb 100644 --- a/packages/cva/package.json +++ b/packages/cva/package.json @@ -53,6 +53,7 @@ "npm-run-all": "4.1.5", "react": "18.2.0", "react-dom": "18.2.0", + "tinybench": "^2.8.0", "ts-node": "10.9.2", "typescript": "5.4.5" }, diff --git a/packages/cva/src/benchmark.ts b/packages/cva/src/benchmark.ts new file mode 100644 index 0000000..d3028c6 --- /dev/null +++ b/packages/cva/src/benchmark.ts @@ -0,0 +1,162 @@ +import { Bench } from "tinybench"; + +import { defineConfig as originalDefineConfig } from "./old-index"; +import { defineConfig as enhancedDefineConfig } from "./index"; + +function logResultsTable(bench: Bench) { + const table: any[] = []; + + for (const task of bench.tasks) { + if (!task.result) continue; + table.push({ + name: task.name, + "ops/sec": task.result.error ? 0 : task.result.hz, + "average (ms)": task.result.error ? "NaN" : task.result.mean.toFixed(3), + samples: task.result.error ? "NaN" : task.result.samples.length, + }); + } + + const results = table + .map((x) => ({ + ...x, + "ops/sec": parseFloat(parseInt(x["ops/sec"].toString(), 10).toString()), + })) + .sort( + (a: Record, b: Record) => + b["ops/sec"] - a["ops/sec"], + ); + + const maxOps = Math.max(...results.map((x) => x["ops/sec"])); + + console.table( + results.map((x, i) => ({ + ...x, + [`relative to ${results[0]["name"]}`]: + i === 0 + ? "" + : `${(maxOps / parseInt(x["ops/sec"])).toFixed(2)} x slower`, + })), + ); +} + +const originalCVA = originalDefineConfig().cva; +const enhancedCVA = enhancedDefineConfig().cva; + +const buttonWithoutBaseWithDefaultsWithClassNameString = { + base: "button font-semibold border rounded", + variants: { + intent: { + unset: null, + primary: + "button--primary bg-blue-500 text-white border-transparent hover:bg-blue-600", + secondary: + "button--secondary bg-white text-gray-800 border-gray-400 hover:bg-gray-100", + warning: + "button--warning bg-yellow-500 border-transparent hover:bg-yellow-600", + danger: [ + "button--danger", + [ + 1 && "bg-red-500", + { baz: false, bat: null }, + ["text-white", ["border-transparent"]], + ], + "hover:bg-red-600", + ], + }, + disabled: { + unset: null, + true: "button--disabled opacity-050 cursor-not-allowed", + false: "button--enabled cursor-pointer", + }, + size: { + unset: null, + small: "button--small text-sm py-1 px-2", + medium: "button--medium text-base py-2 px-4", + large: "button--large text-lg py-2.5 px-4", + }, + m: { + unset: null, + 0: "m-0", + 1: "m-1", + }, + }, + compoundVariants: [ + { + intent: "primary", + size: "medium", + className: "button--primary-medium uppercase", + }, + { + intent: "warning", + disabled: false, + className: "button--warning-enabled text-gray-800", + }, + { + intent: "warning", + disabled: true, + className: [ + "button--warning-disabled", + [1 && "text-black", { baz: false, bat: null }], + ], + }, + { + intent: ["warning", "danger"], + className: "button--warning-danger !border-red-500", + }, + { + intent: ["warning", "danger"], + size: "medium", + className: "button--warning-danger-medium", + }, + ], + defaultVariants: { + m: 0, + disabled: false, + intent: "primary", + size: "medium", + }, +} as any; + +async function run() { + const benchmark = new Bench({ time: 5000 }); + + benchmark.add("cva/main", () => { + const buttonVariants = originalCVA( + buttonWithoutBaseWithDefaultsWithClassNameString, + ); + buttonVariants({}); + buttonVariants({ intent: "primary", disabled: true } as any); + buttonVariants({ intent: "primary", size: "medium" } as any); + buttonVariants({ + intent: "warning", + size: "medium", + disabled: true, + } as any); + buttonVariants({ size: "small" } as any); + buttonVariants({ size: "large", intent: "unset" } as any); + }); + + benchmark.add("feat/performance-enhancement", () => { + const buttonVariants = enhancedCVA( + buttonWithoutBaseWithDefaultsWithClassNameString, + ); + buttonVariants({}); + buttonVariants({ intent: "primary", disabled: true } as any); + buttonVariants({ intent: "primary", size: "medium" } as any); + buttonVariants({ + intent: "warning", + size: "medium", + disabled: true, + } as any); + buttonVariants({ size: "small" } as any); + buttonVariants({ size: "large", intent: "unset" } as any); + }); + + await benchmark.warmup(); + await benchmark.run(); + logResultsTable(benchmark); + + process.exit(); +} + +run(); diff --git a/packages/cva/src/index.ts b/packages/cva/src/index.ts index 7968ab9..6614032 100644 --- a/packages/cva/src/index.ts +++ b/packages/cva/src/index.ts @@ -99,6 +99,19 @@ type CVAClassProp = className?: ClassValue; }; +type CVACompoundVariants = (V extends CVAVariantShape + ? ( + | CVAVariantSchema + | { + [Variant in keyof V]?: + | StringToBoolean + | StringToBoolean[] + | undefined; + } + ) & + CVAClassProp + : CVAClassProp)[]; + export interface CVA { < _ extends "cva's generic parameters are restricted to internal use only.", @@ -107,18 +120,7 @@ export interface CVA { config: V extends CVAVariantShape ? CVAConfigBase & { variants?: V; - compoundVariants?: (V extends CVAVariantShape - ? ( - | CVAVariantSchema - | { - [Variant in keyof V]?: - | StringToBoolean - | StringToBoolean[] - | undefined; - } - ) & - CVAClassProp - : CVAClassProp)[]; + compoundVariants?: CVACompoundVariants; defaultVariants?: CVAVariantSchema; } : CVAConfigBase & { @@ -156,87 +158,162 @@ export interface DefineConfig { cva: CVA; }; } - -/* Exports +/* Internal helper functions ============================================ */ +/** + * Type guard. + * Determines whether an object has a property with the specified name. + * */ +function isKeyOf, V = keyof R>( + record: R, + key: unknown, +): key is V { + return ( + (typeof key === "string" || + typeof key === "number" || + typeof key === "symbol") && + Object.prototype.hasOwnProperty.call(record, key) + ); +} + +/** + * Merges two given objects, Props take precedence over Defaults + * */ +function mergeDefaultsAndProps< + V extends CVAVariantShape, + P extends Record, + D extends CVAVariantSchema, +>(props: P = {} as P, defaults: D = {} as D) { + const result: Record = { ...defaults }; + + for (const key in props) { + if (!isKeyOf(props, key)) continue; + const value = props[key]; + if (typeof value !== "undefined") result[key] = value; + } + + return result as Record>; +} + +/** + * Returns a list of class variants based on the given Props and Defaults + * */ +function getVariantClassNames< + V extends CVAVariantShape, + P extends Record & CVAClassProp, + D extends CVAVariantSchema, +>(variants: V, props: P = {} as P, defaults: D = {} as D) { + const variantClassNames: ClassArray = []; + + for (const variant in variants) { + if (!isKeyOf(variants, variant)) continue; + const variantProp = props[variant]; + const defaultVariantProp = defaults[variant]; + + const variantKey = + falsyToString(variantProp) || falsyToString(defaultVariantProp); + + if (isKeyOf(variants[variant], variantKey)) + variantClassNames.push(variants[variant][variantKey]); + } + + return variantClassNames; +} + +/** + * Returns selected compound className variants based on Props and Defaults + * */ +function getCompoundVariantClassNames( + compoundVariants: CVACompoundVariants, + defaultsAndProps: ClassDictionary, +) { + const compoundClassNames: ClassArray = []; + + for (const compoundConfig of compoundVariants) { + let selectorMatches = true; + + for (const cvKey in compoundConfig) { + if ( + !isKeyOf(compoundConfig, cvKey) || + cvKey === "class" || + cvKey === "className" + ) + continue; + + const cvSelector = compoundConfig[cvKey]; + const selector = defaultsAndProps[cvKey]; + + const matches = Array.isArray(cvSelector) + ? cvSelector.includes(selector) + : selector === cvSelector; + + if (!matches) { + selectorMatches = false; + break; + } + } + + if (selectorMatches) + compoundClassNames.push(compoundConfig.class ?? compoundConfig.className); + } + + return compoundClassNames; +} + const falsyToString = (value: T) => typeof value === "boolean" ? `${value}` : value === 0 ? "0" : value; +/* Exports + ============================================ */ + export const defineConfig: DefineConfig = (options) => { const cx: CX = (...inputs) => { if (typeof options?.hooks?.["cx:done"] !== "undefined") return options?.hooks["cx:done"](clsx(inputs)); - if (typeof options?.hooks?.onComplete !== "undefined") return options?.hooks.onComplete(clsx(inputs)); return clsx(inputs); }; - const cva: CVA = (config) => (props) => { - if (config?.variants == null) - return cx(config?.base, props?.class, props?.className); - - const { variants, defaultVariants } = config; - - const getVariantClassNames = Object.keys(variants).map( - (variant: keyof typeof variants) => { - const variantProp = props?.[variant as keyof typeof props]; - const defaultVariantProp = defaultVariants?.[variant]; - - const variantKey = (falsyToString(variantProp) || - falsyToString( - defaultVariantProp, - )) as keyof (typeof variants)[typeof variant]; - - return variants[variant][variantKey]; - }, - ); - - const defaultsAndProps = { - ...defaultVariants, - // remove `undefined` props - ...(props && - Object.entries(props).reduce( - (acc, [key, value]) => - typeof value === "undefined" ? acc : { ...acc, [key]: value }, - {} as typeof props, - )), - }; + const cva: CVA = (config) => { + const { + variants, + defaultVariants = {}, + base, + compoundVariants = [], + } = config ?? {}; + + if (variants == null) + return (props) => cx(base, props?.class, props?.className); + + return (props) => { + const variantClassNames = getVariantClassNames( + variants, + props, + defaultVariants, + ); - const getCompoundVariantClassNames = config?.compoundVariants?.reduce( - (acc, { class: cvClass, className: cvClassName, ...cvConfig }) => - Object.entries(cvConfig).every(([cvKey, cvSelector]) => { - const selector = - defaultsAndProps[cvKey as keyof typeof defaultsAndProps]; - - return Array.isArray(cvSelector) - ? cvSelector.includes(selector) - : selector === cvSelector; - }) - ? [...acc, cvClass, cvClassName] - : acc, - [] as ClassValue[], - ); - - return cx( - config?.base, - getVariantClassNames, - getCompoundVariantClassNames, - props?.class, - props?.className, - ); + const compoundVariantClassNames = getCompoundVariantClassNames( + compoundVariants, + mergeDefaultsAndProps(props, defaultVariants), + ); + + return cx( + base, + variantClassNames, + compoundVariantClassNames, + props?.class, + props?.className, + ); + }; }; const compose: Compose = (...components) => (props) => { - const propsWithoutClass = Object.fromEntries( - Object.entries(props || {}).filter( - ([key]) => !["class", "className"].includes(key), - ), - ); + const { class: _class, className, ...propsWithoutClass } = props ?? {}; return cx( components.map((component) => component(propsWithoutClass)), diff --git a/packages/cva/src/old-index.ts b/packages/cva/src/old-index.ts new file mode 100644 index 0000000..0815a99 --- /dev/null +++ b/packages/cva/src/old-index.ts @@ -0,0 +1,240 @@ +import { clsx } from "clsx"; + +/* Types + ============================================ */ + +/* clsx + ---------------------------------- */ + +// When compiling with `declaration: true`, many projects experience the dreaded +// TS2742 error. To combat this, we copy clsx's types manually. +// Should this project move to JSDoc, this workaround would no longer be needed. + +type ClassValue = + | ClassArray + | ClassDictionary + | string + | number + | null + | boolean + | undefined; +type ClassDictionary = Record; +type ClassArray = ClassValue[]; + +/* Utils + ---------------------------------- */ + +type OmitUndefined = T extends undefined ? never : T; +type StringToBoolean = T extends "true" | "false" ? boolean : T; +type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( + k: infer I, +) => void + ? I + : never; + +export type VariantProps any> = Omit< + OmitUndefined[0]>, + "class" | "className" +>; + +/* compose + ---------------------------------- */ + +export interface Compose { + []>( + ...components: [...T] + ): ( + props?: ( + | UnionToIntersection< + { + [K in keyof T]: VariantProps; + }[number] + > + | undefined + ) & + CVAClassProp, + ) => string; +} + +/* cx + ---------------------------------- */ + +export interface CX { + (...inputs: ClassValue[]): string; +} + +export type CXOptions = Parameters; +export type CXReturn = ReturnType; + +/* cva + ============================================ */ + +type CVAConfigBase = { base?: ClassValue }; +type CVAVariantShape = Record>; +type CVAVariantSchema = { + [Variant in keyof V]?: StringToBoolean | undefined; +}; +type CVAClassProp = + | { + class?: ClassValue; + className?: never; + } + | { + class?: never; + className?: ClassValue; + }; + +export interface CVA { + < + _ extends "cva's generic parameters are restricted to internal use only.", + V, + >( + config: V extends CVAVariantShape + ? CVAConfigBase & { + variants?: V; + compoundVariants?: (V extends CVAVariantShape + ? ( + | CVAVariantSchema + | { + [Variant in keyof V]?: + | StringToBoolean + | StringToBoolean[] + | undefined; + } + ) & + CVAClassProp + : CVAClassProp)[]; + defaultVariants?: CVAVariantSchema; + } + : CVAConfigBase & { + variants?: never; + compoundVariants?: never; + defaultVariants?: never; + }, + ): ( + props?: V extends CVAVariantShape + ? CVAVariantSchema & CVAClassProp + : CVAClassProp, + ) => string; +} + +/* defineConfig + ---------------------------------- */ + +export interface DefineConfigOptions { + hooks?: { + /** + * @deprecated please use `onComplete` + */ + "cx:done"?: (className: string) => string; + /** + * Returns the completed string of concatenated classes/classNames. + */ + onComplete?: (className: string) => string; + }; +} + +export interface DefineConfig { + (options?: DefineConfigOptions): { + compose: Compose; + cx: CX; + cva: CVA; + }; +} + +/* Exports + ============================================ */ + +const falsyToString = (value: T) => + typeof value === "boolean" ? `${value}` : value === 0 ? "0" : value; + +export const defineConfig: DefineConfig = (options) => { + const cx: CX = (...inputs) => { + if (typeof options?.hooks?.["cx:done"] !== "undefined") + return options?.hooks["cx:done"](clsx(inputs)); + + if (typeof options?.hooks?.onComplete !== "undefined") + return options?.hooks.onComplete(clsx(inputs)); + + return clsx(inputs); + }; + + const cva: CVA = (config) => (props) => { + if (config?.variants == null) + return cx(config?.base, props?.class, props?.className); + + const { variants, defaultVariants } = config; + + const getVariantClassNames = Object.keys(variants).map( + (variant: keyof typeof variants) => { + const variantProp = props?.[variant as keyof typeof props]; + const defaultVariantProp = defaultVariants?.[variant]; + + const variantKey = (falsyToString(variantProp) || + falsyToString( + defaultVariantProp, + )) as keyof (typeof variants)[typeof variant]; + + return variants[variant][variantKey]; + }, + ); + + const defaultsAndProps = { + ...defaultVariants, + // remove `undefined` props + ...(props && + Object.entries(props).reduce( + (acc, [key, value]) => + typeof value === "undefined" ? acc : { ...acc, [key]: value }, + {} as typeof props, + )), + }; + + const getCompoundVariantClassNames = config?.compoundVariants?.reduce( + (acc, { class: cvClass, className: cvClassName, ...cvConfig }) => + Object.entries(cvConfig).every(([cvKey, cvSelector]) => { + const selector = + defaultsAndProps[cvKey as keyof typeof defaultsAndProps]; + + return Array.isArray(cvSelector) + ? cvSelector.includes(selector) + : selector === cvSelector; + }) + ? [...acc, cvClass, cvClassName] + : acc, + [] as ClassValue[], + ); + + return cx( + config?.base, + getVariantClassNames, + getCompoundVariantClassNames, + props?.class, + props?.className, + ); + }; + + const compose: Compose = + (...components) => + (props) => { + const propsWithoutClass = Object.fromEntries( + Object.entries(props || {}).filter( + ([key]) => !["class", "className"].includes(key), + ), + ); + + return cx( + components.map((component) => component(propsWithoutClass)), + props?.class, + props?.className, + ); + }; + + return { + compose, + cva, + cx, + }; +}; + +export const { compose, cva, cx } = defineConfig(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aae72a6..34f50fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -339,6 +339,9 @@ importers: react-dom: specifier: 18.2.0 version: 18.2.0(react@18.2.0) + tinybench: + specifier: ^2.8.0 + version: 2.8.0 ts-node: specifier: 10.9.2 version: 10.9.2(@swc/core@1.4.16(@swc/helpers@0.5.2))(@types/node@20.12.7)(typescript@5.4.5)