Skip to main content

V3 ESM adoption plan for @uhg-abyss/web

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/web get ESM by default through the standard import condition; CJS remains available through require.
  • "sideEffects": false is 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:

  1. sideEffects: false is 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 effect
    V1Table.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 reads Table.Container) before the assignments ran, producing styled(undefined) → runtime crash. This affects any bundler doing production dead-code elimination, not only ESM consumers.

  2. Without sideEffects: false, opt-in ESM buys little. Abyss already enforces deep imports via its exports map, 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 build produces per-file CJS (.js) and, when enabled, per-file ESM (.mjs) via Babel. See products/abyss-internal/src/scripts/commands/abyss-build/strategies/babel.js.
  • ESM emission is opt-in per package: the build emits .mjs only when a package's exports map references a .mjs target (emitEsm = JSON.stringify(packageJson.exports).includes('.mjs')). abyss-web currently has a CJS-only exports map, so it emits no .mjs.
  • The ESM Babel pass is correct: it toggles caller.supportsStaticESM so next/babel preserves ES modules, and rewrites relative imports to explicit .mjs extensions (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-lib test fixture (standard import.mjs, require.js) — the reference for the target exports shape. 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 (chart2Music internals, class prototype assignments) are unrelated X.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 export
Table.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

  • grep inventory returns only intentional, safe same-module attaches.
  • No cross-module "import binding then mutate" remains for exported compounds.

5. Enable and verify sideEffects

  1. Add "sideEffects": false to packages/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"].
  2. Build and smoke-test with a bundler that performs production DCE (the docs site is a ready canary — it aliases @uhg-abyss/web to source and reads the package's sideEffects).
pnpm --filter abyss-docs-web dev # must render, not blank
  1. 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: false is 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.

PlatformSupport
Windows10+
macOSlatest + previous
Chrome / Edgelatest + previous
Safarilatest
iOS14+
Android10+

(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 require and import of 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-env defaults 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 sideEffects work.
  • 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

PhaseGoalExit check
0Regenerate the compound-mutation inventoryCurrent file list produced
1Refactor compounds off cross-module top-level mutationInventory clean; unit tests green
2Add sideEffects: false (or allowlist)Docs SPA renders; DCE smoke test passes
3Switch exports to import.mjs / require.jsabyss-internal build emits dual; byte-identical CJS
3aDecide on library-level singleton contexts (§7.4)Decision recorded; helper added only if warranted
4ValidateVisual/CT matrix green; dual-load + MF documented
5Docs + releaseESM 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 .mjs targets from exports → build reverts to CJS-only (the emitEsm trigger stops firing).
  • Remove sideEffects → bundlers stop DCE-ing the package.

No consumer is affected until the package is republished.


13. References

Table of Contents