Repository navigation
Conversation
…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>
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-packageexistant ; 24 lignes de code, 41 avec les commentaires) reprends l'idée évoqué dans GouvernementFR/dsfr#1128 :Registration.parseignore tout nœud situé sous un attributdata-fr-hydratingnon libéré. Le parcours initial comme leMutationObserverpassent 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 unWeakSet: il ne modifie pas le DOM, et rien ne s'accumule au fil des navigations.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).Modes.REACT: Vue ou Angular peuvent s'en servir de la même façon.Côté
react-dsfr:data-fr-hydrating={useId()}sur sa racine et y rend, en dernier enfant,<HydrationRelease id={…} />.HydrationReleaseest un Client Component qui ne rend rien. Il appelledsfr.hydrated(id)dans son effet, après le chargement du moteur (prDsfrLoaded, déjà utilisé pareulerianAnalyticsetChart).useIdfonctionne en RSC, et seul ce composant vide franchit la frontière serveur/client. Aucun"use client"n'est ajouté, et la racine, larefet le DOM sont inchangés. Le HTML servi ne diffère que par l'attribut.Buttonn'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 brancheLinkest donc rendue par le serveur exactement comme surmain.Tagn'a de porte que sur sa branche<button>, la seule instrumentée (aria-pressed,--dismiss).DsfrProviderdé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(doncCheckboxetRadioButtons),Header,MainNavigation,Modal(doncDisplay),PasswordInput,Range,SegmentedControl,SideMenu,Table,Tabs,Tag(branche<button>),Tile,ToggleSwitch,Tooltip.CardetTilesont 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(branchestreaming-e2e), un fork de la démo Next de react-dsfr. On garde ainsi :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, ennext dev) :/streaming(rendu RSC)/streaming-client(rendu client)/streaming-without-start(sans<StartDsfrOnHydration />)/no-streamingLe moteur instrumente exactement autant d'éléments qu'avant, 67 sur chaque page combinée.
Tests :
/streaming,/streaming-clientet/streaming-without-start(modale, accordéon, onglets, table, curseur), la hauteur des onglets, l'animation de l'accordéon, et/no-streaming.@gouvfr/dsfrfait sauter le patch :dsfrHydrationGate.test.ts: la porte etdsfr.hydrated(id);dsfrModalsGroup.test.ts: la modale ouverte par défaut ;hydrationRelease.test.ts: le rendu serveur, l'hydratation et le montage client, surAccordionet surTable.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 modaleisOpenedByDefaultqu'une fois :_retrieve()est verrouillé par_hasRetrievedet s'exécute à la frame qui suit l'arrivée du premier membre. Une modale arrivée ensuite passe parupdate(), dontgetIndex()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 :
/no-streaming/family/modal/streaming/family/modalCorrectif : une modale qui apprend son état initial après
_retrieve()le signale au groupe parretrieveLate(). 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
data-fr-hydrating=":S1:"reste dans le DOM après libération, c'est voulu.useId, un composant client vide (quelques octets de données RSC) et un appel àdsfr.hydrated.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) :
start())start()+ N ×hydrated(id))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é parStage.put. Avec porte, cet appel disparaît du profil.Stage.putrange 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.Register.activate()traite les enregistrements un par un, chacun sur toute la page. Tous lesCollapsesont 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, etputles compare un par un. Le coût croît plus vite que le carré du nombre de composants.hydrated(id)instrumente un seul composant, dans l'ordre du document. Le nouvel élément se range après le précédent :puts'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.putexiste 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, viaInstance.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._configy écritdata-fr-js-modal-button="true"(ou-collapse-button,-tooltip-referent), et React émet un avertissement d'hydratation.Exemple :
Ce qui se passe :
<modal.Component>libère sa porte, le moteur instrumente la modale, et l'instanceModalenregistre[aria-controls="confirmation"]pour trouver ses boutons. Cet enregistrement est global.<Actions>est inséré dans la page. LeMutationObserverle 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, etdata-fr-js-modal-button="true"y est écrit.<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 :
<Suspense>;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 :.fr-range[data-fr-js-range],.fr-table[data-fr-js-table=true];data-fr-js-tooltip-referent, dansModal._escape().Le moteur ne s'en sert pas pour savoir ce qu'il a instrumenté : il le garde en mémoire. Correction possible :
rangeettable;Modal._escape()par unWeakSetalimenté parTooltipReferent.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
next-pagesdir,viteetcrapassent par le mêmestart(), mais n'ont pas été mesurées.<Suspense>, hors composant react-dsfr, n'est pas sous porte. ExporterHydrationReleasepermettrait de le couvrir.Pour reproduire
streaming-e2e). Son scripte2e/use-react-dsfr.shinstalle un build local et supprime.next, dont le cache servirait sinon l'ancienne version, qui porte le même numéro.npx --yes yarn@1.22.22 …. Sans champpackageManager, corepack peut résoudreyarnen yarn 4, qui n'exécute pas le scriptprepare. Le patch n'est alors pas appliqué, etdist/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 deDisclosuresGroup, qui vaut aussi sans React.🤖 Generated with Claude Code revue par @moroine