This document is the implementation plan for adopting ES modules (ESM) in
@uhg-abyss/web, deferred to the v3 major release per
adr-027-defer-esm-adoption-to-v3.
It is written to be executable start-to-finish by an engineer or an agent with no prior context on this work. Follow the phases in order; each phase has an explicit goal, concrete steps, and an exit check.
1. Objective
Ship @uhg-abyss/web as a modern dual-format package so consumers on modern
bundlers get smaller, faster builds through tree-shaking, without breaking any
current CommonJS (CJS) consumer.
Success criteria
- Consumers importing
@uhg-abyss/webget ESM by default through the standardimportcondition; CJS remains available throughrequire. "sideEffects": falseis set and safe — no consumer build crashes from dropped/reordered modules.- Deep imports (
@uhg-abyss/web/ui/Button) and whole-module dead-code elimination both work; a "import one component" app bundles materially less than the full library. - No visual regressions across the supported browser matrix.
- Micro-frontend / Module Federation consumers have documented guidance.
2. Why this was deferred (summary)
During US10652906 we prototyped opt-in ESM for abyss-web and hit two blockers:
-
sideEffects: falseis unsafe with the current source. Abyss compound components build themselves by mutating the base component at module top level, e.g.packages/abyss-web/src/ui/Table/v1/index.js:import { Table as V1Table } from './Table';V1Table.Container = StyledTable; // ← module-level side effectV1Table.Row = StyledRow;// ...export { V1Table };Declaring the package side-effect-free tells bundlers these assignments are safe to drop or reorder. Webpack then evaluated a consumer (
AccordionTable, which readsTable.Container) before the assignments ran, producingstyled(undefined)→ runtime crash. This affects any bundler doing production dead-code elimination, not only ESM consumers. -
Without
sideEffects: false, opt-in ESM buys little. Abyss already enforces deep imports via itsexportsmap, so the main remaining win is whole-module DCE — which is exactly what the compound-component refactor unlocks.
The fix therefore has a hard prerequisite: remove the top-level mutation
pattern first, then enable sideEffects: false, then make ESM the default.
That sequence is a major-version change and belongs in v3.
3. What is already in place (do not redo)
The build tooling from US10652906 already supports everything needed; ESM is simply dormant for abyss-web.
abyss-internal buildproduces per-file CJS (.js) and, when enabled, per-file ESM (.mjs) via Babel. Seeproducts/abyss-internal/src/scripts/commands/abyss-build/strategies/babel.js.- ESM emission is opt-in per package: the build emits
.mjsonly when a package'sexportsmap references a.mjstarget (emitEsm = JSON.stringify(packageJson.exports).includes('.mjs')). abyss-web currently has a CJS-onlyexportsmap, so it emits no.mjs. - The ESM Babel pass is correct: it toggles
caller.supportsStaticESMsonext/babelpreserves ES modules, and rewrites relative imports to explicit.mjsextensions (esmExtensionPlugin). Verified by the abyss-internal test suite (babel strategy: per-file dual CJS/ESM, native-correct ESM). - The dual-emit shape is validated by the abyss-internal
sample-libtest fixture (standardimport→.mjs,require→.js) — the reference for the targetexportsshape. No shipped package currently emits.mjs; abyss-web, abyss-mobile, and abyss-shared all adopt ESM in v3.
So the machinery is proven. v3 is about making abyss-web's source safe for
it, then flipping the package's exports on.
4. The core work: remove top-level compound mutation
4.1 Inventory
At the time of writing, 29 files under packages/abyss-web/src/ui use the
top-level X.Sub = … mutation pattern. Regenerate the current list before
starting:
grep -rlE "^\s*[A-Z][A-Za-z0-9]+\.[A-Z][A-Za-z0-9]+\s*=" \ packages/abyss-web/src/ui \ --include="*.js" --include="*.jsx" --include="*.ts" --include="*.tsx"Known set to refactor (verify against the grep above):
Accordion, Breadcrumbs, Charts/v1 (+ chart2Music plugin internals),
CheckboxGroup, CodeHighlighter, CollapseProvider, DataGrid/v1,
DataTable, Drawer, Flex, Footer, FormInput, Grid, Header,
Heading, NavMenu, RadioGroup, RichTextEditor, Router (+ Routes),
SegmentedControls, StateRouter, StepTracker, Table/v1, Tabs,
Timeline, ToggleGroup/v1.
Not every match is a compound component. Some (
chart2Musicinternals, class prototype assignments) are unrelatedX.Y =statements. Triage each file: the target is specifically exported components that attach subcomponents to a base component at module scope.
4.2 Refactor pattern
Replace incremental post-hoc mutation with a single object built at definition time so there is no window where the base exists but subcomponents do not.
Before (ui/Table/v1/index.js):
import { Table as V1Table } from './Table';V1Table.Container = StyledTable;V1Table.Row = StyledRow;export { V1Table };After — prefer Object.assign in one expression at export, or attach at
definition:
import { Table as BaseTable } from './Table';
export const V1Table = Object.assign(BaseTable, { Container: StyledTable, TableHeader: StyledTableHead, Row: StyledRow, // ...});Object.assign(Base, {...}) is still technically a mutation of Base, but it
executes as part of the module's single export evaluation, and — critically —
the consuming modules import the assembled V1Table, not the base. The
elimination of the cross-module read-before-write race is what matters.
Better, where feasible: avoid mutating an imported binding entirely. Define the base and subcomponents in the same module and export one frozen compound:
const Table = (props) => { /* ... */ };Table.Container = StyledTable; // same-module attach, before exportTable.Row = StyledRow;export { Table };Same-module attaches are safe under sideEffects: false because the bundler
keeps a module whose exports are used; the risk is only when module A mutates a
binding it imported from module B and module C reads that binding.
4.3 Break the specific cycle that crashed
The chart-accordion component AccordionTable.jsx (under ui/Charts/v1) imports
the Table barrel and reads Table.Container at module scope. Even after the refactor,
audit Charts→Table import ordering. Safest: have AccordionTable import the
concrete styled components it needs directly (e.g.
import { StyledTable } from '../../../../Table/v1/Table') rather than reaching
through the assembled compound barrel.
4.4 Exit check for phase 4
grepinventory returns only intentional, safe same-module attaches.- No cross-module "import binding then mutate" remains for exported compounds.
5. Enable and verify sideEffects
- Add
"sideEffects": falsetopackages/abyss-web/package.json.- If any modules with genuine side effects remain (global CSS, polyfills,
Chart.js registration), use an allowlist instead:
"sideEffects": ["**/*.css", "./src/<file-with-registration>.ts"].
- If any modules with genuine side effects remain (global CSS, polyfills,
Chart.js registration), use an allowlist instead:
- Build and smoke-test with a bundler that performs production DCE (the docs
site is a ready canary — it aliases
@uhg-abyss/webto source and reads the package'ssideEffects).
pnpm --filter abyss-docs-web dev # must render, not blank- Confirm tree-shaking: build a tiny app that imports a single component and verify the bundle excludes unrelated components.
The blank docs SPA was the exact symptom of the original crash — a rendered docs homepage is the fast signal that
sideEffects: falseis now safe.
6. Make ESM the default
For v3, use the standard export conditions: point import at .mjs and
require at .js (the same shape the abyss-internal sample-lib fixture
validates). This makes ESM the default for modern bundlers while keeping CJS
available through require:
// packages/abyss-web/package.json → exports (per glob group)"./ui/*": { "types": "./ui/*/index.d.ts", "import": "./ui/*/index.mjs", "require": "./ui/*/index.js"}Apply to ./hooks/*, ./ui/*, ./tools/*, and ./next. Preserve every
existing null block (the internal-module allowlist). Because the build's
emitEsm trigger keys off a .mjs reference in exports, this one change makes
abyss-web dual-emit automatically — no build-tooling change required.
Apply the same standard-conditions change to @uhg-abyss/shared and
@uhg-abyss/mobile in v3. abyss-shared is the simplest case — it has no
compound-mutation pattern, so it needs only the exports change (add an
exports map with import → .mjs). abyss-mobile additionally requires
verifying Metro / React Native resolution of the .mjs output. @uhg-abyss/api
and @uhg-abyss/parcels stay CJS-only.
7. Validation
ESM + scope hoisting changes evaluation order, so validate behavior, not just bundling.
7.1 Browser matrix (visual/CT)
Run visual-regression / component tests across the supported matrix. The
effective floor is Safari latest and iOS 14+ — do not let a modern default
(e.g. @babel/preset-env defaults) silently drop these.
| Platform | Support |
|---|---|
| Windows | 10+ |
| macOS | latest + previous |
| Chrome / Edge | latest + previous |
| Safari | latest |
| iOS | 14+ |
| Android | 10+ |
(No Firefox / IE.) If build targets change as part of this work, hold the same matrix.
7.2 Dual-package hazard test
A single install loaded as both CJS and ESM in one runtime yields two copies of
abyss-web → duplicate React context objects → ThemeProvider/I18nProvider/
portal context silently fall back to defaults. Add a test that loads the built
package both ways and asserts context identity, e.g.:
const cjs = require('@uhg-abyss/web/ui/ThemeProvider');const esm = await import('@uhg-abyss/web/ui/ThemeProvider');// In a correctly-configured single-format consumer these are never mixed;// the test documents the hazard and guards the resolution config.7.3 Module Federation guidance (docs, not code)
Multiple copies across a host + remotes cause the same duplicate-context problem, independent of ESM. Document that MF consumers must share abyss-web as a singleton:
new ModuleFederationPlugin({ shared: { '@uhg-abyss/web': { singleton: true }, react: { singleton: true }, 'react-dom': { singleton: true }, },});7.4 Decision point: library-level singleton contexts
During US10652906 we prototyped and then reverted a globalThis-based
singleton-context helper (createSingletonContext, caching each context on
globalThis[Symbol.for(key)]) for the cross-cutting contexts (I18nContext,
the internal ThemeContext, PortalZIndexContext). It neutralizes both the
dual-package hazard and the Module Federation multiple-copies problem by making
every copy of the module resolve to one shared context object.
Why it was skipped in v2: it is defense-in-depth, not essential. MUI (our
reference) does not do it; current CJS consumers report no duplicate-context
issues; and the consumer-side fixes above (one format per runtime;
singleton: true for MF) are the standard, sufficient remedy. The agreed rule
was: skip it unless ESM consumers face materially higher duplication risk than
CJS consumers.
Why v3 must re-evaluate it: making ESM the default is exactly the condition that flips that rule. In v2 the dual-package hazard was theoretical (ESM was opt-in and unused); in v3 it becomes a live axis — a single install can be loaded as both CJS and ESM in one runtime — and a major release is the natural time to add defensive infrastructure if it is ever warranted.
Recommendation: still default to not shipping the library singleton, to match MUI and avoid the added complexity. Adopt it only if the v3 validation (or post-release telemetry / consumer reports) shows real duplicate-context breakage that consumer configuration cannot reasonably resolve. If adopted:
- Apply it to the cross-cutting contexts only (
I18nContext,ThemeContext,PortalZIndexContext) — component-local contexts (RadioGroup, Accordion, etc.) ship provider + consumers together and do not need it. - Add a
globalThis[Symbol.for('@uhg-abyss/web/<Name>')] ??= createContext(...)helper and route those three contexts through it. - Extend §7.2 into a real regression test: load a provider via ESM and a consumer via CJS in one runtime and assert they share context identity.
8. Consumer documentation to publish with v3
Add a developer docs page at web/developers/esm.mdx covering:
- ESM is the default in v3; CJS remains supported via
require. - Keep module loading consistent — do not mix
requireandimportof abyss-web in one runtime (dual-package hazard). - Micro-frontend / Module Federation: share abyss-web as a singleton.
- Migration notes from v2 (no action needed for most; call out any changed build-target or peer-dependency expectations).
Headings must be sentence case per the docs style rule.
9. Companion v3 modernization (not ESM-specific)
These modernizations are independent of ESM but share v3's major-version window and its validation pass, so they are best sequenced together rather than done piecemeal on minor releases.
9.1 Transpiler modernization (move off next/babel)
abyss-web and abyss-mobile transpile via the shared next/babel preset
(hosted in abyss-internal/src/configs/babel-preset.js). next/babel is
Next.js's own preset — it pulls Next.js in as a build dependency and applies
Next-specific transforms, which is an odd fit for a framework-agnostic component
library and a React Native package.
- Replace it with a framework-agnostic preset —
@babel/preset-env+@babel/preset-react+@babel/preset-typescript— and drop the Next.js build dependency. - Set explicit browser targets and validate against the support matrix (§7.1):
@babel/preset-envdefaults must not silently drop Safari / iOS 14+. - Byte-diff the CJS output and run full visual / component-test validation — the
transpiler change can alter emitted code, so it needs the same rigor as the
sideEffectswork. - Context: this was identified during US10652906 (build-tooling decoupling) but intentionally not done there to preserve byte-identical output; it is recorded here so the intent is not lost.
9.2 Module-settings modernization
Once ESM is the default, align the base tsconfig module / moduleResolution /
target (currently node16 / es6, with mobile overriding to bundler /
esnext) into a coherent modern baseline. See §6.
9.3 TypeScript project references
Introduce composite + references + a root solution config for incremental
type-checking and enforced build order across packages. Note two repo-specific
constraints: the base config's noEmit: true conflicts with composite: true,
and Babel (not tsc) emits the JS — so project references restructure
type-checking and declaration builds, not the JS build.
10. Phased checklist
| Phase | Goal | Exit check |
|---|---|---|
| 0 | Regenerate the compound-mutation inventory | Current file list produced |
| 1 | Refactor compounds off cross-module top-level mutation | Inventory clean; unit tests green |
| 2 | Add sideEffects: false (or allowlist) | Docs SPA renders; DCE smoke test passes |
| 3 | Switch exports to import→.mjs / require→.js | abyss-internal build emits dual; byte-identical CJS |
| 3a | Decide on library-level singleton contexts (§7.4) | Decision recorded; helper added only if warranted |
| 4 | Validate | Visual/CT matrix green; dual-load + MF documented |
| 5 | Docs + release | ESM docs page published; v3 released |
11. Risks and mitigations
- Missed compound module → consumer crash. Mitigate with the docs-site
canary and a DCE smoke test in CI; prefer same-module attach or the allowlist
form of
sideEffects. - Visual regressions from evaluation-order changes. Mitigate with full matrix visual/CT before release.
12. Rollback
Every step is reversible via exports and sideEffects in
packages/abyss-web/package.json:
- Remove
.mjstargets fromexports→ build reverts to CJS-only (theemitEsmtrigger stops firing). - Remove
sideEffects→ bundlers stop DCE-ing the package.
No consumer is affected until the package is republished.
13. References
- adr-027-defer-esm-adoption-to-v3
- Build tooling:
products/abyss-internal/src/scripts/commands/abyss-build/ - Reference dual-emit implementation (dormant capability): the abyss-internal
sample-libtest fixture andstrategies/babel.js - Node.js: Conditional exports, Dual-package hazard