Skip to content

feat: add configure() for custom Tailwind theme utilities - #14

Open
zwarunek wants to merge 1 commit into
aidenybai:mainfrom
zwarunek:feat/configure
Open

feat: add configure() for custom Tailwind theme utilities#14
zwarunek wants to merge 1 commit into
aidenybai:mainfrom
zwarunek:feat/configure

Conversation

@zwarunek

@zwarunek zwarunek commented Jul 27, 2026

Copy link
Copy Markdown

cn bakes in the default tailwind-merge configuration and exposes no way to extend it, so any utility that comes from a customised Tailwind theme is classified wrong and silently dropped.

What breaks today

// @theme { --text-xxs: 0.625rem }
import { cn } from "cnfast";

cn("text-xxs text-muted-foreground");
// "text-muted-foreground"  — the font size is gone

text-xxs is not in the default text theme scale, so it falls through to the text-color group and conflicts with text-muted-foreground. tailwind-merge with extendTailwindMerge({ extend: { theme: { text: ["xxs"] } } }) keeps both. Every custom type scale hits this; on a design system with ten type tokens, 22 of our 35 merge assertions fail.

There is no workaround on 0.0.8 short of patching the published bundle: createTailwindMerge, getDefaultConfig and mergeConfigs are not exported, and exports is .-only so they cannot be deep-imported either. We currently run cnfast in production behind a pnpm patch that does what this PR does, which is why I would rather send it here.

What this changes

src/lib/merge-configs.ts adds mergeConfigs, ported from tailwind-merge (MIT): override replaces a config property, extend appends to it. configure(extension) in src/lib/tw-merge.ts stores an extension, exported from the entry point along with the ConfigExtension type. The merge config is built from it lazily on the first merge, by the createTailwindMerge initialiser that already exists.

import { cn, configure } from "cnfast";

configure({ extend: { theme: { text: ["xxs"] } } });
cn("text-xxs text-muted-foreground"); // "text-xxs text-muted-foreground"

configure targets the shared cn deliberately. cnfast migrate rewrites imports to cnfast, and registry/cnfast/utils.ts is export { cn } from "cnfast" — both onboarding paths land users on the default instance, so a configuration hook that cannot reach it does not help them.

Calling configure after the first merge throws rather than dropping the caches. Re-configuring a live instance means the same input merges to one string early in the process and a different one later, and strings already returned to a caller cannot be recalled; in React that surfaces as a hydration mismatch. Making it one-shot states the constraint instead of hiding it, and means there is nothing to invalidate: at configure time the whole-string, descriptor, arg-sequence and template caches are all still empty.

One open question. Should a second configure before the first merge also throw? Right now the last call wins. Throwing would catch two libraries fighting over the shared instance, at the cost of breaking HMR when an entry module re-evaluates. I left it permissive, and will flip it if you prefer.

What this does not change

An unconfigured cn is byte-identical: configExtension stays undefined and createConfig returns getDefaultConfig() exactly as before. Nothing lands on a hot path — the extension is read once inside the lazy initialiser, with no per-call branch, allocation or property read added. No existing export changes.

Relationship to #7

#7 is open and unreviewed since 2026-06-23 and fixes the same defect from the other direction: a createCn(config) factory returning an isolated configured instance. I checked it out against current main — it merges cleanly, its 149 tests pass, and it solves our case too. It contains the same mergeConfigs port as this PR, from the same source.

The two are complementary rather than competing: configure for the app-wide default instance that migrate and the registry point at, createCn for library authors, tests, and anything needing more than one configuration in a process. If you take #7 first I will rebase this on top so configure becomes a thin one-shot binding over its factory. If you want only one of the two shapes, say which and I will close the other out of the way. Either way #6, #8 and #13 are all waiting on this.

Checks

pnpm build, pnpm typecheck, and pnpm lint pass; lint reports 14 warnings before and after this branch, none in the new files. pnpm test goes from 130 to 148, 18 new. With the source change reverted those 18 fail: 9 assertion failures in configure.test.ts, and merge-configs.test.ts cannot resolve the module.

pnpm format:check already fails on main, on four markdown files this branch does not touch (README.md, docs/upstream-tailwind-merge.md, packages/cnfast/README.md, packages/cnfast/bench/README.md). I ran vp fmt, kept its output for the files in this PR and reverted the rest so the diff stays reviewable — the other four look like a separate housekeeping commit.


Note

Medium Risk
Changes global merge semantics for every consumer of the default cn/twMerge when configured, with ordering constraints that can break apps if configure runs too late; unconfigured paths are unchanged.

Overview
Adds configure({ override, extend }) so the shared cn and twMerge can register tailwind-merge-style extensions (e.g. custom text scale tokens like xxs) before the first merge. The merge config is built lazily on first use via mergeConfigs; callers who never configure keep the same default behavior.

Late configure throws after any merge has run, so already-returned class strings are not silently inconsistent. README documents usage and the one-shot ordering requirement.

New Vitest coverage exercises theme/class-group extend and override, all cn call shapes, twMerge, default behavior without configure, and misuse; merge-configs behavior is covered in a dedicated test file. Minor release noted in changeset.

Reviewed by Cursor Bugbot for commit df34a2b. Bugbot is set up for automated code reviews on this repo. Configure here.

cn bakes in the default tailwind-merge configuration and exposes no way to
extend it, so a utility from a customised theme is classified wrong and
silently dropped: cn("text-xxs text-muted-foreground") returns just
"text-muted-foreground", because text-xxs is not in the default text scale and
falls through to the text-color group.

Adds mergeConfigs (ported from tailwind-merge, MIT) and a configure() entry
point that registers an { override, extend } extension for the shared cn and
twMerge. The extension is read once, by the existing lazy initialiser on the
first merge, so an unconfigured cn is unchanged and no per-call work is added.

Configuring after the first merge throws rather than dropping the caches: the
same input would merge to one string early in the process and another later,
and the strings already returned cannot be recalled.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant