dequelabs / dequelabs/cauldron

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

Open
#2,466 1 comment 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
127
Forks
31
Avg merge
2d 12h
Merged PRs (30d)
8

Description

## 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).*
- [x] ~~**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 `` (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 `Table` → `TableRow`); 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.

Contributor guide

Open the contributing guide

Research direction

Start with rollup.config.js and package.json, then inspect src/components/Code/index.tsx and the separate src/index.css build. Run the packed-tarball checks with pnpm pack, including publint, @arethetypeswrong/cli, Node require/import, and the consumer harness. Done means validated CJS/ESM resolution, preserved CSS and package entry points, correct type resolution, and passing tree-shaking and dual-context checks.

Written by the indexing model from the issue text.

Assessment

Tech stack
node.js, react, rollup, typescript
Domain
build-system, developer-experience, frontend
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
38/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.