feat(react): tree-shakeable dual CJS/ESM build - #2477
Draft
frankensteinke wants to merge 8 commits into
Draft
Conversation
6 tasks
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
force-pushed
the
chore/2466-dual-cjs-esm-build
branch
from
July 22, 2026 12:55
85ea7d6 to
e138071
Compare
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.
Contributor
There was a problem hiding this comment.
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/andlib/esm/withpreserveModules, per-outputpackage.jsontype markers, and updated package entry fields (main/module/types) plus"sideEffects": false. - Added
interopDefaultand updatedCode,Checkbox, andTreeViewItemto normalize double-wrapped CJS defaults under strict ESM; added a TS shim for fully-specifiedreact-syntax-highlighter.jssubpaths. - Enhanced
verifyPackagingwith 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
What changed
Dual output
lib/cjs/+lib/esm/, each compiled independently with a per-dirpackage.jsontypemarker and co-located.d.ts(soattwsees no masquerading).output.preserveModuleskeeps one output module per source file (mirroringsrc/) instead of bundling everything intoindex.js— this is what makes tree-shaking possible.package.json:main→lib/cjs/index.js,module→lib/esm/index.js,types→lib/cjs/index.d.ts,"sideEffects": false.build:libcleanslib/first.Why
preserveModules+sideEffectstogether: dual formats alone don't tree-shake — Rollup previously bundled the whole library into oneindex.js(the old single CJS build did too), so aButton-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 isCode'sregisterLanguage, 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)
Codeimports the fully-specified.jscjs subpaths, required so strict-ESM bundlers (webpack 5, under ourtype: modulemarker) resolve them; the cjs (not esm) paths are kept deliberately (they work under Noderequire, Node ESM interop, and bundlers). Ambient shim re-declares the.jssubpaths since@typesonly covers extensionless.Internal consumers repointed to the relocated output (webpack/storybook
$-exact barrel alias →lib/esm+ explicit/cauldron.cssalias;docs/index.jsand twoTopBartests import the barrel/source instead of builtlib/).Payoff — measured
import { Button }(nothing else)<Code>renderDeferred to Phase 4 (deliberately — it's breaking)
The conditional
exportsmap: routes Node'simportto the ESM build, exposes./cauldron.css/./package.json, and locks down deep imports. That last part breaks any consumer deep-reaching intolib/*, so it warrants its own PR + a loud changelog note. publint currently emits only non-fatal suggestions pointing at this.Test plan
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.registerLanguage is not a function: under strict ESM, Next/webpack delivers react-syntax-highlighter's CJS default double-wrapped). Fixed by aninteropDefaultnormalization in Code; Next build + SSR prerender now succeed and the ThemeProvider value propagates.importandrequireresolve to the same context object. Measured with areact-dom/serverharness — 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).--strictclean,attwall-green (no masquerading),require()+importsmoke pass.<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.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": falseaudit: the only module-scope side effect is Code'sregisterLanguage(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.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 andrequire'd-CJS in one process. The single-copy gate guards against a future change widening this.ThemeProvideris not SSR-safe — itscontext = document?.bodydefault throwsdocument is not definedunder SSR regardless of module format. Flagged separately.Part of #2466.