Skip to content

feat(react): conditional exports map#2479

Draft
frankensteinke wants to merge 7 commits into
chore/2466-dual-cjs-esm-buildfrom
chore/2466-exports-map
Draft

feat(react): conditional exports map#2479
frankensteinke wants to merge 7 commits into
chore/2466-dual-cjs-esm-buildfrom
chore/2466-exports-map

Conversation

@frankensteinke

Copy link
Copy Markdown
Contributor

Summary

Phase 4 of #2466. Adds a conditional exports map so Node's import resolves to the ESM build and require to CJS, each with matching per-condition types.

Stacked on #2477 (Phase 3). Base is chore/2466-dual-cjs-esm-build; review/merge that first.

⚠️ Potentially breaking — minor release + loud changelog note (per the issue's guidance). See "Breaking edges" below.

What changed

  • exports map on . (import→ESM, require→CJS, types-first per condition), plus ./cauldron.css, ./lib/cauldron.css, and ./package.json. main/module/types kept as fallbacks for legacy tooling.
  • addEsmDeclarationExtensions.js (wired into build:lib): rewrites the ESM .d.ts relative imports to carry explicit extensions. TypeScript emits them extensionless (classic-node source config), which fails node16-from-ESM resolution; the runtime .js already has them. This is what makes all four attw resolutions green (node16 (from ESM): 🟢 ESM).
  • Harness: attw now 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)

  1. Deep imports into 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.
  2. 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 import { X } or import * as.
  3. Dual-package hazard exposure: a runtime loading both builds (bundled ESM + 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

  • Packaging harness green: publint --strict clean, attw all-4 green (node10 / node16-CJS / node16-ESM / bundler), require() + import smoke, dual-resolution, tree-shaking.
  • Next.js App Router SSR builds + prerenders; theme propagates (single copy).
  • Vite consumer renders <Code>/<Tabs>/<Button>; no console errors.
  • Deep-import lockdown confirmed (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.
  • Screenshots CI — as with feat(react): tree-shakeable dual CJS/ESM build #2477, relying on the job to confirm no visual diff.

Part of #2466.

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
frankensteinke force-pushed the chore/2466-exports-map branch from 77147fd to a2cb81a Compare July 17, 2026 17:02
@frankensteinke
frankensteinke force-pushed the chore/2466-dual-cjs-esm-build branch from 85ea7d6 to e138071 Compare July 22, 2026 12:55
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.

1 participant