Green CI, white page
TL;DR
- A release of a shared React component library passed every check: type check, lint, the full unit suite. The app that uses it showed a white page.
- The library leaves React out of its build on purpose and copies everything else in. A new component pulled in a module, deep in a dependency, that calls
require('react')and ships in no other format. The bundler had nothing to point that call at, so it put in a stand-in (a shim) that throws as soon as it runs. - The shim throws only where the built ESM file runs without
require: in this pipeline, only a browser. No check ever loaded the built library in one. - I diagnosed it from
package.jsonand got it wrong twice in a row. Another agent read the built bundle and found it in one step. - The lesson: read the bundle, not the manifest, and make "load the built bundle in a real browser" a release gate. We still don't have that gate.
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:
- External. The library marks
reactas external: it leaves React out of its bundle and expects the app to supply it. That's correct and non negotiable for a shared component library, otherwise consumers get a second React and an invalid hook call. - Bundled. Everything else is copied in. That includes its accessibility primitives (low-level building blocks from an accessibility library it depends on) and their whole tree: everything they import, and everything those import.
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:
- Grep the built output for the error string itself. Not the source,
dist/. - Find which chunk defines the throwing helper. The built output is split into several files, called chunks.
- 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. - Then form a theory.
Two practical notes:
- If your package ships a docs site inside the tarball (the archive your package is published as), a built Storybook for instance, that directory legitimately contains the same shim, so exclude it when scanning or you'll chase your own docs.
- Before releasing a component that reaches a new corner of a big dependency, grep the bundle for the shim's error string and for the
require("react")assignment pattern.
That grep catches this shape, with your current bundler. Its limits:
- Different bundlers word the message differently.
- The string moves between versions.
- Minification restructures the assignment.
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:
- At least one consumer already declares its own copy, so the lib ships a duplicate today, but I don't know that every consumer does.
- Externalising it forces the ones that don't to add a dependency.
- It turns styling primitives into a version-compatibility surface: one more package whose version every consumer must keep compatible.
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()
})
- Point it at
dist/. - Run it in a real browser engine (Playwright, or Vitest's browser mode).
- 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
- A green unit suite says nothing about the artifact. If you publish a bundle, load the bundle in a browser as a release gate.
- Manifest describes intent, artifact describes reality. When they disagree, the artifact wins. For a bundler-shaped error, grep
dist/before forming a theory. - When a peer or a tool comes back with a competing analysis, verify theirs before defending yours.
- A bug like this one can ship with a byte-identical
package.json: what changed was which modules are reachable, so bisect imports, not dependencies. - Externalising is a contract, not a free win: every external becomes a version your consumer must satisfy. Externalise what has instance identity, and don't pretend the rest is free.
Update, November 2026
Since publishing, I've hit the same blind spot twice more, this time with checks rather than a bundle.
- A suite that never started. One library's CI file triggered on push only, and the shared workflow it calls runs tests on pull requests only. So its tests never ran in CI, and nobody, me included, saw that the suite had been crashing before its first spec since a major bump of the test framework. I found it in November. Pinning the test framework to the version our other libraries on the same stack use was enough: all 105 specs pass.
- A
testscript that was anecho. Another library ships locale files to every app that consumes it. Nothing types those files against each other, so the type checker can't see a missing key. Only a validation script compares every locale against the English one, and nothing runs it. CI and the git hooks do run thetestscript, and that script is anecho. A key added only to the English locale merges green, ships, and shows in English to French users of those apps, through the fallback chain. I found it in October, and it was still true when I last checked, later that month.