CI verte, page blanche
TL;DR
- Une release d'une bibliothèque partagée de composants React a passé tous les checks : type check, lint, toute la suite de tests unitaires. L'app qui l'utilise affichait une page blanche.
- La bibliothèque laisse volontairement React hors de son build et y copie tout le reste. Un nouveau composant a tiré un module, enfoui dans une dépendance, qui appelle
require('react')et n'est livré dans aucun autre format. Le bundler n'avait rien vers quoi faire pointer cet appel, alors il a mis à la place un substitut (un shim) qui lève une erreur dès qu'il s'exécute. - Le shim ne lève une erreur que là où le fichier ESM buildé s'exécute sans
require: dans ce pipeline, uniquement dans un navigateur. Aucun check n'a jamais chargé la bibliothèque buildée dans un navigateur. - J'ai diagnostiqué le problème à partir de
package.jsonet je me suis trompé deux fois de suite. Un autre agent a lu le bundle buildé et l'a trouvé en une seule étape. - La leçon : lire le bundle, pas le manifeste, et faire de "charger le bundle buildé dans un vrai navigateur" un check bloquant de la release. Ce check, on ne l'a toujours pas.
Tous les checks sont passés, puis l'app a affiché une page blanche
La release part. Le type check passe, le lint passe, toute la suite unitaire est verte, la lib est publiée : tous les checks bloquants (chaque contrôle qu'une release doit passer) restent verts. L'app consommatrice, celle qui installe la bibliothèque, démarre sur une page blanche avec une seule erreur dans la console :
Calling `require` for "react" in an environment that doesn't expose the `require` function
Comment une bibliothèque de composants est livrée, en deux minutes
Un bundler transforme les sources de la bibliothèque en fichiers buildés dans dist/ : l'artefact qui est publié. Cette bibliothèque builde le même code dans deux formats de modules :
| Build | Fichier | Charge les autres modules avec | Qui l'exécute |
|---|---|---|---|
| CommonJS (CJS) | dist/cjs/index.js |
require('react'), une fonction fournie par Node |
les consommateurs Node : main et exports.require dans package.json pointent vers ce fichier |
| ES modules (ESM) | dist/es/index.js |
import |
l'app, dans le navigateur |
Le bundler décide aussi de ce qui entre dans chaque build :
- Externe. La bibliothèque déclare
reactcomme externe : elle laisse React hors de son bundle et compte sur l'app pour le fournir. C'est correct et non négociable pour une bibliothèque de composants partagée, sinon les consommateurs se retrouvent avec un deuxième React et un invalid hook call. - Bundlé. Tout le reste est copié dedans. Y compris ses primitives d'accessibilité (des briques de bas niveau issues d'une bibliothèque d'accessibilité dont elle dépend) et tout leur arbre : tout ce qu'elles importent, et tout ce que ces modules-là importent.
Pour copier un module dedans, le bundler doit résoudre chacun de ses imports : trouver le code vers lequel pointe chaque nom.
J'ai lu package.json et je me suis trompé deux fois
Quand ça arrive, ne raisonnez pas à partir de package.json. Lisez le bundle. J'ai fait l'inverse et je me suis trompé deux fois de suite.
| Qui | A lu | A trouvé | C'était le bug ? |
|---|---|---|---|
| Moi, deux fois | le manifeste, package.json |
"type": "module", alors que main et exports.require pointent vers dist/cjs/index.js, un fichier .js dont le contenu est du CommonJS |
Non |
| Un autre agent, en une seule étape | le bundle buildé | un nouveau composant qui atteint un module CJS-only qui fait require('react') |
Oui |
"type": "module" dit à Node de traiter les fichiers .js du package comme des ES modules, donc un fichier .js en CommonJS dans ce package est une vraie contradiction, et j'étais sûr de moi.
Ce n'était pas le bug. C'est latent, ça ne se déclenche chez personne aujourd'hui, et j'ai donné cette réponse avec assurance. Deux fois.
La distinction qui m'a coûté la journée : un manifeste décrit une intention, seul l'artefact montre ce qui a vraiment été livré. J'avais déjà buildé dist/. Un seul grep aurait tranché.
Ça vaut la peine de le dire clairement, parce que c'est la moitié la plus utile : quand un collègue ou un outil revient avec une analyse concurrente, vérifiez la sienne avant de défendre la vôtre. La mienne était une vraie trouvaille, mais hors sujet, et c'est la façon de se tromper la plus séduisante.
Ce que le bundle montrait : un substitut de require qui lève une erreur
La trouvaille de l'autre agent, en entier : un composant ajouté récemment était le premier élément de la lib à importer un nouveau coin de la bibliothèque d'accessibilité, une partie que rien dans la lib n'avait importée avant. Ce coin atteint un module de gestion d'état CJS-only (un module sans build ESM), celui qui fait require('react').
react est externe, donc le bundler ne trouve rien pour résoudre cet appel. Il branche l'appel sur un shim runtime à la place, un petit substitut que le bundler écrit dans le build, et celui-ci lève une erreur dès qu'il s'exécute.
flowchart TD
A["Nouveau composant"] --> B["Nouveau coin de la bibliothèque d'accessibilité (bundlé)"]
B --> C["Module CJS-only : require('react')"]
C --> D["react est externe : rien pour le résoudre"]
D --> E["Le bundler branche un shim qui lève une erreur"]
E --> F["Le navigateur exécute le build ESM : le shim lève une erreur, page blanche"]
De manière générale : une bibliothèque de composants qui externalise React et bundle tout le reste finira, tôt ou tard, par tirer un module transitif CJS-only (une dépendance d'une dépendance) qui appelle require('react').
Pourquoi tous les checks sont restés verts
Le shim ne lève une erreur que là où require est absent et où l'artefact ESM s'exécute réellement. Aucune des deux conditions n'a jamais été remplie avant la production :
| Où la bibliothèque tournait | Ce qu'elle chargeait | Le shim a-t-il levé une erreur ? |
|---|---|---|
| Consommateurs Node | l'entrée CJS, dist/cjs/index.js |
Non : require y est une vraie fonction, donc le shim n'est jamais atteint |
| CI : un type check, un linter, une suite unitaire jsdom | la suite unitaire tourne sur les sources ; aucun des trois ne charge dist/es/index.js |
Non : rien dans la CI n'exécute l'artefact ESM, à aucun moment |
| Un navigateur, en production | le build ESM, dist/es/index.js |
Oui |
C'est la ligne CI qui compte, et je veux être précis parce que je me suis trompé moi-même la première fois : ce n'est pas que Node serait immunisé d'une manière ou d'une autre. C'est qu'aucune étape du pipeline n'a jamais exécuté le bundle ESM buildé dans un runtime sans require. Le seul endroit où ça arrive, c'est un navigateur, et aucun check bloquant n'en a ouvert un. (jsdom ne fait que simuler une page de navigateur dans Node.)
Le bug est arrivé avec un package.json inchangé
C'est la partie contre-intuitive, et c'est pour ça que le bug a l'air de sortir de nulle part.
package.json était identique à l'octet près entre la release qui marchait et celle qui était cassée. Rien d'ajouté, rien de retiré, aucune version montée.
Le déclencheur n'est pas un changement de dépendance, c'est un changement dans les modules qui deviennent atteignables : ceux vers lesquels les imports de la bibliothèque mènent vraiment, et donc ceux que le bundler copie dedans. Ajouter un composant qui importe un nouveau coin d'une dépendance existante a fait entrer dans le graphe un module CJS jamais atteint jusque-là.
Bisecter les dépendances (chercher par élimination quel changement de dépendance a tout cassé) ne trouvera jamais ça. Bisecter les imports, si.
La règle que cet incident justifie, et celle qu'il ne justifie pas
J'ai d'abord rédigé ça sous la forme "une bibliothèque ne doit jamais embarquer sa propre copie d'une dépendance singleton" (une dépendance qui ne doit exister qu'une fois à l'exécution, comme React). C'est une vraie règle, et ce n'est pas celle que cet incident justifie.
Le module qui a tout cassé était un shim de useSyncExternalStore : le module de gestion d'état CJS-only mentionné plus haut. (Même mot, autre chose : ce shim-là, c'est ce module, pas le substitut qui lève une erreur écrit par le bundler.) Il n'a pas d'identité d'instance (aucun objet qui doit être le même partout) ni d'état au niveau du module (aucune variable partagée par tout ce qui l'importe). En avoir deux copies est sans danger. Personne ne l'aurait classé comme singleton, donc la règle du singleton n'aurait pas empêché ça.
La règle que l'incident justifie vraiment, son invariant, est plus étroite et plus utile :
Ne bundlez pas un module CJS-only qui référence une dépendance que vous avez déclarée externe. Le bundler ne trouve rien pour résoudre la référence, donc il émet un shim qui lève une erreur à l'exécution.
Gardez la règle du singleton comme un point à part, plus faible. Elle est vraie pour React, pour i18next dans une lib partagée, pour un worker PDF. Ce n'est simplement pas ce qui s'est passé ici.
La prochaine fois : lire d'abord le code buildé
Pour toute erreur du type "marche dans Node, casse dans le navigateur", ou plus généralement qui a la tête d'un problème de bundler :
- Greppez le code buildé pour chercher le message d'erreur lui-même. Pas les sources,
dist/. - Trouvez quel chunk définit le helper qui lève l'erreur. Le code buildé est découpé en plusieurs fichiers, appelés chunks.
- Remontez de l'alias minifié jusqu'à sa définition. La minification donne un alias court au helper ; la table
export { x as m }de ce chunk mène de l'alias à sa définition. - Ensuite, formulez une théorie.
Deux remarques pratiques :
- Si votre package embarque un site de doc dans le tarball (l'archive sous laquelle votre package est publié), un Storybook buildé par exemple, ce dossier contient légitimement le même shim : excluez-le quand vous scannez, sinon vous allez courir après votre propre doc.
- Avant de releaser un composant qui atteint un nouveau coin d'une grosse dépendance, greppez le bundle pour chercher le message d'erreur du shim et le motif d'affectation
require("react").
Ce grep attrape cette forme-là, avec votre bundler actuel. Ses limites :
- Chaque bundler formule le message à sa façon.
- Le texte change d'une version à l'autre.
- La minification restructure l'affectation.
Refaites-le à chaque montée de version plutôt que de faire confiance à une regex copiée.
Les correctifs, et le check bloquant qu'on n'a toujours pas
Le correctif immédiat
Le correctif immédiat a été d'ajouter le module fautif et ses subpaths (les imports de fichiers à l'intérieur de ce package) à la liste external du bundler et de le déclarer dans dependencies, pour que les consommateurs le résolvent depuis le node_modules de la bibliothèque elle-même.
Le correctif structurel, pas livré
Le correctif structurel, que je recommande et que je n'ai pas livré, c'est d'externaliser toute la bibliothèque d'accessibilité. Pour être honnête, voici pourquoi ce n'est pas fait :
- Au moins un consommateur déclare déjà sa propre copie, donc la lib livre un doublon aujourd'hui, mais je ne sais pas si tous les consommateurs le font.
- L'externaliser oblige ceux qui ne le font pas à ajouter une dépendance.
- Ça transforme des primitives de style en surface de compatibilité de versions : un package de plus dont chaque consommateur doit garder la version compatible.
C'est une migration, pas une ligne de config, et je n'en ai pas encore estimé le coût.
Le check bloquant que j'ajouterais en premier
Ce que je ferais en premier à la place, et ce qui aurait vraiment attrapé ce bug, tient en une dizaine de lignes :
// 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()
})
- Faites-le pointer sur
dist/. - Lancez-le dans un vrai moteur de navigateur (Playwright, ou le browser mode de Vitest).
- Faites-en un check bloquant de la release.
C'est le seul check de tout le pipeline qui remplit les deux conditions dont le shim a besoin pour lever une erreur.
Ce que j'en retiens
- Une suite unitaire verte ne dit rien de l'artefact. Si vous publiez un bundle, chargez ce bundle dans un navigateur comme check bloquant de la release.
- Le manifeste décrit l'intention, l'artefact décrit la réalité. Quand ils ne sont pas d'accord, c'est l'artefact qui a raison. Pour une erreur qui a la tête d'un problème de bundler, greppez
dist/avant de formuler une théorie. - Quand un collègue ou un outil revient avec une analyse concurrente, vérifiez la sienne avant de défendre la vôtre.
- Un bug comme celui-ci peut être livré avec un
package.jsonidentique à l'octet près : ce qui a changé, c'est l'ensemble des modules atteignables, donc bisectez les imports, pas les dépendances. - Externaliser, c'est un contrat, pas un cadeau : chaque dépendance externe devient une version que votre consommateur doit satisfaire. Externalisez ce qui a une identité d'instance, et ne faites pas comme si le reste était gratuit.
Mise à jour, novembre 2026
Depuis la publication, je suis retombé deux fois sur le même angle mort, cette fois avec des checks plutôt qu'avec un bundle.
- Une suite qui ne démarrait jamais. Le fichier de CI d'une bibliothèque ne se déclenchait qu'au push, et le workflow partagé qu'il appelle ne lance les tests que sur les pull requests. Ses tests n'ont donc jamais tourné en CI, et personne, moi compris, n'a vu que la suite plantait avant même sa première spec depuis une montée de version majeure du framework de test. Je l'ai trouvé en novembre. Fixer le framework de test à la version qu'utilisent nos autres bibliothèques sur la même stack a suffi : les 105 specs passent toutes.
- Un script
testqui était unecho. Une autre bibliothèque livre des fichiers de locale à toutes les apps qui la consomment. Aucun typage ne relie ces fichiers entre eux, donc le type checker ne peut pas voir une clé manquante. Seul un script de validation compare chaque locale à la locale anglaise, et rien ne le lance. La CI et les hooks git lancent bien le scripttest, et ce script est unecho. Une clé ajoutée seulement à la locale anglaise est mergée avec une CI verte, est livrée, et s'affiche en anglais aux utilisateurs français de ces apps, via la chaîne de fallback. Je l'ai trouvé en octobre, et c'était toujours vrai la dernière fois que j'ai vérifié, plus tard dans le mois.