Skip to content

fix(hydratation): correction des erreurs d'hydratations avec NextJs streaming - #526

Open
moroine wants to merge 10 commits into
codegouvfr:mainfrom
moroine:hydratation-porte
Open

moroine wants to merge 10 commits into
codegouvfr:mainfrom
moroine:hydratation-porte

Conversation

@moroine

@moroine moroine commented Oct 31, 2026

Copy link
Copy Markdown

Contexte

Cette PR propose une méthode pour corriger le problème d'hydratation évoqué dans la discussion #398 et l'issue #305.

Le moteur JS de @gouvfr/dsfr observe le DOM et instrumente immédiatement ce qu'il y trouve. Avec le streaming de Server Components, il mute donc le contenu avant que React ne l'ait hydraté, ce qui produit des divergences d'hydratation.

<StartDsfrOnHydration /> diffère le démarrage du moteur jusqu'au premier effet, mais ne corrige pas le streaming : le contenu arrivé plus tard par un boundary <Suspense> est inséré en HTML brut, muté par le MutationObserver du moteur, puis hydraté. La seule façon d'être à l'abri était de ne plus streamer.

Principe

Côté moteur (via le patch-package existant ; 24 lignes de code, 41 avec les commentaires) reprends l'idée évoqué dans GouvernementFR/dsfr#1128 :

  • Registration.parse ignore tout nœud situé sous un attribut data-fr-hydrating non libéré. Le parcours initial comme le MutationObserver passent par ce point unique.
  • dsfr.hydrated(id) libère le sous-arbre marqué data-fr-hydrating="<id>" et l'analyse aussitôt. Le moteur retient l'élément libéré dans un WeakSet : il ne modifie pas le DOM, et rien ne s'accumule au fil des navigations.
  • Les portes imbriquées sont gérées : un nœud n'est instrumenté que si toutes les portes qui l'englobent sont libérées, dans n'importe quel ordre. Une libération faite avant start() est appliquée au démarrage. Retirer l'attribut libère aussi.
  • DisclosuresGroup.retrieveLate() corrige un défaut du moteur publié (voir plus bas).
  • Aucune référence à React ni à Modes.REACT : Vue ou Angular peuvent s'en servir de la même façon.

Côté react-dsfr :

  • Chaque composant que le moteur instrumente pose data-fr-hydrating={useId()} sur sa racine et y rend, en dernier enfant, <HydrationRelease id={…} />.
  • HydrationRelease est un Client Component qui ne rend rien. Il appelle dsfr.hydrated(id) dans son effet, après le chargement du moteur (prDsfrLoaded, déjà utilisé par eulerianAnalytics et Chart).
  • Ça marche aussi pour les Server Components. useId fonctionne en RSC, et seul ce composant vide franchit la frontière serveur/client. Aucun "use client" n'est ajouté, et la racine, la ref et le DOM sont inchangés. Le HTML servi ne diffère que par l'attribut.
  • Button n'a pas de porte. Le moteur ne le touche que lorsqu'une modale, un panneau repliable, une infobulle ou un groupe de boutons l'enregistre, et ceux-là sont sous porte. Sa branche Link est donc rendue par le serveur exactement comme sur main. Tag n'a de porte que sur sa branche <button>, la seule instrumentée (aria-pressed, --dismiss).
  • DsfrProvider déclenche l'effet d'hydratation (repris de Plutôt react-dsfr (que react_dsfr) ? #1). <StartDsfrOnHydration /> devient optionnel, reste exporté et est annoté @deprecated.

Composants concernés : Accordion, Breadcrumb, ButtonsGroup, Card, Fieldset (donc Checkbox et RadioButtons), Header, MainNavigation, Modal (donc Display), PasswordInput, Range, SegmentedControl, SideMenu, Table, Tabs, Tag (branche <button>), Tile, ToggleSwitch, Tooltip. Card et Tile sont concernés à cause de leurs variantes de téléchargement, que le banc d'essai n'exerce pas.

Vérification

L'app de repro et l'e2e Playwright vivent hors de ce dépôt, dans moroine/react-dsfr-next-appdir-demo (branche streaming-e2e), un fork de la démo Next de react-dsfr. On garde ainsi :

  • le shell réel de la démo : Header avec Display, Footer, consentement, MUI ;
  • Next 16.3 et React 19.2, avec le Strict Mode activé par défaut par l'App Router.

Sa CI construit react-dsfr à la branche du fork et compare chaque build à la version publiée. Le README du fork explique comment mesurer en local. Dernière exécution, verte : streaming-e2e (e2e 6/6 pour cette PR ; 3/6 pour la version publiée et pour #1, attendu).

Relevé (messages d'hydratation en console et pageerror, en next dev) :

Route react-dsfr 1.34.0 (npm) Cette PR
/streaming (rendu RSC) erreur « Hydration failed », l'arbre est régénéré aucun message
/streaming-client (rendu client) 1 avertissement aucun message
/streaming-without-start (sans <StartDsfrOnHydration />) moteur jamais démarré (0 élément instrumenté) aucun message, 67 éléments instrumentés
/no-streaming aucun message aucun message
19 familles isolées, rendu RSC 17 divergentes aucune
19 familles isolées, rendu client 17 divergentes aucune

Le moteur instrumente exactement autant d'éléments qu'avant, 67 sur chaque page combinée.

Tests :

  • e2e (dans le fork) : 6/6. Ils couvrent /streaming, /streaming-client et /streaming-without-start (modale, accordéon, onglets, table, curseur), la hauteur des onglets, l'animation de l'accordéon, et /no-streaming.
  • Unitaires : 136 tests. Trois fichiers chargent le moteur patché dans jsdom, et échouent si une montée de version de @gouvfr/dsfr fait sauter le patch :
    • dsfrHydrationGate.test.ts : la porte et dsfr.hydrated(id) ;
    • dsfrModalsGroup.test.ts : la modale ouverte par défaut ;
    • hydrationRelease.test.ts : le rendu serveur, l'hydratation et le montage client, sur Accordion et sur Table.

Défaut du moteur corrigé au passage : modale ouverte par défaut arrivée tard

DisclosuresGroup (un seul groupe pour toutes les modales de la page) n'ouvre la modale isOpenedByDefault qu'une fois : _retrieve() est verrouillé par _hasRetrieved et s'exécute à la frame qui suit l'arrivée du premier membre. Une modale arrivée ensuite passe par update(), dont getIndex() ne cherche que des membres déjà ouverts. Faute d'en trouver, le groupe referme tout, y compris elle.

Le défaut existe dans le moteur publié : une modale ouverte par défaut, streamée après d'autres modales, reste fermée. La porte l'étendait au contenu non streamé de la démo, dont les modales du shell (consentement, affichage, menu de l'en-tête) sont libérées avant celles de la page. Mesuré dans la démo :

Modale ouverte par défaut npm 1.34.0 Porte seule Porte + correctif
/no-streaming/family/modal ouverte fermée ouverte
/streaming/family/modal fermée fermée ouverte

Correctif : une modale qui apprend son état initial après _retrieve() le signale au groupe par retrieveLate(). Ouverte par défaut, elle est ouverte si aucune autre ne l'est. Une modale déjà ouverte garde la priorité, et une modale fermée par l'utilisateur n'est pas rouverte ; des tests le vérifient.

Contreparties

  • Montage côté client : un composant monté sans hydratation, lors d'une navigation, n'est instrumenté qu'après son effet, et non plus dès son insertion.
  • Attribut conservé : data-fr-hydrating=":S1:" reste dans le DOM après libération, c'est voulu.
  • Coût par composant sous porte : un useId, un composant client vide (quelques octets de données RSC) et un appel à dsfr.hydrated.
  • Coût de la libération : voir la section Performances.

Performances

La porte accélère l'instrumentation des grosses pages

Mesuré dans Chrome sur une page de N accordéons (environ 25 nœuds chacun ; médiane de 3 essais ; travail synchrone du moteur) :

Composants Nœuds DOM Moteur actuel, sans porte (start()) Avec porte (start() + N × hydrated(id))
50 1 254 5,6 ms 7,5 ms
200 5 004 47 ms 35 ms
500 12 504 454 ms 177 ms
1 000 25 004 3,9 s 766 ms
2 000 50 004 49 s 4,2 s

La colonne « avec porte » correspond au pire cas : toutes les libérations dans une seule tâche. Au-delà de 200 composants, elle est déjà plus rapide.

Pourquoi. Le profileur CPU de Chrome, à 1 000 composants, montre que sans porte 93 % du temps passe dans compareDocumentPosition, appelé par Stage.put. Avec porte, cet appel disparaît du profil.

Stage.put range chaque élément instrumenté dans l'arbre interne du moteur. Il remonte pour cela la liste des voisins depuis la fin, jusqu'à trouver l'élément qui le précède dans le document.

  • Au démarrage, Register.activate() traite les enregistrements un par un, chacun sur toute la page. Tous les Collapse sont créés d'abord (premier enregistrement), puis les boutons, puis les accordéons. Chaque accordéon s'insère au milieu de milliers d'éléments déjà rangés, et put les compare un par un. Le coût croît plus vite que le carré du nombre de composants.
  • Avec la porte, chaque hydrated(id) instrumente un seul composant, dans l'ordre du document. Le nouvel élément se range après le précédent : put s'arrête dès la première comparaison.

Découpage dans le temps (déduit du mécanisme, non mesuré). Sans porte, tout ce qui est dans la page au démarrage du moteur est instrumenté en un seul bloc. Avec la porte, l'instrumentation suit l'hydratation : un bloc par <Suspense> hydraté, et chaque bloc ne concerne que ses composants. Une page sans <Suspense> s'hydrate d'un seul tenant et garde un seul bloc, moins cher.

Le coût de Stage.put existe aujourd'hui, indépendamment de la porte, pour tout site qui charge une page déjà riche en composants, avec ou sans React. Côté moteur, on pourrait le supprimer même sans porte : instrumenter dans l'ordre du document, ou ranger chaque élément en remontant ses ancêtres DOM plutôt qu'en parcourant ses voisins.

Le surcoût propre à la porte, et sa correction

hydrated(id) cherche sa porte parmi toutes celles du document (querySelectorAll('[data-fr-hydrating]')) : N libérations coûtent N parcours, soit un coût quadratique. Mesuré sur les mêmes pages : 0,4 ms pour 50 composants, 5 ms pour 200, 30 ms pour 500, 120 ms pour 1 000. Ce surcoût est inclus dans la colonne « avec porte » ci-dessus.

Correction proposée, non implémentée : les libérations d'une même hydratation arrivent dans la même tâche. Le moteur peut les accumuler et les résoudre en un seul parcours en fin de tâche (queueMicrotask). La recherche devient linéaire : environ 0,12 ms par parcours sur 25 000 nœuds, au lieu de 120 ms pour 1 000 appels.

Limite connue : déclencheur streamé après sa cible

Une modale, un panneau repliable ou une infobulle enregistre ses déclencheurs ([aria-controls="…"], [aria-describedby="…"]) à sa propre initialisation, via Instance.register. Cet enregistrement est global : il s'applique aussi aux nœuds insérés plus tard.

Si la cible est libérée en premier (par exemple une modale dans le shell) et que son déclencheur arrive ensuite dans un <Suspense> plus tardif, hors de tout composant sous porte, le moteur instrumente le déclencheur dès son insertion, avant que React ne l'ait hydraté. Instance._config y écrit data-fr-js-modal-button="true" (ou -collapse-button, -tooltip-referent), et React émet un avertissement d'hydratation.

Exemple :

// app/page.tsx : Server Components
import { Suspense } from "react";
import { Button } from "@codegouvfr/react-dsfr/Button";
import { createModal } from "@codegouvfr/react-dsfr/Modal";

const modal = createModal({ id: "confirmation" });

export default function Page() {
    return (
        <main>
            {/* Dans le shell : hydratée tôt, sa porte est libérée tôt. */}
            <modal.Component title="Confirmation">Êtes-vous sûr ?</modal.Component>
            <Suspense fallback={<p>Chargement…</p>}>
                <Actions />
            </Suspense>
        </main>
    );
}

async function Actions() {
    await getActions(); // le contenu arrive plus tard, par streaming

    // Le déclencheur n'est dans aucun composant sous porte : Button n'en a pas.
    return <Button nativeButtonProps={modal.buttonProps}>Ouvrir</Button>;
}

Ce qui se passe :

  1. Le shell est hydraté. <modal.Component> libère sa porte, le moteur instrumente la modale, et l'instance Modal enregistre [aria-controls="confirmation"] pour trouver ses boutons. Cet enregistrement est global.
  2. Le HTML de <Actions> est inséré dans la page. Le MutationObserver le parse avec tous les enregistrements, dont celui de la modale : le <button aria-controls="confirmation"> correspond et n'est sous aucune porte. Il est instrumenté sur-le-champ, et data-fr-js-modal-button="true" y est écrit.
  3. React hydrate <Actions>, trouve sur le bouton un attribut absent de ses props, et émet l'avertissement.

D'après le code (ce cas n'est pas mesuré), l'exemple ne diverge pas si l'on inverse l'ordre (bouton dans le shell, modale dans le <Suspense>), ni si le bouton est rendu dans le même <Suspense> que la modale.

Cas non concernés :

  • le déclencheur et la cible sont dans le même composant ou le même <Suspense> ;
  • le déclencheur est hydraté avant la cible ;
  • le déclencheur est à l'intérieur d'un composant sous porte (Header, ButtonsGroup, Card…).

Ce cas n'est pas couvert par le banc d'essai.

Piste de correction, non implémentée

Les déclencheurs (ModalButton, CollapseButton, TooltipReferent) n'écrivent rien d'autre que ce marqueur à leur initialisation. Sans marqueur, les instrumenter avant leur hydratation serait invisible pour React, quel que soit l'ordre des <Suspense>.

Sur les 51 marqueurs data-fr-js-* que le moteur peut poser, 3 seulement sont lus :

  • par le CSS DSFR : .fr-range[data-fr-js-range], .fr-table[data-fr-js-table=true] ;
  • par le moteur : data-fr-js-tooltip-referent, dans Modal._escape().

Le moteur ne s'en sert pas pour savoir ce qu'il a instrumenté : il le garde en mémoire. Correction possible :

  • ne plus poser les marqueurs, sauf range et table ;
  • remplacer la lecture de Modal._escape() par un WeakSet alimenté par TooltipReferent.

Le code concerné tient en une dizaine de lignes. En revanche, c'est un changement de contrat : une CSS, un test ou un outil côté application qui s'appuie sur ces attributs cesserait de fonctionner. Reste aussi à choisir de l'appliquer partout ou seulement en mode: "react".

Limites de la vérification

  • Développement uniquement : tout a été mesuré en développement, React ne signalant pas les divergences d'hydratation en production.
  • Environnement : Next 16.3 / React 19.2 avec le Strict Mode (la démo), et Next 15.1 / React 19.0 sans lui (l'app de repro de Plutôt react-dsfr (que react_dsfr) ? #1, lors d'une itération précédente, avant le correctif des modales). Les intégrations next-pagesdir, vite et cra passent par le même start(), mais n'ont pas été mesurées.
  • Balisage écrit à la main : le balisage DSFR écrit directement dans un <Suspense>, hors composant react-dsfr, n'est pas sous porte. Exporter HydrationRelease permettrait de le couvrir.

Pour reproduire

  • Mesures : voir le README du fork (streaming-e2e). Son script e2e/use-react-dsfr.sh installe un build local et supprime .next, dont le cache servirait sinon l'ancienne version, qui porte le même numéro.
  • yarn 1.22.22 obligatoire, ici comme dans le fork : utiliser npx --yes yarn@1.22.22 …. Sans champ packageManager, corepack peut résoudre yarn en yarn 4, qui n'exécute pas le script prepare. Le patch n'est alors pas appliqué, et dist/ embarque le moteur d'origine.

Suite

Le patch moteur a vocation à remonter dans GouvernementFR/dsfr (GouvernementFR/dsfr#1128). Il est indépendant du framework : la porte, dsfr.hydrated(id) et le correctif de DisclosuresGroup, qui vaut aussi sans React.

🤖 Generated with Claude Code revue par @moroine

moroine and others added 10 commits October 28, 2026 22:33
…anche hydratation

Mêmes routes et mêmes familles que la PR #1, pour comparer les deux
approches à banc égal. Les relevés COVERAGE.md, MEASUREMENTS.md et
LOAD-EFFECT.md décrivent les mesures de l'autre approche : ils restent sur
la branche hydratation, les commentaires y renvoient.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Repris de la branche hydratation (1d07bda, e486499). Le contenu arrivé
plus tard par streaming est protégé par la porte data-fr-hydrating, pas par
le moment où le moteur démarre : StartDsfrOnHydration devient optionnel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r-hydrating

Proposition de GouvernementFR/dsfr#1128, inversée : au lieu d'exiger un
marqueur « prêt » partout, le moteur ignore les nœuds situés sous un
attribut data-fr-hydrating. Sans marqueur, rien ne change.

Un seul point de passage, Registration.parse, que le parcours initial et le
MutationObserver empruntent tous deux. Le réveil ne demande aucun code
neuf : retirer l'attribut est une mutation d'attribut, à laquelle Stage
répond déjà en analysant de nouveau tout le sous-arbre au rAF suivant.

Indépendant du framework et du mode : aucune référence à Modes.REACT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
useHydrationGate renvoie l'attribut au rendu serveur et pendant
l'hydratation (instantané serveur de useSyncExternalStore), puis plus rien :
React planifie un nouveau rendu juste après l'hydratation du composant, ce
qui retire l'attribut et réveille le moteur. Garantie exacte, composant par
composant, sans heuristique d'ordonnancement. Un composant monté côté client
sans hydratation ne pose jamais l'attribut.

Posé sur la racine des 19 composants exercés par l'app de repro (Checkbox
via Fieldset ; Display via la Modal qu'il rend).

Limite : useSyncExternalStore n'existe pas dans le build react-server. Un
composant rendu comme Server Component ne pose donc aucune porte et garde
le comportement d'avant — il n'a de toute façon aucun effet client pour la
lever.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-client

/streaming-client et /streaming-client/family/[name] rendent les mêmes
familles comme Client Components, seul cas où les composants peuvent poser
la porte.

L'e2e ferme d'abord la modale ouverte par défaut de la famille modal : elle
intercepte les clics sur le reste de la page. Ce défaut du montage restait
invisible tant que le test échouait avant d'arriver aux clics.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Un composant rendu comme Server Component n'a aucun code client pour
retirer son attribut data-fr-hydrating. Il peut en revanche porter un
identifiant (useId fonctionne en RSC) et rendre un Client Component qui
appelle dsfr.hydrated(id) une fois hydraté.

Le moteur retient l'élément libéré dans un WeakSet : l'attribut reste dans
le DOM, rien ne s'accumule au fil des navigations. Un nœud n'est instrumenté
que si toutes les portes qui l'englobent sont libérées. Une libération faite
avant start() est appliquée au démarrage. Le retrait de l'attribut reste
une façon de libérer.

Toujours indépendant du framework.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0153AaDDashSNpHjFMuVVv3M
…pris en RSC

Chaque composant que le moteur instrumente pose
data-fr-hydrating={useId()} sur sa racine et y rend, en dernier enfant,
<HydrationRelease id={...} />. Ce Client Component ne rend rien et appelle
dsfr.hydrated(id) dans son effet, une fois le moteur chargé
(prDsfrLoaded). useId fonctionnant en Server Component, seul ce composant
vide franchit la frontière : racine, ref et DOM inchangés, aucun
"use client" ajouté.

Un seul mécanisme pour les Server et les Client Components ; il remplace
useHydrationGate.

Button n'a plus de porte : le moteur ne le touche que lorsqu'une modale, un
panneau repliable, une infobulle ou un groupe de boutons l'enregistre, et
ceux-là sont sous porte. Sa branche Link reste donc rendue par le serveur
telle quelle. Tag n'est gardé que sur sa branche <button>, seule
instrumentée (aria-pressed, --dismiss).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0153AaDDashSNpHjFMuVVv3M
…fermée avant les clics

Les deux routes de streaming ne divergent plus, ce qui fait atteindre au
test de /streaming-legacy l'étape des clics, qu'il n'avait jamais atteinte :
la modale ouverte par défaut les intercepte. Sa fermeture par Échap est
factorisée et vérifie au passage que le moteur la pilote.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0153AaDDashSNpHjFMuVVv3M
…verte

DisclosuresGroup n'ouvre la modale marquée isOpenedByDefault qu'une fois,
à la frame qui suit l'arrivée de son premier membre (_retrieve, verrouillé
par _hasRetrieved). Un membre arrivé ensuite passe par update(), dont
getIndex() ne cherche que des membres déjà ouverts : faute d'en trouver, il
referme tout, y compris le nouveau.

Le défaut existe dans le moteur publié pour toute modale ouverte par défaut
streamée après d'autres modales. La porte l'étendait au contenu non streamé
de la démo Next (garronej/react-dsfr-next-appdir-demo), dont les modales du
shell sont libérées avant celles de la page.

Une modale qui apprend son état initial après _retrieve() le signale par
retrieveLate() : ouverte par défaut, elle est ouverte si aucune autre ne
l'est. Une modale déjà ouverte garde la priorité et une modale fermée par
l'utilisateur n'est pas rouverte (tests de garde).

Le chargeur jsdom du moteur est mis en commun, arrête le moteur avant de
fermer la fenêtre et neutralise window.scrollTo, que jsdom n'implémente pas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0153AaDDashSNpHjFMuVVv3M
Ils vivent désormais dans moroine/react-dsfr-next-appdir-demo (branche
streaming-e2e), un fork de garronej/react-dsfr-next-appdir-demo : Next 16 et
React 19.2, avec le shell réel de la démo (Header, Footer, Display,
consentement, MUI). Sa CI construit react-dsfr à une ref donnée et compare
les versions.

Restent ici les tests jsdom du moteur patché, qui échouent si une montée de
version de @gouvfr/dsfr fait sauter le patch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0153AaDDashSNpHjFMuVVv3M

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant