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. +

+ ))} +
+ ) + } + ]} + /> + ) + }, + { + id: "segmented", + render: () => ( + + ) + }, + { + id: "button", + render: () => ( + + ) + }, + { + id: "range", + render: () => + }, + { + id: "tooltip", + render: () => ( + <> + + + + ) + }, + + // -- familles ajoutées par cette tâche -- + { + id: "breadcrumb", + render: () => ( + + ) + }, + { + id: "card", + render: () => ( + + ) + }, + { + id: "checkbox", + render: () => ( + + ) + }, + { + id: "display", + render: () => + }, + { + id: "header", + render: () => ( +
+ Intitulé +
+ officiel + + } + homeLinkProps={{ href: "#", title: "Accueil" }} + serviceTitle="Service de test" + disableDisplay + quickAccessItems={[ + { + iconId: "fr-icon-lock-line", + text: "Se connecter", + linkProps: { href: "#" } + } + ]} + /> + ) + }, + { + id: "modal", + render: () => ( + <> + + Contenu de la modale. + + ) + }, + { + id: "navigation", + render: () => ( + + ) + }, + { + id: "password", + render: () => + }, + { + id: "sidemenu", + render: () => ( + + ) + }, + { + id: "tag", + render: () => ( + <> + + Tag pressable + + + Tag dismissible + + + ) + }, + { + id: "tile", + render: () => + }, + { + id: "toggle", + render: () => + } +]; + +export function getDsfrFamily(id: string): DsfrFamily | undefined { + return dsfrFamilies.find(family => family.id === id); +} diff --git a/test/integration/next-app-router-streaming/app/layout.tsx b/test/integration/next-app-router-streaming/app/layout.tsx new file mode 100644 index 000000000..dfdaf6b4e --- /dev/null +++ b/test/integration/next-app-router-streaming/app/layout.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from "react"; +import { DsfrProvider } from "./dsfr-bootstrap"; +import { DsfrHead, getHtmlAttributes } from "./dsfr-bootstrap/server-only"; + +export default function RootLayout({ children }: { children: ReactNode }) { + const lang = "fr"; + + return ( + + + Repro streaming DSFR + + + + {children} + + + ); +} diff --git a/test/integration/next-app-router-streaming/app/no-streaming/family/[name]/page.tsx b/test/integration/next-app-router-streaming/app/no-streaming/family/[name]/page.tsx new file mode 100644 index 000000000..e4309ea7e --- /dev/null +++ b/test/integration/next-app-router-streaming/app/no-streaming/family/[name]/page.tsx @@ -0,0 +1,29 @@ +import { notFound } from "next/navigation"; +import { getDsfrFamily } from "../../../dsfr-families"; + +export const dynamic = "force-dynamic"; + +// Pendant de /streaming/family/[name] mais sans Suspense/délai : sert de +// témoin par famille (voir COVERAGE.md de la branche hydratation). +export default async function NoStreamingFamilyPage({ + params +}: { + params: Promise<{ name: string }>; +}) { + const { name } = await params; + + const family = getDsfrFamily(name); + + if (family === undefined) { + notFound(); + } + + return ( +
+

Shell immédiat

+
+
{family.render()}
+
+
+ ); +} diff --git a/test/integration/next-app-router-streaming/app/no-streaming/page.tsx b/test/integration/next-app-router-streaming/app/no-streaming/page.tsx new file mode 100644 index 000000000..86100556d --- /dev/null +++ b/test/integration/next-app-router-streaming/app/no-streaming/page.tsx @@ -0,0 +1,12 @@ +import { DsfrShowcase } from "../DsfrShowcase"; + +export const dynamic = "force-dynamic"; + +export default function NoStreamingPage() { + return ( +
+

Shell immédiat

+ +
+ ); +} 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 ( +
+
{family.render()}
+
+ ); +} + +export default async function StreamingFamilyPage({ + params +}: { + params: Promise<{ name: string }>; +}) { + const { name } = await params; + + return ( +
+

Shell immédiat

+ Chargement…

}> + +
+
+ ); +} diff --git a/test/integration/next-app-router-streaming/app/streaming/page.tsx b/test/integration/next-app-router-streaming/app/streaming/page.tsx new file mode 100644 index 000000000..2fab6099b --- /dev/null +++ b/test/integration/next-app-router-streaming/app/streaming/page.tsx @@ -0,0 +1,20 @@ +import { Suspense } from "react"; +import { DsfrShowcase } from "../DsfrShowcase"; + +export const dynamic = "force-dynamic"; + +async function SlowShowcase() { + await new Promise(resolve => setTimeout(resolve, 1500)); + return ; +} + +export default function StreamingPage() { + return ( +
+

Shell immédiat

+ Chargement…

}> + +
+
+ ); +} diff --git a/test/integration/next-app-router-streaming/next-env.d.ts b/test/integration/next-app-router-streaming/next-env.d.ts new file mode 100644 index 000000000..1b3be0840 --- /dev/null +++ b/test/integration/next-app-router-streaming/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/test/integration/next-app-router-streaming/next.config.js b/test/integration/next-app-router-streaming/next.config.js new file mode 100644 index 000000000..531dfe28d --- /dev/null +++ b/test/integration/next-app-router-streaming/next.config.js @@ -0,0 +1,25 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: false, + // `dist/` est compilé avec les types React 18 du package.json racine du + // dépôt, alors que cette app de mesure tourne en React 19 (requis pour + // que nos propres Server Components async se typent correctement). Les + // deux exigences sont mutuellement incompatibles au niveau des types + // (`DsfrProviderBase` n'est pas assignable comme composant JSX sous + // React 19) sans que cela n'affecte la compilation ni le runtime — le + // webpack build réussit avant ce gate. On désactive donc uniquement le + // gate de typecheck du build ; voir MEASUREMENTS.md (branche hydratation) pour le détail et un + // `tsc --noEmit` séparé pour ne pas masquer nos propres erreurs de types. + typescript: { + ignoreBuildErrors: true + }, + webpack: config => { + config.module.rules.push({ + test: /\.woff2$/, + type: "asset/resource" + }); + return config; + } +}; + +module.exports = nextConfig; diff --git a/test/integration/next-app-router-streaming/package.json b/test/integration/next-app-router-streaming/package.json new file mode 100644 index 000000000..35a9ab750 --- /dev/null +++ b/test/integration/next-app-router-streaming/package.json @@ -0,0 +1,23 @@ +{ + "name": "next-app-router-streaming-repro", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "node --stack-size=8000 node_modules/.bin/next dev -p 3100", + "build": "node --stack-size=8000 node_modules/.bin/next build", + "start": "next start -p 3100" + }, + "dependencies": { + "@codegouvfr/react-dsfr": "file:../../../dist", + "next": "15.1.6", + "react": "19.0.0", + "react-dom": "19.0.0" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "@types/react": "19.0.0", + "@types/react-dom": "19.0.0", + "sass": "^1.62.1", + "typescript": "^5.3.0" + } +} diff --git a/test/integration/next-app-router-streaming/tsconfig.json b/test/integration/next-app-router-streaming/tsconfig.json new file mode 100644 index 000000000..26c8236a1 --- /dev/null +++ b/test/integration/next-app-router-streaming/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }] + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/test/integration/next-app-router-streaming/yarn.lock b/test/integration/next-app-router-streaming/yarn.lock new file mode 100644 index 000000000..67d6ee217 --- /dev/null +++ b/test/integration/next-app-router-streaming/yarn.lock @@ -0,0 +1,542 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@codegouvfr/react-dsfr@file:../../../dist": + version "1.34.0" + dependencies: + tsafe "^1.8.5" + yargs-parser "^21.1.1" + +"@emnapi/runtime@^1.2.0": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.11.3.tgz#84257ae3b0531eb2aec1ffa23d70700da007ba95" + integrity sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA== + dependencies: + tslib "^2.4.0" + +"@img/sharp-darwin-arm64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz#ef5b5a07862805f1e8145a377c8ba6e98813ca08" + integrity sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ== + optionalDependencies: + "@img/sharp-libvips-darwin-arm64" "1.0.4" + +"@img/sharp-darwin-x64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz#e03d3451cd9e664faa72948cc70a403ea4063d61" + integrity sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q== + optionalDependencies: + "@img/sharp-libvips-darwin-x64" "1.0.4" + +"@img/sharp-libvips-darwin-arm64@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz#447c5026700c01a993c7804eb8af5f6e9868c07f" + integrity sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg== + +"@img/sharp-libvips-darwin-x64@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz#e0456f8f7c623f9dbfbdc77383caa72281d86062" + integrity sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ== + +"@img/sharp-libvips-linux-arm64@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz#979b1c66c9a91f7ff2893556ef267f90ebe51704" + integrity sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA== + +"@img/sharp-libvips-linux-arm@1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz#99f922d4e15216ec205dcb6891b721bfd2884197" + integrity sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g== + +"@img/sharp-libvips-linux-s390x@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz#f8a5eb1f374a082f72b3f45e2fb25b8118a8a5ce" + integrity sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA== + +"@img/sharp-libvips-linux-x64@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz#d4c4619cdd157774906e15770ee119931c7ef5e0" + integrity sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw== + +"@img/sharp-libvips-linuxmusl-arm64@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz#166778da0f48dd2bded1fa3033cee6b588f0d5d5" + integrity sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA== + +"@img/sharp-libvips-linuxmusl-x64@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz#93794e4d7720b077fcad3e02982f2f1c246751ff" + integrity sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw== + +"@img/sharp-linux-arm64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz#edb0697e7a8279c9fc829a60fc35644c4839bb22" + integrity sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA== + optionalDependencies: + "@img/sharp-libvips-linux-arm64" "1.0.4" + +"@img/sharp-linux-arm@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz#422c1a352e7b5832842577dc51602bcd5b6f5eff" + integrity sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ== + optionalDependencies: + "@img/sharp-libvips-linux-arm" "1.0.5" + +"@img/sharp-linux-s390x@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz#f5c077926b48e97e4a04d004dfaf175972059667" + integrity sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q== + optionalDependencies: + "@img/sharp-libvips-linux-s390x" "1.0.4" + +"@img/sharp-linux-x64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz#d806e0afd71ae6775cc87f0da8f2d03a7c2209cb" + integrity sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA== + optionalDependencies: + "@img/sharp-libvips-linux-x64" "1.0.4" + +"@img/sharp-linuxmusl-arm64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz#252975b915894fb315af5deea174651e208d3d6b" + integrity sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g== + optionalDependencies: + "@img/sharp-libvips-linuxmusl-arm64" "1.0.4" + +"@img/sharp-linuxmusl-x64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz#3f4609ac5d8ef8ec7dadee80b560961a60fd4f48" + integrity sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw== + optionalDependencies: + "@img/sharp-libvips-linuxmusl-x64" "1.0.4" + +"@img/sharp-wasm32@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz#6f44f3283069d935bb5ca5813153572f3e6f61a1" + integrity sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg== + dependencies: + "@emnapi/runtime" "^1.2.0" + +"@img/sharp-win32-ia32@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz#1a0c839a40c5351e9885628c85f2e5dfd02b52a9" + integrity sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ== + +"@img/sharp-win32-x64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz#56f00962ff0c4e0eb93d34a047d29fa995e3e342" + integrity sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg== + +"@next/env@15.1.6": + version "15.1.6" + resolved "https://registry.yarnpkg.com/@next/env/-/env-15.1.6.tgz#2fa863d8c568a56b1c8328a86e621b8bdd4f2a20" + integrity sha512-d9AFQVPEYNr+aqokIiPLNK/MTyt3DWa/dpKveiAaVccUadFbhFEvY6FXYX2LJO2Hv7PHnLBu2oWwB4uBuHjr/w== + +"@next/swc-darwin-arm64@15.1.6": + version "15.1.6" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.1.6.tgz#92f99badab6cb41f4c5c11a3feffa574bd6a9276" + integrity sha512-u7lg4Mpl9qWpKgy6NzEkz/w0/keEHtOybmIl0ykgItBxEM5mYotS5PmqTpo+Rhg8FiOiWgwr8USxmKQkqLBCrw== + +"@next/swc-darwin-x64@15.1.6": + version "15.1.6" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.6.tgz#f56f4f8d5f6cb5d3915912ac95590d387f897da5" + integrity sha512-x1jGpbHbZoZ69nRuogGL2MYPLqohlhnT9OCU6E6QFewwup+z+M6r8oU47BTeJcWsF2sdBahp5cKiAcDbwwK/lg== + +"@next/swc-linux-arm64-gnu@15.1.6": + version "15.1.6" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.6.tgz#0aaffae519c93d1006419d7b98c34ebfd80ecacd" + integrity sha512-jar9sFw0XewXsBzPf9runGzoivajeWJUc/JkfbLTC4it9EhU8v7tCRLH7l5Y1ReTMN6zKJO0kKAGqDk8YSO2bg== + +"@next/swc-linux-arm64-musl@15.1.6": + version "15.1.6" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.6.tgz#e7398d3d31ca60033f708a718cd6c31edcee2e9a" + integrity sha512-+n3u//bfsrIaZch4cgOJ3tXCTbSxz0s6brJtU3SzLOvkJlPQMJ+eHVRi6qM2kKKKLuMY+tcau8XD9CJ1OjeSQQ== + +"@next/swc-linux-x64-gnu@15.1.6": + version "15.1.6" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.6.tgz#d76c72508f4d79d6016cab0c52640b93e590cffb" + integrity sha512-SpuDEXixM3PycniL4iVCLyUyvcl6Lt0mtv3am08sucskpG0tYkW1KlRhTgj4LI5ehyxriVVcfdoxuuP8csi3kQ== + +"@next/swc-linux-x64-musl@15.1.6": + version "15.1.6" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.6.tgz#0b8ba80a53e65bf8970ed11ea923001e2512c7cb" + integrity sha512-L4druWmdFSZIIRhF+G60API5sFB7suTbDRhYWSjiw0RbE+15igQvE2g2+S973pMGvwN3guw7cJUjA/TmbPWTHQ== + +"@next/swc-win32-arm64-msvc@15.1.6": + version "15.1.6" + resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.6.tgz#81b5dbbfdada2c05deef688e799af4a24097b65f" + integrity sha512-s8w6EeqNmi6gdvM19tqKKWbCyOBvXFbndkGHl+c9YrzsLARRdCHsD9S1fMj8gsXm9v8vhC8s3N8rjuC/XrtkEg== + +"@next/swc-win32-x64-msvc@15.1.6": + version "15.1.6" + resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.6.tgz#131993c45ffd124fb4b15258e2f3f9669c143e3c" + integrity sha512-6xomMuu54FAFxttYr5PJbEfu96godcxBTRk1OhAvJq0/EnmFU/Ybiax30Snis4vdWZ9LGpf7Roy5fSs7v/5ROQ== + +"@parcel/watcher-android-arm64@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz#99aaa3223d43807c9340af439cad7e9b6d26ada6" + integrity sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA== + +"@parcel/watcher-darwin-arm64@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz#024496e586b4744f09ce532bbe89fe38ef02a64e" + integrity sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw== + +"@parcel/watcher-darwin-x64@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz#a4621df1359a93d39a332d9bab5ff09016a0608f" + integrity sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw== + +"@parcel/watcher-freebsd-x64@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz#7f565ed1a5b3a5e604e6a4799121518265d62a3d" + integrity sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ== + +"@parcel/watcher-linux-arm-glibc@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz#ad7d3825e67b81999165da42593022045abc0889" + integrity sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg== + +"@parcel/watcher-linux-arm-musl@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz#fe7d1cccb2c483215c090e938cf5cf404d2f9a8c" + integrity sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw== + +"@parcel/watcher-linux-arm64-glibc@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz#7e239dcb4646c4c79f006a7131a48238249530da" + integrity sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g== + +"@parcel/watcher-linux-arm64-musl@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz#c58b8d9c6d8d81594be00dd83aab741c1aaf7e0e" + integrity sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA== + +"@parcel/watcher-linux-x64-glibc@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz#5184fa9a770478d86e56875f4ee163a0abdc8791" + integrity sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A== + +"@parcel/watcher-linux-x64-musl@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz#2d1c55aa7246cbc7670e2612058a8a542c9cf246" + integrity sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw== + +"@parcel/watcher-win32-arm64@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz#15e09432040fee9e2213aa9c10ed589012526def" + integrity sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ== + +"@parcel/watcher-win32-x64@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz#9bee199a2a4accd557b451ac2c1c793f305ae012" + integrity sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A== + +"@parcel/watcher@^2.4.1": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.6.0.tgz#99661f6220070b76a766aba6b7e313a087a1be4f" + integrity sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w== + dependencies: + detect-libc "^2.0.3" + is-glob "^4.0.3" + node-addon-api "^7.0.0" + picomatch "^4.0.4" + optionalDependencies: + "@parcel/watcher-android-arm64" "2.6.0" + "@parcel/watcher-darwin-arm64" "2.6.0" + "@parcel/watcher-darwin-x64" "2.6.0" + "@parcel/watcher-freebsd-x64" "2.6.0" + "@parcel/watcher-linux-arm-glibc" "2.6.0" + "@parcel/watcher-linux-arm-musl" "2.6.0" + "@parcel/watcher-linux-arm64-glibc" "2.6.0" + "@parcel/watcher-linux-arm64-musl" "2.6.0" + "@parcel/watcher-linux-x64-glibc" "2.6.0" + "@parcel/watcher-linux-x64-musl" "2.6.0" + "@parcel/watcher-win32-arm64" "2.6.0" + "@parcel/watcher-win32-x64" "2.6.0" + +"@swc/counter@0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@swc/counter/-/counter-0.1.3.tgz#cc7463bd02949611c6329596fccd2b0ec782b0e9" + integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ== + +"@swc/helpers@0.5.15": + version "0.5.15" + resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.15.tgz#79efab344c5819ecf83a43f3f9f811fc84b516d7" + integrity sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g== + dependencies: + tslib "^2.8.0" + +"@types/node@^20.11.0": + version "20.19.43" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.19.43.tgz#fcecf580ba42a0db55cf404c372c97973c376c97" + integrity sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA== + dependencies: + undici-types "~6.21.0" + +"@types/react-dom@19.0.0": + version "19.0.0" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.0.0.tgz#e7f5d618a080486eaf9952246dbf59eaa2c64130" + integrity sha512-1KfiQKsH1o00p9m5ag12axHQSb3FOU9H20UTrujVSkNhuCrRHiQWFqgEnTNK5ZNfnzZv8UWrnXVqCmCF9fgY3w== + dependencies: + "@types/react" "*" + +"@types/react@*", "@types/react@19.0.0": + version "19.0.0" + resolved "https://registry.yarnpkg.com/@types/react/-/react-19.0.0.tgz#fbbb53ce223f4e2b750ad5dd09580b2c43522bbf" + integrity sha512-MY3oPudxvMYyesqs/kW1Bh8y9VqSmf+tzqw3ae8a9DZW68pUe3zAdHeI1jc6iAysuRdACnVknHP8AhwD4/dxtg== + dependencies: + csstype "^3.0.2" + +busboy@1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" + integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== + dependencies: + streamsearch "^1.1.0" + +caniuse-lite@^1.0.30001579: + version "1.0.30001810" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz#4970b477dea3278374de9bc43aa8f5d39fc3cda2" + integrity sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg== + +chokidar@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-5.0.0.tgz#949c126a9238a80792be9a0265934f098af369a5" + integrity sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw== + dependencies: + readdirp "^5.0.0" + +client-only@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1" + integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@^1.0.0, color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +color-string@^1.9.0: + version "1.9.1" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.1.tgz#4467f9146f036f855b764dfb5bf8582bf342c7a4" + integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg== + dependencies: + color-name "^1.0.0" + simple-swizzle "^0.2.2" + +color@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/color/-/color-4.2.3.tgz#d781ecb5e57224ee43ea9627560107c0e0c6463a" + integrity sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A== + dependencies: + color-convert "^2.0.1" + color-string "^1.9.0" + +csstype@^3.0.2: + version "3.2.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" + integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== + +detect-libc@^2.0.3: + version "2.1.2" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" + integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== + +immutable@^5.1.5: + version "5.1.9" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.9.tgz#ac23c3a01992ab665e14ac9ffff298f28cd74a0c" + integrity sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg== + +is-arrayish@^0.3.1: + version "0.3.4" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.4.tgz#1ee5553818511915685d33bb13d31bf854e5059d" + integrity sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA== + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-glob@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +nanoid@^3.3.6: + version "3.3.18" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913" + integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w== + +next@15.1.6: + version "15.1.6" + resolved "https://registry.yarnpkg.com/next/-/next-15.1.6.tgz#ce22fd0a8f36da1fc4aba86e3ec7e98eb248c555" + integrity sha512-Hch4wzbaX0vKQtalpXvUiw5sYivBy4cm5rzUKrBnUB/y436LGrvOUqYvlSeNVCWFO/770gDlltR9gqZH62ct4Q== + dependencies: + "@next/env" "15.1.6" + "@swc/counter" "0.1.3" + "@swc/helpers" "0.5.15" + busboy "1.6.0" + caniuse-lite "^1.0.30001579" + postcss "8.4.31" + styled-jsx "5.1.6" + optionalDependencies: + "@next/swc-darwin-arm64" "15.1.6" + "@next/swc-darwin-x64" "15.1.6" + "@next/swc-linux-arm64-gnu" "15.1.6" + "@next/swc-linux-arm64-musl" "15.1.6" + "@next/swc-linux-x64-gnu" "15.1.6" + "@next/swc-linux-x64-musl" "15.1.6" + "@next/swc-win32-arm64-msvc" "15.1.6" + "@next/swc-win32-x64-msvc" "15.1.6" + sharp "^0.33.5" + +node-addon-api@^7.0.0: + version "7.1.1" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558" + integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ== + +picocolors@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +picomatch@^4.0.4: + version "4.0.7" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.7.tgz#6313360034ccb36b3dc61ecbdff78121f90fe21f" + integrity sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA== + +postcss@8.4.31: + version "8.4.31" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d" + integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ== + dependencies: + nanoid "^3.3.6" + picocolors "^1.0.0" + source-map-js "^1.0.2" + +react-dom@19.0.0: + version "19.0.0" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.0.0.tgz#43446f1f01c65a4cd7f7588083e686a6726cfb57" + integrity sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ== + dependencies: + scheduler "^0.25.0" + +react@19.0.0: + version "19.0.0" + resolved "https://registry.yarnpkg.com/react/-/react-19.0.0.tgz#6e1969251b9f108870aa4bff37a0ce9ddfaaabdd" + integrity sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ== + +readdirp@^5.0.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-5.1.1.tgz#520bca06f9d1ae1b96cc0800dbe84b983d19422c" + integrity sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA== + +sass@^1.62.1: + version "1.103.1" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.103.1.tgz#13b70f5ff69288db956dc27d27c8fb79f3a27c61" + integrity sha512-9icZURbP51S6S0QGoyaeqk9uB06GNWxsFYWfH5RgpFgqK5FA8tJcM3AdVxrZEVJ7dz+L87nG95gBKf4VuaMHGw== + dependencies: + chokidar "^5.0.0" + immutable "^5.1.5" + source-map-js ">=0.6.2 <2.0.0" + optionalDependencies: + "@parcel/watcher" "^2.4.1" + +scheduler@^0.25.0: + version "0.25.0" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.25.0.tgz#336cd9768e8cceebf52d3c80e3dcf5de23e7e015" + integrity sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA== + +semver@^7.6.3: + version "7.8.5" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69" + integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA== + +sharp@^0.33.5: + version "0.33.5" + resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.33.5.tgz#13e0e4130cc309d6a9497596715240b2ec0c594e" + integrity sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw== + dependencies: + color "^4.2.3" + detect-libc "^2.0.3" + semver "^7.6.3" + optionalDependencies: + "@img/sharp-darwin-arm64" "0.33.5" + "@img/sharp-darwin-x64" "0.33.5" + "@img/sharp-libvips-darwin-arm64" "1.0.4" + "@img/sharp-libvips-darwin-x64" "1.0.4" + "@img/sharp-libvips-linux-arm" "1.0.5" + "@img/sharp-libvips-linux-arm64" "1.0.4" + "@img/sharp-libvips-linux-s390x" "1.0.4" + "@img/sharp-libvips-linux-x64" "1.0.4" + "@img/sharp-libvips-linuxmusl-arm64" "1.0.4" + "@img/sharp-libvips-linuxmusl-x64" "1.0.4" + "@img/sharp-linux-arm" "0.33.5" + "@img/sharp-linux-arm64" "0.33.5" + "@img/sharp-linux-s390x" "0.33.5" + "@img/sharp-linux-x64" "0.33.5" + "@img/sharp-linuxmusl-arm64" "0.33.5" + "@img/sharp-linuxmusl-x64" "0.33.5" + "@img/sharp-wasm32" "0.33.5" + "@img/sharp-win32-ia32" "0.33.5" + "@img/sharp-win32-x64" "0.33.5" + +simple-swizzle@^0.2.2: + version "0.2.4" + resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.4.tgz#a8d11a45a11600d6a1ecdff6363329e3648c3667" + integrity sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw== + dependencies: + is-arrayish "^0.3.1" + +"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +streamsearch@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" + integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== + +styled-jsx@5.1.6: + version "5.1.6" + resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.1.6.tgz#83b90c077e6c6a80f7f5e8781d0f311b2fe41499" + integrity sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA== + dependencies: + client-only "0.0.1" + +tsafe@^1.8.5: + version "1.8.12" + resolved "https://registry.yarnpkg.com/tsafe/-/tsafe-1.8.12.tgz#68a410b4b7687ef497b1c85904b1215532335c3e" + integrity sha512-nFRqW0ttu/2o6XTXsHiVZWJBCOaxhVqZLg7dgs3coZNsCMPXPfwz+zPHAQA+70fNnVJLAPg1EgGIqK9Q84tvAw== + +tslib@^2.4.0, tslib@^2.8.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + +typescript@^5.3.0: + version "5.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== + +undici-types@~6.21.0: + version "6.21.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" + integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== + +yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== From f44a91b11bbf82984bb4caed6e1c49e51a2f4690 Mon Sep 17 00:00:00 2001 From: Moroine Bentefrit Date: Wed, 28 Oct 2026 22:34:25 +0100 Subject: [PATCH 02/10] =?UTF-8?q?feat(next-app-router):=20DsfrProvider=20d?= =?UTF-8?q?=C3=A9clenche=20l'effet=20d'hydratation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repris de la branche hydratation (1d07bda3, e4864998). Le contenu arrivé plus tard par streaming est protégé par la porte data-fr-hydrating, pas par le moment où le moteur démarre : StartDsfrOnHydration devient optionnel. Co-Authored-By: Claude Opus 5.5 --- src/next-app-router/DsfrProvider.tsx | 13 +- .../lib/dsfrProviderHydrationEffect.test.ts | 144 ++++++++++++++++++ 2 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 test/runtime/lib/dsfrProviderHydrationEffect.test.ts diff --git a/src/next-app-router/DsfrProvider.tsx b/src/next-app-router/DsfrProvider.tsx index a1d6799b7..6808d47e9 100644 --- a/src/next-app-router/DsfrProvider.tsx +++ b/src/next-app-router/DsfrProvider.tsx @@ -60,11 +60,14 @@ export function DsfrProviderBase( trustedTypesPolicyName = "react-dsfr" } = props; - /* + // 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. useEffect(() => { dsfrEffect(); }, []); - */ useMemo(() => { if (!isBrowser) { @@ -122,6 +125,12 @@ function dsfrEffect(): void { }); } +/** + * @deprecated This component is no longer needed: `DsfrProvider` now triggers + * the hydration effect itself. It remains exported and fully functional — + * `dsfrEffect()` is idempotent — so that applications already mounting it keep + * working. You can safely remove it from your pages. + */ export function StartDsfrOnHydration() { useEffect(() => { dsfrEffect(); diff --git a/test/runtime/lib/dsfrProviderHydrationEffect.test.ts b/test/runtime/lib/dsfrProviderHydrationEffect.test.ts new file mode 100644 index 000000000..8ad08e24d --- /dev/null +++ b/test/runtime/lib/dsfrProviderHydrationEffect.test.ts @@ -0,0 +1,144 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from "vitest"; +import * as fs from "fs"; +import { join as pathJoin } from "path"; +import React from "react"; +import { renderToString } from "react-dom/server"; +import { hydrateRoot } from "react-dom/client"; +import { act } from "react-dom/test-utils"; + +// Le module `start` est remplacé : le vrai démarre le moteur DSFR (import +// dynamique du bundle, écritures dans le DOM), hors sujet ici. Ce qui est +// testé est le contrat observable du provider : une action enregistrée via +// `registerEffectAction` avant l'hydratation n'est exécutée qu'une fois +// l'effet du provider déclenché, c'est-à-dire après hydratation. +// La fabrique ne référence que `globalThis` : `vi.mock` est hoisté en tête de +// module, une variable de ce module ne serait pas encore initialisée. +vi.mock("../../../src/start", () => ({ + "start": (params: any) => { + (globalThis as any).__registerEffectAction = params.nextParams.registerEffectAction; + } +})); + +// Chaque test doit repartir d'un module neuf : `isAfterFirstEffect` et +// `actions` sont des variables de MODULE, un premier test les laisserait dans +// l'état « après hydratation » pour les suivants. +async function loadFreshProviderModule() { + vi.resetModules(); + delete (globalThis as any).__registerEffectAction; + return await import("../../../src/next-app-router/DsfrProvider"); +} + +function makeElement( + DsfrProviderBase: any, + children: React.ReactNode = React.createElement("p", null, "contenu") +) { + return React.createElement(DsfrProviderBase, { + "lang": "fr", + "Link": (props: any) => React.createElement("a", props), + "defaultColorScheme": "light", + children + }); +} + +async function hydrate(element: React.ReactElement) { + const html = renderToString(element); + + const container = document.createElement("div"); + container.innerHTML = html; + document.body.appendChild(container); + + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + + await act(async () => { + hydrateRoot(container, element); + }); + + return container; +} + +describe("DsfrProvider, effet d'hydratation", () => { + it("exécute à l'hydratation les actions enregistrées avant elle", async () => { + const { DsfrProviderBase } = await loadFreshProviderModule(); + + const element = makeElement(DsfrProviderBase); + + // Le rendu serveur appelle `start()`, qui fournit + // `registerEffectAction` ; aucun effet n'a encore tourné. + renderToString(element); + + const registerEffectAction = (globalThis as any).__registerEffectAction; + expect(typeof registerEffectAction).toBe("function"); + + let callCount = 0; + registerEffectAction(() => { + callCount++; + }); + + // Avant hydratation, l'action est en attente, pas exécutée. + expect(callCount).toBe(0); + + await hydrate(element); + + expect(callCount).toBe(1); + }); + + it("exécute immédiatement une action enregistrée après l'hydratation", async () => { + const { DsfrProviderBase } = await loadFreshProviderModule(); + + const element = makeElement(DsfrProviderBase); + + await hydrate(element); + + const registerEffectAction = (globalThis as any).__registerEffectAction; + + let hasRun = false; + registerEffectAction(() => { + hasRun = true; + }); + + expect(hasRun).toBe(true); + }); + + it("n'exécute les actions en attente qu'une fois, StartDsfrOnHydration monté en plus", async () => { + const { DsfrProviderBase, StartDsfrOnHydration } = await loadFreshProviderModule(); + + const element = makeElement( + DsfrProviderBase, + React.createElement( + React.Fragment, + null, + React.createElement(StartDsfrOnHydration, { key: "start" }), + React.createElement("p", { key: "content" }, "contenu") + ) + ); + + renderToString(element); + + let callCount = 0; + (globalThis as any).__registerEffectAction(() => { + callCount++; + }); + + await hydrate(element); + + // Les deux effets (celui du provider et celui de + // StartDsfrOnHydration) appellent `dsfrEffect()` ; il est idempotent. + expect(callCount).toBe(1); + }); + + it("garde StartDsfrOnHydration exporté et annoté @deprecated", async () => { + const { StartDsfrOnHydration } = await loadFreshProviderModule(); + + expect(typeof StartDsfrOnHydration).toBe("function"); + + // L'annotation elle-même n'est pas observable au runtime : c'est un + // contrat de documentation, vérifié sur la source. + const source = fs.readFileSync( + pathJoin(__dirname, "..", "..", "..", "src", "next-app-router", "DsfrProvider.tsx"), + "utf8" + ); + + expect(source).toMatch(/@deprecated[\s\S]*?export function StartDsfrOnHydration\(\)/); + }); +}); From 5574e077724d83e94ebd01db79886cf257285bba Mon Sep 17 00:00:00 2001 From: Moroine Bentefrit Date: Wed, 28 Oct 2026 22:36:57 +0100 Subject: [PATCH 03/10] =?UTF-8?q?feat(dsfr-patch):=20le=20moteur=20ne=20to?= =?UTF-8?q?uche=20pas=20un=20sous-arbre=20marqu=C3=A9=20data-fr-hydrating?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- patches/@gouvfr+dsfr+1.14.2.patch | 26 +++- test/runtime/lib/dsfrHydrationGate.test.ts | 168 +++++++++++++++++++++ 2 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 test/runtime/lib/dsfrHydrationGate.test.ts diff --git a/patches/@gouvfr+dsfr+1.14.2.patch b/patches/@gouvfr+dsfr+1.14.2.patch index 9f7764bef..e4f9ac333 100644 --- a/patches/@gouvfr+dsfr+1.14.2.patch +++ b/patches/@gouvfr+dsfr+1.14.2.patch @@ -8,10 +8,32 @@ 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 fcde523..7996e40 100644 +index 0640386..394ebf8 100644 --- a/node_modules/@gouvfr/dsfr/dist/dsfr.module.js +++ b/node_modules/@gouvfr/dsfr/dist/dsfr.module.js -@@ -6461,9 +6461,11 @@ class HeaderLinks extends api.core.Instance { +@@ -400,6 +400,8 @@ const queryParentSelector = (element, selectors) => { + return queryParentSelector(parent, selectors); + }; + ++const HYDRATING_SELECTOR = ns.attr.selector('hydrating'); ++ + class Registration { + constructor (selector, InstanceClass, creator) { + this.selector = selector; +@@ -441,7 +443,11 @@ class Registration { + if (node.matches && node.matches(this.selector)) nodes.push(node); + // eslint-disable-next-line no-useless-call + if (!nonRecursive && node.querySelectorAll && node.querySelector(this.selector)) nodes.push.apply(nodes, querySelectorAllArray(node, this.selector)); +- return nodes; ++ // Un sous-arbre marqué data-fr-hydrating est en cours d'hydratation par le ++ // framework qui l'a rendu côté serveur : le moteur n'y touche pas. Le retrait ++ // de l'attribut est une mutation d'attribut, à laquelle Stage répond déjà en ++ // analysant de nouveau tout le sous-arbre. ++ return nodes.filter(n => !n.closest(HYDRATING_SELECTOR)); + } + + create (element) { +@@ -6461,9 +6467,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 new file mode 100644 index 000000000..faa717480 --- /dev/null +++ b/test/runtime/lib/dsfrHydrationGate.test.ts @@ -0,0 +1,168 @@ +// @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" +); + +const accordionMarkup = (id: string) => ` +
+

+ +

+
Content
+
+`; + +async function loadEngine(params: { markup: string }) { + const { markup } = 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; + (window as any).dsfr.start(); + await nextFrames(); + + return { window }; +} + +// 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"); + +describe("@gouvfr/dsfr engine, data-fr-hydrating gate", () => { + it("instruments a subtree that carries no gate (unchanged behavior)", async () => { + const { window } = await loadEngine({ markup: accordionMarkup("acc") }); + + expect(isInstrumented(window, "acc")).toBe(true); + + window.close(); + }); + + it("does not instrument a subtree gated at start", async () => { + const { window } = await loadEngine({ + markup: `
${accordionMarkup("acc")}
` + }); + + expect(isInstrumented(window, "acc")).toBe(false); + expect(window.document.getElementById("acc-collapse")!.getAttributeNames()).not.toContain( + "data-fr-js-collapse" + ); + + window.close(); + }); + + it("gates the element carrying the attribute itself", async () => { + const { window } = await loadEngine({ + markup: accordionMarkup("acc").replace( + `class="fr-accordion"`, + `class="fr-accordion" data-fr-hydrating="true"` + ) + }); + + expect(isInstrumented(window, "acc")).toBe(false); + + window.close(); + }); + + it("instruments the whole subtree once the gate is removed", async () => { + const { window } = await loadEngine({ + markup: `
${accordionMarkup("acc")}
` + }); + + window.document.getElementById("gate")!.removeAttribute("data-fr-hydrating"); + await nextFrames(); + + expect(isInstrumented(window, "acc")).toBe(true); + expect(window.document.getElementById("acc-collapse")!.getAttributeNames()).toContain( + "data-fr-js-collapse" + ); + + window.close(); + }); + + it("does not instrument gated nodes inserted after start (streaming)", async () => { + const { window } = await loadEngine({ markup: "" }); + + const streamed = window.document.createElement("div"); + streamed.innerHTML = ` +
${accordionMarkup("gated")}
+ ${accordionMarkup("plain")} + `; + window.document.body.appendChild(streamed); + await nextFrames(); + + expect(isInstrumented(window, "gated")).toBe(false); + expect(isInstrumented(window, "plain")).toBe(true); + + window.close(); + }); + + it("keeps a nested gate closed when its ancestor gate opens", async () => { + const { window } = await loadEngine({ + markup: ` +
+ ${accordionMarkup("outer-acc")} +
${accordionMarkup("inner-acc")}
+
+ ` + }); + + window.document.getElementById("outer")!.removeAttribute("data-fr-hydrating"); + await nextFrames(); + + expect(isInstrumented(window, "outer-acc")).toBe(true); + expect(isInstrumented(window, "inner-acc")).toBe(false); + + window.document.getElementById("inner")!.removeAttribute("data-fr-hydrating"); + await nextFrames(); + + expect(isInstrumented(window, "inner-acc")).toBe(true); + + window.close(); + }); +}); From ff62acaa73b8ceaf64b59ca0545c3516495bbb6c Mon Sep 17 00:00:00 2001 From: Moroine Bentefrit Date: Wed, 28 Oct 2026 22:55:47 +0100 Subject: [PATCH 04/10] =?UTF-8?q?feat:=20les=20composants=20posent=20data-?= =?UTF-8?q?fr-hydrating=20jusqu'=C3=A0=20leur=20hydratation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/Accordion.tsx | 4 ++ src/Breadcrumb.tsx | 4 ++ src/Button.tsx | 5 ++ src/ButtonsGroup.tsx | 12 ++++- src/Card.tsx | 4 ++ src/Header/Header.tsx | 4 ++ src/MainNavigation/MainNavigation.tsx | 4 ++ src/Modal/Modal.tsx | 3 ++ src/Range.tsx | 4 ++ src/SegmentedControl.tsx | 4 ++ src/SideMenu.tsx | 4 ++ src/Table.tsx | 4 ++ src/Tabs.tsx | 4 ++ src/Tag.tsx | 6 +++ src/Tile.tsx | 4 ++ src/ToggleSwitch.tsx | 4 ++ src/Tooltip.tsx | 4 ++ src/blocks/PasswordInput.tsx | 4 ++ src/shared/Fieldset.tsx | 5 ++ src/tools/useHydrationGate.ts | 35 ++++++++++++++ test/runtime/lib/useHydrationGate.test.ts | 58 +++++++++++++++++++++++ 21 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 src/tools/useHydrationGate.ts create mode 100644 test/runtime/lib/useHydrationGate.test.ts diff --git a/src/Accordion.tsx b/src/Accordion.tsx index 86165e64f..f89457c3e 100644 --- a/src/Accordion.tsx +++ b/src/Accordion.tsx @@ -15,6 +15,7 @@ import { cx } from "./tools/cx"; import { symToStr } from "tsafe/symToStr"; import { useConstCallback } from "./tools/powerhooks/useConstCallback"; import { useAnalyticsId } from "./tools/useAnalyticsId"; +import { useHydrationGate } from "./tools/useHydrationGate"; export type AccordionProps = AccordionProps.Controlled | AccordionProps.Uncontrolled; @@ -72,6 +73,8 @@ export const Accordion = memo( "explicitlyProvidedId": id_props }); + const hydrationGate = useHydrationGate(); + const collapseElementId = `${id}-collapse`; const [isExpanded, setIsExpanded] = useState(expanded_props ?? defaultExpanded); @@ -100,6 +103,7 @@ export const Accordion = memo(
diff --git a/src/Breadcrumb.tsx b/src/Breadcrumb.tsx index 4079d46be..1dae1e5ad 100644 --- a/src/Breadcrumb.tsx +++ b/src/Breadcrumb.tsx @@ -8,6 +8,7 @@ import { createComponentI18nApi } from "./i18n"; import { fr } from "./fr"; import { cx } from "./tools/cx"; import { useAnalyticsId } from "./tools/useAnalyticsId"; +import { useHydrationGate } from "./tools/useHydrationGate"; export type BreadcrumbProps = { id?: string; @@ -43,6 +44,8 @@ export const Breadcrumb = memo( "explicitlyProvidedId": props_id }); + const hydrationGate = useHydrationGate(); + const { t } = useTranslation(); const { Link } = getLink(); @@ -51,6 +54,7 @@ export const Breadcrumb = memo( return (
`; -async function loadEngine(params: { markup: string }) { - const { markup } = params; +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, @@ -62,10 +66,11 @@ async function loadEngine(params: { markup: string }) { (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 }; + return { window, dsfr: (window as any).dsfr }; } // Mutations are replayed by the engine on the next animation frame. @@ -166,3 +171,126 @@ describe("@gouvfr/dsfr engine, data-fr-hydrating gate", () => { window.close(); }); }); + +describe("@gouvfr/dsfr engine, dsfr.hydrated(id)", () => { + it("keeps a gate carrying an id closed until it is released", async () => { + const { window, dsfr } = await loadEngine({ + markup: `
${accordionMarkup("acc")}
` + }); + + expect(isInstrumented(window, "acc")).toBe(false); + + dsfr.hydrated("g1"); + await nextFrames(); + + expect(isInstrumented(window, "acc")).toBe(true); + + window.close(); + }); + + it("leaves the gate attribute in the DOM", async () => { + const { window, dsfr } = await loadEngine({ + markup: `
${accordionMarkup("acc")}
` + }); + + dsfr.hydrated("g1"); + await nextFrames(); + + expect(window.document.getElementById("gate")!.getAttribute("data-fr-hydrating")).toBe( + "g1" + ); + + window.close(); + }); + + it("only releases the gate carrying that id", async () => { + const { window, dsfr } = await loadEngine({ + markup: ` +
${accordionMarkup("acc-1")}
+
${accordionMarkup("acc-2")}
+ ` + }); + + dsfr.hydrated("g1"); + await nextFrames(); + + expect(isInstrumented(window, "acc-1")).toBe(true); + expect(isInstrumented(window, "acc-2")).toBe(false); + + window.close(); + }); + + it("applies a release made before the engine starts", async () => { + const { window } = await loadEngine({ + markup: `
${accordionMarkup("acc")}
`, + beforeStart: dsfr => dsfr.hydrated("g1") + }); + + expect(isInstrumented(window, "acc")).toBe(true); + + window.close(); + }); + + it("instruments nodes added later under a released gate", async () => { + const { window, dsfr } = await loadEngine({ + markup: `
` + }); + + dsfr.hydrated("g1"); + + const added = window.document.createElement("div"); + added.innerHTML = accordionMarkup("acc"); + window.document.getElementById("gate")!.appendChild(added); + await nextFrames(); + + expect(isInstrumented(window, "acc")).toBe(true); + + window.close(); + }); + + it("keeps a nested gate closed until its own release", async () => { + const { window, dsfr } = await loadEngine({ + markup: ` +
+ ${accordionMarkup("outer-acc")} +
${accordionMarkup("inner-acc")}
+
+ ` + }); + + dsfr.hydrated("outer"); + await nextFrames(); + + expect(isInstrumented(window, "outer-acc")).toBe(true); + expect(isInstrumented(window, "inner-acc")).toBe(false); + + dsfr.hydrated("inner"); + await nextFrames(); + + expect(isInstrumented(window, "inner-acc")).toBe(true); + + window.close(); + }); + + it("keeps an inner gate closed while its enclosing gate is not released", async () => { + const { window, dsfr } = await loadEngine({ + markup: ` +
+
${accordionMarkup("inner-acc")}
+
+ ` + }); + + dsfr.hydrated("inner"); + await nextFrames(); + + expect(isInstrumented(window, "inner-acc")).toBe(false); + + dsfr.hydrated("outer"); + await nextFrames(); + + expect(isInstrumented(window, "inner-acc")).toBe(true); + + window.close(); + }); +}); From 9d844e898f5fe0eb9ca0aa407b06d41f1e3c6187 Mon Sep 17 00:00:00 2001 From: Moroine Bentefrit Date: Thu, 29 Oct 2026 00:15:15 +0100 Subject: [PATCH 07/10] =?UTF-8?q?feat:=20les=20composants=20lib=C3=A8rent?= =?UTF-8?q?=20leur=20porte=20par=20dsfr.hydrated(id),=20y=20compris=20en?= =?UTF-8?q?=20RSC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chaque composant que le moteur instrumente pose data-fr-hydrating={useId()} sur sa racine et y rend, en dernier enfant, . 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
); diff --git a/src/MainNavigation/MainNavigation.tsx b/src/MainNavigation/MainNavigation.tsx index 32530cb0f..dd2c91961 100644 --- a/src/MainNavigation/MainNavigation.tsx +++ b/src/MainNavigation/MainNavigation.tsx @@ -1,4 +1,4 @@ -import React, { memo, forwardRef, type ReactNode, type CSSProperties } from "react"; +import React, { memo, forwardRef, useId, type ReactNode, type CSSProperties } from "react"; import { createComponentI18nApi } from "../i18n"; import { symToStr } from "tsafe/symToStr"; import { assert } from "tsafe/assert"; @@ -12,7 +12,7 @@ import { Menu } from "./Menu"; import type { MegaMenuProps } from "./MegaMenu"; import { MegaMenu } from "./MegaMenu"; import { useAnalyticsId } from "../tools/useAnalyticsId"; -import { useHydrationGate } from "../tools/useHydrationGate"; +import { HydrationRelease } from "../tools/HydrationRelease"; export type MainNavigationProps = { className?: string; @@ -90,7 +90,7 @@ export const MainNavigation = memo( "defaultIdPrefix": "main-navigation" }); - const hydrationGate = useHydrationGate(); + const hydrationId = useId(); const getMenuId = (i: number) => `${id}-menu-${i}`; @@ -101,7 +101,7 @@ export const MainNavigation = memo( style={style} role="navigation" aria-label={t("main menu")} - {...hydrationGate} + data-fr-hydrating={hydrationId} ref={ref} {...rest} > @@ -184,6 +184,7 @@ export const MainNavigation = memo( ) )} + ); }) diff --git a/src/Modal/Modal.tsx b/src/Modal/Modal.tsx index 1cb2301a8..0927a703e 100644 --- a/src/Modal/Modal.tsx +++ b/src/Modal/Modal.tsx @@ -1,5 +1,5 @@ import type { JSX } from "../tools/JSX"; -import React, { memo, forwardRef, type CSSProperties, type ReactNode } from "react"; +import React, { memo, forwardRef, useId, type CSSProperties, type ReactNode } from "react"; import { fr } from "../fr"; import { cx } from "../tools/cx"; import { assert } from "tsafe/assert"; @@ -9,7 +9,7 @@ import { createComponentI18nApi } from "../i18n"; import type { FrIconClassName, RiIconClassName } from "../fr/generatedFromCss/classNames"; import Button, { ButtonProps } from "../Button"; import { overwriteReadonlyProp } from "tsafe/lab/overwriteReadonlyProp"; -import { useHydrationGate } from "../tools/useHydrationGate"; +import { HydrationRelease } from "../tools/HydrationRelease"; export type ModalProps = { className?: string; @@ -67,7 +67,7 @@ const Modal = memo( : [buttons_props]; const { t } = useTranslation(); - const hydrationGate = useHydrationGate(); + const hydrationId = useId(); const titleId = `fr-modal-title-${id}`; return ( @@ -176,6 +176,7 @@ const Modal = memo( + ); }) diff --git a/src/Range.tsx b/src/Range.tsx index ac2f0cab3..23761a044 100644 --- a/src/Range.tsx +++ b/src/Range.tsx @@ -3,6 +3,7 @@ import React, { type CSSProperties, forwardRef, + useId, memo, type ReactNode, type ComponentProps @@ -13,7 +14,7 @@ import { type CxArg } from "tss-react"; import { fr } from "./fr"; import { cx } from "./tools/cx"; import { useAnalyticsId } from "./tools/useAnalyticsId"; -import { useHydrationGate } from "./tools/useHydrationGate"; +import { HydrationRelease } from "./tools/HydrationRelease"; export type RangeProps = { id?: string; @@ -107,7 +108,7 @@ export const Range = memo( "explicitlyProvidedId": props_id }); - const hydrationGate = useHydrationGate(); + const hydrationId = useId(); const labelId = `${id}-label`; @@ -128,7 +129,7 @@ export const Range = memo( className )} style={style} - {...hydrationGate} + data-fr-hydrating={hydrationId} ref={ref} id={`${id}-group`} {...rest} @@ -223,6 +224,7 @@ export const Range = memo( {stateRelatedMessage}

+ ); }) diff --git a/src/SegmentedControl.tsx b/src/SegmentedControl.tsx index fa8fa5727..18b023b3b 100644 --- a/src/SegmentedControl.tsx +++ b/src/SegmentedControl.tsx @@ -12,7 +12,7 @@ import React from "react"; import { type CxArg } from "tss-react"; import { cx } from "./tools/cx"; import { useAnalyticsId } from "./tools/useAnalyticsId"; -import { useHydrationGate } from "./tools/useHydrationGate"; +import { HydrationRelease } from "./tools/HydrationRelease"; export type SegmentedControlProps = { id?: string; @@ -112,7 +112,7 @@ export const SegmentedControl = memo( "explicitlyProvidedId": props_id }); - const hydrationGate = useHydrationGate(); + const hydrationId = useId(); const getInputId = (i: number) => `${id}-${i}`; @@ -134,7 +134,7 @@ export const SegmentedControl = memo( classes.root, className )} - {...hydrationGate} + data-fr-hydrating={hydrationId} ref={ref} style={style} {...rest} @@ -194,6 +194,7 @@ export const SegmentedControl = memo( ); })} + ); }) diff --git a/src/SideMenu.tsx b/src/SideMenu.tsx index 1e43e2c17..f500f46a1 100644 --- a/src/SideMenu.tsx +++ b/src/SideMenu.tsx @@ -1,4 +1,4 @@ -import React, { memo, forwardRef, type ReactNode, type CSSProperties } from "react"; +import React, { memo, forwardRef, useId, type ReactNode, type CSSProperties } from "react"; import { symToStr } from "tsafe/symToStr"; import { assert } from "tsafe/assert"; import type { Equals } from "tsafe"; @@ -7,7 +7,7 @@ import { getLink } from "./link"; import { fr } from "./fr"; 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/sidemenu/ export type SideMenuProps = { @@ -76,7 +76,7 @@ export const SideMenu = memo( "explicitlyProvidedId": id_props }); - const hydrationGate = useHydrationGate(); + const hydrationId = useId(); const collapseId = `${id}-collapse`; @@ -92,7 +92,7 @@ export const SideMenu = memo( ); }) diff --git a/src/Table.tsx b/src/Table.tsx index 4c25c4bcd..b8408fec7 100644 --- a/src/Table.tsx +++ b/src/Table.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, type ReactNode, type CSSProperties } from "react"; +import React, { forwardRef, useId, memo, type ReactNode, type CSSProperties } from "react"; import { assert } from "tsafe/assert"; import type { Equals } from "tsafe"; import { fr } from "./fr"; @@ -6,7 +6,7 @@ import { cx } from "./tools/cx"; import { symToStr } from "tsafe/symToStr"; import type { FrClassName } from "./fr/generatedFromCss/classNames"; import { useAnalyticsId } from "./tools/useAnalyticsId"; -import { useHydrationGate } from "./tools/useHydrationGate"; +import { HydrationRelease } from "./tools/HydrationRelease"; export type TableProps = { id?: string; @@ -65,12 +65,12 @@ export const Table = memo( "explicitlyProvidedId": id_props }); - const hydrationGate = useHydrationGate(); + const hydrationId = useId(); return (
+ ); }) diff --git a/src/Tabs.tsx b/src/Tabs.tsx index ab996612f..fede6b5e6 100644 --- a/src/Tabs.tsx +++ b/src/Tabs.tsx @@ -9,7 +9,7 @@ import { assert } from "tsafe/assert"; import type { Equals } from "tsafe"; import { useCallbackFactory } from "./tools/powerhooks/useCallbackFactory"; import { useAnalyticsId } from "./tools/useAnalyticsId"; -import { useHydrationGate } from "./tools/useHydrationGate"; +import { HydrationRelease } from "./tools/HydrationRelease"; export type TabsProps = TabsProps.Uncontrolled | TabsProps.Controlled; @@ -71,7 +71,7 @@ export const Tabs = memo( "explicitlyProvidedId": id_props }); - const hydrationGate = useHydrationGate(); + const hydrationId = useId(); const selectedTabIndex = useMemo(() => { const index = tabs.findIndex(tab => @@ -134,7 +134,7 @@ export const Tabs = memo(
)} +
); }) 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 }) => ` + + +
+
+

Title

+
+
+`; + +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 ( -
-
{family.render()}
-
- ); -} diff --git a/test/integration/next-app-router-streaming/app/DsfrShowcase.tsx b/test/integration/next-app-router-streaming/app/DsfrShowcase.tsx deleted file mode 100644 index e051decba..000000000 --- a/test/integration/next-app-router-streaming/app/DsfrShowcase.tsx +++ /dev/null @@ -1,13 +0,0 @@ -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 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. -

- ))} -
- ) - } - ]} - /> - ) - }, - { - id: "segmented", - render: () => ( - - ) - }, - { - id: "button", - render: () => ( - - ) - }, - { - id: "range", - render: () => - }, - { - id: "tooltip", - render: () => ( - <> - - - - ) - }, - - // -- familles ajoutées par cette tâche -- - { - id: "breadcrumb", - render: () => ( - - ) - }, - { - id: "card", - render: () => ( - - ) - }, - { - id: "checkbox", - render: () => ( - - ) - }, - { - id: "display", - render: () => - }, - { - id: "header", - render: () => ( -
- Intitulé -
- officiel - - } - homeLinkProps={{ href: "#", title: "Accueil" }} - serviceTitle="Service de test" - disableDisplay - quickAccessItems={[ - { - iconId: "fr-icon-lock-line", - text: "Se connecter", - linkProps: { href: "#" } - } - ]} - /> - ) - }, - { - id: "modal", - render: () => ( - <> - - Contenu de la modale. - - ) - }, - { - id: "navigation", - render: () => ( - - ) - }, - { - id: "password", - render: () => - }, - { - id: "sidemenu", - render: () => ( - - ) - }, - { - id: "tag", - render: () => ( - <> - - Tag pressable - - - Tag dismissible - - - ) - }, - { - id: "tile", - render: () => - }, - { - id: "toggle", - render: () => - } -]; - -export function getDsfrFamily(id: string): DsfrFamily | undefined { - return dsfrFamilies.find(family => family.id === id); -} diff --git a/test/integration/next-app-router-streaming/app/layout.tsx b/test/integration/next-app-router-streaming/app/layout.tsx deleted file mode 100644 index dfdaf6b4e..000000000 --- a/test/integration/next-app-router-streaming/app/layout.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import type { ReactNode } from "react"; -import { DsfrProvider } from "./dsfr-bootstrap"; -import { DsfrHead, getHtmlAttributes } from "./dsfr-bootstrap/server-only"; - -export default function RootLayout({ children }: { children: ReactNode }) { - const lang = "fr"; - - return ( - - - Repro streaming DSFR - - - - {children} - - - ); -} diff --git a/test/integration/next-app-router-streaming/app/no-streaming/family/[name]/page.tsx b/test/integration/next-app-router-streaming/app/no-streaming/family/[name]/page.tsx deleted file mode 100644 index e4309ea7e..000000000 --- a/test/integration/next-app-router-streaming/app/no-streaming/family/[name]/page.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { notFound } from "next/navigation"; -import { getDsfrFamily } from "../../../dsfr-families"; - -export const dynamic = "force-dynamic"; - -// Pendant de /streaming/family/[name] mais sans Suspense/délai : sert de -// témoin par famille (voir COVERAGE.md de la branche hydratation). -export default async function NoStreamingFamilyPage({ - params -}: { - params: Promise<{ name: string }>; -}) { - const { name } = await params; - - const family = getDsfrFamily(name); - - if (family === undefined) { - notFound(); - } - - return ( -
-

Shell immédiat

-
-
{family.render()}
-
-
- ); -} diff --git a/test/integration/next-app-router-streaming/app/no-streaming/page.tsx b/test/integration/next-app-router-streaming/app/no-streaming/page.tsx deleted file mode 100644 index 86100556d..000000000 --- a/test/integration/next-app-router-streaming/app/no-streaming/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { DsfrShowcase } from "../DsfrShowcase"; - -export const dynamic = "force-dynamic"; - -export default function NoStreamingPage() { - return ( -
-

Shell immédiat

- -
- ); -} diff --git a/test/integration/next-app-router-streaming/app/streaming-client/family/[name]/page.tsx b/test/integration/next-app-router-streaming/app/streaming-client/family/[name]/page.tsx deleted file mode 100644 index 38c9ac452..000000000 --- a/test/integration/next-app-router-streaming/app/streaming-client/family/[name]/page.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Suspense } from "react"; -import { notFound } from "next/navigation"; -import { ClientShowcase } from "../../../ClientShowcase"; -import { getDsfrFamily } from "../../../dsfr-families"; - -export const dynamic = "force-dynamic"; - -// Variante de /streaming/family/[name] où la famille est rendue par un Client -// Component. -async function SlowFamily({ name }: { name: string }) { - await new Promise(resolve => setTimeout(resolve, 1500)); - - if (getDsfrFamily(name) === undefined) { - notFound(); - } - - return ; -} - -export default async function StreamingClientFamilyPage({ - params -}: { - params: Promise<{ name: string }>; -}) { - const { name } = await params; - - return ( -
-

Shell immédiat

- Chargement…

}> - -
-
- ); -} diff --git a/test/integration/next-app-router-streaming/app/streaming-client/page.tsx b/test/integration/next-app-router-streaming/app/streaming-client/page.tsx deleted file mode 100644 index 77c5d200a..000000000 --- a/test/integration/next-app-router-streaming/app/streaming-client/page.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Suspense } from "react"; -import { ClientShowcase } from "../ClientShowcase"; - -export const dynamic = "force-dynamic"; - -async function SlowShowcase() { - await new Promise(resolve => setTimeout(resolve, 1500)); - return ; -} - -// Variante de /streaming où le contenu streamé est un Client Component. -export default function StreamingClientPage() { - return ( -
-

Shell immédiat

- Chargement…

}> - -
-
- ); -} 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 deleted file mode 100644 index c1aa20d26..000000000 --- a/test/integration/next-app-router-streaming/app/streaming-legacy/page.tsx +++ /dev/null @@ -1,22 +0,0 @@ -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 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 ( -
-
{family.render()}
-
- ); -} - -export default async function StreamingFamilyPage({ - params -}: { - params: Promise<{ name: string }>; -}) { - const { name } = await params; - - return ( -
-

Shell immédiat

- Chargement…

}> - -
-
- ); -} diff --git a/test/integration/next-app-router-streaming/app/streaming/page.tsx b/test/integration/next-app-router-streaming/app/streaming/page.tsx deleted file mode 100644 index 2fab6099b..000000000 --- a/test/integration/next-app-router-streaming/app/streaming/page.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Suspense } from "react"; -import { DsfrShowcase } from "../DsfrShowcase"; - -export const dynamic = "force-dynamic"; - -async function SlowShowcase() { - await new Promise(resolve => setTimeout(resolve, 1500)); - return ; -} - -export default function StreamingPage() { - return ( -
-

Shell immédiat

- Chargement…

}> - -
-
- ); -} diff --git a/test/integration/next-app-router-streaming/next-env.d.ts b/test/integration/next-app-router-streaming/next-env.d.ts deleted file mode 100644 index 1b3be0840..000000000 --- a/test/integration/next-app-router-streaming/next-env.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/// -/// - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/test/integration/next-app-router-streaming/next.config.js b/test/integration/next-app-router-streaming/next.config.js deleted file mode 100644 index 531dfe28d..000000000 --- a/test/integration/next-app-router-streaming/next.config.js +++ /dev/null @@ -1,25 +0,0 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = { - reactStrictMode: false, - // `dist/` est compilé avec les types React 18 du package.json racine du - // dépôt, alors que cette app de mesure tourne en React 19 (requis pour - // que nos propres Server Components async se typent correctement). Les - // deux exigences sont mutuellement incompatibles au niveau des types - // (`DsfrProviderBase` n'est pas assignable comme composant JSX sous - // React 19) sans que cela n'affecte la compilation ni le runtime — le - // webpack build réussit avant ce gate. On désactive donc uniquement le - // gate de typecheck du build ; voir MEASUREMENTS.md (branche hydratation) pour le détail et un - // `tsc --noEmit` séparé pour ne pas masquer nos propres erreurs de types. - typescript: { - ignoreBuildErrors: true - }, - webpack: config => { - config.module.rules.push({ - test: /\.woff2$/, - type: "asset/resource" - }); - return config; - } -}; - -module.exports = nextConfig; diff --git a/test/integration/next-app-router-streaming/package.json b/test/integration/next-app-router-streaming/package.json deleted file mode 100644 index 35a9ab750..000000000 --- a/test/integration/next-app-router-streaming/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "next-app-router-streaming-repro", - "version": "0.1.0", - "private": true, - "scripts": { - "dev": "node --stack-size=8000 node_modules/.bin/next dev -p 3100", - "build": "node --stack-size=8000 node_modules/.bin/next build", - "start": "next start -p 3100" - }, - "dependencies": { - "@codegouvfr/react-dsfr": "file:../../../dist", - "next": "15.1.6", - "react": "19.0.0", - "react-dom": "19.0.0" - }, - "devDependencies": { - "@types/node": "^20.11.0", - "@types/react": "19.0.0", - "@types/react-dom": "19.0.0", - "sass": "^1.62.1", - "typescript": "^5.3.0" - } -} diff --git a/test/integration/next-app-router-streaming/tsconfig.json b/test/integration/next-app-router-streaming/tsconfig.json deleted file mode 100644 index 26c8236a1..000000000 --- a/test/integration/next-app-router-streaming/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "target": "es2017", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "plugins": [{ "name": "next" }] - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] -} diff --git a/test/integration/next-app-router-streaming/yarn.lock b/test/integration/next-app-router-streaming/yarn.lock deleted file mode 100644 index 67d6ee217..000000000 --- a/test/integration/next-app-router-streaming/yarn.lock +++ /dev/null @@ -1,542 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -"@codegouvfr/react-dsfr@file:../../../dist": - version "1.34.0" - dependencies: - tsafe "^1.8.5" - yargs-parser "^21.1.1" - -"@emnapi/runtime@^1.2.0": - version "1.11.3" - resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.11.3.tgz#84257ae3b0531eb2aec1ffa23d70700da007ba95" - integrity sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA== - dependencies: - tslib "^2.4.0" - -"@img/sharp-darwin-arm64@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz#ef5b5a07862805f1e8145a377c8ba6e98813ca08" - integrity sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ== - optionalDependencies: - "@img/sharp-libvips-darwin-arm64" "1.0.4" - -"@img/sharp-darwin-x64@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz#e03d3451cd9e664faa72948cc70a403ea4063d61" - integrity sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q== - optionalDependencies: - "@img/sharp-libvips-darwin-x64" "1.0.4" - -"@img/sharp-libvips-darwin-arm64@1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz#447c5026700c01a993c7804eb8af5f6e9868c07f" - integrity sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg== - -"@img/sharp-libvips-darwin-x64@1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz#e0456f8f7c623f9dbfbdc77383caa72281d86062" - integrity sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ== - -"@img/sharp-libvips-linux-arm64@1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz#979b1c66c9a91f7ff2893556ef267f90ebe51704" - integrity sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA== - -"@img/sharp-libvips-linux-arm@1.0.5": - version "1.0.5" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz#99f922d4e15216ec205dcb6891b721bfd2884197" - integrity sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g== - -"@img/sharp-libvips-linux-s390x@1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz#f8a5eb1f374a082f72b3f45e2fb25b8118a8a5ce" - integrity sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA== - -"@img/sharp-libvips-linux-x64@1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz#d4c4619cdd157774906e15770ee119931c7ef5e0" - integrity sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw== - -"@img/sharp-libvips-linuxmusl-arm64@1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz#166778da0f48dd2bded1fa3033cee6b588f0d5d5" - integrity sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA== - -"@img/sharp-libvips-linuxmusl-x64@1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz#93794e4d7720b077fcad3e02982f2f1c246751ff" - integrity sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw== - -"@img/sharp-linux-arm64@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz#edb0697e7a8279c9fc829a60fc35644c4839bb22" - integrity sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA== - optionalDependencies: - "@img/sharp-libvips-linux-arm64" "1.0.4" - -"@img/sharp-linux-arm@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz#422c1a352e7b5832842577dc51602bcd5b6f5eff" - integrity sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ== - optionalDependencies: - "@img/sharp-libvips-linux-arm" "1.0.5" - -"@img/sharp-linux-s390x@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz#f5c077926b48e97e4a04d004dfaf175972059667" - integrity sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q== - optionalDependencies: - "@img/sharp-libvips-linux-s390x" "1.0.4" - -"@img/sharp-linux-x64@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz#d806e0afd71ae6775cc87f0da8f2d03a7c2209cb" - integrity sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA== - optionalDependencies: - "@img/sharp-libvips-linux-x64" "1.0.4" - -"@img/sharp-linuxmusl-arm64@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz#252975b915894fb315af5deea174651e208d3d6b" - integrity sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g== - optionalDependencies: - "@img/sharp-libvips-linuxmusl-arm64" "1.0.4" - -"@img/sharp-linuxmusl-x64@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz#3f4609ac5d8ef8ec7dadee80b560961a60fd4f48" - integrity sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw== - optionalDependencies: - "@img/sharp-libvips-linuxmusl-x64" "1.0.4" - -"@img/sharp-wasm32@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz#6f44f3283069d935bb5ca5813153572f3e6f61a1" - integrity sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg== - dependencies: - "@emnapi/runtime" "^1.2.0" - -"@img/sharp-win32-ia32@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz#1a0c839a40c5351e9885628c85f2e5dfd02b52a9" - integrity sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ== - -"@img/sharp-win32-x64@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz#56f00962ff0c4e0eb93d34a047d29fa995e3e342" - integrity sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg== - -"@next/env@15.1.6": - version "15.1.6" - resolved "https://registry.yarnpkg.com/@next/env/-/env-15.1.6.tgz#2fa863d8c568a56b1c8328a86e621b8bdd4f2a20" - integrity sha512-d9AFQVPEYNr+aqokIiPLNK/MTyt3DWa/dpKveiAaVccUadFbhFEvY6FXYX2LJO2Hv7PHnLBu2oWwB4uBuHjr/w== - -"@next/swc-darwin-arm64@15.1.6": - version "15.1.6" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.1.6.tgz#92f99badab6cb41f4c5c11a3feffa574bd6a9276" - integrity sha512-u7lg4Mpl9qWpKgy6NzEkz/w0/keEHtOybmIl0ykgItBxEM5mYotS5PmqTpo+Rhg8FiOiWgwr8USxmKQkqLBCrw== - -"@next/swc-darwin-x64@15.1.6": - version "15.1.6" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.6.tgz#f56f4f8d5f6cb5d3915912ac95590d387f897da5" - integrity sha512-x1jGpbHbZoZ69nRuogGL2MYPLqohlhnT9OCU6E6QFewwup+z+M6r8oU47BTeJcWsF2sdBahp5cKiAcDbwwK/lg== - -"@next/swc-linux-arm64-gnu@15.1.6": - version "15.1.6" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.6.tgz#0aaffae519c93d1006419d7b98c34ebfd80ecacd" - integrity sha512-jar9sFw0XewXsBzPf9runGzoivajeWJUc/JkfbLTC4it9EhU8v7tCRLH7l5Y1ReTMN6zKJO0kKAGqDk8YSO2bg== - -"@next/swc-linux-arm64-musl@15.1.6": - version "15.1.6" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.6.tgz#e7398d3d31ca60033f708a718cd6c31edcee2e9a" - integrity sha512-+n3u//bfsrIaZch4cgOJ3tXCTbSxz0s6brJtU3SzLOvkJlPQMJ+eHVRi6qM2kKKKLuMY+tcau8XD9CJ1OjeSQQ== - -"@next/swc-linux-x64-gnu@15.1.6": - version "15.1.6" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.6.tgz#d76c72508f4d79d6016cab0c52640b93e590cffb" - integrity sha512-SpuDEXixM3PycniL4iVCLyUyvcl6Lt0mtv3am08sucskpG0tYkW1KlRhTgj4LI5ehyxriVVcfdoxuuP8csi3kQ== - -"@next/swc-linux-x64-musl@15.1.6": - version "15.1.6" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.6.tgz#0b8ba80a53e65bf8970ed11ea923001e2512c7cb" - integrity sha512-L4druWmdFSZIIRhF+G60API5sFB7suTbDRhYWSjiw0RbE+15igQvE2g2+S973pMGvwN3guw7cJUjA/TmbPWTHQ== - -"@next/swc-win32-arm64-msvc@15.1.6": - version "15.1.6" - resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.6.tgz#81b5dbbfdada2c05deef688e799af4a24097b65f" - integrity sha512-s8w6EeqNmi6gdvM19tqKKWbCyOBvXFbndkGHl+c9YrzsLARRdCHsD9S1fMj8gsXm9v8vhC8s3N8rjuC/XrtkEg== - -"@next/swc-win32-x64-msvc@15.1.6": - version "15.1.6" - resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.6.tgz#131993c45ffd124fb4b15258e2f3f9669c143e3c" - integrity sha512-6xomMuu54FAFxttYr5PJbEfu96godcxBTRk1OhAvJq0/EnmFU/Ybiax30Snis4vdWZ9LGpf7Roy5fSs7v/5ROQ== - -"@parcel/watcher-android-arm64@2.6.0": - version "2.6.0" - resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz#99aaa3223d43807c9340af439cad7e9b6d26ada6" - integrity sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA== - -"@parcel/watcher-darwin-arm64@2.6.0": - version "2.6.0" - resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz#024496e586b4744f09ce532bbe89fe38ef02a64e" - integrity sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw== - -"@parcel/watcher-darwin-x64@2.6.0": - version "2.6.0" - resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz#a4621df1359a93d39a332d9bab5ff09016a0608f" - integrity sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw== - -"@parcel/watcher-freebsd-x64@2.6.0": - version "2.6.0" - resolved "https://registry.yarnpkg.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz#7f565ed1a5b3a5e604e6a4799121518265d62a3d" - integrity sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ== - -"@parcel/watcher-linux-arm-glibc@2.6.0": - version "2.6.0" - resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz#ad7d3825e67b81999165da42593022045abc0889" - integrity sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg== - -"@parcel/watcher-linux-arm-musl@2.6.0": - version "2.6.0" - resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz#fe7d1cccb2c483215c090e938cf5cf404d2f9a8c" - integrity sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw== - -"@parcel/watcher-linux-arm64-glibc@2.6.0": - version "2.6.0" - resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz#7e239dcb4646c4c79f006a7131a48238249530da" - integrity sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g== - -"@parcel/watcher-linux-arm64-musl@2.6.0": - version "2.6.0" - resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz#c58b8d9c6d8d81594be00dd83aab741c1aaf7e0e" - integrity sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA== - -"@parcel/watcher-linux-x64-glibc@2.6.0": - version "2.6.0" - resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz#5184fa9a770478d86e56875f4ee163a0abdc8791" - integrity sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A== - -"@parcel/watcher-linux-x64-musl@2.6.0": - version "2.6.0" - resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz#2d1c55aa7246cbc7670e2612058a8a542c9cf246" - integrity sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw== - -"@parcel/watcher-win32-arm64@2.6.0": - version "2.6.0" - resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz#15e09432040fee9e2213aa9c10ed589012526def" - integrity sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ== - -"@parcel/watcher-win32-x64@2.6.0": - version "2.6.0" - resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz#9bee199a2a4accd557b451ac2c1c793f305ae012" - integrity sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A== - -"@parcel/watcher@^2.4.1": - version "2.6.0" - resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.6.0.tgz#99661f6220070b76a766aba6b7e313a087a1be4f" - integrity sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w== - dependencies: - detect-libc "^2.0.3" - is-glob "^4.0.3" - node-addon-api "^7.0.0" - picomatch "^4.0.4" - optionalDependencies: - "@parcel/watcher-android-arm64" "2.6.0" - "@parcel/watcher-darwin-arm64" "2.6.0" - "@parcel/watcher-darwin-x64" "2.6.0" - "@parcel/watcher-freebsd-x64" "2.6.0" - "@parcel/watcher-linux-arm-glibc" "2.6.0" - "@parcel/watcher-linux-arm-musl" "2.6.0" - "@parcel/watcher-linux-arm64-glibc" "2.6.0" - "@parcel/watcher-linux-arm64-musl" "2.6.0" - "@parcel/watcher-linux-x64-glibc" "2.6.0" - "@parcel/watcher-linux-x64-musl" "2.6.0" - "@parcel/watcher-win32-arm64" "2.6.0" - "@parcel/watcher-win32-x64" "2.6.0" - -"@swc/counter@0.1.3": - version "0.1.3" - resolved "https://registry.yarnpkg.com/@swc/counter/-/counter-0.1.3.tgz#cc7463bd02949611c6329596fccd2b0ec782b0e9" - integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ== - -"@swc/helpers@0.5.15": - version "0.5.15" - resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.15.tgz#79efab344c5819ecf83a43f3f9f811fc84b516d7" - integrity sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g== - dependencies: - tslib "^2.8.0" - -"@types/node@^20.11.0": - version "20.19.43" - resolved "https://registry.yarnpkg.com/@types/node/-/node-20.19.43.tgz#fcecf580ba42a0db55cf404c372c97973c376c97" - integrity sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA== - dependencies: - undici-types "~6.21.0" - -"@types/react-dom@19.0.0": - version "19.0.0" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.0.0.tgz#e7f5d618a080486eaf9952246dbf59eaa2c64130" - integrity sha512-1KfiQKsH1o00p9m5ag12axHQSb3FOU9H20UTrujVSkNhuCrRHiQWFqgEnTNK5ZNfnzZv8UWrnXVqCmCF9fgY3w== - dependencies: - "@types/react" "*" - -"@types/react@*", "@types/react@19.0.0": - version "19.0.0" - resolved "https://registry.yarnpkg.com/@types/react/-/react-19.0.0.tgz#fbbb53ce223f4e2b750ad5dd09580b2c43522bbf" - integrity sha512-MY3oPudxvMYyesqs/kW1Bh8y9VqSmf+tzqw3ae8a9DZW68pUe3zAdHeI1jc6iAysuRdACnVknHP8AhwD4/dxtg== - dependencies: - csstype "^3.0.2" - -busboy@1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" - integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== - dependencies: - streamsearch "^1.1.0" - -caniuse-lite@^1.0.30001579: - version "1.0.30001810" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz#4970b477dea3278374de9bc43aa8f5d39fc3cda2" - integrity sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg== - -chokidar@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-5.0.0.tgz#949c126a9238a80792be9a0265934f098af369a5" - integrity sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw== - dependencies: - readdirp "^5.0.0" - -client-only@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1" - integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== - -color-convert@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" - integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== - dependencies: - color-name "~1.1.4" - -color-name@^1.0.0, color-name@~1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" - integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== - -color-string@^1.9.0: - version "1.9.1" - resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.1.tgz#4467f9146f036f855b764dfb5bf8582bf342c7a4" - integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg== - dependencies: - color-name "^1.0.0" - simple-swizzle "^0.2.2" - -color@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/color/-/color-4.2.3.tgz#d781ecb5e57224ee43ea9627560107c0e0c6463a" - integrity sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A== - dependencies: - color-convert "^2.0.1" - color-string "^1.9.0" - -csstype@^3.0.2: - version "3.2.3" - resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" - integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== - -detect-libc@^2.0.3: - version "2.1.2" - resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" - integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== - -immutable@^5.1.5: - version "5.1.9" - resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.9.tgz#ac23c3a01992ab665e14ac9ffff298f28cd74a0c" - integrity sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg== - -is-arrayish@^0.3.1: - version "0.3.4" - resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.4.tgz#1ee5553818511915685d33bb13d31bf854e5059d" - integrity sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA== - -is-extglob@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" - integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== - -is-glob@^4.0.3: - version "4.0.3" - resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" - integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== - dependencies: - is-extglob "^2.1.1" - -nanoid@^3.3.6: - version "3.3.18" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913" - integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w== - -next@15.1.6: - version "15.1.6" - resolved "https://registry.yarnpkg.com/next/-/next-15.1.6.tgz#ce22fd0a8f36da1fc4aba86e3ec7e98eb248c555" - integrity sha512-Hch4wzbaX0vKQtalpXvUiw5sYivBy4cm5rzUKrBnUB/y436LGrvOUqYvlSeNVCWFO/770gDlltR9gqZH62ct4Q== - dependencies: - "@next/env" "15.1.6" - "@swc/counter" "0.1.3" - "@swc/helpers" "0.5.15" - busboy "1.6.0" - caniuse-lite "^1.0.30001579" - postcss "8.4.31" - styled-jsx "5.1.6" - optionalDependencies: - "@next/swc-darwin-arm64" "15.1.6" - "@next/swc-darwin-x64" "15.1.6" - "@next/swc-linux-arm64-gnu" "15.1.6" - "@next/swc-linux-arm64-musl" "15.1.6" - "@next/swc-linux-x64-gnu" "15.1.6" - "@next/swc-linux-x64-musl" "15.1.6" - "@next/swc-win32-arm64-msvc" "15.1.6" - "@next/swc-win32-x64-msvc" "15.1.6" - sharp "^0.33.5" - -node-addon-api@^7.0.0: - version "7.1.1" - resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558" - integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ== - -picocolors@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" - integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== - -picomatch@^4.0.4: - version "4.0.7" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.7.tgz#6313360034ccb36b3dc61ecbdff78121f90fe21f" - integrity sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA== - -postcss@8.4.31: - version "8.4.31" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d" - integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ== - dependencies: - nanoid "^3.3.6" - picocolors "^1.0.0" - source-map-js "^1.0.2" - -react-dom@19.0.0: - version "19.0.0" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.0.0.tgz#43446f1f01c65a4cd7f7588083e686a6726cfb57" - integrity sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ== - dependencies: - scheduler "^0.25.0" - -react@19.0.0: - version "19.0.0" - resolved "https://registry.yarnpkg.com/react/-/react-19.0.0.tgz#6e1969251b9f108870aa4bff37a0ce9ddfaaabdd" - integrity sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ== - -readdirp@^5.0.0: - version "5.1.1" - resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-5.1.1.tgz#520bca06f9d1ae1b96cc0800dbe84b983d19422c" - integrity sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA== - -sass@^1.62.1: - version "1.103.1" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.103.1.tgz#13b70f5ff69288db956dc27d27c8fb79f3a27c61" - integrity sha512-9icZURbP51S6S0QGoyaeqk9uB06GNWxsFYWfH5RgpFgqK5FA8tJcM3AdVxrZEVJ7dz+L87nG95gBKf4VuaMHGw== - dependencies: - chokidar "^5.0.0" - immutable "^5.1.5" - source-map-js ">=0.6.2 <2.0.0" - optionalDependencies: - "@parcel/watcher" "^2.4.1" - -scheduler@^0.25.0: - version "0.25.0" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.25.0.tgz#336cd9768e8cceebf52d3c80e3dcf5de23e7e015" - integrity sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA== - -semver@^7.6.3: - version "7.8.5" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69" - integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA== - -sharp@^0.33.5: - version "0.33.5" - resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.33.5.tgz#13e0e4130cc309d6a9497596715240b2ec0c594e" - integrity sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw== - dependencies: - color "^4.2.3" - detect-libc "^2.0.3" - semver "^7.6.3" - optionalDependencies: - "@img/sharp-darwin-arm64" "0.33.5" - "@img/sharp-darwin-x64" "0.33.5" - "@img/sharp-libvips-darwin-arm64" "1.0.4" - "@img/sharp-libvips-darwin-x64" "1.0.4" - "@img/sharp-libvips-linux-arm" "1.0.5" - "@img/sharp-libvips-linux-arm64" "1.0.4" - "@img/sharp-libvips-linux-s390x" "1.0.4" - "@img/sharp-libvips-linux-x64" "1.0.4" - "@img/sharp-libvips-linuxmusl-arm64" "1.0.4" - "@img/sharp-libvips-linuxmusl-x64" "1.0.4" - "@img/sharp-linux-arm" "0.33.5" - "@img/sharp-linux-arm64" "0.33.5" - "@img/sharp-linux-s390x" "0.33.5" - "@img/sharp-linux-x64" "0.33.5" - "@img/sharp-linuxmusl-arm64" "0.33.5" - "@img/sharp-linuxmusl-x64" "0.33.5" - "@img/sharp-wasm32" "0.33.5" - "@img/sharp-win32-ia32" "0.33.5" - "@img/sharp-win32-x64" "0.33.5" - -simple-swizzle@^0.2.2: - version "0.2.4" - resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.4.tgz#a8d11a45a11600d6a1ecdff6363329e3648c3667" - integrity sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw== - dependencies: - is-arrayish "^0.3.1" - -"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2: - version "1.2.1" - resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" - integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== - -streamsearch@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" - integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== - -styled-jsx@5.1.6: - version "5.1.6" - resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.1.6.tgz#83b90c077e6c6a80f7f5e8781d0f311b2fe41499" - integrity sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA== - dependencies: - client-only "0.0.1" - -tsafe@^1.8.5: - version "1.8.12" - resolved "https://registry.yarnpkg.com/tsafe/-/tsafe-1.8.12.tgz#68a410b4b7687ef497b1c85904b1215532335c3e" - integrity sha512-nFRqW0ttu/2o6XTXsHiVZWJBCOaxhVqZLg7dgs3coZNsCMPXPfwz+zPHAQA+70fNnVJLAPg1EgGIqK9Q84tvAw== - -tslib@^2.4.0, tslib@^2.8.0: - version "2.8.1" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" - integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== - -typescript@^5.3.0: - version "5.9.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" - integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== - -undici-types@~6.21.0: - version "6.21.0" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" - integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== - -yargs-parser@^21.1.1: - version "21.1.1" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" - integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==