feat(react): conditional exports map#2479
Draft
frankensteinke wants to merge 7 commits into
Draft
Conversation
Phase 1 of #2466. Validates the published artifact (not the workspace source) so packaging regressions are caught before release: - publint --strict against the pnpm-packed tarball - @arethetypeswrong/cli to verify type resolution across conditions - tarball smoke test: install into a throwaway consumer and confirm the barrel resolves under both require() and native import Wired into the Tests workflow as a 'packaging' job. Passes against the current CJS-only output; establishes install-from-tarball (not symlink) ahead of the dual CJS/ESM build.
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.
Phase 4 of #2466. Replace field-based resolution with a conditional "exports" map so Node's import resolves to the ESM build, require to CJS, each with matching per-condition types (attw clean across node10 / node16 CJS+ESM / bundler). main/module/types are kept as fallbacks for legacy tooling. The barrel, ./cauldron.css, ./lib/cauldron.css, and ./package.json are preserved as public entry points. To make node16-from-ESM types resolve, addEsmDeclarationExtensions.js rewrites the ESM .d.ts relative imports to carry explicit extensions (TypeScript emits them extensionless from the classic-node source config; the runtime .js already has them). Wired into build:lib. Packaging harness updated: attw excludes the CSS entrypoints (no types), and the former single-copy check becomes a dual-resolution check (import->ESM, require->CJS both expose the API) since the exports map intentionally splits the two conditions. POTENTIALLY BREAKING (minor release + changelog note, per #2466): - Deep imports into lib/* now hard-error (ERR_PACKAGE_PATH_NOT_EXPORTED). The barrel is the supported entry; no internal code deep-imported. - `import X from '@deque/cauldron-react'` (default import of the package) no longer works under the ESM condition — the barrel is named-exports only; the previous default was a CJS-interop artifact. Use named or namespace imports. - Consumers in a runtime that loads BOTH builds (bundled ESM + require'd CJS in one process) now get two copies with split React-context identity. Pure bundler, pure Node, and standard Next App Router are single-copy (measured); documented as a known dual-package caveat.
frankensteinke
force-pushed
the
chore/2466-exports-map
branch
from
July 17, 2026 17:02
77147fd to
a2cb81a
Compare
frankensteinke
force-pushed
the
chore/2466-dual-cjs-esm-build
branch
from
July 22, 2026 12:55
85ea7d6 to
e138071
Compare
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 4 of #2466. Adds a conditional
exportsmap so Node'simportresolves to the ESM build andrequireto CJS, each with matching per-condition types.What changed
exportsmap on.(import→ESM,require→CJS, types-first per condition), plus./cauldron.css,./lib/cauldron.css, and./package.json.main/module/typeskept as fallbacks for legacy tooling.addEsmDeclarationExtensions.js(wired intobuild:lib): rewrites the ESM.d.tsrelative imports to carry explicit extensions. TypeScript emits them extensionless (classic-node source config), which fails node16-from-ESM resolution; the runtime.jsalready has them. This is what makes all four attw resolutions green (node16 (from ESM): 🟢 ESM).attwnow excludes the CSS entrypoints (no types); the former single-copy check becomes a dual-resolution check (import→ESM,require→CJS both expose the API), since the exports map intentionally splits the two conditions.Breaking edges (all verified)
lib/*hard-error (ERR_PACKAGE_PATH_NOT_EXPORTED). The barrel is the supported entry; no internal code deep-imports, and the documented CSS paths are preserved.import X from '@deque/cauldron-react'(default import of the package) no longer works under the ESM condition — the barrel is named-exports only; the previous default was a CJS-interop artifact. Useimport { X }orimport * as.require'd CJS in one process) now gets two copies with split React-context identity. Pure bundler, pure Node, and standard Next App Router are single-copy (measured). Documented as a known caveat.Why this is separate from #2477
Tree-shaking (the headline payoff) ships in #2477, non-breaking. This PR adds Node-ESM resolution + per-condition types + API encapsulation — at the cost of the breaking edges above and a bit more dual-package-hazard surface. Keeping it separate keeps the changelog/semver story clean.
Test plan
publint --strictclean,attwall-4 green (node10 / node16-CJS / node16-ESM / bundler),require()+importsmoke, dual-resolution, tree-shaking.<Code>/<Tabs>/<Button>; no console errors.ERR_PACKAGE_PATH_NOT_EXPORTED); documented CSS paths (./cauldron.css,./lib/cauldron.css) still resolve.pnpm typecheck,lint,format:check,build:docs, and Code tests pass.Part of #2466.