From 22457a63ef34cdd4b662402c0e0878688ad73277 Mon Sep 17 00:00:00 2001
From: Moroine Bentefrit
Date: Wed, 28 Oct 2026 22:33:06 +0100
Subject: [PATCH 01/10] test(streaming): importe l'app de repro Next 15 et
l'e2e depuis la branche hydratation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
test/e2e/.gitignore | 3 +
test/e2e/README.md | 21 +
test/e2e/package.json | 11 +
test/e2e/playwright.config.ts | 54 ++
test/e2e/streaming.spec.ts | 210 +++++++
test/e2e/yarn.lock | 29 +
.../next-app-router-streaming/.gitignore | 37 ++
.../app/DsfrShowcase.tsx | 13 +
.../app/ToggleSwitchShowcase.tsx | 13 +
.../app/dsfr-bootstrap/defaultColorScheme.ts | 3 +
.../app/dsfr-bootstrap/index.tsx | 22 +
.../app/dsfr-bootstrap/server-only.tsx | 13 +
.../app/dsfr-families.tsx | 285 +++++++++
.../next-app-router-streaming/app/layout.tsx | 19 +
.../app/no-streaming/family/[name]/page.tsx | 29 +
.../app/no-streaming/page.tsx | 12 +
.../app/streaming-legacy/page.tsx | 22 +
.../app/streaming/family/[name]/page.tsx | 42 ++
.../app/streaming/page.tsx | 20 +
.../next-app-router-streaming/next-env.d.ts | 5 +
.../next-app-router-streaming/next.config.js | 25 +
.../next-app-router-streaming/package.json | 23 +
.../next-app-router-streaming/tsconfig.json | 20 +
.../next-app-router-streaming/yarn.lock | 542 ++++++++++++++++++
24 files changed, 1473 insertions(+)
create mode 100644 test/e2e/.gitignore
create mode 100644 test/e2e/README.md
create mode 100644 test/e2e/package.json
create mode 100644 test/e2e/playwright.config.ts
create mode 100644 test/e2e/streaming.spec.ts
create mode 100644 test/e2e/yarn.lock
create mode 100644 test/integration/next-app-router-streaming/.gitignore
create mode 100644 test/integration/next-app-router-streaming/app/DsfrShowcase.tsx
create mode 100644 test/integration/next-app-router-streaming/app/ToggleSwitchShowcase.tsx
create mode 100644 test/integration/next-app-router-streaming/app/dsfr-bootstrap/defaultColorScheme.ts
create mode 100644 test/integration/next-app-router-streaming/app/dsfr-bootstrap/index.tsx
create mode 100644 test/integration/next-app-router-streaming/app/dsfr-bootstrap/server-only.tsx
create mode 100644 test/integration/next-app-router-streaming/app/dsfr-families.tsx
create mode 100644 test/integration/next-app-router-streaming/app/layout.tsx
create mode 100644 test/integration/next-app-router-streaming/app/no-streaming/family/[name]/page.tsx
create mode 100644 test/integration/next-app-router-streaming/app/no-streaming/page.tsx
create mode 100644 test/integration/next-app-router-streaming/app/streaming-legacy/page.tsx
create mode 100644 test/integration/next-app-router-streaming/app/streaming/family/[name]/page.tsx
create mode 100644 test/integration/next-app-router-streaming/app/streaming/page.tsx
create mode 100644 test/integration/next-app-router-streaming/next-env.d.ts
create mode 100644 test/integration/next-app-router-streaming/next.config.js
create mode 100644 test/integration/next-app-router-streaming/package.json
create mode 100644 test/integration/next-app-router-streaming/tsconfig.json
create mode 100644 test/integration/next-app-router-streaming/yarn.lock
diff --git a/test/e2e/.gitignore b/test/e2e/.gitignore
new file mode 100644
index 000000000..db2d12ff4
--- /dev/null
+++ b/test/e2e/.gitignore
@@ -0,0 +1,3 @@
+/node_modules
+/test-results
+/playwright-report
diff --git a/test/e2e/README.md b/test/e2e/README.md
new file mode 100644
index 000000000..ca3623024
--- /dev/null
+++ b/test/e2e/README.md
@@ -0,0 +1,21 @@
+# Tests e2e
+
+Ces tests ne font pas partie de la CI : ils demandent un build Next et un
+Chrome installé. Ils servent à prouver le correctif et à documenter la PR.
+
+## Prérequis
+
+- Chrome installé (`/usr/bin/google-chrome`)
+- La bibliothèque construite : `npx --yes yarn@1.22.22 build` à la racine
+- L'application de reproduction construite :
+ `cd test/integration/next-app-router-streaming && npx --yes yarn@1.22.22 build`
+
+## Exécution
+
+```bash
+cd test/e2e
+npx --yes yarn@1.22.22 install
+npx playwright test
+```
+
+Le serveur Next est démarré automatiquement par Playwright sur le port 3100.
diff --git a/test/e2e/package.json b/test/e2e/package.json
new file mode 100644
index 000000000..1ae1a8526
--- /dev/null
+++ b/test/e2e/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "react-dsfr-e2e",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "test": "playwright test"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.49.0"
+ }
+}
diff --git a/test/e2e/playwright.config.ts b/test/e2e/playwright.config.ts
new file mode 100644
index 000000000..5bb0a8648
--- /dev/null
+++ b/test/e2e/playwright.config.ts
@@ -0,0 +1,54 @@
+import { defineConfig } from "@playwright/test";
+import { rmSync } from "fs";
+import { join as pathJoin } from "path";
+
+const appDirPath = pathJoin(__dirname, "..", "integration", "next-app-router-streaming");
+
+// Le cache webpack persistant de Next.js (`.next/cache`) sert indéfiniment la
+// version compilée d'une dépendance `file:` : `@codegouvfr/react-dsfr` est
+// recopié par `yarn install --force` sans changer de numéro de version, et
+// webpack considère `node_modules` comme immuable à version constante. Un
+// serveur de dev démarré avant un cycle build/install peut donc livrer au
+// navigateur un chunk d'AVANT le patch, sans le moindre signe extérieur.
+// Ce piège a coûté une enquête entière : voir
+// test/integration/next-app-router-streaming/LOAD-EFFECT.md, branche hydratation.
+// D'où les deux mesures ci-dessous, indissociables :
+// - `.next` est supprimé au chargement de cette configuration, donc avant que
+// Playwright ne démarre le serveur ;
+// - `reuseExistingServer` est à false, pour qu'aucun serveur déjà lancé
+// (donc déjà compilé, éventuellement à partir d'un `.next` périmé) ne
+// puisse servir la mesure.
+// `TEST_WORKER_INDEX` n'est défini que dans les processus « worker », qui
+// rechargent eux aussi cette configuration — alors que le serveur de dev
+// tourne déjà. Sans ce garde, chaque worker supprimerait `.next` sous les
+// pieds du serveur, qui répondrait alors
+// « missing required error components, refreshing... ».
+if (process.env.TEST_WORKER_INDEX === undefined) {
+ rmSync(pathJoin(appDirPath, ".next"), { recursive: true, force: true });
+}
+
+export default defineConfig({
+ testDir: ".",
+ // Le serveur de dev compile à la première requête, en plus du délai de
+ // streaming de 1,5 s. Le `.next` étant systématiquement supprimé, la
+ // toute première compilation est toujours à froid.
+ timeout: 120_000,
+ use: {
+ baseURL: "http://localhost:3100",
+ // Chrome système : évite le téléchargement d'un navigateur.
+ channel: "chrome"
+ },
+ webServer: {
+ // Serveur de DÉVELOPPEMENT, délibérément. La mesure de la Task 1 a
+ // établi qu'en production les trois routes sont strictement
+ // silencieuses, avec ou sans le correctif : React ne remonte le diff
+ // d'hydratation qu'en développement. Un e2e visant `next start` ne
+ // pourrait donc ni observer le défaut ni démontrer sa correction —
+ // il passerait à vide dans les deux sens.
+ command: "npx --yes yarn@1.22.22 dev",
+ cwd: appDirPath,
+ url: "http://localhost:3100/no-streaming",
+ reuseExistingServer: false,
+ timeout: 180_000
+ }
+});
diff --git a/test/e2e/streaming.spec.ts b/test/e2e/streaming.spec.ts
new file mode 100644
index 000000000..182773b47
--- /dev/null
+++ b/test/e2e/streaming.spec.ts
@@ -0,0 +1,210 @@
+import { test, expect, type ConsoleMessage } from "@playwright/test";
+
+// React 19 a reformulé le message : il dit « didn't match », pas
+// « did not match ». Un motif qui ne couvrirait que l'ancienne formulation
+// laisserait passer le seul message que cette app produit réellement, et le
+// test passerait à vide. Mesuré en Task 1.
+const HYDRATION_PATTERNS = [
+ "A tree hydrated but some attributes",
+ "didn't match",
+ "did not match",
+ "Extra attributes from the server",
+ "Hydration failed",
+ "There was an error while hydrating"
+];
+
+function isHydrationMessage(text: string): boolean {
+ return HYDRATION_PATTERNS.some(pattern => text.includes(pattern));
+}
+
+test("streaming : aucune divergence d'hydratation, moteur fonctionnel", async ({ page }) => {
+ const hydrationMessages: string[] = [];
+ const pageErrors: string[] = [];
+
+ page.on("console", (message: ConsoleMessage) => {
+ const text = message.text();
+ if (isHydrationMessage(text)) {
+ hydrationMessages.push(text);
+ }
+ });
+ page.on("pageerror", error => {
+ pageErrors.push(error.message);
+ });
+
+ // "commit" (et non le "load" par défaut) : la réponse streamée ne
+ // termine (événement "load") qu'une fois le Suspense résolu, ce qui
+ // masquerait l'état intermédiaire (shell + fallback) qu'on veut observer
+ // ici. "commit" rend la main dès que les en-têtes arrivent, et laisse
+ // les assertions Playwright ci-dessous attendre chaque état par
+ // auto-retry.
+ await page.goto("/streaming", { waitUntil: "commit" });
+
+ // Le shell arrive avant le contenu streamé.
+ await expect(page.getByTestId("shell")).toBeVisible();
+ await expect(page.getByTestId("fallback")).toBeVisible();
+
+ // Puis le boundary se résout.
+ await expect(page.getByTestId("dsfr-showcase")).toBeVisible({ timeout: 30_000 });
+
+ expect(hydrationMessages).toEqual([]);
+ expect(pageErrors).toEqual([]);
+
+ // Le moteur pilote bien le contenu arrivé tardivement.
+ const accordionButton = page.locator(".fr-accordion__btn").first();
+ await expect(accordionButton).toHaveAttribute("aria-expanded", "false");
+ await accordionButton.click();
+ await expect(accordionButton).toHaveAttribute("aria-expanded", "true");
+
+ // Les onglets réagissent.
+ const secondTab = page.locator(".fr-tabs__tab").nth(1);
+ await secondTab.click();
+ await expect(secondTab).toHaveAttribute("aria-selected", "true");
+
+ // La table a bien survécu à l'hydratation (contenu, pas seulement
+ // absence de message) — c'est le composant qui porte
+ // suppressHydrationWarning, on vérifie donc que le contenu réel est
+ // correct, pas juste que l'avertissement est tu.
+ const table = page.getByTestId("dsfr-showcase").locator("table");
+ await expect(table.locator("caption")).toHaveText("Table de test");
+ await expect(table.locator("tbody tr")).toHaveCount(2);
+ await expect(table.locator("tbody tr").first().locator("td").first()).toHaveText("a1");
+
+ // Le curseur (Range, lui aussi suppressHydrationWarning) est bien
+ // piloté par le moteur DSFR après hydratation : sa sortie est
+ // renseignée à l'initialisation puis suit l'interaction utilisateur.
+ const rangeInput = page.locator("input[type=range]").first();
+ const rangeOutput = page.locator(".fr-range__output").first();
+ await expect(rangeOutput).not.toHaveText("");
+ await rangeInput.fill("80");
+ await rangeInput.dispatchEvent("input");
+ await expect(rangeOutput).toHaveText("80");
+});
+
+test("StartDsfrOnHydration monté : toujours fonctionnel", async ({ page }) => {
+ const hydrationMessages: string[] = [];
+ // Le message React « Hydration failed... » n'est jamais émis via
+ // console : c'est une exception, remontée uniquement via
+ // l'événement "pageerror". Le premier test le vérifie déjà ; on le
+ // vérifie ici aussi pour ne pas laisser cette route sans ce filet —
+ // React ne remonte pas toujours le même diagnostic par la même voie
+ // sur les deux routes de streaming.
+ const pageErrors: string[] = [];
+
+ page.on("console", (message: ConsoleMessage) => {
+ const text = message.text();
+ if (isHydrationMessage(text)) {
+ hydrationMessages.push(text);
+ }
+ });
+ page.on("pageerror", error => {
+ pageErrors.push(error.message);
+ });
+
+ await page.goto("/streaming-legacy");
+ await expect(page.getByTestId("dsfr-showcase")).toBeVisible({ timeout: 30_000 });
+
+ expect(hydrationMessages).toEqual([]);
+ expect(pageErrors).toEqual([]);
+
+ // dsfrEffect() est idempotent : le monter en plus de DsfrProvider ne doit
+ // rien casser. C'est le critère d'acceptation n°4 de la spec.
+ const accordionButton = page.locator(".fr-accordion__btn").first();
+ await accordionButton.click();
+ await expect(accordionButton).toHaveAttribute("aria-expanded", "true");
+});
+
+test("onglets : le conteneur prend la hauteur du panneau sélectionné", async ({ page }) => {
+ // Tous les panneaux d'un fr-tabs sont superposés dans le même flux : sans
+ // la variable --tabs-height, .fr-tabs reste en height:auto et prend la
+ // hauteur du panneau le PLUS HAUT, laissant des centaines de pixels de vide
+ // sous le panneau visible jusqu'au premier changement d'onglet. Les deux
+ // panneaux de la famille `tab` ont délibérément des hauteurs très
+ // différentes (voir app/dsfr-families.tsx) : avec deux panneaux de même
+ // hauteur, ce défaut est strictement invisible.
+ //
+ // Route d'ISOLATION, et non la page combinée : celle-ci porte une
+ // divergence d'hydratation appariée non résolue sur `tab` (voir
+ // COVERAGE.md de la branche hydratation), sans rapport avec la hauteur mesurée ici.
+ await page.goto("/streaming/family/tab");
+ await expect(page.getByTestId("dsfr-showcase")).toBeVisible({ timeout: 30_000 });
+
+ const tabsGroup = page.locator(".fr-tabs").first();
+
+ await expect
+ .poll(() => tabsGroup.evaluate(node => node.style.getPropertyValue("--tabs-height")))
+ .not.toBe("");
+
+ const tallPanelHeight = await page
+ .getByTestId("tab-panel-2-content")
+ .evaluate(node => node.scrollHeight);
+ const tabsHeight = (await tabsGroup.boundingBox())?.height ?? 0;
+
+ // Le panneau non sélectionné est bien plus haut que le conteneur : le test
+ // ne passerait pas à vide si les deux panneaux redevenaient identiques.
+ expect(tallPanelHeight).toBeGreaterThan(300);
+ expect(tabsHeight).toBeLessThan(tallPanelHeight);
+
+ // Les onglets restent pilotables.
+ const secondTab = page.locator(".fr-tabs__tab").nth(1);
+ await secondTab.click();
+ await expect(secondTab).toHaveAttribute("aria-selected", "true");
+});
+
+test("accordéon : la première ouverture est animée", async ({ page }) => {
+ // Un fr-collapse non groupé — ce que rend CHAQUE , react-dsfr ne
+ // rendant jamais fr-accordions-group — ne voit tourner aucun disclose() à
+ // l'initialisation. Un chemin rapide conditionné à « aucun disclose n'a
+ // encore tourné » serait donc pris au PREMIER CLIC UTILISATEUR, qui
+ // s'ouvrirait alors d'un coup, sans animation.
+ //
+ // Le chemin animé passe par Collapse.collapsing(), qui appelle adjust() et
+ // pose la variable --collapse ; le chemin rapide ne l'appelle pas. Cette
+ // variable, à la différence de la classe transitoire fr-collapsing, n'est
+ // jamais retirée ensuite : c'est une trace stable du chemin emprunté.
+ await page.goto("/streaming/family/accordion");
+ await expect(page.getByTestId("dsfr-showcase")).toBeVisible({ timeout: 30_000 });
+
+ const accordionButton = page.locator(".fr-accordion__btn").first();
+ const accordionPanel = page.locator(".fr-accordion .fr-collapse").first();
+ const getCollapseProperty = () =>
+ accordionPanel.evaluate(node => node.style.getPropertyValue("--collapse"));
+
+ // Avant le clic : la synchronisation initiale n'a rien posé de transitoire.
+ expect(await getCollapseProperty()).toBe("");
+
+ await accordionButton.click();
+
+ await expect.poll(getCollapseProperty).not.toBe("");
+ await expect(accordionButton).toHaveAttribute("aria-expanded", "true");
+});
+
+test("sans streaming : aucune divergence d'hydratation", async ({ page }) => {
+ const hydrationMessages: string[] = [];
+ const pageErrors: string[] = [];
+
+ page.on("console", (message: ConsoleMessage) => {
+ const text = message.text();
+ if (isHydrationMessage(text)) {
+ hydrationMessages.push(text);
+ }
+ });
+ page.on("pageerror", error => {
+ pageErrors.push(error.message);
+ });
+
+ await page.goto("/no-streaming");
+ await expect(page.getByTestId("dsfr-showcase")).toBeVisible();
+
+ expect(hydrationMessages).toEqual([]);
+ expect(pageErrors).toEqual([]);
+
+ // Pas de streaming ici : tout l'arbre, y compris la table et le
+ // curseur, est hydraté en un seul passage. On vérifie qu'ils sont
+ // bien fonctionnels, pas seulement silencieux.
+ const table = page.getByTestId("dsfr-showcase").locator("table");
+ await expect(table.locator("caption")).toHaveText("Table de test");
+ await expect(table.locator("tbody tr")).toHaveCount(2);
+
+ const rangeOutput = page.locator(".fr-range__output").first();
+ await expect(rangeOutput).not.toHaveText("");
+});
diff --git a/test/e2e/yarn.lock b/test/e2e/yarn.lock
new file mode 100644
index 000000000..128daecf1
--- /dev/null
+++ b/test/e2e/yarn.lock
@@ -0,0 +1,29 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+"@playwright/test@^1.49.0":
+ version "1.62.1"
+ resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.62.1.tgz#68e1aaf6e480e1923936dee6c66fae1921d3a65a"
+ integrity sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==
+ dependencies:
+ playwright "1.62.1"
+
+fsevents@2.3.2:
+ version "2.3.2"
+ resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
+ integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
+
+playwright-core@1.62.1:
+ version "1.62.1"
+ resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.62.1.tgz#120f67a19181bfd183c60fa903c0d99330b56785"
+ integrity sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==
+
+playwright@1.62.1:
+ version "1.62.1"
+ resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.62.1.tgz#8447b6755e8aec85a3cb7207c823e3ed2fc66700"
+ integrity sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==
+ dependencies:
+ playwright-core "1.62.1"
+ optionalDependencies:
+ fsevents "2.3.2"
diff --git a/test/integration/next-app-router-streaming/.gitignore b/test/integration/next-app-router-streaming/.gitignore
new file mode 100644
index 000000000..55e747e58
--- /dev/null
+++ b/test/integration/next-app-router-streaming/.gitignore
@@ -0,0 +1,37 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# typescript
+*.tsbuildinfo
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# local env files
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+# vercel
+.vercel
diff --git a/test/integration/next-app-router-streaming/app/DsfrShowcase.tsx b/test/integration/next-app-router-streaming/app/DsfrShowcase.tsx
new file mode 100644
index 000000000..e051decba
--- /dev/null
+++ b/test/integration/next-app-router-streaming/app/DsfrShowcase.tsx
@@ -0,0 +1,13 @@
+import { dsfrFamilies } from "./dsfr-families";
+
+export function DsfrShowcase() {
+ return (
+
+ {dsfrFamilies.map(family => (
+
+ {family.render()}
+
+ ))}
+
+ );
+}
diff --git a/test/integration/next-app-router-streaming/app/ToggleSwitchShowcase.tsx b/test/integration/next-app-router-streaming/app/ToggleSwitchShowcase.tsx
new file mode 100644
index 000000000..d9e6f7fc9
--- /dev/null
+++ b/test/integration/next-app-router-streaming/app/ToggleSwitchShowcase.tsx
@@ -0,0 +1,13 @@
+"use client";
+
+// ToggleSwitch (src/ToggleSwitch.tsx) utilise useState/useEffect en interne
+// sans déclarer "use client" lui-même (à la différence de Range ou de
+// PasswordInput). L'utiliser directement depuis DsfrShowcase.tsx (Server
+// Component) fait échouer le rendu RSC ("useState only works in Client
+// Components"). On l'isole donc ici, conformément à la consigne du brief,
+// plutôt que de convertir tout le showcase en composant client.
+import { ToggleSwitch } from "@codegouvfr/react-dsfr/ToggleSwitch";
+
+export function ToggleSwitchShowcase() {
+ return ;
+}
diff --git a/test/integration/next-app-router-streaming/app/dsfr-bootstrap/defaultColorScheme.ts b/test/integration/next-app-router-streaming/app/dsfr-bootstrap/defaultColorScheme.ts
new file mode 100644
index 000000000..b00d4ea9a
--- /dev/null
+++ b/test/integration/next-app-router-streaming/app/dsfr-bootstrap/defaultColorScheme.ts
@@ -0,0 +1,3 @@
+import type { DefaultColorScheme } from "@codegouvfr/react-dsfr/next-app-router";
+
+export const defaultColorScheme: DefaultColorScheme = "system";
diff --git a/test/integration/next-app-router-streaming/app/dsfr-bootstrap/index.tsx b/test/integration/next-app-router-streaming/app/dsfr-bootstrap/index.tsx
new file mode 100644
index 000000000..f406bc4df
--- /dev/null
+++ b/test/integration/next-app-router-streaming/app/dsfr-bootstrap/index.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import Link from "next/link";
+import {
+ DsfrProviderBase,
+ type DsfrProviderProps
+} from "@codegouvfr/react-dsfr/next-app-router";
+import { defaultColorScheme } from "./defaultColorScheme";
+
+declare module "@codegouvfr/react-dsfr/next-app-router" {
+ interface RegisterLink {
+ Link: typeof Link;
+ }
+}
+
+export function DsfrProvider(props: DsfrProviderProps) {
+ return (
+
+ );
+}
+
+export { StartDsfrOnHydration } from "@codegouvfr/react-dsfr/next-app-router";
diff --git a/test/integration/next-app-router-streaming/app/dsfr-bootstrap/server-only.tsx b/test/integration/next-app-router-streaming/app/dsfr-bootstrap/server-only.tsx
new file mode 100644
index 000000000..6b9907e25
--- /dev/null
+++ b/test/integration/next-app-router-streaming/app/dsfr-bootstrap/server-only.tsx
@@ -0,0 +1,13 @@
+import Link from "next/link";
+import {
+ createGetHtmlAttributes,
+ DsfrHeadBase,
+ type DsfrHeadProps
+} from "@codegouvfr/react-dsfr/next-app-router/server-only-index";
+import { defaultColorScheme } from "./defaultColorScheme";
+
+export const { getHtmlAttributes } = createGetHtmlAttributes({ defaultColorScheme });
+
+export function DsfrHead(props: DsfrHeadProps) {
+ return ;
+}
diff --git a/test/integration/next-app-router-streaming/app/dsfr-families.tsx b/test/integration/next-app-router-streaming/app/dsfr-families.tsx
new file mode 100644
index 000000000..f703422e7
--- /dev/null
+++ b/test/integration/next-app-router-streaming/app/dsfr-families.tsx
@@ -0,0 +1,285 @@
+import type { ReactNode } from "react";
+import { Table } from "@codegouvfr/react-dsfr/Table";
+import { Accordion } from "@codegouvfr/react-dsfr/Accordion";
+import { Button } from "@codegouvfr/react-dsfr/Button";
+import { ButtonsGroup } from "@codegouvfr/react-dsfr/ButtonsGroup";
+import { Tabs } from "@codegouvfr/react-dsfr/Tabs";
+import { SegmentedControl } from "@codegouvfr/react-dsfr/SegmentedControl";
+import { Tooltip } from "@codegouvfr/react-dsfr/Tooltip";
+import { Range } from "@codegouvfr/react-dsfr/Range";
+import { Breadcrumb } from "@codegouvfr/react-dsfr/Breadcrumb";
+import { Card } from "@codegouvfr/react-dsfr/Card";
+import { Checkbox } from "@codegouvfr/react-dsfr/Checkbox";
+import { Display } from "@codegouvfr/react-dsfr/Display";
+import { Header } from "@codegouvfr/react-dsfr/Header";
+import { createModal } from "@codegouvfr/react-dsfr/Modal";
+import { MainNavigation } from "@codegouvfr/react-dsfr/MainNavigation";
+import { PasswordInput } from "@codegouvfr/react-dsfr/blocks/PasswordInput";
+import { SideMenu } from "@codegouvfr/react-dsfr/SideMenu";
+import { Tag } from "@codegouvfr/react-dsfr/Tag";
+import { Tile } from "@codegouvfr/react-dsfr/Tile";
+import { ToggleSwitchShowcase } from "./ToggleSwitchShowcase";
+
+// createModal() doit être appelé au niveau module, hors du composant
+// (voir src/Modal/Modal.tsx) : il enregistre un id DOM fixe partagé par
+// toutes les instances de la modale qu'il retourne.
+// isOpenedByDefault: true — pour exercer réellement le mécanisme
+// DisclosuresGroup (ModalsGroup) au chargement, à l'image de Tabs qui force
+// la sélection de l'onglet 0 (TabsGroup.getIndex(defaultIndex = 0)). Une
+// modale fermée par défaut ne force aucun index initial et ne teste donc
+// pas ce chemin de code.
+const testModal = createModal({
+ isOpenedByDefault: true,
+ id: "showcase-test-modal"
+});
+
+export type DsfrFamily = {
+ /** Utilisé pour data-testid={`famille-${id}`} et pour l'URL des routes d'isolation */
+ id: string;
+ render: () => ReactNode;
+};
+
+// Une entrée par famille moteur DSFR instrumentée. Chaque `render` est
+// utilisé à la fois par le showcase combiné (DsfrShowcase.tsx) et par les
+// routes d'isolation (app/streaming/family/[name], app/no-streaming/family/[name])
+// — nécessaires car le diff d'hydratation imprimé par React est tronqué
+// (« ... ») une fois toutes les familles réunies dans un seul arbre : voir
+// COVERAGE.md de la branche hydratation.
+export const dsfrFamilies: DsfrFamily[] = [
+ // -- familles déjà couvertes avant cette tâche --
+ {
+ id: "accordion",
+ render: () => Contenu de l'accordéon.
+ },
+ {
+ id: "accordion-expanded",
+ // Accordéon ouvert par défaut : react-dsfr rend le bouton avec
+ // aria-expanded="true" mais NE rend PAS fr-collapse--expanded sur le
+ // panneau. Le moteur doit donc l'y poser à l'initialisation. Cas
+ // volontairement couvert pour mesurer ce qu'il coûte en divergence
+ // d'hydratation.
+ render: () => (
+
+ Contenu de l'accordéon ouvert.
+
+ )
+ },
+ {
+ id: "table",
+ render: () => (
+
+ )
+ },
+ {
+ id: "tab",
+ // Les deux panneaux ont DÉLIBÉRÉMENT des hauteurs très différentes.
+ // Tous les panneaux d'un fr-tabs sont superposés dans le même flux :
+ // sans --tabs-height, le conteneur prend la hauteur du plus haut. Avec
+ // deux panneaux de même hauteur, ce défaut est strictement invisible —
+ // c'est ce qui l'avait laissé passer. Le panneau 2, non sélectionné, est
+ // donc beaucoup plus haut que le panneau 1.
+ render: () => (
+ Contenu 1, une seule ligne.
},
+ {
+ label: "Onglet 2",
+ content: (
+
+ {Array.from({ length: 12 }, (_, index) => (
+
+ Contenu 2, ligne {index + 1} — ce panneau est
+ volontairement beaucoup plus haut que le premier.
+
+
+
+ );
+}
diff --git a/test/integration/next-app-router-streaming/app/streaming-legacy/page.tsx b/test/integration/next-app-router-streaming/app/streaming-legacy/page.tsx
new file mode 100644
index 000000000..c1aa20d26
--- /dev/null
+++ b/test/integration/next-app-router-streaming/app/streaming-legacy/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+import { StartDsfrOnHydration } from "../dsfr-bootstrap";
+import { DsfrShowcase } from "../DsfrShowcase";
+
+export const dynamic = "force-dynamic";
+
+async function SlowShowcase() {
+ await new Promise(resolve => setTimeout(resolve, 1500));
+ return ;
+}
+
+export default function StreamingLegacyPage() {
+ return (
+
+
+
Shell immédiat
+ Chargement…}>
+
+
+
+ );
+}
diff --git a/test/integration/next-app-router-streaming/app/streaming/family/[name]/page.tsx b/test/integration/next-app-router-streaming/app/streaming/family/[name]/page.tsx
new file mode 100644
index 000000000..02af0d6c5
--- /dev/null
+++ b/test/integration/next-app-router-streaming/app/streaming/family/[name]/page.tsx
@@ -0,0 +1,42 @@
+import { Suspense } from "react";
+import { notFound } from "next/navigation";
+import { getDsfrFamily } from "../../../dsfr-families";
+
+export const dynamic = "force-dynamic";
+
+// Route de mesure (pas destinée à l'usage normal de l'app) : rend UNE seule
+// famille moteur DSFR, en streaming, isolée de toutes les autres. Nécessaire
+// parce que le diff d'hydratation imprimé par React est tronqué (« ... »)
+// une fois toutes les familles réunies dans /streaming — voir COVERAGE.md de la branche hydratation.
+async function SlowFamily({ name }: { name: string }) {
+ await new Promise(resolve => setTimeout(resolve, 1500));
+
+ const family = getDsfrFamily(name);
+
+ if (family === undefined) {
+ notFound();
+ }
+
+ return (
+
);
})
diff --git a/src/Tag.tsx b/src/Tag.tsx
index e33593a96..72a8b7be2 100644
--- a/src/Tag.tsx
+++ b/src/Tag.tsx
@@ -1,6 +1,7 @@
import React, {
memo,
forwardRef,
+ useId,
type ReactNode,
type RefAttributes,
type MemoExoticComponent,
@@ -17,7 +18,7 @@ import { assert } from "tsafe/assert";
import type { Equals } from "tsafe";
import { symToStr } from "tsafe/symToStr";
import { useAnalyticsId } from "./tools/useAnalyticsId";
-import { useHydrationGate } from "./tools/useHydrationGate";
+import { HydrationRelease } from "./tools/HydrationRelease";
type DataAttribute = Record<`data-${string}`, string | boolean | null | undefined>;
@@ -128,7 +129,7 @@ export const Tag = memo(
"explicitlyProvidedId": id_props
});
- const hydrationGate = useHydrationGate();
+ const hydrationId = useId();
const { Link } = getLink();
@@ -159,7 +160,6 @@ export const Tag = memo(
...linkProps?.style,
...style
}}
- {...hydrationGate}
ref={ref as React.ForwardedRef}
{...rest}
>
@@ -178,12 +178,13 @@ export const Tag = memo(
title={title ?? nativeButtonProps?.title}
onClick={onClick ?? nativeButtonProps?.onClick}
disabled={nativeButtonProps?.disabled}
- {...hydrationGate}
+ data-fr-hydrating={hydrationId}
ref={ref as React.ForwardedRef}
aria-pressed={pressed}
{...rest}
>
{children}
+
)}
{linkProps === undefined &&
@@ -198,7 +199,6 @@ export const Tag = memo(
...style
}}
title={title ?? nativeParagraphOrSpanProps?.title}
- {...hydrationGate}
ref={ref as React.ForwardedRef}
{...rest}
>
diff --git a/src/Tile.tsx b/src/Tile.tsx
index fcd8784a6..51a5c6c8f 100644
--- a/src/Tile.tsx
+++ b/src/Tile.tsx
@@ -1,5 +1,5 @@
import type { ComponentProps, CSSProperties, ReactNode } from "react";
-import React, { forwardRef, memo } from "react";
+import React, { forwardRef, useId, memo } from "react";
import type { Equals } from "tsafe";
import { assert } from "tsafe/assert";
import { symToStr } from "tsafe/symToStr";
@@ -8,7 +8,7 @@ import { fr } from "./fr";
import { getLink, type RegisteredLinkProps } from "./link";
import { cx } from "./tools/cx";
import { useAnalyticsId } from "./tools/useAnalyticsId";
-import { useHydrationGate } from "./tools/useHydrationGate";
+import { HydrationRelease } from "./tools/HydrationRelease";
//https://main--ds-gouv.netlify.app/example/component/tile/
export type TileProps = {
@@ -137,7 +137,7 @@ export const Tile = memo(
"explicitlyProvidedId": id_props
});
- const hydrationGate = useHydrationGate();
+ const hydrationId = useId();
return (
)}
+
);
})
diff --git a/src/ToggleSwitch.tsx b/src/ToggleSwitch.tsx
index 78be9143f..2a94ad9b8 100644
--- a/src/ToggleSwitch.tsx
+++ b/src/ToggleSwitch.tsx
@@ -1,4 +1,12 @@
-import React, { memo, forwardRef, ReactNode, useState, useEffect, type CSSProperties } from "react";
+import React, {
+ memo,
+ forwardRef,
+ useId,
+ ReactNode,
+ useState,
+ useEffect,
+ type CSSProperties
+} from "react";
import { symToStr } from "tsafe/symToStr";
import { assert } from "tsafe/assert";
import type { Equals } from "tsafe";
@@ -7,7 +15,7 @@ import { fr } from "./fr";
import { createComponentI18nApi } from "./i18n";
import { useConstCallback } from "./tools/powerhooks/useConstCallback";
import { useAnalyticsId } from "./tools/useAnalyticsId";
-import { useHydrationGate } from "./tools/useHydrationGate";
+import { HydrationRelease } from "./tools/HydrationRelease";
export type ToggleSwitchProps = ToggleSwitchProps.Controlled | ToggleSwitchProps.Uncontrolled;
@@ -70,7 +78,7 @@ export const ToggleSwitch = memo(
"explicitlyProvidedId": id_props
});
- const hydrationGate = useHydrationGate();
+ const hydrationId = useId();
const inputId = `${id}-input`;
@@ -109,7 +117,7 @@ export const ToggleSwitch = memo(
classes.root,
className
)}
- {...hydrationGate}
+ data-fr-hydrating={hydrationId}
ref={ref}
style={style}
>
@@ -139,6 +147,7 @@ export const ToggleSwitch = memo(
{helperText}
)}
+
);
})
diff --git a/src/Tooltip.tsx b/src/Tooltip.tsx
index eacf07b35..68f43acb7 100644
--- a/src/Tooltip.tsx
+++ b/src/Tooltip.tsx
@@ -1,10 +1,10 @@
-import React, { forwardRef, memo } from "react";
+import React, { forwardRef, useId, memo } from "react";
import type { ReactNode, CSSProperties } from "react";
import type { Equals } from "tsafe";
import { assert } from "tsafe/assert";
import { symToStr } from "tsafe/symToStr";
import { useAnalyticsId } from "./tools/useAnalyticsId";
-import { useHydrationGate } from "./tools/useHydrationGate";
+import { HydrationRelease } from "./tools/HydrationRelease";
import { createComponentI18nApi } from "./i18n";
import { fr } from "./fr";
import { cx } from "./tools/cx";
@@ -43,7 +43,7 @@ export const Tooltip = memo(
"explicitlyProvidedId": id_prop
});
- const hydrationGate = useHydrationGate();
+ const hydrationId = useId();
return (
<>
@@ -72,12 +72,13 @@ export const Tooltip = memo(
{title}
+
>
);
diff --git a/src/blocks/PasswordInput.tsx b/src/blocks/PasswordInput.tsx
index 69393f826..a83abc7cd 100644
--- a/src/blocks/PasswordInput.tsx
+++ b/src/blocks/PasswordInput.tsx
@@ -18,7 +18,7 @@ import type { InputProps } from "../Input";
import { cx } from "../tools/cx";
import type { FrClassName } from "../fr/generatedFromCss/classNames";
import { useAnalyticsId } from "../tools/useAnalyticsId";
-import { useHydrationGate } from "../tools/useHydrationGate";
+import { HydrationRelease } from "../tools/HydrationRelease";
export type PasswordInputProps = Omit<
InputProps.Common,
@@ -73,7 +73,7 @@ export const PasswordInput = memo(
"defaultIdPrefix": "password-input"
});
- const hydrationGate = useHydrationGate();
+ const hydrationId = useId();
const inputId = (function useClosure() {
const id = useId();
@@ -136,7 +136,7 @@ export const PasswordInput = memo(
)}
id={id}
style={style}
- {...hydrationGate}
+ data-fr-hydrating={hydrationId}
ref={ref}
{...rest}
>
@@ -213,6 +213,7 @@ export const PasswordInput = memo(
{t("show")}
+
);
})
diff --git a/src/next-app-router/DsfrProvider.tsx b/src/next-app-router/DsfrProvider.tsx
index 6808d47e9..0c6d417f8 100644
--- a/src/next-app-router/DsfrProvider.tsx
+++ b/src/next-app-router/DsfrProvider.tsx
@@ -62,9 +62,9 @@ export function DsfrProviderBase(
// Children effects run before their parent's: when this one fires, the
// shell is hydrated. Content streamed in later is not hydrated yet, but
- // react-dsfr components mark their root with data-fr-hydrating until they
- // are (see useHydrationGate), and the DSFR engine leaves such subtrees
- // alone. This is what makes optional.
+ // react-dsfr components mark their root with data-fr-hydrating, which the
+ // DSFR engine leaves alone until they release it once hydrated (see
+ // HydrationRelease). This is what makes optional.
useEffect(() => {
dsfrEffect();
}, []);
diff --git a/src/shared/Fieldset.tsx b/src/shared/Fieldset.tsx
index 4bad4cf66..cb8c0ec1a 100644
--- a/src/shared/Fieldset.tsx
+++ b/src/shared/Fieldset.tsx
@@ -12,7 +12,7 @@ import type { Equals } from "tsafe";
import { cx } from "../tools/cx";
import { fr } from "../fr";
import { useAnalyticsId } from "../tools/useAnalyticsId";
-import { useHydrationGate } from "../tools/useHydrationGate";
+import { HydrationRelease } from "../tools/HydrationRelease";
export type FieldsetProps = FieldsetProps.Radio | FieldsetProps.Checkbox;
@@ -93,7 +93,7 @@ export const Fieldset = memo(
"explicitlyProvidedId": id_props
});
- const hydrationGate = useHydrationGate();
+ const hydrationId = useId();
const getInputId = (i: number) => `${id}-${i}`;
@@ -132,7 +132,7 @@ export const Fieldset = memo(
isRoot ? className : undefined,
classes.inputGroup
)}
- {...(isRoot ? hydrationGate : {})}
+ data-fr-hydrating={isRoot ? hydrationId : undefined}
key={i}
>
@@ -145,6 +145,7 @@ export const Fieldset = memo(
{"illustration" in rest && (
{rest.illustration}
)}
+ {isRoot && }
);
};
@@ -191,7 +192,7 @@ export const Fieldset = memo(
aria-labelledby={cx(legend !== undefined && legendId, messagesWrapperId)}
role={state === "default" ? undefined : "group"}
{...rest}
- {...hydrationGate}
+ data-fr-hydrating={hydrationId}
ref={ref}
>
{legend !== undefined && (
@@ -238,6 +239,7 @@ export const Fieldset = memo(
)}
+
);
})
diff --git a/src/tools/HydrationRelease.tsx b/src/tools/HydrationRelease.tsx
new file mode 100644
index 000000000..0fde1ba48
--- /dev/null
+++ b/src/tools/HydrationRelease.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { useEffect } from "react";
+import { prDsfrLoaded } from "../start";
+
+/**
+ * Releases the subtree marked with `data-fr-hydrating={id}` once it is
+ * hydrated, so that the DSFR engine instruments it.
+ *
+ * Render it inside that subtree. It renders nothing, so it can be used by
+ * components rendered as Server Components: only it crosses the boundary,
+ * with a serializable id (`useId` works in Server Components).
+ */
+export function HydrationRelease(props: { id: string }) {
+ const { id } = props;
+
+ useEffect(() => {
+ // The engine module may not be loaded yet: the call would be lost.
+ prDsfrLoaded.then(() => {
+ // @ts-expect-error
+ window.dsfr.hydrated(id);
+ });
+ }, [id]);
+
+ return null;
+}
diff --git a/src/tools/useHydrationGate.ts b/src/tools/useHydrationGate.ts
deleted file mode 100644
index 3cc9cc462..000000000
--- a/src/tools/useHydrationGate.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import React from "react";
-
-type HydrationGateProps = { "data-fr-hydrating"?: true };
-
-const gateProps: HydrationGateProps = { "data-fr-hydrating": true };
-const noGateProps: HydrationGateProps = {};
-
-const unsubscribe = () => undefined;
-const subscribe = () => unsubscribe;
-const getSnapshot = () => true;
-const getServerSnapshot = () => false;
-
-// Absent from the react-server build of React. A component rendered as a
-// Server Component has no client effect that could ever lift the gate, so it
-// renders none and keeps its previous behavior.
-const { useSyncExternalStore } = React as Partial;
-
-/**
- * Props to spread on the root element of a component the DSFR engine
- * instruments. The engine leaves a subtree carrying `data-fr-hydrating` alone.
- *
- * The attribute is rendered on the server and kept while React hydrates the
- * component (server snapshot), then removed by the re-render React schedules
- * right after hydration (client snapshot). The engine observes that removal
- * and instruments the subtree. A component mounted on the client without
- * hydration never renders it.
- */
-export const useHydrationGate: () => HydrationGateProps =
- useSyncExternalStore === undefined
- ? () => noGateProps
- : function useHydrationGate() {
- const isHydrated = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
-
- return isHydrated ? noGateProps : gateProps;
- };
diff --git a/test/runtime/lib/hydrationRelease.test.ts b/test/runtime/lib/hydrationRelease.test.ts
new file mode 100644
index 000000000..bd7de2977
--- /dev/null
+++ b/test/runtime/lib/hydrationRelease.test.ts
@@ -0,0 +1,76 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import React from "react";
+import { renderToString } from "react-dom/server";
+import { hydrateRoot, createRoot } from "react-dom/client";
+import { act } from "react-dom/test-utils";
+import { Accordion } from "../../../src/Accordion";
+import { Table } from "../../../src/Table";
+
+// The real module starts the DSFR engine. Only the promise HydrationRelease
+// waits on matters here.
+vi.mock("../../../src/start", () => ({ "prDsfrLoaded": Promise.resolve() }));
+
+const cases = [
+ {
+ name: "Accordion (Client Component)",
+ element: React.createElement(Accordion, { label: "Title" }, "Content"),
+ rootSelector: ".fr-accordion"
+ },
+ {
+ name: "Table (usable as a Server Component)",
+ element: React.createElement(Table, { data: [["a1"]] }),
+ rootSelector: ".fr-table"
+ }
+];
+
+const flushPromises = () => new Promise(resolve => setTimeout(resolve, 0));
+
+beforeEach(() => {
+ (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+ (window as any).dsfr = { "hydrated": vi.fn() };
+ document.body.innerHTML = "";
+});
+
+describe.each(cases)("$name", ({ element, rootSelector }) => {
+ it("renders a gate carrying an id on the server", () => {
+ const container = document.createElement("div");
+ container.innerHTML = renderToString(element);
+
+ const id = container.querySelector(rootSelector)!.getAttribute("data-fr-hydrating");
+
+ expect(id).toMatch(/\S/);
+ expect(id).not.toBe("true");
+ });
+
+ it("releases that id once hydrated", async () => {
+ const container = document.createElement("div");
+ container.innerHTML = renderToString(element);
+ document.body.appendChild(container);
+
+ const id = container.querySelector(rootSelector)!.getAttribute("data-fr-hydrating");
+
+ expect((window as any).dsfr.hydrated).not.toHaveBeenCalled();
+
+ await act(async () => {
+ hydrateRoot(container, element);
+ });
+ await flushPromises();
+
+ expect((window as any).dsfr.hydrated).toHaveBeenCalledWith(id);
+ });
+
+ it("releases its id when mounted on the client without hydration", async () => {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+
+ await act(async () => {
+ createRoot(container).render(element);
+ });
+ await flushPromises();
+
+ const id = container.querySelector(rootSelector)!.getAttribute("data-fr-hydrating");
+
+ expect((window as any).dsfr.hydrated).toHaveBeenCalledWith(id);
+ });
+});
diff --git a/test/runtime/lib/useHydrationGate.test.ts b/test/runtime/lib/useHydrationGate.test.ts
deleted file mode 100644
index 99cb67df3..000000000
--- a/test/runtime/lib/useHydrationGate.test.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-// @vitest-environment jsdom
-import { describe, it, expect } from "vitest";
-import React from "react";
-import { renderToString } from "react-dom/server";
-import { hydrateRoot, createRoot } from "react-dom/client";
-import { act } from "react-dom/test-utils";
-import { Accordion } from "../../../src/Accordion";
-
-const element = React.createElement(Accordion, { label: "Title" }, "Content");
-
-const getGate = (container: HTMLElement) =>
- container.querySelector(".fr-accordion")!.getAttribute("data-fr-hydrating");
-
-describe("useHydrationGate", () => {
- it("renders the gate on the server", () => {
- const container = document.createElement("div");
- container.innerHTML = renderToString(element);
-
- expect(getGate(container)).toBe("true");
- });
-
- it("lifts the gate once the component is hydrated", async () => {
- const container = document.createElement("div");
- container.innerHTML = renderToString(element);
- document.body.appendChild(container);
-
- (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
-
- await act(async () => {
- hydrateRoot(container, element);
- });
-
- expect(getGate(container)).toBeNull();
- });
-
- it("never renders the gate when mounted on the client without hydration", async () => {
- const container = document.createElement("div");
- document.body.appendChild(container);
-
- (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
-
- const gateValues: (string | null)[] = [];
- const observer = new MutationObserver(() => {
- const accordion = container.querySelector(".fr-accordion");
- if (accordion !== null) gateValues.push(accordion.getAttribute("data-fr-hydrating"));
- });
- observer.observe(container, { childList: true, subtree: true, attributes: true });
-
- await act(async () => {
- createRoot(container).render(element);
- });
-
- observer.disconnect();
-
- expect(getGate(container)).toBeNull();
- expect(gateValues.filter(value => value !== null)).toEqual([]);
- });
-});
From 15966e1d1ae98a4eb4149045cc21b587d430fe93 Mon Sep 17 00:00:00 2001
From: Moroine Bentefrit
Date: Thu, 29 Oct 2026 00:15:18 +0100
Subject: [PATCH 08/10] =?UTF-8?q?test(e2e):=20/streaming=20en=20RSC=20pass?=
=?UTF-8?q?e,=20la=20modale=20ouverte=20par=20d=C3=A9faut=20est=20ferm?=
=?UTF-8?q?=C3=A9e=20avant=20les=20clics?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
Claude-Session: https://claude.ai/code/session_0153AaDDashSNpHjFMuVVv3M
---
test/e2e/streaming.spec.ts | 24 ++++++++++++-------
.../app/ClientShowcase.tsx | 5 ++--
2 files changed, 17 insertions(+), 12 deletions(-)
diff --git a/test/e2e/streaming.spec.ts b/test/e2e/streaming.spec.ts
index 4f66f079a..f664c6af7 100644
--- a/test/e2e/streaming.spec.ts
+++ b/test/e2e/streaming.spec.ts
@@ -18,8 +18,8 @@ function isHydrationMessage(text: string): boolean {
}
// /streaming rend les familles comme Server Components, /streaming-client
-// comme Client Components. Seuls ces derniers peuvent poser la porte
-// data-fr-hydrating : un Server Component n'a aucun effet client pour la lever.
+// comme Client Components. Dans les deux cas, chaque composant pose sa porte
+// data-fr-hydrating et la lève une fois hydraté (HydrationRelease).
for (const path of ["/streaming", "/streaming-client"]) {
test(`${path} : aucune divergence d'hydratation, moteur fonctionnel`, async ({ page }) => {
await expectStreamedShowcaseToWork(page, path);
@@ -58,13 +58,7 @@ async function expectStreamedShowcaseToWork(page: Page, path: string) {
expect(hydrationMessages).toEqual([]);
expect(pageErrors).toEqual([]);
- // La modale de la famille `modal` est ouverte par défaut
- // (isOpenedByDefault) et intercepte les clics sur le reste de la page.
- // Le moteur l'ouvre, puis la ferme sur Échap.
- const modal = page.locator("#showcase-test-modal");
- await expect(modal).toHaveClass(/fr-modal--opened/);
- await page.keyboard.press("Escape");
- await expect(modal).not.toHaveClass(/fr-modal--opened/);
+ await closeModalOpenedByDefault(page);
// Le moteur pilote bien le contenu arrivé tardivement.
const accordionButton = page.locator(".fr-accordion__btn").first();
@@ -95,6 +89,16 @@ async function expectStreamedShowcaseToWork(page: Page, path: string) {
await expect(rangeOutput).toHaveText("80");
}
+// La modale de la famille `modal` est ouverte par défaut (isOpenedByDefault)
+// et intercepte les clics sur le reste de la page. Le moteur l'ouvre, puis la
+// ferme sur Échap.
+async function closeModalOpenedByDefault(page: Page) {
+ const modal = page.locator("#showcase-test-modal");
+ await expect(modal).toHaveClass(/fr-modal--opened/);
+ await page.keyboard.press("Escape");
+ await expect(modal).not.toHaveClass(/fr-modal--opened/);
+}
+
test("StartDsfrOnHydration monté : toujours fonctionnel", async ({ page }) => {
const hydrationMessages: string[] = [];
// Le message React « Hydration failed... » n'est jamais émis via
@@ -121,6 +125,8 @@ test("StartDsfrOnHydration monté : toujours fonctionnel", async ({ page }) => {
expect(hydrationMessages).toEqual([]);
expect(pageErrors).toEqual([]);
+ await closeModalOpenedByDefault(page);
+
// dsfrEffect() est idempotent : le monter en plus de DsfrProvider ne doit
// rien casser. C'est le critère d'acceptation n°4 de la spec.
const accordionButton = page.locator(".fr-accordion__btn").first();
diff --git a/test/integration/next-app-router-streaming/app/ClientShowcase.tsx b/test/integration/next-app-router-streaming/app/ClientShowcase.tsx
index 50e00a02d..903ef8b15 100644
--- a/test/integration/next-app-router-streaming/app/ClientShowcase.tsx
+++ b/test/integration/next-app-router-streaming/app/ClientShowcase.tsx
@@ -4,9 +4,8 @@ import { DsfrShowcase } from "./DsfrShowcase";
import { getDsfrFamily } from "./dsfr-families";
// Mêmes familles que DsfrShowcase, rendues cette fois comme Client
-// Components. C'est le seul cas où les composants react-dsfr peuvent poser la
-// porte data-fr-hydrating : rendus comme Server Components, ils n'ont aucun
-// effet client pour la lever, et useHydrationGate n'en pose donc aucune.
+// Components : les deux chemins par lesquels un composant react-dsfr pose et
+// lève sa porte data-fr-hydrating doivent fonctionner.
export function ClientShowcase(props: { familyId?: string }) {
const { familyId } = props;
From e5ca9bb4a9f5bb81ccf564d1dcf3d9ee420a76a1 Mon Sep 17 00:00:00 2001
From: Moroine Bentefrit
Date: Thu, 29 Oct 2026 22:54:25 +0100
Subject: [PATCH 09/10] =?UTF-8?q?fix(dsfr-patch):=20une=20modale=20ouverte?=
=?UTF-8?q?=20par=20d=C3=A9faut=20qui=20arrive=20tard=20est=20ouverte?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
Claude-Session: https://claude.ai/code/session_0153AaDDashSNpHjFMuVVv3M
---
patches/@gouvfr+dsfr+1.14.2.patch | 28 +++++-
test/runtime/lib/dsfrHydrationGate.test.ts | 68 +------------
test/runtime/lib/dsfrModalsGroup.test.ts | 111 +++++++++++++++++++++
test/runtime/lib/loadDsfrEngine.ts | 78 +++++++++++++++
4 files changed, 217 insertions(+), 68 deletions(-)
create mode 100644 test/runtime/lib/dsfrModalsGroup.test.ts
create mode 100644 test/runtime/lib/loadDsfrEngine.ts
diff --git a/patches/@gouvfr+dsfr+1.14.2.patch b/patches/@gouvfr+dsfr+1.14.2.patch
index 5e52c09c2..ef165897d 100644
--- a/patches/@gouvfr+dsfr+1.14.2.patch
+++ b/patches/@gouvfr+dsfr+1.14.2.patch
@@ -8,7 +8,7 @@ index e4d335c..6c53dce 100644
+
diff --git a/node_modules/@gouvfr/dsfr/dist/dsfr.module.js b/node_modules/@gouvfr/dsfr/dist/dsfr.module.js
-index 0640386..2b996cb 100644
+index 0640386..8e30846 100644
--- a/node_modules/@gouvfr/dsfr/dist/dsfr.module.js
+++ b/node_modules/@gouvfr/dsfr/dist/dsfr.module.js
@@ -400,6 +400,24 @@ const queryParentSelector = (element, selectors) => {
@@ -71,7 +71,31 @@ index 0640386..2b996cb 100644
api$1.inspector = inspector;
api$1.colors = colors;
-@@ -6461,9 +6491,11 @@ class HeaderLinks extends api.core.Instance {
+@@ -2365,6 +2395,7 @@ class Disclosure extends Instance {
+
+ if (this.group) {
+ this.group.retrieve();
++ this.group.retrieveLate(this);
+ return;
+ }
+
+@@ -2627,6 +2658,15 @@ class DisclosuresGroup extends Instance {
+ return this.getIndex();
+ }
+
++ // Un membre qui rejoint le groupe après _retrieve() (contenu streamé, ou
++ // libéré par dsfr.hydrated) n'y a pas été vu : ouvert par défaut, il est
++ // ouvert, sauf si un autre membre l'est déjà.
++ retrieveLate (member) {
++ if (!this._hasRetrieved || !member.isInitiallyDisclosed) return;
++ if (!this.isGrouped) member.disclose(true);
++ else if (this.current === null) this.current = member;
++ }
++
+ update () {
+ this.getMembers();
+ if (this._hasRetrieved) {
+@@ -6461,9 +6501,11 @@ class HeaderLinks extends api.core.Instance {
case api.Modes.ANGULAR:
case api.Modes.REACT:
case api.Modes.VUE:
diff --git a/test/runtime/lib/dsfrHydrationGate.test.ts b/test/runtime/lib/dsfrHydrationGate.test.ts
index 802fcddd3..dd0a64b9c 100644
--- a/test/runtime/lib/dsfrHydrationGate.test.ts
+++ b/test/runtime/lib/dsfrHydrationGate.test.ts
@@ -1,22 +1,7 @@
// @vitest-environment node
import { describe, it, expect } from "vitest";
-import { JSDOM, type DOMWindow } from "jsdom";
-import * as fs from "fs";
-import { join as pathJoin } from "path";
-
-// The engine as patched by patch-package (prepare script), i.e. the one the
-// build copies into dist/dsfr/.
-const ENGINE_PATH = pathJoin(
- __dirname,
- "..",
- "..",
- "..",
- "node_modules",
- "@gouvfr",
- "dsfr",
- "dist",
- "dsfr.module.js"
-);
+import type { DOMWindow } from "jsdom";
+import { loadEngine, nextFrames } from "./loadDsfrEngine";
const accordionMarkup = (id: string) => `
@@ -27,55 +12,6 @@ const accordionMarkup = (id: string) => `
`;
-async function loadEngine(params: {
- markup: string;
- /** Called once the markup is in place, before the engine starts. */
- beforeStart?: (dsfr: any) => void;
-}) {
- const { markup, beforeStart } = params;
-
- const dom = new JSDOM(``, {
- pretendToBeVisual: true,
- runScripts: "outside-only",
- url: "https://example.test/"
- });
-
- const window = dom.window as DOMWindow;
-
- /* eslint-disable @typescript-eslint/no-empty-function */
- class ObserverStub {
- observe() {}
- unobserve() {}
- disconnect() {}
- }
-
- (window as any).ResizeObserver = ObserverStub;
- (window as any).IntersectionObserver = ObserverStub;
- (window as any).matchMedia = (query: string) => ({
- matches: false,
- media: query,
- addListener() {},
- removeListener() {},
- addEventListener() {},
- removeEventListener() {}
- });
- /* eslint-enable @typescript-eslint/no-empty-function */
-
- (window as any).dsfr = { verbose: false, mode: "react" };
-
- (window as any).eval(fs.readFileSync(ENGINE_PATH, "utf8"));
-
- window.document.body.innerHTML = markup;
- beforeStart?.((window as any).dsfr);
- (window as any).dsfr.start();
- await nextFrames();
-
- return { window, dsfr: (window as any).dsfr };
-}
-
-// Mutations are replayed by the engine on the next animation frame.
-const nextFrames = () => new Promise(resolve => setTimeout(resolve, 50));
-
const isInstrumented = (window: DOMWindow, id: string) =>
window.document.getElementById(id)!.hasAttribute("data-fr-js-accordion");
diff --git a/test/runtime/lib/dsfrModalsGroup.test.ts b/test/runtime/lib/dsfrModalsGroup.test.ts
new file mode 100644
index 000000000..866a64b2c
--- /dev/null
+++ b/test/runtime/lib/dsfrModalsGroup.test.ts
@@ -0,0 +1,111 @@
+// @vitest-environment node
+import { describe, it, expect } from "vitest";
+import type { DOMWindow } from "jsdom";
+import { loadEngine, nextFrames } from "./loadDsfrEngine";
+
+// What createModal() renders: a control button, outside the dialog, whose
+// data-fr-opened carries isOpenedByDefault.
+const modalMarkup = (id: string, params: { isOpenedByDefault: boolean }) => `
+
+
+`;
+
+const isOpened = (window: DOMWindow, id: string) =>
+ window.document.getElementById(id)!.classList.contains("fr-modal--opened");
+
+const append = (window: DOMWindow, markup: string) => {
+ const container = window.document.createElement("div");
+ container.innerHTML = markup;
+ window.document.body.appendChild(container);
+};
+
+// Primary buttons are elected on a later frame than the modal instance.
+const settle = async () => {
+ await nextFrames();
+ await nextFrames();
+};
+
+describe("@gouvfr/dsfr engine, modal opened by default", () => {
+ it("opens it when it is present at start (unchanged behavior)", async () => {
+ const { window } = await loadEngine({
+ markup: modalMarkup("a", { isOpenedByDefault: true })
+ });
+ await settle();
+
+ expect(isOpened(window, "a")).toBe(true);
+
+ window.close();
+ });
+
+ it("opens it when it arrives after the other modals (streaming)", async () => {
+ const { window } = await loadEngine({
+ markup: modalMarkup("a", { isOpenedByDefault: false })
+ });
+ await settle();
+
+ append(window, modalMarkup("b", { isOpenedByDefault: true }));
+ await settle();
+
+ expect(isOpened(window, "b")).toBe(true);
+ expect(isOpened(window, "a")).toBe(false);
+
+ window.close();
+ });
+
+ it("opens it when its gate is released after the other modals", async () => {
+ const { window, dsfr } = await loadEngine({
+ markup: `
+ ${modalMarkup("a", { isOpenedByDefault: false })}
+
${modalMarkup("b", { isOpenedByDefault: true })}
+ `
+ });
+ await settle();
+
+ expect(isOpened(window, "b")).toBe(false);
+
+ dsfr.hydrated("g");
+ await settle();
+
+ expect(isOpened(window, "b")).toBe(true);
+
+ window.close();
+ });
+
+ it("keeps the modal already open when a late one is opened by default", async () => {
+ const { window } = await loadEngine({
+ markup: modalMarkup("a", { isOpenedByDefault: true })
+ });
+ await settle();
+
+ append(window, modalMarkup("b", { isOpenedByDefault: true }));
+ await settle();
+
+ expect(isOpened(window, "a")).toBe(true);
+ expect(isOpened(window, "b")).toBe(false);
+
+ window.close();
+ });
+
+ it("does not reopen a modal the user closed when another modal arrives", async () => {
+ const { window, dsfr } = await loadEngine({
+ markup: modalMarkup("a", { isOpenedByDefault: true })
+ });
+ await settle();
+
+ dsfr(window.document.getElementById("a")).modal.conceal();
+ await settle();
+
+ append(window, modalMarkup("c", { isOpenedByDefault: false }));
+ await settle();
+
+ expect(isOpened(window, "a")).toBe(false);
+ expect(isOpened(window, "c")).toBe(false);
+
+ window.close();
+ });
+});
diff --git a/test/runtime/lib/loadDsfrEngine.ts b/test/runtime/lib/loadDsfrEngine.ts
new file mode 100644
index 000000000..43672d792
--- /dev/null
+++ b/test/runtime/lib/loadDsfrEngine.ts
@@ -0,0 +1,78 @@
+import { JSDOM, type DOMWindow } from "jsdom";
+import * as fs from "fs";
+import { join as pathJoin } from "path";
+
+// The engine as patched by patch-package (prepare script), i.e. the one the
+// build copies into dist/dsfr/.
+const ENGINE_PATH = pathJoin(
+ __dirname,
+ "..",
+ "..",
+ "..",
+ "node_modules",
+ "@gouvfr",
+ "dsfr",
+ "dist",
+ "dsfr.module.js"
+);
+
+/** Loads the DSFR engine in a fresh jsdom window, in react mode, and starts it. */
+export async function loadEngine(params: {
+ markup: string;
+ /** Called once the markup is in place, before the engine starts. */
+ beforeStart?: (dsfr: any) => void;
+}) {
+ const { markup, beforeStart } = params;
+
+ const dom = new JSDOM(``, {
+ pretendToBeVisual: true,
+ runScripts: "outside-only",
+ url: "https://example.test/"
+ });
+
+ const window = dom.window as DOMWindow;
+
+ /* eslint-disable @typescript-eslint/no-empty-function */
+ class ObserverStub {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ }
+
+ (window as any).ResizeObserver = ObserverStub;
+ (window as any).IntersectionObserver = ObserverStub;
+ (window as any).matchMedia = (query: string) => ({
+ matches: false,
+ media: query,
+ addListener() {},
+ removeListener() {},
+ addEventListener() {},
+ removeEventListener() {}
+ });
+ // Called by the engine when a modal opens; not implemented by jsdom.
+ (window as any).scrollTo = () => {};
+ /* eslint-enable @typescript-eslint/no-empty-function */
+
+ (window as any).dsfr = { verbose: false, mode: "react" };
+
+ (window as any).eval(fs.readFileSync(ENGINE_PATH, "utf8"));
+
+ // Stop the engine first, so that its pending frames and observers do not
+ // run against a torn down document.
+ const close = window.close.bind(window);
+ window.close = () => {
+ (window as any).dsfr.stop();
+ close();
+ };
+
+ window.document.body.innerHTML = markup;
+ beforeStart?.((window as any).dsfr);
+ (window as any).dsfr.start();
+ await nextFrames();
+
+ return { window, dsfr: (window as any).dsfr };
+}
+
+// Mutations and deferred work are replayed by the engine on the next
+// animation frames.
+export const nextFrames = () => new Promise(resolve => setTimeout(resolve, 50));
From f8cababc6ffa59222a6a2f4807199706c5c74150 Mon Sep 17 00:00:00 2001
From: Moroine Bentefrit
Date: Thu, 29 Oct 2026 22:55:17 +0100
Subject: [PATCH 10/10] =?UTF-8?q?test:=20l'app=20de=20repro=20et=20l'e2e?=
=?UTF-8?q?=20d=C3=A9m=C3=A9nagent=20dans=20un=20fork=20de=20la=20d=C3=A9m?=
=?UTF-8?q?o=20Next?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
Claude-Session: https://claude.ai/code/session_0153AaDDashSNpHjFMuVVv3M
---
test/e2e/.gitignore | 3 -
test/e2e/README.md | 21 -
test/e2e/package.json | 11 -
test/e2e/playwright.config.ts | 54 --
test/e2e/streaming.spec.ts | 231 --------
test/e2e/yarn.lock | 29 -
.../next-app-router-streaming/.gitignore | 37 --
.../app/ClientShowcase.tsx | 27 -
.../app/DsfrShowcase.tsx | 13 -
.../app/ToggleSwitchShowcase.tsx | 13 -
.../app/dsfr-bootstrap/defaultColorScheme.ts | 3 -
.../app/dsfr-bootstrap/index.tsx | 22 -
.../app/dsfr-bootstrap/server-only.tsx | 13 -
.../app/dsfr-families.tsx | 285 ---------
.../next-app-router-streaming/app/layout.tsx | 19 -
.../app/no-streaming/family/[name]/page.tsx | 29 -
.../app/no-streaming/page.tsx | 12 -
.../streaming-client/family/[name]/page.tsx | 35 --
.../app/streaming-client/page.tsx | 21 -
.../app/streaming-legacy/page.tsx | 22 -
.../app/streaming/family/[name]/page.tsx | 42 --
.../app/streaming/page.tsx | 20 -
.../next-app-router-streaming/next-env.d.ts | 5 -
.../next-app-router-streaming/next.config.js | 25 -
.../next-app-router-streaming/package.json | 23 -
.../next-app-router-streaming/tsconfig.json | 20 -
.../next-app-router-streaming/yarn.lock | 542 ------------------
27 files changed, 1577 deletions(-)
delete mode 100644 test/e2e/.gitignore
delete mode 100644 test/e2e/README.md
delete mode 100644 test/e2e/package.json
delete mode 100644 test/e2e/playwright.config.ts
delete mode 100644 test/e2e/streaming.spec.ts
delete mode 100644 test/e2e/yarn.lock
delete mode 100644 test/integration/next-app-router-streaming/.gitignore
delete mode 100644 test/integration/next-app-router-streaming/app/ClientShowcase.tsx
delete mode 100644 test/integration/next-app-router-streaming/app/DsfrShowcase.tsx
delete mode 100644 test/integration/next-app-router-streaming/app/ToggleSwitchShowcase.tsx
delete mode 100644 test/integration/next-app-router-streaming/app/dsfr-bootstrap/defaultColorScheme.ts
delete mode 100644 test/integration/next-app-router-streaming/app/dsfr-bootstrap/index.tsx
delete mode 100644 test/integration/next-app-router-streaming/app/dsfr-bootstrap/server-only.tsx
delete mode 100644 test/integration/next-app-router-streaming/app/dsfr-families.tsx
delete mode 100644 test/integration/next-app-router-streaming/app/layout.tsx
delete mode 100644 test/integration/next-app-router-streaming/app/no-streaming/family/[name]/page.tsx
delete mode 100644 test/integration/next-app-router-streaming/app/no-streaming/page.tsx
delete mode 100644 test/integration/next-app-router-streaming/app/streaming-client/family/[name]/page.tsx
delete mode 100644 test/integration/next-app-router-streaming/app/streaming-client/page.tsx
delete mode 100644 test/integration/next-app-router-streaming/app/streaming-legacy/page.tsx
delete mode 100644 test/integration/next-app-router-streaming/app/streaming/family/[name]/page.tsx
delete mode 100644 test/integration/next-app-router-streaming/app/streaming/page.tsx
delete mode 100644 test/integration/next-app-router-streaming/next-env.d.ts
delete mode 100644 test/integration/next-app-router-streaming/next.config.js
delete mode 100644 test/integration/next-app-router-streaming/package.json
delete mode 100644 test/integration/next-app-router-streaming/tsconfig.json
delete mode 100644 test/integration/next-app-router-streaming/yarn.lock
diff --git a/test/e2e/.gitignore b/test/e2e/.gitignore
deleted file mode 100644
index db2d12ff4..000000000
--- a/test/e2e/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-/node_modules
-/test-results
-/playwright-report
diff --git a/test/e2e/README.md b/test/e2e/README.md
deleted file mode 100644
index ca3623024..000000000
--- a/test/e2e/README.md
+++ /dev/null
@@ -1,21 +0,0 @@
-# Tests e2e
-
-Ces tests ne font pas partie de la CI : ils demandent un build Next et un
-Chrome installé. Ils servent à prouver le correctif et à documenter la PR.
-
-## Prérequis
-
-- Chrome installé (`/usr/bin/google-chrome`)
-- La bibliothèque construite : `npx --yes yarn@1.22.22 build` à la racine
-- L'application de reproduction construite :
- `cd test/integration/next-app-router-streaming && npx --yes yarn@1.22.22 build`
-
-## Exécution
-
-```bash
-cd test/e2e
-npx --yes yarn@1.22.22 install
-npx playwright test
-```
-
-Le serveur Next est démarré automatiquement par Playwright sur le port 3100.
diff --git a/test/e2e/package.json b/test/e2e/package.json
deleted file mode 100644
index 1ae1a8526..000000000
--- a/test/e2e/package.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "name": "react-dsfr-e2e",
- "version": "0.1.0",
- "private": true,
- "scripts": {
- "test": "playwright test"
- },
- "devDependencies": {
- "@playwright/test": "^1.49.0"
- }
-}
diff --git a/test/e2e/playwright.config.ts b/test/e2e/playwright.config.ts
deleted file mode 100644
index 5bb0a8648..000000000
--- a/test/e2e/playwright.config.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { defineConfig } from "@playwright/test";
-import { rmSync } from "fs";
-import { join as pathJoin } from "path";
-
-const appDirPath = pathJoin(__dirname, "..", "integration", "next-app-router-streaming");
-
-// Le cache webpack persistant de Next.js (`.next/cache`) sert indéfiniment la
-// version compilée d'une dépendance `file:` : `@codegouvfr/react-dsfr` est
-// recopié par `yarn install --force` sans changer de numéro de version, et
-// webpack considère `node_modules` comme immuable à version constante. Un
-// serveur de dev démarré avant un cycle build/install peut donc livrer au
-// navigateur un chunk d'AVANT le patch, sans le moindre signe extérieur.
-// Ce piège a coûté une enquête entière : voir
-// test/integration/next-app-router-streaming/LOAD-EFFECT.md, branche hydratation.
-// D'où les deux mesures ci-dessous, indissociables :
-// - `.next` est supprimé au chargement de cette configuration, donc avant que
-// Playwright ne démarre le serveur ;
-// - `reuseExistingServer` est à false, pour qu'aucun serveur déjà lancé
-// (donc déjà compilé, éventuellement à partir d'un `.next` périmé) ne
-// puisse servir la mesure.
-// `TEST_WORKER_INDEX` n'est défini que dans les processus « worker », qui
-// rechargent eux aussi cette configuration — alors que le serveur de dev
-// tourne déjà. Sans ce garde, chaque worker supprimerait `.next` sous les
-// pieds du serveur, qui répondrait alors
-// « missing required error components, refreshing... ».
-if (process.env.TEST_WORKER_INDEX === undefined) {
- rmSync(pathJoin(appDirPath, ".next"), { recursive: true, force: true });
-}
-
-export default defineConfig({
- testDir: ".",
- // Le serveur de dev compile à la première requête, en plus du délai de
- // streaming de 1,5 s. Le `.next` étant systématiquement supprimé, la
- // toute première compilation est toujours à froid.
- timeout: 120_000,
- use: {
- baseURL: "http://localhost:3100",
- // Chrome système : évite le téléchargement d'un navigateur.
- channel: "chrome"
- },
- webServer: {
- // Serveur de DÉVELOPPEMENT, délibérément. La mesure de la Task 1 a
- // établi qu'en production les trois routes sont strictement
- // silencieuses, avec ou sans le correctif : React ne remonte le diff
- // d'hydratation qu'en développement. Un e2e visant `next start` ne
- // pourrait donc ni observer le défaut ni démontrer sa correction —
- // il passerait à vide dans les deux sens.
- command: "npx --yes yarn@1.22.22 dev",
- cwd: appDirPath,
- url: "http://localhost:3100/no-streaming",
- reuseExistingServer: false,
- timeout: 180_000
- }
-});
diff --git a/test/e2e/streaming.spec.ts b/test/e2e/streaming.spec.ts
deleted file mode 100644
index f664c6af7..000000000
--- a/test/e2e/streaming.spec.ts
+++ /dev/null
@@ -1,231 +0,0 @@
-import { test, expect, type ConsoleMessage, type Page } from "@playwright/test";
-
-// React 19 a reformulé le message : il dit « didn't match », pas
-// « did not match ». Un motif qui ne couvrirait que l'ancienne formulation
-// laisserait passer le seul message que cette app produit réellement, et le
-// test passerait à vide. Mesuré en Task 1.
-const HYDRATION_PATTERNS = [
- "A tree hydrated but some attributes",
- "didn't match",
- "did not match",
- "Extra attributes from the server",
- "Hydration failed",
- "There was an error while hydrating"
-];
-
-function isHydrationMessage(text: string): boolean {
- return HYDRATION_PATTERNS.some(pattern => text.includes(pattern));
-}
-
-// /streaming rend les familles comme Server Components, /streaming-client
-// comme Client Components. Dans les deux cas, chaque composant pose sa porte
-// data-fr-hydrating et la lève une fois hydraté (HydrationRelease).
-for (const path of ["/streaming", "/streaming-client"]) {
- test(`${path} : aucune divergence d'hydratation, moteur fonctionnel`, async ({ page }) => {
- await expectStreamedShowcaseToWork(page, path);
- });
-}
-
-async function expectStreamedShowcaseToWork(page: Page, path: string) {
- const hydrationMessages: string[] = [];
- const pageErrors: string[] = [];
-
- page.on("console", (message: ConsoleMessage) => {
- const text = message.text();
- if (isHydrationMessage(text)) {
- hydrationMessages.push(text);
- }
- });
- page.on("pageerror", error => {
- pageErrors.push(error.message);
- });
-
- // "commit" (et non le "load" par défaut) : la réponse streamée ne
- // termine (événement "load") qu'une fois le Suspense résolu, ce qui
- // masquerait l'état intermédiaire (shell + fallback) qu'on veut observer
- // ici. "commit" rend la main dès que les en-têtes arrivent, et laisse
- // les assertions Playwright ci-dessous attendre chaque état par
- // auto-retry.
- await page.goto(path, { waitUntil: "commit" });
-
- // Le shell arrive avant le contenu streamé.
- await expect(page.getByTestId("shell")).toBeVisible();
- await expect(page.getByTestId("fallback")).toBeVisible();
-
- // Puis le boundary se résout.
- await expect(page.getByTestId("dsfr-showcase")).toBeVisible({ timeout: 30_000 });
-
- expect(hydrationMessages).toEqual([]);
- expect(pageErrors).toEqual([]);
-
- await closeModalOpenedByDefault(page);
-
- // Le moteur pilote bien le contenu arrivé tardivement.
- const accordionButton = page.locator(".fr-accordion__btn").first();
- await expect(accordionButton).toHaveAttribute("aria-expanded", "false");
- await accordionButton.click();
- await expect(accordionButton).toHaveAttribute("aria-expanded", "true");
-
- // Les onglets réagissent.
- const secondTab = page.locator(".fr-tabs__tab").nth(1);
- await secondTab.click();
- await expect(secondTab).toHaveAttribute("aria-selected", "true");
-
- // La table a bien survécu à l'hydratation (contenu, pas seulement
- // absence de message).
- const table = page.getByTestId("dsfr-showcase").locator("table");
- await expect(table.locator("caption")).toHaveText("Table de test");
- await expect(table.locator("tbody tr")).toHaveCount(2);
- await expect(table.locator("tbody tr").first().locator("td").first()).toHaveText("a1");
-
- // Le curseur est bien piloté par le moteur DSFR après hydratation : sa
- // sortie est renseignée à l'initialisation puis suit l'interaction
- // utilisateur.
- const rangeInput = page.locator("input[type=range]").first();
- const rangeOutput = page.locator(".fr-range__output").first();
- await expect(rangeOutput).not.toHaveText("");
- await rangeInput.fill("80");
- await rangeInput.dispatchEvent("input");
- await expect(rangeOutput).toHaveText("80");
-}
-
-// La modale de la famille `modal` est ouverte par défaut (isOpenedByDefault)
-// et intercepte les clics sur le reste de la page. Le moteur l'ouvre, puis la
-// ferme sur Échap.
-async function closeModalOpenedByDefault(page: Page) {
- const modal = page.locator("#showcase-test-modal");
- await expect(modal).toHaveClass(/fr-modal--opened/);
- await page.keyboard.press("Escape");
- await expect(modal).not.toHaveClass(/fr-modal--opened/);
-}
-
-test("StartDsfrOnHydration monté : toujours fonctionnel", async ({ page }) => {
- const hydrationMessages: string[] = [];
- // Le message React « Hydration failed... » n'est jamais émis via
- // console : c'est une exception, remontée uniquement via
- // l'événement "pageerror". Le premier test le vérifie déjà ; on le
- // vérifie ici aussi pour ne pas laisser cette route sans ce filet —
- // React ne remonte pas toujours le même diagnostic par la même voie
- // sur les deux routes de streaming.
- const pageErrors: string[] = [];
-
- page.on("console", (message: ConsoleMessage) => {
- const text = message.text();
- if (isHydrationMessage(text)) {
- hydrationMessages.push(text);
- }
- });
- page.on("pageerror", error => {
- pageErrors.push(error.message);
- });
-
- await page.goto("/streaming-legacy");
- await expect(page.getByTestId("dsfr-showcase")).toBeVisible({ timeout: 30_000 });
-
- expect(hydrationMessages).toEqual([]);
- expect(pageErrors).toEqual([]);
-
- await closeModalOpenedByDefault(page);
-
- // dsfrEffect() est idempotent : le monter en plus de DsfrProvider ne doit
- // rien casser. C'est le critère d'acceptation n°4 de la spec.
- const accordionButton = page.locator(".fr-accordion__btn").first();
- await accordionButton.click();
- await expect(accordionButton).toHaveAttribute("aria-expanded", "true");
-});
-
-test("onglets : le conteneur prend la hauteur du panneau sélectionné", async ({ page }) => {
- // Tous les panneaux d'un fr-tabs sont superposés dans le même flux : sans
- // la variable --tabs-height, .fr-tabs reste en height:auto et prend la
- // hauteur du panneau le PLUS HAUT, laissant des centaines de pixels de vide
- // sous le panneau visible jusqu'au premier changement d'onglet. Les deux
- // panneaux de la famille `tab` ont délibérément des hauteurs très
- // différentes (voir app/dsfr-families.tsx) : avec deux panneaux de même
- // hauteur, ce défaut est strictement invisible.
- //
- // Route d'ISOLATION, et non la page combinée : celle-ci porte une
- // divergence d'hydratation appariée non résolue sur `tab` (voir
- // COVERAGE.md de la branche hydratation), sans rapport avec la hauteur mesurée ici.
- await page.goto("/streaming/family/tab");
- await expect(page.getByTestId("dsfr-showcase")).toBeVisible({ timeout: 30_000 });
-
- const tabsGroup = page.locator(".fr-tabs").first();
-
- await expect
- .poll(() => tabsGroup.evaluate(node => node.style.getPropertyValue("--tabs-height")))
- .not.toBe("");
-
- const tallPanelHeight = await page
- .getByTestId("tab-panel-2-content")
- .evaluate(node => node.scrollHeight);
- const tabsHeight = (await tabsGroup.boundingBox())?.height ?? 0;
-
- // Le panneau non sélectionné est bien plus haut que le conteneur : le test
- // ne passerait pas à vide si les deux panneaux redevenaient identiques.
- expect(tallPanelHeight).toBeGreaterThan(300);
- expect(tabsHeight).toBeLessThan(tallPanelHeight);
-
- // Les onglets restent pilotables.
- const secondTab = page.locator(".fr-tabs__tab").nth(1);
- await secondTab.click();
- await expect(secondTab).toHaveAttribute("aria-selected", "true");
-});
-
-test("accordéon : la première ouverture est animée", async ({ page }) => {
- // Un fr-collapse non groupé — ce que rend CHAQUE , react-dsfr ne
- // rendant jamais fr-accordions-group — ne voit tourner aucun disclose() à
- // l'initialisation. Un chemin rapide conditionné à « aucun disclose n'a
- // encore tourné » serait donc pris au PREMIER CLIC UTILISATEUR, qui
- // s'ouvrirait alors d'un coup, sans animation.
- //
- // Le chemin animé passe par Collapse.collapsing(), qui appelle adjust() et
- // pose la variable --collapse ; le chemin rapide ne l'appelle pas. Cette
- // variable, à la différence de la classe transitoire fr-collapsing, n'est
- // jamais retirée ensuite : c'est une trace stable du chemin emprunté.
- await page.goto("/streaming/family/accordion");
- await expect(page.getByTestId("dsfr-showcase")).toBeVisible({ timeout: 30_000 });
-
- const accordionButton = page.locator(".fr-accordion__btn").first();
- const accordionPanel = page.locator(".fr-accordion .fr-collapse").first();
- const getCollapseProperty = () =>
- accordionPanel.evaluate(node => node.style.getPropertyValue("--collapse"));
-
- // Avant le clic : la synchronisation initiale n'a rien posé de transitoire.
- expect(await getCollapseProperty()).toBe("");
-
- await accordionButton.click();
-
- await expect.poll(getCollapseProperty).not.toBe("");
- await expect(accordionButton).toHaveAttribute("aria-expanded", "true");
-});
-
-test("sans streaming : aucune divergence d'hydratation", async ({ page }) => {
- const hydrationMessages: string[] = [];
- const pageErrors: string[] = [];
-
- page.on("console", (message: ConsoleMessage) => {
- const text = message.text();
- if (isHydrationMessage(text)) {
- hydrationMessages.push(text);
- }
- });
- page.on("pageerror", error => {
- pageErrors.push(error.message);
- });
-
- await page.goto("/no-streaming");
- await expect(page.getByTestId("dsfr-showcase")).toBeVisible();
-
- expect(hydrationMessages).toEqual([]);
- expect(pageErrors).toEqual([]);
-
- // Pas de streaming ici : tout l'arbre, y compris la table et le
- // curseur, est hydraté en un seul passage. On vérifie qu'ils sont
- // bien fonctionnels, pas seulement silencieux.
- const table = page.getByTestId("dsfr-showcase").locator("table");
- await expect(table.locator("caption")).toHaveText("Table de test");
- await expect(table.locator("tbody tr")).toHaveCount(2);
-
- const rangeOutput = page.locator(".fr-range__output").first();
- await expect(rangeOutput).not.toHaveText("");
-});
diff --git a/test/e2e/yarn.lock b/test/e2e/yarn.lock
deleted file mode 100644
index 128daecf1..000000000
--- a/test/e2e/yarn.lock
+++ /dev/null
@@ -1,29 +0,0 @@
-# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
-# yarn lockfile v1
-
-
-"@playwright/test@^1.49.0":
- version "1.62.1"
- resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.62.1.tgz#68e1aaf6e480e1923936dee6c66fae1921d3a65a"
- integrity sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==
- dependencies:
- playwright "1.62.1"
-
-fsevents@2.3.2:
- version "2.3.2"
- resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
- integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
-
-playwright-core@1.62.1:
- version "1.62.1"
- resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.62.1.tgz#120f67a19181bfd183c60fa903c0d99330b56785"
- integrity sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==
-
-playwright@1.62.1:
- version "1.62.1"
- resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.62.1.tgz#8447b6755e8aec85a3cb7207c823e3ed2fc66700"
- integrity sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==
- dependencies:
- playwright-core "1.62.1"
- optionalDependencies:
- fsevents "2.3.2"
diff --git a/test/integration/next-app-router-streaming/.gitignore b/test/integration/next-app-router-streaming/.gitignore
deleted file mode 100644
index 55e747e58..000000000
--- a/test/integration/next-app-router-streaming/.gitignore
+++ /dev/null
@@ -1,37 +0,0 @@
-# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
-
-# dependencies
-/node_modules
-/.pnp
-.pnp.js
-
-# testing
-/coverage
-
-# next.js
-/.next/
-/out/
-
-# production
-/build
-
-# typescript
-*.tsbuildinfo
-
-# misc
-.DS_Store
-*.pem
-
-# debug
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-
-# local env files
-.env.local
-.env.development.local
-.env.test.local
-.env.production.local
-
-# vercel
-.vercel
diff --git a/test/integration/next-app-router-streaming/app/ClientShowcase.tsx b/test/integration/next-app-router-streaming/app/ClientShowcase.tsx
deleted file mode 100644
index 903ef8b15..000000000
--- a/test/integration/next-app-router-streaming/app/ClientShowcase.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-"use client";
-
-import { DsfrShowcase } from "./DsfrShowcase";
-import { getDsfrFamily } from "./dsfr-families";
-
-// Mêmes familles que DsfrShowcase, rendues cette fois comme Client
-// Components : les deux chemins par lesquels un composant react-dsfr pose et
-// lève sa porte data-fr-hydrating doivent fonctionner.
-export function ClientShowcase(props: { familyId?: string }) {
- const { familyId } = props;
-
- if (familyId === undefined) {
- return ;
- }
-
- const family = getDsfrFamily(familyId);
-
- if (family === undefined) {
- return null;
- }
-
- return (
-
- );
-}
diff --git a/test/integration/next-app-router-streaming/app/ToggleSwitchShowcase.tsx b/test/integration/next-app-router-streaming/app/ToggleSwitchShowcase.tsx
deleted file mode 100644
index d9e6f7fc9..000000000
--- a/test/integration/next-app-router-streaming/app/ToggleSwitchShowcase.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-"use client";
-
-// ToggleSwitch (src/ToggleSwitch.tsx) utilise useState/useEffect en interne
-// sans déclarer "use client" lui-même (à la différence de Range ou de
-// PasswordInput). L'utiliser directement depuis DsfrShowcase.tsx (Server
-// Component) fait échouer le rendu RSC ("useState only works in Client
-// Components"). On l'isole donc ici, conformément à la consigne du brief,
-// plutôt que de convertir tout le showcase en composant client.
-import { ToggleSwitch } from "@codegouvfr/react-dsfr/ToggleSwitch";
-
-export function ToggleSwitchShowcase() {
- return ;
-}
diff --git a/test/integration/next-app-router-streaming/app/dsfr-bootstrap/defaultColorScheme.ts b/test/integration/next-app-router-streaming/app/dsfr-bootstrap/defaultColorScheme.ts
deleted file mode 100644
index b00d4ea9a..000000000
--- a/test/integration/next-app-router-streaming/app/dsfr-bootstrap/defaultColorScheme.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import type { DefaultColorScheme } from "@codegouvfr/react-dsfr/next-app-router";
-
-export const defaultColorScheme: DefaultColorScheme = "system";
diff --git a/test/integration/next-app-router-streaming/app/dsfr-bootstrap/index.tsx b/test/integration/next-app-router-streaming/app/dsfr-bootstrap/index.tsx
deleted file mode 100644
index f406bc4df..000000000
--- a/test/integration/next-app-router-streaming/app/dsfr-bootstrap/index.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-"use client";
-
-import Link from "next/link";
-import {
- DsfrProviderBase,
- type DsfrProviderProps
-} from "@codegouvfr/react-dsfr/next-app-router";
-import { defaultColorScheme } from "./defaultColorScheme";
-
-declare module "@codegouvfr/react-dsfr/next-app-router" {
- interface RegisterLink {
- Link: typeof Link;
- }
-}
-
-export function DsfrProvider(props: DsfrProviderProps) {
- return (
-
- );
-}
-
-export { StartDsfrOnHydration } from "@codegouvfr/react-dsfr/next-app-router";
diff --git a/test/integration/next-app-router-streaming/app/dsfr-bootstrap/server-only.tsx b/test/integration/next-app-router-streaming/app/dsfr-bootstrap/server-only.tsx
deleted file mode 100644
index 6b9907e25..000000000
--- a/test/integration/next-app-router-streaming/app/dsfr-bootstrap/server-only.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import Link from "next/link";
-import {
- createGetHtmlAttributes,
- DsfrHeadBase,
- type DsfrHeadProps
-} from "@codegouvfr/react-dsfr/next-app-router/server-only-index";
-import { defaultColorScheme } from "./defaultColorScheme";
-
-export const { getHtmlAttributes } = createGetHtmlAttributes({ defaultColorScheme });
-
-export function DsfrHead(props: DsfrHeadProps) {
- return ;
-}
diff --git a/test/integration/next-app-router-streaming/app/dsfr-families.tsx b/test/integration/next-app-router-streaming/app/dsfr-families.tsx
deleted file mode 100644
index f703422e7..000000000
--- a/test/integration/next-app-router-streaming/app/dsfr-families.tsx
+++ /dev/null
@@ -1,285 +0,0 @@
-import type { ReactNode } from "react";
-import { Table } from "@codegouvfr/react-dsfr/Table";
-import { Accordion } from "@codegouvfr/react-dsfr/Accordion";
-import { Button } from "@codegouvfr/react-dsfr/Button";
-import { ButtonsGroup } from "@codegouvfr/react-dsfr/ButtonsGroup";
-import { Tabs } from "@codegouvfr/react-dsfr/Tabs";
-import { SegmentedControl } from "@codegouvfr/react-dsfr/SegmentedControl";
-import { Tooltip } from "@codegouvfr/react-dsfr/Tooltip";
-import { Range } from "@codegouvfr/react-dsfr/Range";
-import { Breadcrumb } from "@codegouvfr/react-dsfr/Breadcrumb";
-import { Card } from "@codegouvfr/react-dsfr/Card";
-import { Checkbox } from "@codegouvfr/react-dsfr/Checkbox";
-import { Display } from "@codegouvfr/react-dsfr/Display";
-import { Header } from "@codegouvfr/react-dsfr/Header";
-import { createModal } from "@codegouvfr/react-dsfr/Modal";
-import { MainNavigation } from "@codegouvfr/react-dsfr/MainNavigation";
-import { PasswordInput } from "@codegouvfr/react-dsfr/blocks/PasswordInput";
-import { SideMenu } from "@codegouvfr/react-dsfr/SideMenu";
-import { Tag } from "@codegouvfr/react-dsfr/Tag";
-import { Tile } from "@codegouvfr/react-dsfr/Tile";
-import { ToggleSwitchShowcase } from "./ToggleSwitchShowcase";
-
-// createModal() doit être appelé au niveau module, hors du composant
-// (voir src/Modal/Modal.tsx) : il enregistre un id DOM fixe partagé par
-// toutes les instances de la modale qu'il retourne.
-// isOpenedByDefault: true — pour exercer réellement le mécanisme
-// DisclosuresGroup (ModalsGroup) au chargement, à l'image de Tabs qui force
-// la sélection de l'onglet 0 (TabsGroup.getIndex(defaultIndex = 0)). Une
-// modale fermée par défaut ne force aucun index initial et ne teste donc
-// pas ce chemin de code.
-const testModal = createModal({
- isOpenedByDefault: true,
- id: "showcase-test-modal"
-});
-
-export type DsfrFamily = {
- /** Utilisé pour data-testid={`famille-${id}`} et pour l'URL des routes d'isolation */
- id: string;
- render: () => ReactNode;
-};
-
-// Une entrée par famille moteur DSFR instrumentée. Chaque `render` est
-// utilisé à la fois par le showcase combiné (DsfrShowcase.tsx) et par les
-// routes d'isolation (app/streaming/family/[name], app/no-streaming/family/[name])
-// — nécessaires car le diff d'hydratation imprimé par React est tronqué
-// (« ... ») une fois toutes les familles réunies dans un seul arbre : voir
-// COVERAGE.md de la branche hydratation.
-export const dsfrFamilies: DsfrFamily[] = [
- // -- familles déjà couvertes avant cette tâche --
- {
- id: "accordion",
- render: () => Contenu de l'accordéon.
- },
- {
- id: "accordion-expanded",
- // Accordéon ouvert par défaut : react-dsfr rend le bouton avec
- // aria-expanded="true" mais NE rend PAS fr-collapse--expanded sur le
- // panneau. Le moteur doit donc l'y poser à l'initialisation. Cas
- // volontairement couvert pour mesurer ce qu'il coûte en divergence
- // d'hydratation.
- render: () => (
-
- Contenu de l'accordéon ouvert.
-
- )
- },
- {
- id: "table",
- render: () => (
-
- )
- },
- {
- id: "tab",
- // Les deux panneaux ont DÉLIBÉRÉMENT des hauteurs très différentes.
- // Tous les panneaux d'un fr-tabs sont superposés dans le même flux :
- // sans --tabs-height, le conteneur prend la hauteur du plus haut. Avec
- // deux panneaux de même hauteur, ce défaut est strictement invisible —
- // c'est ce qui l'avait laissé passer. Le panneau 2, non sélectionné, est
- // donc beaucoup plus haut que le panneau 1.
- render: () => (
- Contenu 1, une seule ligne. },
- {
- label: "Onglet 2",
- content: (
-
- {Array.from({ length: 12 }, (_, index) => (
-
- Contenu 2, ligne {index + 1} — ce panneau est
- volontairement beaucoup plus haut que le premier.
-
- Chargement…}>
-
-
-
- );
-}
diff --git a/test/integration/next-app-router-streaming/app/streaming/family/[name]/page.tsx b/test/integration/next-app-router-streaming/app/streaming/family/[name]/page.tsx
deleted file mode 100644
index 02af0d6c5..000000000
--- a/test/integration/next-app-router-streaming/app/streaming/family/[name]/page.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { Suspense } from "react";
-import { notFound } from "next/navigation";
-import { getDsfrFamily } from "../../../dsfr-families";
-
-export const dynamic = "force-dynamic";
-
-// Route de mesure (pas destinée à l'usage normal de l'app) : rend UNE seule
-// famille moteur DSFR, en streaming, isolée de toutes les autres. Nécessaire
-// parce que le diff d'hydratation imprimé par React est tronqué (« ... »)
-// une fois toutes les familles réunies dans /streaming — voir COVERAGE.md de la branche hydratation.
-async function SlowFamily({ name }: { name: string }) {
- await new Promise(resolve => setTimeout(resolve, 1500));
-
- const family = getDsfrFamily(name);
-
- if (family === undefined) {
- notFound();
- }
-
- return (
-