Skip to content

feat(cva): add cva/tools entry point with getSchema and pva - #383

Draft
joe-bell wants to merge 3 commits into
mainfrom
claude/cva-styles-data-attributes-8bgn68
Draft

feat(cva): add cva/tools entry point with getSchema and pva#383
joe-bell wants to merge 3 commits into
mainfrom
claude/cva-styles-data-attributes-8bgn68

Conversation

@joe-bell

@joe-bell joe-bell commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Description

Implements JB-179: lets consumers derive data-* attributes from the same cva component that produces class names.

Adds a second package entry point, cva/tools, for utilities that introspect the config cva attaches to every component. It exports two functions: getSchema (moved from the package root, which keeps a deprecated re-export) and pva.

pva(component, props?) (Prop Variant Authority: glue that merges your props) resolves a component's class string and its variant state, as data-* attributes, from a single props object:

import { cva } from "cva";
import { pva } from "cva/tools";

const button = cva({
  base: "button",
  variants: { intent: { primary: "button--primary", secondary: "button--secondary" } },
  defaultVariants: { intent: "primary" },
});

pva(button, { intent: "secondary" });
// => {
//   class: "button button--secondary",
//   className: "button button--secondary",
//   data: { "data-intent": "secondary" },
// }

The class string is returned under both class and className (so React, Vue, Svelte, and Astro can each destructure their native prop name), with data nested rather than spread at the root, so there's no ambiguity about which key holds the class string. Attribute names are kebab-cased variant names; values are always stringified.

Parity guarantee: the data attributes always report exactly the variant values class resolution selected — both sides share falsyToString's fallback semantics (lockstep comments in tools.ts/index.ts), so even untyped edge cases (null/"" props falling back to defaults, false/0 resolving as themselves) can't produce an element whose classes and data-* disagree. Pinned by a dedicated parity test suite.

Internal variants (#382) are omitted from pva's data output the same way getSchema omits them — no attribute at runtime, no key in the type — while their classes still resolve normally.

Performance: per-component work (kebab-casing, default stringification) is cached in a WeakMap keyed by component identity as a spreadable defaults object plus a null-prototype variant-to-attribute lookup (which also makes an own __proto__ key in JSON-sourced props harmless); each call spreads the defaults and overrides from the props keys. Benchmarked at 1433 → 1196 ns/op per pva call versus the previous loop shape (5-variant component). The cache relies on component.config being construction-time immutable, now documented at its assignment site.

getDataAttributes, the lower-level primitive pva is built on, stays internal — pva covers the common case, and pva(component, props).data (ignoring .class/.className) covers the rest (e.g. attributes on a different element than the class).

Build: rides the tsdown pipeline from #385 — the new entry point is one line in tsdown.config.mts; the exports/publishConfig.exports maps are build-regenerated. node10 subpath types resolve via a hand-added publishConfig.typesVersions mapping (tsdown preserves it; same pattern as class-variance-authority's ./types). Rolldown emits the root's deprecated getSchema re-export as an entry-to-entry import (index.mjs imports ./tools.mjs), so it stays one instance across both entry points. size-limit gains a dist/tools.cjs entry (620 B, capped at 0.7 kB); dist/index.cjs stays within its existing 1.6 kB cap.

Docs: the beta "Utilities" page becomes "Tools" (redirect included), with pva documented there (including a note to define components at module scope so the per-component cache is effective), in the API reference, in What's New, and replacing the hand-written data attributes in the polymorphism example. The exported pva/getSchema also carry JSDoc @example blocks for editor hover docs.

Additional context

Marked draft for review before merge. Rebased onto latest main (internal variants #382, 100% coverage enforcement #390, cva@1.0.0-beta.8). Verification performed: full test suite (350 tests, meeting the enforced 100% statement/branch/function/line coverage; tools.test.ts covers defaults/overrides, booleans, negatives, kebab-casing incl. the spec-correct data--tone encoding of leading-uppercase names, composed components, cache consistency, class-resolution parity for null/""/false/0, __proto__-key safety, internal-variant omission, and type-level rejections), check:tsc, the build's built-in attw (strict, no problems across all resolution modes for both entry points) + publint + unused-dependency gates, bundlesize, docs build, and the packed artifact consumed from a fixture with tsc --strict under both nodenext and bundler resolution plus CJS/ESM runtime smokes. Security review pass applied to the diff; no findings.


What is the purpose of this pull request?

  • Bug fix
  • New Feature
  • Documentation update
  • Other

Before submitting the PR, please make sure you do the following

  • Read the Contributing Guidelines.
  • Follow the Style Guide.
  • Check that there isn't already a PR that solves the problem the same way to avoid creating a duplicate.
  • Provide a description in this PR that addresses what the PR is solving, or reference the issue that it solves (e.g. fixes #123).
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 10, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
cva 6eec423 Commit Preview URL

Branch Preview URL
Aug 11 2026, 02:49 PM
@joe-bell joe-bell changed the title feat(cva): add cva/tools entry point with getDataAttributes Jul 10, 2026
@joe-bell joe-bell changed the title feat(cva): add cva/tools entry point with getDataAttributes and pva Jul 10, 2026
@joe-bell
joe-bell force-pushed the claude/cva-styles-data-attributes-8bgn68 branch from ca139f1 to bab9c9e Compare July 11, 2026 15:22

Copy link
Copy Markdown
Owner Author

Follow-up idea: build an example that showcases using Tailwind's data-* attribute variant (e.g. data-[intent=primary]:...) driven by pva's output to define a component's prop structure, rather than hand-writing variant classes. Would help demonstrate the intended styling pattern end to end.


Generated by Claude Code

@cursor
cursor Bot force-pushed the claude/cva-styles-data-attributes-8bgn68 branch from bab9c9e to c03c1b3 Compare July 12, 2026 10:44
@github-actions

github-actions Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Benchmarks

Comparing this PR's local benchmark run against the latest published npm versions.

Aim for higher ops/s. Treat deltas within ±5% as noise.

class-variance-authority

Note

class-variance-authority is in maintenance mode, and its latest npm baseline predates the tsdown build migration. Treat the deltas here as indicative.

Task This PR 0.7.1 Δ
cva (runtime)
component call with defaults
600,841 ops/s ±0.19% 585,272 ops/s ±4.97% +2.7%
cva (runtime)
component call with props
98,774 ops/s ±0.18% 96,752 ops/s ±0.60% +2.1%
cx (runtime)
class join
5,602,270 ops/s ±0.19% 5,705,678 ops/s ±0.47% -1.8%
cva (static)
component definition
13,876,496 ops/s ±0.08% 14,096,242 ops/s ±0.34% -1.6%
compose (static + runtime)
define, join and call
503,594 ops/s ±0.18% 510,415 ops/s ±0.16% -1.3%

cva

Task This PR 1.0.0-beta.8 (beta) Δ
cva (runtime)
component call with defaults
610,358 ops/s ±0.15% 609,591 ops/s ±0.18% +0.1%
cva (runtime)
component call with props
97,600 ops/s ±0.16% 95,343 ops/s ±0.22% +2.4%
cx (runtime)
class join
5,414,813 ops/s ±0.10% 4,703,719 ops/s ±0.12% 🟢 +15.1%
cva (static)
component definition
1,954,490 ops/s ±0.43% 1,847,435 ops/s ±0.18% 🟢 +5.8%
composes property (static + runtime)
define, join and call
250,306 ops/s ±0.16% 250,936 ops/s ±0.17% -0.3%

Commit 363a098 · Node v24.18.0 · linux x64 · 2026-08-11T14:48:53.553Z

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.

@cursor
cursor Bot force-pushed the claude/cva-styles-data-attributes-8bgn68 branch from c03c1b3 to 425d1f7 Compare July 12, 2026 18:52
@joe-bell
joe-bell force-pushed the claude/cva-styles-data-attributes-8bgn68 branch from 425d1f7 to 61fbf32 Compare July 15, 2026 13:59
claude and others added 3 commits August 11, 2026 17:35
Add a second package entry point, cva/tools, for utilities that
introspect the config cva attaches to every component:

- pva (Prop Variant Authority) resolves a component's class string and
  its variant state, as data-* attributes (e.g. data-intent="primary"),
  from a single props object, returning { class, className, data }: the
  class string under both framework prop names, and the attributes
  nested under data. Resolution falls back to defaultVariants exactly
  like class resolution; attribute names are kebab-cased variant names;
  values are always stringified. A WeakMap keyed by component caches the
  kebab-cased names and stringified defaults, so per-render calls skip
  the regex work. Closes JB-179.
- getSchema moves here as its canonical home; the package root keeps a
  deprecated re-export for backwards compatibility.

The build side rides on the tsdown pipeline from #385: the new entry is
one line in tsdown.config.mts, the exports/publishConfig.exports maps
are build-regenerated, and node10 subpath types resolve via a
hand-added publishConfig.typesVersions mapping (tsdown preserves it;
same pattern as class-variance-authority's ./types). Rolldown emits the
root's deprecated re-export as an entry-to-entry import (index.mjs
imports ./tools.mjs), so getSchema stays one instance across both entry
points. size-limit gains a dist/tools.cjs entry (527 B, capped 0.6 kB);
dist/index.cjs stays within its 1.6 kB cap.

Docs: the beta "Utilities" page becomes "Tools" (redirect included),
with pva documented there, in the API reference, in What's New, and
replacing the hand-written data attributes in the polymorphism example.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W5Qfb2yGnGnTZReRKSt6hr
pva's data attributes resolved props with `prop === undefined` fallback
while class resolution uses `falsyToString(prop) || falsyToString(default)`,
so an (untyped) null or empty-string prop produced an element carrying the
default's classes but announcing data-intent="null" - a data-[intent=primary]
selector would miss the very element styled as primary. The data pass now
shares falsyToString's exact semantics (lockstep comment on both sides), so
data always reports the variant key class resolution selected; a parity test
suite pins null, "", false, 0, and negative values.

While reworking the resolution loop, reshape the per-component cache from a
tuple array to { defaults, byKey }: defaults is a spreadable object of
resolved default attributes, byKey maps variant names to attribute names, and
each call spreads defaults then overrides from the (typically fewer) own
props keys. Benchmarked against the previous shape at 1433 -> 1196 ns/op per
pva call (5-variant component, 2M iterations). byKey has a null prototype so
an own __proto__ key in JSON-sourced props can never resolve to a junk
attribute (pinned by test). Attribute insertion order changes (defaults
first, no-default variants after) - order carries no meaning for DOM
attributes and tests compare order-insensitively.

Also: document the immutability invariant the cache relies on at the
component.config assignment in index.ts, add JSDoc (with examples) to the
exported getSchema and pva, pin the spec-correct data--tone encoding of
leading-uppercase variant names (dataset.Tone round-trip), and advise
module-scope component definitions in the tools docs. dist/tools.cjs grew
527 B -> 598 B with the parity logic, so its size-limit cap moves 0.6KB ->
0.7KB (dist/index.cjs unchanged under 1.6KB).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W5Qfb2yGnGnTZReRKSt6hr
Adapt cva/tools to the internal variants feature from #382: a
_-prefixed variant is hidden from introspection, so pva's data output
now omits it the same way getSchema does - no default attribute is
precomputed and no byKey entry exists for a prop to emit one, at
runtime and in CVADataAttributes' type. Its classes still resolve
normally. Documented in the tools page, API reference, and the
What's New internal-variants section.

Also restore two getSchema coverage tests (defaulted variant with no
values; variant with neither values nor default) and the
plain-function runtime assertion that previously lived in
index.test.ts's getSchema block, which this branch relocates to
tools.test.ts - keeping the enforced 100% coverage thresholds green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W5Qfb2yGnGnTZReRKSt6hr
@joe-bell
joe-bell force-pushed the claude/cva-styles-data-attributes-8bgn68 branch from 025f9f4 to 6eec423 Compare August 11, 2026 14:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants