Skip to content

chore(react): publish a dual CJS/ESM build with sideEffects: false for automatic tree-shaking #2466

Description

@frankensteinke

Summary

@deque/cauldron-react ships CJS only today: rollup.config.js emits a single
format: 'cjs' bundle, and package.json exposes only main/types — no exports
map, no module field. As a result the ergonomic barrel import
(import { Modal } from '@deque/cauldron-react') can't be tree-shaken: bundlers keep
the whole component graph.

This ticket adds a dual CJS/ESM build plus "sideEffects": false so the barrel
import prunes unused components automatically — no consumer import-style change.

Payoff

Consumers keep import { X } from '@deque/cauldron-react' and automatically bundle only
what they use — no deep imports, no codemod. A consumer importing only Button should
pull in neither react-aria-components (TreeView) nor react-syntax-highlighter (Code).

Scope notes from an initial code audit

  • The JS bundle imports zero CSS. All styling lives in src/index.css (built
    separately by PostCSS into lib/cauldron.css); no .tsx module does import './x.css'.
    So the correct declaration is "sideEffects": false, not a ["*.css"] list.
    sideEffects: false does not affect the separate cauldron.css artifact — consumers
    still import it explicitly.
  • The only module-scope side effect found is the registerLanguage(...) block in
    src/components/Code/index.tsx. It's local (only runs when Code is imported) and
    therefore tree-shake-safe. All other addEventListener/window/document usage is
    inside hooks/lifecycle (runtime, not import-time) and irrelevant to sideEffects.
  • Code hard-imports CJS subpaths (react-syntax-highlighter/dist/cjs/light). This
    is the single most likely thing to break in an ESM consumer graph and must be switched
    to ESM paths before the ESM build is validated.
  • Adding an exports map is itself a potentially breaking change: any consumer
    currently deep-reaching into lib/* will hard-error once exports is present. The
    barrel, ./cauldron.css, and ./package.json must be preserved. Treat as a minor
    release with a loud changelog note.
  • Icons stay lazy-loaded and are out of scope. Icon loads SVGs through a dynamic
    import(), so they resolve as async chunks rather than eager weight. This ticket does
    not change that. The tree-shaking payoff here is pruning unused components and their
    heavy deps (react-aria-components, react-syntax-highlighter), not shrinking the icon
    set. Zeroing out per-icon weight is a separate follow-up, not part of this work.

Phased breakdown

Sequential; phases 2 and 3 can proceed in parallel. Each phase is its own PR for
reviewability.

  • Phase 1 — Packaging validation harness + CI gate. Add publint and
    @arethetypeswrong/cli against pnpm pack output; wire into CI. Minimal
    packed-tarball smoke test (Node require + Node import). Delivers value against
    the current CJS output immediately; establishes install-from-tarball (not symlink).
  • Phase 2 — Fix react-syntax-highlighter imports. Switch Code from
    dist/cjs/... to ESM paths; confirm Code still renders + highlights.

    Dropped — verified not needed (and the switch would regress Node
    resolution, since react-syntax-highlighter has no exports map). Bundlers
    already resolve the dist/cjs paths correctly; the RSH-path concern folds
    into Phase 3/5 as a documented caveat. See the verification comment below.
  • Phase 3 — Dual Rollup output. Emit lib/cjs + lib/esm with correct
    extensions / type marker and shared .d.ts. Decide module granularity here:
    one bundled file per format is the default (see Phase 3b).
  • Phase 3b (optional) — preserveModules for reliable pruning. Especially if the
    Phase 5 tree-shaking test shows a single bundle doesn't prune cleanly. Emit each
    component as its own module (preserveModules: true) instead of one bundled file
    per format. Module-boundary elimination is more reliable than statement-level
    dead-code elimination across one large bundle. It matters most for the module-scope
    registerLanguage(...) calls in src/components/Code/index.tsx and for dropping the
    react-aria-components import when a consumer doesn't use TreeView — the exact
    cases "sideEffects": false has to get right. Cost: many small files per format
    instead of one; no runtime difference through a bundler, slightly more module loads
    for an unbundled Node require.
  • Phase 4 — exports map + fields. Add conditional exports (types-first
    ordering), module, preserve barrel + ./cauldron.css + ./package.json.
    Sub-task: audit module-scope side effects and set "sideEffects": false (expected).
  • Phase 4b (conditional) — Fix unsafe side effects. Only if the Phase 4 audit
    surfaces a cross-module load-time effect. Expected to be a no-op based on the audit
    above.
  • Phase 5 — Consumer harness matrix + verification (see below).
  • Phase 6 — Release, semver, changelog, docs.

Risks

  • Dual-package hazard (highest impact). The library creates React context in ≥6
    places (theme, Dialog, Combobox, Listbox, Table, ActionList). If a consumer graph loads
    both the CJS and ESM copies, contexts become distinct identities and providers silently
    stop reaching consumers — no error, just broken theming/compound components.
  • exports map is a breaking change for deep-import consumers (see scope notes).
  • react-syntax-highlighter interop under strict ESM.
  • Types/runtime mismatch ("masquerading") if condition ordering is wrong under
    moduleResolution: node16/nodenext.
  • Mis-declared sideEffects silently dropping needed code, or leaving dead code
    un-shakeable.

Verification

Automated (add to CI):

  • publint clean against packed tarball.
  • @arethetypeswrong/cli clean (no masquerading, all conditions resolve).

Consumer harness matrix (install from pnpm pack tarball, not workspace symlink):

  • Node require(...) resolves CJS; Node native import(...) resolves ESM.
  • Vite prod build — renders <Code> (proves syntax-highlighter interop).
  • Next.js App Router (RSC + a "use client" page) — SSR + dual-graph context.
  • webpack 5 / CRA — legacy bundler still resolves CJS.
  • tsc --noEmit under moduleResolution = bundler, node16, node.

Behavioral / tree-shaking:

  • Dual-package-hazard test: provider in one place, consumer nested (e.g.
    ThemeProvider → themed component, or TableTableRow); assert behavior works
    and only one context copy is present in the bundle.
  • Tree-shaking test: a consumer importing only Button bundles none of the other
    components, react-aria-components, or react-syntax-highlighter.
  • .d.ts resolution intact for both entry styles.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions