DEV Community

Sinisa Kusic
Sinisa Kusic

Posted on • Originally published at ku5ic.substack.com

Your Repo Is Not Your Library

The distribution gap, and three enforcement layers that close it

nuka-ui's main test suite is green. Vitest passes. ESLint passes. TypeScript passes. The Storybook a11y panel shows zero violations on every story. And then someone installs the package in a Next.js App Router project, drops a <Toaster> into a server component, and the build fails with an error that points at React's internals.

I have lost more hours to this kind of bug than I want to admit. The shape repeats. The repo is happy. The consumer's project is not. The thing that broke is invisible from inside the repo, because it lives in the part of the codebase that nobody on the project ever runs: the dist directory.

What follows is what happened with two of those bugs and what I added to make them stay fixed. A "use client" directive that never made it into dist, and a Tailwind class that the consumer's bundler purged before it ever rendered. All the code referenced below lives in github.com/ku5ic/nuka-ui.

Bug one: the directive that wasn't there

I forgot to put "use client"; at the top of a hook-using component. The directive is invisible until production. The repo does not need it. Storybook does not need it. Vitest does not need it. The first thing that needs it is a React Server Components compiler running inside a consumer's framework, and by then I have already shipped.

That was the first surprise.

The second surprise: even when I put the directive in, the build did not preserve it. Vite's default Rollup configuration bundles every source file into a single index.js. The directive either gets dropped or merged onto the wrong module boundary. The fix is one option in vite.config.ts:

output: [
  {
    format: "es",
    preserveModules: true,
    preserveModulesRoot: "src",
    entryFileNames: "[name].js",
  },
  // ... same for cjs
]
Enter fullscreen mode Exit fullscreen mode

preserveModules: true keeps each source file as its own module in dist. The directive stays attached to the file that declared it.

The third surprise: even with that option set, nothing in the test suite would notice if it disappeared. Six months from now I disable preserveModules to fix a different problem, every component in dist is in one file again, every test still passes, and the next release breaks RSC for everyone.

So I added three things.

The first is a custom ESLint rule, nuka/require-use-client, that lives in tools/eslint-plugin-nuka/. It runs over every source file and decides whether a "use client" directive is required. The conditions are explicit. If the file calls a React hook, has a ref or asChild prop in its public type, imports from @nuka/hooks, imports Slot from @nuka/utils/slot, or imports anything from @floating-ui/react, the directive must be there. If those conditions are met and the directive is missing, ESLint fails and offers an autofix that inserts it. If the directive is there but none of those conditions are met, ESLint also fails, and asks for a removal or a comment explaining why.

I do not have to remember the rule. The rule remembers itself. The error fires in my editor while I am still writing the component.

The second is the preserveModules option above. There is nothing clever about it; it is one line. The point is that nothing else in the project would notice if I changed it.

The third is a separate test suite. nuka-ui has a vitest.dist.config.ts that runs after npm run build. It does not import source. It runs in node, not jsdom, because it reads files on disk rather than rendering components.

use-client-directives.test.ts walks the source tree to find every file that starts with "use client";, computes the dist paths for each (one .js for ESM, one .cjs for CJS), then walks dist to check that the directive made it through:

it("dist directive set equals expected set with no extras and no missing", () => {
  const expectedAll = new Set<string>([...expected.esm, ...expected.cjs]);
  const extras = diff(actual, expectedAll);
  const missing = diff(expectedAll, actual);
  expect(extras).toEqual([]);
  expect(missing).toEqual([]);
});
Enter fullscreen mode Exit fullscreen mode

The full file makes four more assertions in the same shape: every expected file starts with the directive on line one, the dist count is exactly twice the source count, the directive appears exactly once per file. If anyone changes the build in a way that loses a directive, this test screams. If anyone changes the build in a way that adds directives to files that do not need them, it also screams.

I ended up needing all three only after removing each one in turn over a few months and watching the bug come back.

Bug two: the class the consumer purged

The second bug had the same shape. Different domain.

I shipped a responsive Stack component that accepted props like direction={{ base: "column", md: "row" }}. Internally that resolved to Tailwind classes by combining a breakpoint prefix and a value: md:flex-row, lg:gap-8. I wrote it the obvious way:

const cls = `${prefix}:${tokenFor(value)}`;
Enter fullscreen mode Exit fullscreen mode

That construction has real benefits. The base map is one entry per prop value, column -> flex-col, and the prefix logic combines it with every breakpoint for free. Adding a breakpoint is one change; every prop value picks up the new variant without me touching it. It is also the obvious code: ${prefix}:${value} is how you build a class name in any other context where strings are just strings.

The alternative is to enumerate every (prefix, value) pair as a complete string literal in a lookup table. For a layout component with a handful of props and six breakpoints, that is hundreds of entries. Adding a breakpoint means walking every table. The runtime is the same either way; the difference is where the verbosity lives. Construction keeps the code small at the cost of computing the class name at runtime. The lookup table keeps every name visible at the cost of writing them all down.

Given those tradeoffs, I picked construction. It was the right call for a developer reading the code. It was the wrong call for the Tailwind scanner reading the code, which is the reader I had forgotten about.

That code is broken in two places, and the first place is inside my own library.

Tailwind v4 scans source files for complete class strings at build time. It does not evaluate expressions. A template literal that constructs a class name at runtime is invisible to the scanner. The class never makes it into dist/styles.css. My own Storybook renders unstyled.

ADR-022 fixed this with static lookup tables: every (breakpoint, prop value) pair enumerated as a complete string literal somewhere in source. The responsive utility resolves to entries in those tables instead of constructing names at runtime.

That fixed Storybook. Then I shipped 1.0.x and a consumer reported the responsive classes were still being purged. Different bundler, same shape. The consumer's own Tailwind build does not scan node_modules by default. Even when every class is statically present in my compiled CSS, the consumer's Tailwind sees only their own source files and purges the rest.

This is the second place the bug lives. It is not in my library. It is in the relationship between my library and the consumer's build.

Three pieces again.

The first is the lookup tables themselves. Static. Every class a complete string literal that Tailwind can scan. Without this, my own build is broken; nothing downstream matters.

The second is the package.json exports map. Three CSS bundles to handle the consumer-side problem:

"./styles": "./dist/styles.css",
"./styles/root": "./dist/styles-root.css",
"./styles/tailwind": "./dist/tailwind.css"
Enter fullscreen mode Exit fullscreen mode

nuka-ui/styles is precompiled. Every class already inlined into CSS rules. The consumer's Tailwind cannot purge what is already CSS. nuka-ui/styles/root is the same, scoped to :root for consumers using nuka-ui alongside another design system on the same page. nuka-ui/styles/tailwind is the raw source for consumers running their own Tailwind v4 with @source directives pointed at node_modules. Three paths because consumers' build pipelines are not the same.

The third is the test suite again. responsive-safelist.test.ts asserts that the lookup tables flow correctly through every downstream artifact. It does three things.

First, it computes the expected class set from the lookup tables directly, cross-producted with every breakpoint prefix:

function buildExpectedSet(): Set<string> {
  const set = new Set<string>();
  for (const entry of ALL_BASE_MAPS) {
    for (const prefix of Object.values(BREAKPOINT_PREFIXES)) {
      for (const value of Object.values(entry.map)) {
        for (const token of value.split(/\s+/).filter(Boolean)) {
          set.add(prefix ? `${prefix}${token}` : token);
        }
      }
    }
  }
  return set;
}
Enter fullscreen mode Exit fullscreen mode

Second, it asserts the generated safelist, produced by tools/generate-safelist.mjs and committed to the repo, contains exactly this set.

Third, and this is the assertion that catches the actual bug, it reads dist/styles.css and verifies every class in the safelist appears as a properly escaped CSS selector in the compiled bundle:

it("every class in _safelist appears as an escaped rule in dist/styles.css", () => {
  const missing: string[] = [];
  for (const cls of _safelist) {
    const selector = toEscapedSelector(cls);
    if (!cssText.includes(selector)) missing.push(cls);
  }
  expect(missing).toEqual([]);
});
Enter fullscreen mode Exit fullscreen mode

The toEscapedSelector helper is itself instructive. Tailwind v4 escapes class selectors in non-obvious ways. sm:gap-4 becomes .sm\:gap-4. 2xl:aspect-[4/3] becomes .\32 xl\:aspect-\[4\/3\]. The leading-digit case needs a Unicode escape with a trailing space delimiter. The test only works because it accounts for the actual encoding rules of the compiled output. A naive substring check on the unescaped class name would pass for the wrong reasons or fail for the wrong reasons.

What this catches: a new responsive class added to the base map but missing from the safelist regeneration, a class that the safelist names but the Tailwind compiler purged anyway, an escape rule in Tailwind that changes in a future version and breaks the selector pattern silently.

What I am left with

Both bugs ended up in the same shape. Something correct in source, transformed by a build, arriving at a consumer in a state that did not match the source.

I could have avoided half of this by writing out the full Tailwind class names instead of constructing them. I know this because I tried. The first time I forgot, the build went green and the artifact went wrong. Every layer in this article exists to catch the moment I stop being careful.

Top comments (0)