Skip to content
codefastlabs

Command Palette

Search for a command to run...

v0.9.1· Changelog

@codefast/tailwind-variants

0.9.1

Patch Changes

  • #1028 tv({ extend }) keeps the extended resolver's variant and slot names when the extension declares no variants or slots of its own. With nothing to infer from, the extension's schema fell back to its string-indexed constraint, so the merged props took any key: button({ sise: "sm" }) and VariantProps<typeof button> accepted a misspelt variant, and a slot resolver accepted any slot name, though an unknown variant value was still caught. An extension that only adds base or compoundVariants is now typed exactly like the resolver it extends. Compounds and defaults no longer take part in inferring the extension's schema, so they cannot widen it either.

0.9.0

Minor Changes

  • #976 engines.node is now >=24.0.0, up from >=22.12.0, and Node 22 is no longer supported. Node 24.0.0 is the first release with explicit resource management built in (using, await using, DisposableStack, AsyncDisposableStack, SuppressedError) and all of ES2025, so the packages use both as the platform ships them instead of shimming them for an older line, and the CI matrix runs the unit suite on 24.0.0 itself. Move to Node 24, or stay on the current minor while a deployment still runs Node 22.

Patch Changes

  • #977 The README states the browser floor: Chrome and Edge 136, Firefox 136, and Safari 18.4 or later, the first releases that ship every ES2025 builtin. @codefast/di's README also says what a browser program without explicit resource management does: it keeps skipLibCheck on and calls dispose() instead of await using.

  • #965 README.md now states TypeScript 7 or later as the floor for the package's types — the one compiler every @codefast/* package is built and checked with. No code or declaration changed.

0.8.1

Patch Changes

  • #892 4c97c5b Thanks @thevuong! - Lower the monorepo's Node floor from 24 to 22.12, so the packages install and run on the active Node 22 LTS line.

    engines.node becomes >=22.12.0 across every package — the floor the shared toolchain (oxlint, Vite, Vitest, TanStack Start) already requires. Development stays on the latest Node (.node-version) for speed, and a CI matrix exercises the floor and the active LTS directly, so the floor is a contract CI proves rather than one everyone has to run. @types/node is pinned to the floor's major (^22), with a workspace override holding the whole tree there so a dev tool's @types/node: "*" peer can no longer pull a newer major and mask an API the floor lacks. The floor stays mechanical, not advisory: @codefast/di keeps its own Map upsert helpers rather than the ES2025 Map.prototype.getOrInsert (which would raise the floor to 26) and its lib stays ES2024. @codefast/cli's mirror step now calls the node:path functions directly instead of aliasing them, which the floor's types correctly flag as unbound methods.

    The shared @codefast/typescript-config presets pin lib and target to ES2024 (was ESNext) so the compiler's ECMAScript surface matches the Node floor: an ES2025 builtin such as Map.prototype.getOrInsert now fails to type-check rather than compiling and crashing on Node 22.12. @codefast/di and @codefast/di-testing already pinned lib and are unchanged.

    Internal subpath imports move from a #/ prefix to a bare # (#core/token, not #/core/token), and the package.json#imports keys become #*/#tests/*/#examples/* to match. Node's native ESM resolver rejects a #/-prefixed specifier with ERR_INVALID_MODULE_SPECIFIER on the whole Node 22 line (and on Node 24 before 24.14), and each package ships those specifiers verbatim inside its published dist/*.js for a consumer's Node to resolve — so this rename is what actually lets the packages import on the new floor. Purely internal: a consumer's own import paths are unchanged.

0.8.0

Minor Changes

  • #824 98fc6c7 Thanks @thevuong! - tv() no longer compiles at definition. The first call resolves straight from the configuration through a cold lane that answers exactly what the compiled plan answers, and the second call compiles the plan, so defining a component and rendering it once both cost less than upstream while every render after keeps its speed. Two visible consequences: config is a plain property rather than a defined read-only one, and a slot component returns the same slot object for a repeated selection from the second call on.

    The second call is cheaper too: a flat-lane variant group whose values are all strings is read in place rather than copied onto a prototype-less object, behind the type check the flat lane already makes. Resolvers expose variantKeys, the variant names in declaration order, as upstream does.

    For editors, VariantProps is now read off the resolver's call signature instead of re-derived from its schema, which makes it far cheaper for the checker on large configurations, and every configuration field and option carries a doc comment that shows on hover.

0.7.1

Patch Changes

  • #791 37a212b Thanks @thevuong! - Point the README's license badge and "License" section at the package's own LICENSE file instead of the monorepo root.

  • #793 83cceeb Thanks @thevuong! - Make "MIT" in the README's License section the link to the package's LICENSE file.

  • #796 ba04d27 Thanks @thevuong! - Rewrite the README around one structure — what the package is, installation and requirements, a complete quick start, the concepts in learning order, then the documents and the license — with every API claim checked against the current code and no figures that go stale.

  • #784 ad2f93a Thanks @thevuong! - Ship the MIT LICENSE file in the published package. files already listed it, but the file was missing from the package directory, so the tarball had none.

  • #809 6cd6a0e Thanks @thevuong! - VariantSelection — the type a variant function accepts at its call site — now admits an explicit undefined on every field, so a consumer under exactOptionalPropertyTypes can forward a possibly-undefined prop (variant={variant}) without first omitting it. VariantValues, which defaultVariants declares, stays strict. Types only — no runtime change.

0.7.0

0.6.2

0.6.1

0.6.0

Minor Changes

  • #700 ea48ae2 Thanks @thevuong! - Authoring a configuration is now type-checked as strictly as calling one. Three typos used to compile and then do nothing: a defaultVariants key naming no variant, a compoundVariants key naming no variant, and a variant class map naming no slot. Each failed the same silent way — nothing read the stray key, the compound never matched, the slot map was dropped — so a mistyped character removed a style with no error anywhere.

    The root cause was one overload. extend was optional on ExtendedVariantConfig, which made the last overload a catch-all: a configuration the earlier three correctly rejected still matched it, and TBase with nothing to infer from widened to VariantSchema, whose key is string — so every mistyped name became legal again. extend is now required there, which is what that overload was always for. Alongside it, defaultVariants and compoundVariants no longer act as inference sites for the variant schema (NoInfer), so a stray key is rejected instead of quietly widening the schema to include it.

    Two smaller corrections fall out. defaultVariants is typed by the new VariantValues<T> rather than the call-site VariantSelection<T>, so it no longer accepts a className a configuration has no use for. And in a slot configuration a variant's object value is now held to the declared slots — SlotClassValue<S> — because resolution has always read an object there as slot names rather than clsx conditions; base stays admissible whether or not it is declared, matching the plan that synthesises it.

    A configuration without slots keeps its clsx object values, and a compound condition naming an undeclared variant still resolves at runtime for JavaScript callers and merged configurations — it is only the typed authoring path that now rejects it, since no typed call could ever satisfy such a condition.

    tests/types/common/config-authoring.test.ts holds all of this with @ts-expect-error, the first negative type tests in the package. The 110 existing assertions only ever proved what compiles, which is exactly how three gaps survived.

    One configuration shape stops compiling. Requiring extend closes the overload that used to accept anything, and that overload was also what accepted a configuration whose literal types had widened — a hoisted const defaultVariants = { size: "sm" }, a hoisted compoundVariants array, a spread of a shared partial. Those have type { size: string }, which was never assignable to { size?: "sm" }; they compiled only because the catch-all widened the schema to swallow them. Add as const to the hoisted value, or inline it.

    The error TypeScript reports for this is Property 'extend' is missing, which names the last overload tried rather than the real mismatch. It is the same message a plain variant typo now produces. Nothing in this repository hit either case, but a consumer with a shared configuration fragment will.

  • #700 93b18ac Thanks @thevuong! - A variant function now remembers what each selection resolved to, so a repeated selection skips both the plan walk and tailwind-merge. Against the previous build the resolution rows measure 1.08× to 11.9×, and against tailwind-variants the suite geomean moves from 6.18× to 19.8×. The motivating measurement: in the merged lane most of the cost was never the merge algorithm — tailwind-merge caches — but building and hashing the joined class string to look that cache up, which a key built from the selection avoids entirely.

    The key is a mixed-radix number, one digit per variant. A variant no compound tests is keyed by its group key, since two values sharing a key select the same classes; a variant a compound tests is keyed by the raw value, because a compound compares against what the caller passed and true and "true" share a group key while comparing differently. A call the key cannot represent — an axis past its capacity, a configuration too large to address in one safe integer, a clsx-shaped className — resolves the long way as before.

    Two consequences are worth knowing. A slot component called twice with the same selection gets back the same object of slot functions: stable enough for a React dependency array, and shared, so nothing may mutate it. And the store is bounded and keyed by the selection, so a component whose variant values are effectively unique per call fills it with entries nothing reads again — the new cacheResolutions: false option turns it off for that component.

    Alongside it: extendTailwindMerge is now memoised by twMergeConfig identity, so a design system handing one config to a hundred components builds one merge function instead of a hundred, each with its own cache; a slot resolution keeps only the props a compound can read rather than the caller's whole props object, which would otherwise pin children for as long as the entry lives; and each slot's merged text is memoised, so re-reading a slot no longer re-runs the merge.

    tv itself costs about a quarter of a microsecond more per component definition. The encoder is compiled on first resolution rather than in tv, so a component that is defined and never rendered pays nothing for it.

  • #701 710d533 Thanks @thevuong! - @codefast/tailwind-variants now ships with no runtime dependencies of its own, matching what tailwind-variants has always done. Two changes get it there.

    clsx is gone, replaced by a flattener inside the package. It is deliberately identical, corners included: a bigint contributes nothing despite being a ClassValue, and an object's keys are read with for…in, so an inherited enumerable one counts. Verified by running the behaviour sweep — every configuration, every variant value, every slot, with and without merging — against main and diffing: zero difference across 118,505 outcomes. The dependency was only ever reached at compile time anyway, since resolution stopped calling it when the plan was introduced.

    ClassValue is now declared here rather than re-exported from clsx, so the type survives the dependency leaving. Its shape is unchanged except that ClassDictionary is Record<string, unknown> rather than clsx's Record<string, any> — which means passing a function where a class value is expected is now a type error. It contributed nothing at runtime either way.

    tailwind-merge moves from a dependency to a peer (>=3.0.0). A consumer who pins their own version now gets one copy at the version they chose, instead of a second one arriving underneath this package. Install it alongside — @codefast/ui already does on your behalf.

  • #700 d0dd326 Thanks @thevuong! - Compile the configuration once, in tv, instead of re-deriving it on every resolver call.

    tv now builds a plan. Variant groups become an array of entries whose default classes are already looked up, compound variants become flat condition lists whose fallbacks are already resolved, and every class value is flattened to a string. Resolution reads monomorphic fields and concatenates strings, so the per-call work that used to happen — Object.keys per compound variant, a dictionary lookup per variant per slot, an intermediate array plus a spread into clsx — is gone.

    Slot resolution is also inverted. A slot map names only a few slots, so having each of N slots scan every variant meant mostly-missing lookups. Each variant value now carries the slot positions it targets, and one pass distributes classes into a per-slot buffer that every resolver of that call shares; a slot called without its own props just reads its entry. Compound slots ride the same pass.

    Measured against the previous release on benchmarks/tailwind-variants (paired A/B, one subprocess per side per scenario, three passes with the order alternated, median of per-pass ratios): every one of the sixteen scenarios is faster, geometric mean 2.86×.

    shape without tailwind-merge with
    extreme-slots 9.64× 5.99×
    slots 5.55× 3.48×
    compound-slots 4.94× 3.21×
    complex 2.59× 2.00×
    simple / extends / create-tv / extreme 2.22×–2.33× 1.56×–1.89×

    The merge-enabled rows gain least because merging, not resolution, is what is left in them. The suite's A/A noise floor on the same machine is ±1%.

    The trade is a slower tv call: flattening every class value and precomputing slot positions costs roughly 400ns more for a simple config and ~3.4µs for a ten-slot one. That is once per component definition, at module load, and against a slot resolution that got ~1.6µs cheaper it pays for itself after about two renders.

    One behavioural change, in a corner: a configuration whose classes are all truthy but render to nothing — tv({ base: {} }) — now returns undefined with twMerge enabled, where it previously returned "". It already returned undefined with twMerge disabled, so undefined is now what "no classes" means either way.

Patch Changes

  • #700 fe7e9e4 Thanks @thevuong! - A variant value naming an Object.prototype member no longer reaches it. A compiled variant group is indexed by whatever a caller passes, and it was a plain object — so group["toString"] answered with a function instead of undefined. The flat lane concatenated that function's source text into the class string, and the slot lane read slot positions off Object.prototype and threw:

    tv({ base: "block", variants: { size: { sm: "p-2" } } })({ size: "toString" })
      →  "block function toString() { [native code] }"
    
    tv({ slots: { base: "rounded" }, … })({ size: "__proto__" }).base()
      →  TypeError: Cannot read properties of undefined (reading 'length')

    Groups and the slot index map are now compiled onto prototype-less objects, which closes both resolvers and the selection encoder at once since all three read the same object. It costs roughly twice what reusing the source object did, once per component definition.

    Two smaller things fall out of the same reading. A value the group does not answer is no longer memoised into the id table, which a long-lived server rendering user-supplied values would otherwise grow without bound; and inherited keys no longer consume the ids a group's declared values need, which used to disable a resolver's cache permanently after a few junk values.

0.5.0

Patch Changes

  • #676 641e233 Thanks @thevuong! - Collapse the types and default lanes of package.json#imports from fallback arrays to single strings.

    Node resolves an imports array by taking the first candidate it can parse, without checking that the file exists and without falling through — a specifier whose first candidate is missing throws ERR_MODULE_NOT_FOUND rather than trying the second. ./dist/*/index.js and ./dist/*/index.d.ts could therefore never be reached, so they read as a safety net that does not exist. The source lane keeps its extension candidates, which only tsc and Vite read and both probe.

0.5.0-canary.9

Patch Changes

  • #676 641e233 Thanks @thevuong! - Collapse the types and default lanes of package.json#imports from fallback arrays to single strings.

    Node resolves an imports array by taking the first candidate it can parse, without checking that the file exists and without falling through — a specifier whose first candidate is missing throws ERR_MODULE_NOT_FOUND rather than trying the second. ./dist/*/index.js and ./dist/*/index.d.ts could therefore never be reached, so they read as a safety net that does not exist. The source lane keeps its extension candidates, which only tsc and Vite read and both probe.

0.5.0-canary.8

0.5.0-canary.7

0.5.0-canary.6

1.0.0-canary.7

1.0.0-canary.6

0.5.0-canary.5

0.5.0-canary.4

0.5.0-canary.3

0.5.0-canary.2


Showing the latest 20 releases; the full changelog on GitHub has 45 more releases.