Skip to content

feat(react): tree-shakeable dual CJS/ESM build - #2477

Draft
frankensteinke wants to merge 8 commits into
developfrom
chore/2466-dual-cjs-esm-build
Draft

feat(react): tree-shakeable dual CJS/ESM build#2477
frankensteinke wants to merge 8 commits into
developfrom
chore/2466-dual-cjs-esm-build

Conversation

@frankensteinke

@frankensteinke frankensteinke commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Phase 3 (+ the tree-shaking half of Phase 4) of #2466. Rollup now emits a tree-shakeable dual CJS/ESM build. This is a complete, working, non-breaking deliverable — bundler consumers get the actual payoff (unused components shake out), and Node keeps resolving CJS.

Stacked on #2476 (Phase 1). Base branch is chore/2466-packaging-validation-harness; review/merge that first.

What changed

Dual output

  • lib/cjs/ + lib/esm/, each compiled independently with a per-dir package.json type marker and co-located .d.ts (so attw sees no masquerading).
  • output.preserveModules keeps one output module per source file (mirroring src/) instead of bundling everything into index.js — this is what makes tree-shaking possible.
  • package.json: mainlib/cjs/index.js, modulelib/esm/index.js, typeslib/cjs/index.d.ts, "sideEffects": false. build:lib cleans lib/ first.

Why preserveModules + sideEffects together: dual formats alone don't tree-shake — Rollup previously bundled the whole library into one index.js (the old single CJS build did too), so a Button-only import pulled in everything. Preserving modules + declaring no side effects lets a consumer's bundler drop what it doesn't use. The only module-scope effect is Code's registerLanguage, which is local to the Code module and safe to treat as side-effect-free.

react-syntax-highlighter interop (folds in the dropped Phase 2)

  • Code imports the fully-specified .js cjs subpaths, required so strict-ESM bundlers (webpack 5, under our type: module marker) resolve them; the cjs (not esm) paths are kept deliberately (they work under Node require, Node ESM interop, and bundlers). Ambient shim re-declares the .js subpaths since @types only covers extensionless.

Internal consumers repointed to the relocated output (webpack/storybook $-exact barrel alias → lib/esm + explicit /cauldron.css alias; docs/index.js and two TopBar tests import the barrel/source instead of built lib/).

Payoff — measured

Consumer import Before After
import { Button } (nothing else) 1.1 MB, 82 chunks, includes Code + react-syntax-highlighter + react-aria 4 KB, 1 chunk, none of them
full <Code> render renders + highlights, no console errors

Deferred to Phase 4 (deliberately — it's breaking)

The conditional exports map: routes Node's import to the ESM build, exposes ./cauldron.css / ./package.json, and locks down deep imports. That last part breaks any consumer deep-reaching into lib/*, so it warrants its own PR + a loud changelog note. publint currently emits only non-fatal suggestions pointing at this.

Test plan

  • Tree-shaking (now an automated gate): the packaging harness bundles a Button-only consumer with Vite/Rollup from the packed tarball and asserts the output excludes react-syntax-highlighter / react-aria / registerLanguage / hljs / lowlight. Measured 4 KB / 1 chunk (was 1.1 MB / 82 chunks). Locks in the payoff so the barrel/sideEffects/build can't silently regress it.
  • Next.js App Router SSR: a real App Router consumer building against the tarball — initially this exposed a ship-blocker (SSR prerender threw registerLanguage is not a function: under strict ESM, Next/webpack delivers react-syntax-highlighter's CJS default double-wrapped). Fixed by an interopDefault normalization in Code; Next build + SSR prerender now succeed and the ThemeProvider value propagates.
  • Single-copy / dual-package-hazard gate (automated): the harness asserts import and require resolve to the same context object. Measured with a react-dom/server harness — a forced two-copy setup breaks provider→consumer propagation (theme falls back to default); single-copy propagates. Standard Next App Router loads one copy (no hazard).
  • Packaging harness: publint --strict clean, attw all-green (no masquerading), require() + import smoke pass.
  • Vite consumer renders <Code> (highlighting), <Tabs> (child detection), <Button> from the ESM build; no console errors.
  • pnpm typecheck, pnpm lint, pnpm format:check, pnpm build:docs, pnpm build:storybook, and Code/TopBar tests pass.
  • Screenshots CI — the Playwright specs consume the built package (dir import → module), so they now bundle the ESM output via Vite. Rendered DOM is identical locally; relying on the screenshot job to confirm no visual diff.

Notes / caveats

  • "sideEffects": false audit: the only module-scope side effect is Code's registerLanguage (local, safe); no CSS imports, globals, or prototype patching. Compound components detect children via identity (child.type === Tab), verified intact through the ESM build. Full audit in the thread.
  • Dual-package hazard: shipping main(CJS)+module(ESM) means a runtime that loads both builds gets two copies with split React-context identity. Pure bundler, pure Node, and standard Next App Router are all single-copy (measured). The narrow exposure is a runtime that mixes bundled-ESM and require'd-CJS in one process. The single-copy gate guards against a future change widening this.
  • Out of scope (pre-existing): ThemeProvider is not SSR-safe — its context = document?.body default throws document is not defined under SSR regardless of module format. Flagged separately.

Part of #2466.

@frankensteinke frankensteinke changed the title feat(react): dual CJS/ESM build feat(react): tree-shakeable dual CJS/ESM build Jul 15, 2026
Base automatically changed from chore/2466-packaging-validation-harness to develop July 22, 2026 12:48
Phase 3 of #2466. Rollup now emits two builds instead of one:

- lib/cjs (CommonJS) + lib/esm (ES modules), each with a package.json
  "type" marker and co-located .d.ts so @arethetypeswrong/cli sees no
  types/runtime masquerading.
- package.json: main -> lib/cjs/index.js, module -> lib/esm/index.js,
  types -> lib/cjs/index.d.ts. build:lib cleans lib first so stale
  single-build artifacts can't leak into the package.

Bundlers now consume the tree-shakeable ESM output via "module" (a Vite
consumer's bundle drops ~4.1MB -> ~1.3MB with icons split into lazy
chunks); Node keeps resolving CJS via "main". The full exports map,
"sideEffects": false, and routing Node's import to the ESM build are
deferred to Phase 4.

react-syntax-highlighter interop (folds in the dropped Phase 2):
- Code imports the fully-specified .js cjs subpaths. The esm build's
  "type": "module" marker makes strict bundlers (webpack 5) resolve
  fully-specified, so extensionless specifiers fail there. cjs paths are
  kept deliberately -- they resolve under Node require, Node ESM interop,
  and bundlers, whereas the esm subpaths are bare ESM in a non-module
  package and break under Node (verified on #2466).
- Ambient shim re-declares the .js subpaths since @types only covers the
  extensionless ones.

Internal consumers repointed to the relocated output:
- webpack (docs) + storybook aliases use a $-exact barrel alias to
  lib/esm plus an explicit /cauldron.css alias.
- docs/index.js and two TopBar tests import the barrel/source instead of
  the built lib/index.

Validated: packaging harness (publint + attw + require/import smoke),
typecheck, lint, docs + storybook builds, and a Vite consumer rendering
<Code> with highlighting from the ESM build.
Emitting dual formats alone does not tree-shake: rollup bundled the whole
library into a single index.js (the old single build did too), so a
Button-only import still pulled in every component. Deliver the actual
payoff:

- output.preserveModules keeps one output module per source file (mirroring
  src/ via preserveModulesRoot) instead of one bundle, so a consumer's
  bundler can drop the modules it doesn't use.
- "sideEffects": false lets it do so. The only module-scope effect is
  Code's registerLanguage, which is local to the Code module and therefore
  safe to declare side-effect-free.
- exports: 'named' (the public entry is the named-export barrel) replaces
  'auto', which warned under preserveModules.

Verified: a consumer importing only { Button } now bundles 4 KB / 1 chunk
(was 1.1 MB / 82 chunks) with zero react-syntax-highlighter, react-aria, or
Code code. Full <Code> render, packaging harness, docs, storybook,
typecheck, and tests still pass.

The conditional exports map (routing Node's import to ESM, locking deep
imports) remains Phase 4 — it is a breaking change warranting its own PR.
Add a Button-only consumer that Vite (Rollup) bundles from the packed
tarball, then assert the output contains none of react-syntax-highlighter,
react-aria, registerLanguage, hljs, or lowlight. This locks in the
tree-shaking payoff so a future change to the barrel, sideEffects, or the
build can't silently regress it.

Uses Vite rather than esbuild: esbuild does not tree-shake this module
graph (keeps the full barrel), so it is not representative of what real
production bundlers (Vite/Rollup, webpack) emit.
The ESM build broke Next.js App Router SSR: prerendering any page that
imports the barrel threw "registerLanguage is not a function". Under strict
ESM (Next/webpack SSR and Node-native), a CJS default export that sets
`__esModule` is delivered double-wrapped ({ __esModule, default }), so
`import SyntaxHighlighter from '.../dist/cjs/light.js'` yielded the wrapper,
not the highlighter — and Code's module-scope registerLanguage crashed on
load. (Vite/Rollup unwrap this, which is why bundler builds passed.)

Normalize the react-syntax-highlighter default imports via a small
interopDefault helper so they resolve to the real value under Node/webpack
strict ESM and remain a no-op under bundlers.

Caught by a Next.js App Router consumer harness. Verified: Next build +
SSR prerender succeeds, and Code still renders/highlights under Vite.
Add a single-copy check to verify:packaging: assert that `import` and
`require` of the specifier resolve to the same React context object. Today
they do (both -> CJS via main), so a runtime mixing both loads one copy and
providers reach consumers. If a later change (e.g. an exports map splitting
import->ESM and require->CJS) silently split resolution into two copies,
this gate flips red instead of shipping broken theming/compound components.

Measured with a Node + react-dom/server harness: a forced two-copy setup
makes a ThemeProvider's value fail to reach a cross-copy consumer (theme
falls back to default), while single-copy propagates correctly.
@frankensteinke
frankensteinke force-pushed the chore/2466-dual-cjs-esm-build branch from 85ea7d6 to e138071 Compare July 22, 2026 12:55
The a11y CI job failed loading the Checkbox docs page ("k is not a
function") because the ESM build's default import of react-id-generator
comes back double-wrapped under strict ESM (webpack). react-id-generator
ships `__esModule`, so `import nextId from 'react-id-generator'` resolves to
{ __esModule, default } rather than the function, and `nextId()` throws.
Checkbox and TreeViewItem both hit this.

Extract the interop normalization Code already used into a shared
utils/interopDefault helper and apply it to react-id-generator's default in
Code, Checkbox, and TreeViewItem. Plain-CJS deps (classnames, keyname,
focusable — no `__esModule`) are unaffected and left as-is.

Verified: full `pnpm test:a11y` passes (Checkbox + TreeView included),
plus packaging harness, typecheck, lint, and component tests.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements Phase 3 of the packaging work for @deque/cauldron-react by producing a tree-shakeable dual CJS/ESM output (preserved module graph), updating local consumers to target the ESM build, and extending the packaging validation harness to guard against dual-package hazards and tree-shaking regressions.

Changes:

  • Rollup now emits lib/cjs/ and lib/esm/ with preserveModules, per-output package.json type markers, and updated package entry fields (main/module/types) plus "sideEffects": false.
  • Added interopDefault and updated Code, Checkbox, and TreeViewItem to normalize double-wrapped CJS defaults under strict ESM; added a TS shim for fully-specified react-syntax-highlighter .js subpaths.
  • Enhanced verifyPackaging with a single-copy (dual-package hazard) guard and an automated tree-shaking assertion using a Vite consumer fixture.

Reviewed changes

Copilot reviewed 15 out of 16 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
webpack.config.mjs Aliases bare @deque/cauldron-react to built ESM entry and CSS for docs webpack build.
packages/react/src/utils/interopDefault.ts Adds helper to normalize strict-ESM CJS default interop.
packages/react/src/react-syntax-highlighter.d.ts Declares .js subpath modules for react-syntax-highlighter to match runtime imports.
packages/react/src/components/TreeView/TreeViewItem.tsx Uses interopDefault for react-id-generator default interop.
packages/react/src/components/TopBar/TopBarMenu.test.tsx Updates test import to use source barrel (src/index).
packages/react/src/components/TopBar/topBar.test.tsx Updates test import to use source barrel (src/index).
packages/react/src/components/Code/index.tsx Switches to fully-specified RSH .js CJS subpaths and normalizes defaults via interopDefault.
packages/react/src/components/Checkbox/index.tsx Uses interopDefault for react-id-generator default interop.
packages/react/scripts/verifyPackaging.js Adds single-copy and tree-shaking gates to packaging verification.
packages/react/scripts/packaging-smoke/treeshake.vite.config.js Vite config fixture to build a minimal Button-only consumer bundle.
packages/react/scripts/packaging-smoke/treeshake.entry.js Fixture entry importing only Button for tree-shaking verification.
packages/react/scripts/packaging-smoke/single-copy.mjs Fixture to assert import and require share one package instance/context.
packages/react/rollup.config.js Refactors build to dual outputs with preserveModules and per-dir type markers.
packages/react/package.json Updates entrypoints, adds sideEffects:false, and cleans lib/ before Rollup build.
docs/index.js Switches docs usage to import from package specifier.
.storybook/main.ts Aliases bare import to built ESM entry and CSS for Storybook builds.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 1 to +3
import React, { useRef, useState, useEffect } from 'react';
import { SyntaxHighlighterProps } from 'react-syntax-highlighter';
import SyntaxHighlighter from 'react-syntax-highlighter/dist/cjs/light';
// Fully-specified (`.js`) cjs subpaths: react-syntax-highlighter has no
Comment on lines 19 to 22
"build": "pnpm build:lib && pnpm build:css",
"prebuild:lib": "node scripts/buildIconTypes.js",
"build:lib": "rollup -c",
"build:lib": "rimraf lib && rollup -c",
"build:css": "postcss --output=lib/cauldron.css src/index.css",
The dual build moved every published artifact from lib/ to lib/cjs/, which
breaks consumers deep-importing types. An org-wide search found 52 such call
sites across 6 repos (walnut, comply, axeD, launchpad, deque-university,
axe-ml-extension) importing RadioItem, TextFieldProps, PopoverProps,
ComboboxValue from @deque/cauldron-react/lib/components/* and ContentNode
from /lib/types. Verified: published 7.4.0 compiles all five real patterns
(exit 0); the lib/cjs layout failed every one with TS2307.

Keep the CJS build at lib/ and nest ESM at lib/esm/, so deep imports keep
resolving and Phase 3 is non-breaking as the PR body claims. Deferring the
exports map stays the right call: a split import->esm / require->cjs map
demonstrably loads two copies (ThemeContext identities differ), which would
silently break theming across a mixed graph.

Also fixes two tree-shaking defects the harness could not see:

- sideEffects was a blanket false while the package publishes
  lib/cauldron.css, so webpack's production pass deleted the stylesheet.
  Reproduced: 99 bytes emitted with the CSS gone vs 2.02 KiB retained
  against a control manifest. Scoped to ["**/*.css"].
- The emitted lib/esm/package.json carried only the type marker. webpack
  reads sideEffects from the package.json NEAREST the module, so it shadowed
  the root manifest and every ESM module fell back to "assumed to have side
  effects". Reproduced: a Button-only import emitted 1,170,527 bytes with
  registerLanguage and react-aria-components present; carrying
  sideEffects: false into the marker drops it to 22,825 with neither.

Declarations are now emitted once, by the CJS build. The 161 copies under
lib/esm were unreachable (types points at the CJS tree, nothing exposes
lib/esm to a type resolver) and invalid where they sat: extensionless
relative specifiers in a {"type":"module"} directory are a TS2835 error
under moduleResolution node16/nodenext. This required dropping the stale
declarationDir from tsconfig, leaving rollup.config.js the sole owner.

@types/react-syntax-highlighter moves to dependencies: Code's published
props intersect SyntaxHighlighterProps, so consumers never received the
types and the whole prop bag silently degraded to any. Verified with a
consumer probe — without the types package a deliberately wrong prop value
compiles; with it, it is correctly rejected. Note the major skew (v15 types,
v16 runtime) is unavoidable: no v16 @types line exists and v16 ships none.

Extends verify:packaging from 6 steps to 9:

- ESM build step: imports lib/esm by path (Node resolves the bare specifier
  through main to the CJS tree, so nothing else covered it) and renders
  Code, Checkbox and TreeView. Both interop regressions fixed on this
  branch reproduce here when their wrappers are removed.
- webpack consumer step: asserts the stylesheet survives a production
  build, tree-shaking holds under webpack's nearest-package.json lookup,
  and a mixed import/require graph loads a single copy. The existing
  single-copy.mjs only exercises Node's loader, where module is ignored and
  both conditions land on main, so it cannot fail today.

Adds a unit test for interopDefault, whose unwrap branch was covered by
nothing: replacing the body with 'return mod' left all 1166 tests passing.

Each new gate was verified by breaking what it guards and confirming it goes
red, then restoring.
It is only used as a type, so a value import leaves an unnecessary runtime
import in the emitted JS. Matches the existing 'import type { ContentNode }'
in the same file. Addresses a review comment on #2477.
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.

2 participants