Clément Farkas DauduEN

Les imports de sous-chemin sont à double tranchant

TL;DR

Barrels, sous-chemins et la map exports

Le point d'entrée racine d'un package est souvent un barrel : un seul fichier qui réexporte tout ce que le package propose. import { x } from 'some-lib' passe par ce fichier.

Un import de sous-chemin demande un seul module à la place : import { x } from 'some-lib/x'.

Un package peut lister les chemins qu'il propose dans la map exports de son package.json, chacun pointant vers un fichier, ou vers un fichier par condition (voir plus bas). Chaque clé y est un point d'entrée. "." est ce que reçoit 'some-lib', et une clé comme "./charts" est un export de sous-chemin, ce que reçoit 'some-lib/charts'.

Le bon cas : un seul hook, importé via le barrel, faisait résoudre 918 modules à Jest

Un composant avait besoin d'un seul hook d'une grosse bibliothèque d'accessibilité. L'import évident passe par le barrel :

import { useFocusRing } from 'ui-primitives'

L'import ciblé va directement au hook :

import { useFocusRing } from 'ui-primitives/useFocusRing'

Ça marche parce que le projet utilise moduleResolution: "bundler" et que la lib livre une map exports.

Import Modules résolus Effet sur les tests
'ui-primitives' (barrel) 918 modules CJS de plus Suite Jest de 59 s à 110 s, deux suites de graphiques en échec
'ui-primitives/useFocusRing' (sous-chemin) 19 modules

Les deux suites de graphiques échouaient parce qu'un waitFor sur un chunk différé (du code chargé plus tard, à la demande) n'avait que le délai par défaut de 1 s, et les modules en plus l'ont fait dépasser.

Ce chiffre mesure un coût de test, pas un poids de bundle

Soyez précis sur ce que mesure ce chiffre, parce que je ne l'ai pas été au début. C'est un coût de résolution dans le test runner, pas un poids de bundle.

La bibliothèque livre deux builds du même code : CommonJS (CJS, chargé avec require()) et modules ES (ESM, chargés avec import). Sa map exports donne à chaque outil le build qu'il demande :

Ce n'est pas une raison pour hausser les épaules. Doubler la durée d'une suite de tests est un vrai coût que personne n'attribue à une ligne d'import, et c'est le genre de chose qui, sans bruit, rend un repo pénible au quotidien. Mais ne laissez personne vous vendre le passage du barrel au sous-chemin comme un gain de taille de bundle sans une mesure du bundle.

Le piège, c'est de copier ses voisins

Le piège ici n'est pas technique, il est social : quatre composants existants du même repo importaient directement depuis le barrel. Copier ses voisins est normalement le bon réflexe, et ici ça vous coûte 900 résolutions de modules. Avant de copier un pattern d'un voisin, comptez combien de fichiers font l'inverse.

Module Federation en deux minutes

Le reste de cet article parle de micro-frontends : un seul produit assemblé à partir de plusieurs apps buildées et déployées séparément. J'appelle l'ensemble un parc. Module Federation (MF) les assemble à l'exécution : une app host, aussi appelée le shell, charge du code depuis des apps remote.

Par défaut, le build de chaque app embarque chaque package qu'elle importe, donc deux remotes qui utilisent la même bibliothèque en portent chacun une copie. La section shared de la config MF change ça pour les packages qu'elle liste. Chaque build peut fournir sa propre copie, et à l'exécution les apps se mettent d'accord sur la copie utilisée, vérifiée par rapport à la plage de versions de requiredVersion. Ce registre tenu au runtime, c'est le share scope. singleton: true va plus loin et demande une seule instance que tous les remotes utilisent.

Ce qui compte comme partagé est décidé par la clé de partage, le nom de chaque entrée sous shared. MF la compare à la requête de module, la chaîne après from dans une instruction d'import (aussi appelée le specifier). Deux sortes de clés comptent ici :

Le mauvais cas : un nouveau point d'entrée duplique le design system

Le package est un design system, publié en interne, consommé par un parc de micro-frontends. Module Federation le partage en singleton pour que tous les remotes utilisent une seule instance.

Quelqu'un veut livrer une fonctionnalité lourde (des graphiques, un lecteur PDF, n'importe quoi qui pèse vraiment) sans la faire payer à chaque consommateur. Réflexe intuitif, un second point d'entrée :

{
  "exports": {
    ".": "./dist/es/index.js",
    "./charts": "./dist/es/charts.js"
  }
}

De l'isolation, non ?

Non. Dans un parc fédéré, un nouvel export de sous-chemin n'est pas un mécanisme d'isolation du poids, c'est un mécanisme de duplication.

Pourquoi le sous-chemin échappe au singleton

Les clés shared de MF sont comparées à la requête de module. Le host déclare :

shared: {
  '@acme/design-system': { singleton: true, requiredVersion: '^19.0.0' },
}

C'est une clé nue. Une requête pour @acme/design-system/charts n'y correspond pas, donc elle sort complètement de la négociation du share scope. Chaque remote qui l'importe embarque sa propre copie de ce point d'entrée, plus sa propre copie non partagée de tout le code interne qu'il entraîne avec lui (le composant d'icône, le contexte de thème, la feuille de style).

flowchart LR
  R1["Requête : '@acme/design-system'"] -->|"correspond à la clé nue"| S["Share scope : une seule instance pour tous les remotes"]
  R2["Requête : '@acme/design-system/charts'"] -->|"ne correspond à aucune clé"| D["Chaque remote embarque sa propre copie"]

Ce que fait un partage par préfixe, et le folklore que je répétais

Regardez une config MF qui fonctionne et React y est en général déclaré deux fois :

shared: {
  'react':      { singleton: true, eager: true, requiredVersion: '^19.0.0' },
  'react/':     { singleton: true, eager: true, requiredVersion: '^19.0.0' },
  'react-dom':  { singleton: true, eager: true, requiredVersion: '^19.0.0' },
  'react-dom/': { singleton: true, eager: true, requiredVersion: '^19.0.0' },
}

J'expliquais l'entrée avec le slash final par "elle fait résoudre react/jsx-runtime vers la même instance que react". C'est faux, et ça vaut la peine de le corriger parce que c'est un bout de folklore répandu.

react/jsx-runtime est un module séparé. Il n'importe pas du tout react. Il construit les éléments à partir de Symbol.for('react.transitional.element'), qui passe par le registre de symboles cross-realm : un registre global, donc chaque copie obtient le même symbole. Si vous le dupliquez, les copies produisent quand même des éléments que le React partagé accepte. Il n'y a pas d'instance à préserver.

Ce que fait un partage par préfixe est plus simple : il fait du module de sous-chemin lui-même une entrée du share scope, pour qu'il soit dédupliqué et que sa version soit négociée dans tout le parc au lieu d'être embarqué une fois par remote. L'enjeu est donc proportionnel à ce qu'il y a derrière le sous-chemin :

Même mécanisme pour votre propre bibliothèque, et c'est tout le sujet : votre package n'a presque certainement pas de clé voisine '@acme/design-system/'. Personne ne pense à en ajouter une, parce que le package n'avait qu'un seul point d'entrée quand la config a été écrite.

Pourquoi personne ne le remarque : la décision est répartie sur deux repos

Les deux moitiés de la décision vivent dans des repos différents :

Depuis le repo de la bibliothèque, ajouter un export a l'air parfaitement sûr. Les vérifications locales de déduplication inspectent le bundle buildé de la bibliothèque elle-même ; elles n'ont aucune visibilité sur la négociation du share scope qui se passe dans le build de quelqu'un d'autre. Pas d'erreur, pas de warning, pas de test en échec. Le seul symptôme, c'est que le parc devient plus lent, et personne ne relie ça à un changement d'une ligne dans une map exports.

Comment vérifier si vous avez ce bug en ce moment

  1. À l'exécution. Ouvrez une page fédérée dans le navigateur et lisez le share scope depuis la console :

    Object.keys(__webpack_share_scopes__.default)
    

    Ça liste toutes les clés réellement négociées. Si le module que vous pensiez partagé n'y est pas, il n'est pas partagé. Ça prend dix secondes et ça répond directement à la question, ce qu'aucune vérification statique que je connaisse ne fera pour vous.

  2. Dans la config. Faites un grep sur votre config MF pour trouver les clés nues sans clé voisine 'name/'. Chacune d'elles est un package dont les sous-chemins vont se dupliquer.

  3. Pour les packages tiers aussi, et c'est la partie sur laquelle je me suis trompé pendant des mois. Un parc typique partage un routeur, une bibliothèque de data fetching, un runtime i18n et un state manager sur des clés nues sans clé voisine. Un import de sous-chemin de n'importe lequel d'entre eux se duplique exactement de la même façon qu'un sous-chemin de votre propre bibliothèque. Savoir à qui appartient le package n'y change rien.

Un seul choix de packaging ne peut pas servir les deux types de consommateurs

J'avais d'abord écrit qu'un export de sous-chemin "échoue dans les deux topologies à la fois". C'était exagéré, et le reviewer qui l'a contesté avait raison.

Une bibliothèque publiée est en général consommée de deux façons, que j'appelle des topologies :

Topologie Comment l'app obtient la bibliothèque Ce qu'un export de sous-chemin avec sideEffects: false y fait
Fédérée Via Module Federation Casse le singleton
Standalone Installée directement Le mécanisme classique pour isoler du poids optionnel. Ça marche

Donc le vrai argument n'est pas "ça échoue dans les deux". C'est qu'un seul choix de packaging ne peut pas servir les deux topologies, et la seule forme qui y arrive est l'import lazy dans la bibliothèque.

Cette distinction compte pour une autre raison. On est tenté de raisonner "le design system est partagé, donc son poids est payé une fois par le host". Ce n'est vrai que pour la moitié fédérée. Les apps standalone paient chaque ko ajouté en entier, par app, qu'elles utilisent la fonctionnalité ou non.

Les remèdes, du meilleur au pire

  1. Lazy-loader dans la bibliothèque. Un import() dynamique derrière le composant lourd (il charge ce code dans un chunk séparé, seulement quand on en a besoin) garde une seule instance partagée tout en gardant le poids hors du chunk initial (le code chargé dès le départ). Transparent pour les consommateurs, aucune coordination avec un autre repo. C'est celui vers lequel j'irais.
  2. Déclarer le sous-chemin exact comme sa propre clé de partage. '@acme/design-system/charts' à côté de la clé nue. Plus ciblé qu'un partage par préfixe, qui fait entrer sans bruit dans le share scope chaque futur sous-chemin que vous ajoutez.
  3. Ajouter le partage par préfixe. Ça marche, et ça atterrit dans le repo consommateur, dans une config souvent protégée par des invariants (des règles qu'elle doit toujours respecter) justement parce que c'est en décidant du partage entrée par entrée qu'on fait tomber un shell. C'est un aller simple, difficile à défaire : chiffrez-le comme tel.

Deux clés peuvent vouloir dire deux instances

Une réserve sur les deux derniers, que je n'ai pas vérifiée et que je ne prétendrai pas avoir vérifiée : la clé nue et la clé de sous-chemin deviennent deux entrées indépendantes du share scope. Si votre entrée de sous-chemin atteint des modules internes partagés (un contexte de thème, par exemple) via des imports relatifs, c'est le build qui fournit chaque entrée qui décide si vous vous retrouvez avec une instance ou deux.

Le host fournit la racine, un remote fournit /charts, et il est plausible que vous obteniez deux contextes de thème et des valeurs par défaut utilisées en silence. Testez-le avant de compter dessus.

Un piège plus discret : un second point d'entrée et un nom de fichier CSS constant

Il y a un piège de second ordre qui vaut la peine d'être connu, parce qu'il est encore plus discret :

Ce que j'en retiens

Mise à jour, novembre 2026

Le chunk lazy que je recommandais plus haut a maintenant un chiffre, et une affirmation que j'ai faite à son sujet doit être nuancée.

Le chunk lazy, mesuré. Sur une dépendance de graphiques, un chunk lazy dans la bibliothèque (un import() dynamique) n'a demandé aucun changement dans l'instruction d'import du consommateur. Sur un seul build, il a changé le poids que cette dépendance ajoutait au point d'entrée de la bibliothèque comme ceci :

Point d'entrée de la bibliothèque Poids ajouté par la dépendance de graphiques (gzip)
Avant le chunk lazy +104 849 octets
Avec le chunk lazy +1 348 octets, 98,7 % différés

Le CSS, lui, ne se découpe pas, du moins par défaut : le mode bibliothèque de Vite (son build pour publier une bibliothèque) laisse cssCodeSplit désactivé, donc Vite remonte toutes les feuilles de style du build, celle de la partie lazy comprise, dans l'unique bundle CSS. Le reste faisait environ 174 octets gzip, et le découper voudrait dire une seconde feuille de style publiée et un second import dans chaque app.

Une affirmation nuancée. J'ai aussi écrit "la seule forme qui y arrive est l'import lazy dans la bibliothèque". Ce n'est vrai qu'en partie. La bibliothèque livre aussi un build CommonJS, et là l'import dynamique se dégrade en Promise.resolve().then(() => require(...)). Le découpage survit sur le disque, mais un bundler statique résout ce require comme une dépendance ordinaire et remet directement le chunk dans le bundle eager du consommateur, le code chargé dès le départ. Toutes les apps embarquent le build ESM aujourd'hui, donc ça ne coûte rien pour l'instant. Par contre, ne dites pas aux équipes des apps que c'est lazy partout.

Le garde-fou. La partie durable s'est révélée être un garde-fou, pas le remède. La règle ne vivait que dans une note de décision, donc rien n'empêchait quelqu'un d'ajouter ./charts à exports la semaine d'après, avec la CI de la bibliothèque au vert et la duplication qui apparaît dans le runtime d'un autre repo. Le garde-fou que j'ai retenu fait échouer le build de la bibliothèque quand on ajoute un second point d'entrée JavaScript à exports :