Clément Farkas DauduFR

Green CI, white page

TL;DR

Every check passed, then the app showed a white page

Release goes out. Types check, lint passes, full unit suite green, the lib publishes: every gate (every check a release must pass) stays green. The consuming app, the one that installs the library, boots to a blank page with one console error:

Calling `require` for "react" in an environment that doesn't expose the `require` function

How a component library ships, in two minutes

A bundler turns the library's source into built files in dist/: the artifact that gets published. This library builds the same code in two module formats:

Build File Loads other modules with Who runs it
CommonJS (CJS) dist/cjs/index.js require('react'), a function Node provides Node consumers: main and exports.require in package.json point at this file
ES modules (ESM) dist/es/index.js import the app, in the browser

The bundler also decides what goes inside each build:

To copy a module in, the bundler has to resolve each of its imports: find the code each name points to.

I read package.json and got it wrong twice

When this happens, don't reason about it from package.json. Read the bundle. I did the opposite and got it wrong twice in a row.

Who Read Found Was it the bug?
Me, twice the manifest, package.json "type": "module", while main and exports.require point at dist/cjs/index.js, a .js file whose content is CommonJS No
Another agent, in one step the built bundle a new component reaching a CJS-only module that does require('react') Yes

"type": "module" tells Node to treat the package's .js files as ES modules, so a .js file of CommonJS under it is a real contradiction, and I was confident about it.

It wasn't the bug. It's latent, it fires for nobody today, and I delivered that answer confidently. Twice.

The distinction that cost me the day: a manifest describes intent, only the artifact shows what actually shipped. I had already built dist/. One grep would have settled it.

Worth saying out loud since it's the more useful half: when a peer or a tool comes back with a competing analysis, verify theirs before defending yours. Mine was a real finding that happened to be irrelevant, which is the most seductive kind of wrong.

What the bundle showed: a stand-in for require that throws

The other agent's finding, in full: a newly added component was the first thing in the lib to import a new corner of the accessibility library, a part of it nothing in the lib had imported before. That corner reaches a CJS-only state-management module (a module with no ESM build), the one doing require('react').

react is external, so the bundler has nothing to resolve that call to. It wires the call to a runtime shim instead, a small stand-in the bundler writes into the build, and this one throws the moment it runs.

flowchart TD
  A["New component"] --> B["New corner of the accessibility library (bundled)"]
  B --> C["CJS-only module: require('react')"]
  C --> D["react is external: nothing to resolve to"]
  D --> E["Bundler wires in a shim that throws"]
  E --> F["Browser runs the ESM build: shim throws, white page"]

In general: a component library that externalises React and bundles everything else will, sooner or later, pull in a CJS-only transitive module (a dependency of a dependency) that calls require('react').

Why every check stayed green

The shim only throws where require is absent and the ESM artifact actually executes. Neither condition was ever met before production:

Where the library ran What it loaded Did the shim throw?
Node consumers the CJS entry, dist/cjs/index.js No: require is a real function there, so the shim is never reached
CI: a type check, a linter, a jsdom unit suite the unit suite runs against source; not one of the three loads dist/es/index.js No: nothing in CI executes the ESM artifact at all
A browser, in production the ESM build, dist/es/index.js Yes

The CI row is the one that matters, and I want to be precise because I got it wrong myself the first time: it isn't that Node is somehow immune. It's that no step in the pipeline ever ran the built ESM bundle in a runtime without require. The only place that happens is a browser, and no gate opened one. (jsdom only simulates a browser page inside Node.)

The bug came with an unchanged package.json

This is the counter-intuitive part, and it's why the bug feels like it came from nowhere.

package.json was byte-identical between the working release and the broken one. Nothing added, nothing removed, nothing bumped.

The trigger isn't a dependency change, it's a change in which modules become reachable: which ones the library's imports actually lead to, and so which ones the bundler copies in. Adding one component that imports one new corner of an existing dependency pulled a previously unreached CJS module into the graph.

Bisecting dependencies (narrowing down which dependency change broke it) will never find that. Bisecting imports will.

The rule this incident supports, and the one it doesn't

I first wrote this up as "a library must never carry its own copy of a singleton dependency" (one that must exist only once at runtime, like React). That's a real rule, and it is not the rule this incident supports.

The module that broke us was a useSyncExternalStore shim: the CJS-only state-management module above. (Same word, different thing: this shim is that module, not the throwing stand-in the bundler wrote.) It has no instance identity (no object that must be the same one everywhere) and no module-level state (no variables shared by everything that imports it). Two copies of it are harmless. Nobody would have classified it as a singleton, so the singleton rule would not have prevented this.

The rule the incident actually supports, its invariant, is narrower and more useful:

Don't bundle a CJS-only module that references a dependency you have marked external. The bundler has nothing to resolve the reference to, so it emits a shim that throws at runtime.

Keep the singleton rule as a separate, weaker point. It's true for React, for i18next in a shared lib, for a PDF worker. It just isn't what happened here.

Next time: read the built output first

For any "works in Node, breaks in the browser" or otherwise bundler-shaped error:

  1. Grep the built output for the error string itself. Not the source, dist/.
  2. Find which chunk defines the throwing helper. The built output is split into several files, called chunks.
  3. Trace the minified alias back to its definition. Minification gives the helper a short alias; that chunk's export { x as m } map leads from the alias back to its definition.
  4. Then form a theory.

Two practical notes:

That grep catches this shape, with your current bundler. Its limits:

Re-derive it when you upgrade rather than trusting a copied regex.

The fixes, and the gate we still don't have

The immediate fix

The immediate fix was to add the offending module and its subpaths (imports of files inside that package) to the bundler external list and declare it in dependencies, so consumers resolve it from the library's own node_modules.

The structural fix, not shipped

The structural fix, which I recommend and have not shipped, is to externalise the whole accessibility library. Being honest about why it hasn't happened:

That's a migration, not a config line, and I haven't priced it yet.

The gate I'd add first

What I'd do first instead, and what would actually have caught this, is about ten lines:

// smoke.spec.ts, run in a real browser against the built artifact
import { Button } from '../dist/es/index.js'

test('the built ESM bundle mounts in a browser', async () => {
  render(<Button>ok</Button>)
  expect(screen.getByRole('button')).toBeVisible()
})
  1. Point it at dist/.
  2. Run it in a real browser engine (Playwright, or Vitest's browser mode).
  3. Make it a release gate.

It's the only check in the whole pipeline that satisfies both conditions the shim needs to throw.

What I learned

Update, November 2026

Since publishing, I've hit the same blind spot twice more, this time with checks rather than a bundle.