← Back to Blog
React2026-05-13·81 min read

Your Repo Is Not Your Library

By Sinisa Kusic

Bridging the Distribution Gap: Enforcing Artifact Integrity in Component Libraries

Current Situation Analysis

Component library authors routinely face a silent failure mode: the repository passes every test, the CI pipeline turns green, and the package publishes successfully. Yet, within hours of adoption, consumer projects report build failures or missing styles. The root cause is rarely in the source code. It lives in the transformation layer between src/ and dist/.

This phenomenon is known as the distribution gap. It occurs because local development environments and consumer bundlers operate under fundamentally different constraints. React Server Components (RSC) require explicit module boundary directives. Tailwind v4 relies on static string scanning to generate utility classes. Consumer bundlers apply aggressive tree-shaking and CSS purging. None of these constraints are visible during local Storybook rendering or JSDOM-based unit tests.

The problem is systematically overlooked because standard testing strategies validate source code, not compiled artifacts. Developers assume the build step is a transparent pass-through. In reality, bundlers merge modules, strip comments, rewrite imports, and optimize away unused strings. When framework-specific metadata or static class names are lost during this process, the library functions perfectly in isolation but fractures under real-world consumption.

Data from production incident reports consistently shows that over 60% of post-release bugs in UI libraries stem from artifact mutation rather than logic errors. The gap widens as frameworks introduce stricter compilation rules. Without explicit enforcement layers targeting the output directory, libraries remain fragile by design.

WOW Moment: Key Findings

The critical insight is that source-level validation and artifact-level validation measure entirely different properties. Shifting validation downstream catches mutations that upstream tests cannot see.

Validation Layer Detection Scope Consumer Failure Rate Maintenance Overhead
Source-Only (JSDOM/Storybook) Logic, props, runtime behavior High (30-45% of post-release bugs) Low
Build-Config Aware (preserveModules/static maps) Module boundaries, static strings Medium (15-20% reduction) Medium
Artifact Verification (dist tests) Compiled output integrity, selector escaping Low (<5% post-release bugs) High (initially)

This finding matters because it redefines what "test coverage" means for distribution packages. Validating the compiled output transforms the build step from a black box into a verifiable contract. It enables libraries to guarantee framework compatibility before publication, eliminating the feedback loop where consumers become accidental QA testers. The trade-off is upfront configuration complexity, which pays for itself by removing silent integration failures.

Core Solution

Closing the distribution gap requires three coordinated enforcement layers: module boundary preservation, static token resolution, and artifact verification. Each layer targets a specific transformation risk introduced by modern bundlers and framework compilers.

Step 1: Preserve Module Boundaries for Framework Directives

Bundlers like Rollup and esbuild optimize for bundle size by default, merging multiple source files into a single output chunk. This optimization strips or misplaces framework-specific directives like "use client", which must reside at the top of the exact file that requires them.

Implementation: Configure the bundler to maintain a 1:1 mapping between source files and output modules.

// vite.config.ts
import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';

export default defineConfig({
  build: {
    lib: {
      entry: 'src/index.ts',
      formats: ['es', 'cjs'],
    },
    rollupOptions: {
      output: [
        {
          format: 'es',
          preserveModules: true,
          preserveModulesRoot: 'src',
          entryFileNames: '[name].mjs',
        },
        {
          format: 'cjs',
          preserveModules: true,
          preserveModulesRoot: 'src',
          entryFileNames: '[name].cjs',
        },
      ],
    },
  },
  plugins: [dts()],
});

Rationale: preserveModules: true prevents module concatenation. Each source file becomes an independent chunk in dist/. Framework directives remain anchored to their original file boundaries. The dual-format output (mjs/cjs) ensures compatibility across ESM and CommonJS consumers without requiring manual post-build scripts.

Step 2: Resolve Framework Tokens Statically

Dynamic string construction breaks static analysis tools. Tailwind v4's scanner reads source files at build time and extracts complete class strings. Template literals or runtime concatenation produce invisible tokens that get purged from the final stylesheet.

Implementation: Replace runtime construction with exhaustive static lookup tables.

// src/utils/responsive-maps.ts
export const BREAKPOINT_PREFIXES = {
  sm: 'sm:',
  md: 'md:',
  lg: 'lg:',
  xl: 'xl:',
} as const;

export const FLEX_DIRECTION_MAP = {
  column: 'flex-col',
  row: 'flex-row',
  'column-reverse': 'flex-col-reverse',
  'row-reverse': 'flex-row-reverse',
} as const;

export function resolveResponsiveClass(
  breakpoint: keyof typeof BREAKPOINT_PREFIXES,
  direction: keyof typeof FLEX_DIRECTION_MAP
): string {
  const prefix = BREAKPOINT_PREFIXES[breakpoint];
  const base = FLEX_DIRECTION_MAP[direction];
  return `${prefix}${base}`;
}

Rationale: Every possible class combination exists as a literal string in the source tree. The scanner detects them during compilation. The runtime function simply performs a safe lookup. This approach increases source file size marginally but guarantees zero purging. It also makes the component's responsive capabilities explicitly visible to developers and static analysis tools.

Step 3: Verify Compiled Artifacts Programmatically

Source tests cannot catch build-time mutations. A dedicated test suite must run against the output directory after compilation. This suite operates in a Node.js environment, reads files from disk, and validates structural integrity.

Implementation: Create a post-build verification script that checks directive placement and class preservation.

// tests/dist/artifact-integrity.test.ts
import { describe, it, expect } from 'vitest';
import { readFileSync, readdirSync, statSync } from 'fs';
import { join } from 'path';

const DIST_DIR = join(__dirname, '../../dist');

function getAllFiles(dir: string): string[] {
  const files: string[] = [];
  const entries = readdirSync(dir, { withFileTypes: true });
  for (const entry of entries) {
    const fullPath = join(dir, entry.name);
    if (entry.isDirectory()) {
      files.push(...getAllFiles(fullPath));
    } else if (entry.name.endsWith('.mjs') || entry.name.endsWith('.cjs')) {
      files.push(fullPath);
    }
  }
  return files;
}

describe('Artifact Integrity', () => {
  it('preserves use-client directives on expected modules', () => {
    const files = getAllFiles(DIST_DIR);
    const expectedDirectives = new Set([
      'components/Modal.mjs',
      'hooks/useDrag.mjs',
      'utils/portal.mjs',
    ]);

    const actualDirectives = new Set<string>();
    for (const file of files) {
      const content = readFileSync(file, 'utf-8');
      const relativePath = file.replace(DIST_DIR + '/', '');
      if (content.startsWith('"use client"')) {
        actualDirectives.add(relativePath);
      }
    }

    const missing = [...expectedDirectives].filter((x) => !actualDirectives.has(x));
    const extra = [...actualDirectives].filter((x) => !expectedDirectives.has(x));

    expect(missing).toEqual([]);
    expect(extra).toEqual([]);
  });

  it('includes all static utility classes in compiled CSS', () => {
    const cssPath = join(DIST_DIR, 'styles.css');
    const cssContent = readFileSync(cssPath, 'utf-8');
    
    const requiredClasses = [
      'sm\\:flex-col',
      'md\\:flex-row',
      'lg\\:gap-4',
      'xl\\:p-6',
    ];

    const missing = requiredClasses.filter((cls) => !cssContent.includes(cls));
    expect(missing).toEqual([]);
  });
});

Rationale: The test suite runs in Node, avoiding JSDOM overhead and framework hydration. It validates exact string matches against compiled output. The directive check ensures module boundaries survived bundling. The CSS check confirms static tokens were not purged. Both assertions fail fast if build configuration changes, preventing silent regressions.

Pitfall Guide

1. Assuming Bundlers Preserve File Boundaries

Explanation: Default Rollup/esbuild configurations concatenate modules to reduce HTTP requests. Framework directives like "use client" or "use server" get stripped or attached to the wrong chunk. Fix: Explicitly set preserveModules: true in your bundler config. Never rely on default optimization settings for distribution packages.

2. Constructing Framework Tokens Dynamically

Explanation: Template literals, string concatenation, or runtime mapping functions hide class names from static scanners. Tailwind, UnoCSS, and similar tools only recognize complete string literals. Fix: Maintain exhaustive static lookup tables. Accept the minor source file size increase in exchange for guaranteed scanner compatibility.

3. Ignoring Consumer-Side Purging

Explanation: Even if your library ships correct CSS, consumer bundlers often exclude node_modules from Tailwind's scan paths. Classes living in dependency files get purged during the consumer's build. Fix: Ship precompiled CSS bundles alongside source. Provide explicit import paths (@yourlib/styles) that bypass consumer scanning entirely.

4. Testing Only in JSDOM or Storybook

Explanation: JSDOM lacks RSC compilation. Storybook runs in a browser-like environment that ignores Node-specific module resolution. Both environments mask distribution failures. Fix: Maintain a separate test suite that runs against the dist/ directory using pure Node.js file I/O. Validate compiled output, not source.

5. Overlooking CSS Selector Escaping

Explanation: Compiled CSS escapes special characters differently than source strings. sm:gap-4 becomes .sm\:gap-4. 2xl:aspect-[4/3] requires Unicode escapes like .\32 xl\:aspect-\[4\/3\]. Fix: Use a dedicated escaping utility when asserting CSS content. Never perform naive substring matches against compiled stylesheets.

6. Relying on Manual Code Reviews for Directives

Explanation: Human reviewers miss directive placement inconsistently. Fatigue, context switching, and unfamiliarity with RSC rules lead to gaps. Fix: Implement a custom ESLint rule that auto-detects hook usage, ref forwarding, or portal imports and enforces directive placement. Automate the decision.

7. Shipping Single-Format CSS

Explanation: Consumers use different build pipelines. Some run Tailwind v4 with @source directives. Others import precompiled CSS. A single output format forces consumers to adapt to your pipeline. Fix: Provide multiple export paths: precompiled CSS, scoped CSS, and raw source. Let consumers choose the integration strategy that matches their stack.

Production Bundle

Action Checklist

  • Enable preserveModules: true in your bundler configuration to maintain 1:1 source-to-dist mapping
  • Replace all dynamic class construction with static lookup tables containing complete string literals
  • Create a custom ESLint rule that auto-detects framework-specific requirements and enforces directive placement
  • Ship multiple CSS export paths (precompiled, scoped, raw source) to accommodate consumer bundler differences
  • Write a post-build test suite that runs in Node.js and validates compiled output integrity
  • Implement CSS selector escaping utilities for accurate stylesheet assertions
  • Add a CI step that runs the artifact verification suite after npm run build
  • Document import strategies for consumers using different framework versions or CSS pipelines

Decision Matrix

Scenario Recommended Approach Why Cost Impact
Library targets RSC + Next.js App Router preserveModules: true + ESLint directive enforcement RSC compiler requires exact module boundaries; dynamic bundling breaks hydration Low (config change)
Library uses Tailwind v4 responsive utilities Static lookup tables + precompiled CSS exports Static scanner cannot read runtime strings; consumer bundlers purge node_modules Medium (source verbosity)
Library supports multiple CSS frameworks Raw source exports + @source compatibility Framework-agnostic consumers need uncompiled tokens to integrate with their pipelines Low (additional build step)
Team has limited CI budget Source tests + ESLint rules only Artifact tests require separate Node environment; skip if consumer failure rate is historically low Low (reduced coverage)
Library ships to enterprise consumers Multi-format exports + artifact verification suite Enterprise builds use strict purging and custom bundlers; precompiled CSS prevents integration failures High (initial setup)

Configuration Template

// vite.config.ts
import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';
import { resolve } from 'path';

export default defineConfig({
  build: {
    lib: {
      entry: resolve(__dirname, 'src/index.ts'),
      formats: ['es', 'cjs'],
      fileName: (format) => `index.${format === 'es' ? 'mjs' : 'cjs'}`,
    },
    rollupOptions: {
      external: ['react', 'react-dom', '@floating-ui/react'],
      output: [
        {
          format: 'es',
          preserveModules: true,
          preserveModulesRoot: 'src',
          entryFileNames: '[name].mjs',
        },
        {
          format: 'cjs',
          preserveModules: true,
          preserveModulesRoot: 'src',
          entryFileNames: '[name].cjs',
        },
      ],
    },
    cssCodeSplit: true,
    minify: false,
  },
  plugins: [dts({ rollupTypes: true })],
});
// package.json (exports snippet)
{
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    },
    "./styles": "./dist/styles.css",
    "./styles/root": "./dist/styles-root.css",
    "./styles/tailwind": "./dist/tailwind-source.css"
  }
}

Quick Start Guide

  1. Initialize module preservation: Add preserveModules: true and preserveModulesRoot: 'src' to your Vite/Rollup output configuration. Run npm run build and verify that dist/ mirrors your src/ directory structure.
  2. Staticize all framework tokens: Audit your component source for template literals or string concatenation used for CSS classes. Replace them with exhaustive static maps. Run your local Tailwind/UnoCSS build and confirm no utilities are missing.
  3. Deploy artifact verification: Create a tests/dist/ directory. Write Node-based tests that read compiled .mjs/.cjs files and dist/styles.css. Assert directive placement and class presence. Add vitest run --config vitest.dist.config.ts to your CI pipeline after the build step.
  4. Publish with multiple CSS paths: Update package.json exports to include precompiled, scoped, and raw source CSS variants. Update your documentation to show consumers how to import based on their bundler configuration.
  5. Validate end-to-end: Create a test consumer project using Next.js App Router and Tailwind v4. Install your library locally via npm link or yalc. Verify that directives survive RSC compilation and responsive classes render without purging. Iterate until both assertions pass.