diff --git a/patches/@gouvfr+dsfr+1.14.2.patch b/patches/@gouvfr+dsfr+1.14.2.patch index 9f7764bef..ef165897d 100644 --- a/patches/@gouvfr+dsfr+1.14.2.patch +++ b/patches/@gouvfr+dsfr+1.14.2.patch @@ -8,10 +8,94 @@ 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..8e30846 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,24 @@ const queryParentSelector = (element, selectors) => { + return queryParentSelector(parent, selectors); + }; + ++// 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 tant qu'il ++// n'est pas libéré, soit par le retrait de l'attribut, soit par ++// dsfr.hydrated(id) quand l'attribut porte un identifiant. Dans ce second cas, ++// le moteur ne modifie pas le DOM : il retient l'élément libéré. ++const HYDRATING_ATTRIBUTE = ns.attr('hydrating'); ++const HYDRATING_SELECTOR = ns.attr.selector('hydrating'); ++const hydratedGates = new WeakSet(); ++ ++const isHydrating = (node) => { ++ let gate = node.closest(HYDRATING_SELECTOR); ++ while (gate) { ++ if (!hydratedGates.has(gate)) return true; ++ gate = gate.parentElement && gate.parentElement.closest(HYDRATING_SELECTOR); ++ } ++ return false; ++}; ++ + class Registration { + constructor (selector, InstanceClass, creator) { + this.selector = selector; +@@ -441,7 +459,9 @@ 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; ++ // 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 => !isHydrating(n)); + } + + create (element) { +@@ -1149,6 +1169,15 @@ class Engine { + state.isActive = false; + rootDispatch(RootEvent.STOP); + } ++ ++ // À appeler par le framework une fois hydraté le sous-arbre marqué ++ // data-fr-hydrating="" : il est alors dans le document. Avant le ++ // démarrage, la libération est retenue et appliquée par start(). ++ hydrated (id) { ++ const gates = querySelectorAllArray(document.documentElement, HYDRATING_SELECTOR).filter(gate => gate.getAttribute(HYDRATING_ATTRIBUTE) === id); ++ for (const gate of gates) hydratedGates.add(gate); ++ if (state.isActive) for (const gate of gates) state.getModule('stage').parse(gate); ++ } + } + + const engine = new Engine(); +@@ -1441,6 +1470,7 @@ api$1.version = config.version; + + api$1.start = engine.start; + api$1.stop = engine.stop; ++api$1.hydrated = engine.hydrated; + + api$1.inspector = inspector; + api$1.colors = colors; +@@ -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/src/Accordion.tsx b/src/Accordion.tsx index 86165e64f..71f2a62df 100644 --- a/src/Accordion.tsx +++ b/src/Accordion.tsx @@ -2,6 +2,7 @@ import React, { forwardRef, + useId, memo, useState, useEffect, @@ -15,6 +16,7 @@ import { cx } from "./tools/cx"; import { symToStr } from "tsafe/symToStr"; import { useConstCallback } from "./tools/powerhooks/useConstCallback"; import { useAnalyticsId } from "./tools/useAnalyticsId"; +import { HydrationRelease } from "./tools/HydrationRelease"; export type AccordionProps = AccordionProps.Controlled | AccordionProps.Uncontrolled; @@ -72,6 +74,8 @@ export const Accordion = memo( "explicitlyProvidedId": id_props }); + const hydrationId = useId(); + const collapseElementId = `${id}-collapse`; const [isExpanded, setIsExpanded] = useState(expanded_props ?? defaultExpanded); @@ -100,6 +104,7 @@ export const Accordion = memo(
@@ -118,6 +123,7 @@ export const Accordion = memo(
{children}
+
); }) diff --git a/src/Breadcrumb.tsx b/src/Breadcrumb.tsx index 4079d46be..442d5d867 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 { HydrationRelease } from "./tools/HydrationRelease"; export type BreadcrumbProps = { id?: string; @@ -43,6 +44,8 @@ export const Breadcrumb = memo( "explicitlyProvidedId": props_id }); + const hydrationId = useId(); + const { t } = useTranslation(); const { Link } = getLink(); @@ -51,6 +54,7 @@ export const Breadcrumb = memo( return ( ); }) diff --git a/src/ButtonsGroup.tsx b/src/ButtonsGroup.tsx index 0e2498e13..fbcc2deea 100644 --- a/src/ButtonsGroup.tsx +++ b/src/ButtonsGroup.tsx @@ -1,4 +1,4 @@ -import React, { memo, forwardRef, type CSSProperties } from "react"; +import React, { memo, forwardRef, useId, type CSSProperties } from "react"; import { Button } from "./Button"; import { ButtonProps } from "./Button"; import { symToStr } from "tsafe/symToStr"; @@ -7,6 +7,7 @@ import type { Equals } from "tsafe"; import { fr } from "./fr"; import { cx } from "./tools/cx"; import { useAnalyticsId } from "./tools/useAnalyticsId"; +import { HydrationRelease } from "./tools/HydrationRelease"; export type ButtonsGroupProps = ButtonsGroupProps.AlwaysStacked | ButtonsGroupProps.Inline; @@ -71,6 +72,8 @@ export const ButtonsGroup = memo( "explicitlyProvidedId": props_id }); + const hydrationId = useId(); + const buttonsGroupClassName = cx( fr.cx( "fr-btns-group", @@ -105,12 +108,20 @@ export const ButtonsGroup = memo( ); return ( - + ); }) diff --git a/src/Modal/Modal.tsx b/src/Modal/Modal.tsx index f63303c48..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,6 +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 { HydrationRelease } from "../tools/HydrationRelease"; export type ModalProps = { className?: string; @@ -66,6 +67,7 @@ const Modal = memo( : [buttons_props]; const { t } = useTranslation(); + const hydrationId = useId(); const titleId = `fr-modal-title-${id}`; return ( @@ -173,6 +176,7 @@ const Modal = memo( + ); }) diff --git a/src/Range.tsx b/src/Range.tsx index 99c8c4221..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,6 +14,7 @@ import { type CxArg } from "tss-react"; import { fr } from "./fr"; import { cx } from "./tools/cx"; import { useAnalyticsId } from "./tools/useAnalyticsId"; +import { HydrationRelease } from "./tools/HydrationRelease"; export type RangeProps = { id?: string; @@ -106,6 +108,8 @@ export const Range = memo( "explicitlyProvidedId": props_id }); + const hydrationId = useId(); + const labelId = `${id}-label`; const errorMessageId = `${id}-message-error`; @@ -125,6 +129,7 @@ export const Range = memo( className )} style={style} + data-fr-hydrating={hydrationId} ref={ref} id={`${id}-group`} {...rest} @@ -219,6 +224,7 @@ export const Range = memo( {stateRelatedMessage}

+ ); }) diff --git a/src/SegmentedControl.tsx b/src/SegmentedControl.tsx index 04636d2fb..18b023b3b 100644 --- a/src/SegmentedControl.tsx +++ b/src/SegmentedControl.tsx @@ -12,6 +12,7 @@ import React from "react"; import { type CxArg } from "tss-react"; import { cx } from "./tools/cx"; import { useAnalyticsId } from "./tools/useAnalyticsId"; +import { HydrationRelease } from "./tools/HydrationRelease"; export type SegmentedControlProps = { id?: string; @@ -111,6 +112,8 @@ export const SegmentedControl = memo( "explicitlyProvidedId": props_id }); + const hydrationId = useId(); + const getInputId = (i: number) => `${id}-${i}`; const segmentedName = (function useClosure() { @@ -131,6 +134,7 @@ export const SegmentedControl = memo( classes.root, className )} + data-fr-hydrating={hydrationId} ref={ref} style={style} {...rest} @@ -190,6 +194,7 @@ export const SegmentedControl = memo( ); })} + ); }) diff --git a/src/SideMenu.tsx b/src/SideMenu.tsx index 8819cc71e..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,6 +7,7 @@ import { getLink } from "./link"; import { fr } from "./fr"; import { cx } from "./tools/cx"; import { useAnalyticsId } from "./tools/useAnalyticsId"; +import { HydrationRelease } from "./tools/HydrationRelease"; //https://main--ds-gouv.netlify.app/example/component/sidemenu/ export type SideMenuProps = { @@ -75,6 +76,8 @@ export const SideMenu = memo( "explicitlyProvidedId": id_props }); + const hydrationId = useId(); + const collapseId = `${id}-collapse`; const titleId = `${id}-title`; @@ -89,6 +92,7 @@ export const SideMenu = memo( ); }) diff --git a/src/Table.tsx b/src/Table.tsx index 7a67b7a84..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,6 +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 { HydrationRelease } from "./tools/HydrationRelease"; export type TableProps = { id?: string; @@ -64,9 +65,12 @@ export const Table = memo( "explicitlyProvidedId": id_props }); + const hydrationId = useId(); + return (
+
); }) diff --git a/src/Tabs.tsx b/src/Tabs.tsx index bc2ff3632..fede6b5e6 100644 --- a/src/Tabs.tsx +++ b/src/Tabs.tsx @@ -9,6 +9,7 @@ import { assert } from "tsafe/assert"; import type { Equals } from "tsafe"; import { useCallbackFactory } from "./tools/powerhooks/useCallbackFactory"; import { useAnalyticsId } from "./tools/useAnalyticsId"; +import { HydrationRelease } from "./tools/HydrationRelease"; export type TabsProps = TabsProps.Uncontrolled | TabsProps.Controlled; @@ -70,6 +71,8 @@ export const Tabs = memo( "explicitlyProvidedId": id_props }); + const hydrationId = useId(); + const selectedTabIndex = useMemo(() => { const index = tabs.findIndex(tab => "content" in tab ? tab.isDefault ?? false : tab.tabId === selectedTabId @@ -131,6 +134,7 @@ export const Tabs = memo(
)} +
); }) diff --git a/src/Tag.tsx b/src/Tag.tsx index 02347303d..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,6 +18,7 @@ import { assert } from "tsafe/assert"; import type { Equals } from "tsafe"; import { symToStr } from "tsafe/symToStr"; import { useAnalyticsId } from "./tools/useAnalyticsId"; +import { HydrationRelease } from "./tools/HydrationRelease"; type DataAttribute = Record<`data-${string}`, string | boolean | null | undefined>; @@ -127,6 +129,8 @@ export const Tag = memo( "explicitlyProvidedId": id_props }); + const hydrationId = useId(); + const { Link } = getLink(); const className = cx( @@ -174,11 +178,13 @@ export const Tag = memo( title={title ?? nativeButtonProps?.title} onClick={onClick ?? nativeButtonProps?.onClick} disabled={nativeButtonProps?.disabled} + data-fr-hydrating={hydrationId} ref={ref as React.ForwardedRef} aria-pressed={pressed} {...rest} > {children} + )} {linkProps === undefined && diff --git a/src/Tile.tsx b/src/Tile.tsx index b43091b47..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,6 +8,7 @@ import { fr } from "./fr"; import { getLink, type RegisteredLinkProps } from "./link"; import { cx } from "./tools/cx"; import { useAnalyticsId } from "./tools/useAnalyticsId"; +import { HydrationRelease } from "./tools/HydrationRelease"; //https://main--ds-gouv.netlify.app/example/component/tile/ export type TileProps = { @@ -136,6 +137,8 @@ export const Tile = memo( "explicitlyProvidedId": id_props }); + const hydrationId = useId(); + return (
)} +
); }) diff --git a/src/ToggleSwitch.tsx b/src/ToggleSwitch.tsx index 5a53fb31d..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,6 +15,7 @@ import { fr } from "./fr"; import { createComponentI18nApi } from "./i18n"; import { useConstCallback } from "./tools/powerhooks/useConstCallback"; import { useAnalyticsId } from "./tools/useAnalyticsId"; +import { HydrationRelease } from "./tools/HydrationRelease"; export type ToggleSwitchProps = ToggleSwitchProps.Controlled | ToggleSwitchProps.Uncontrolled; @@ -69,6 +78,8 @@ export const ToggleSwitch = memo( "explicitlyProvidedId": id_props }); + const hydrationId = useId(); + const inputId = `${id}-input`; const hintId = `${id}-hint-text`; @@ -106,6 +117,7 @@ export const ToggleSwitch = memo( classes.root, className )} + data-fr-hydrating={hydrationId} ref={ref} style={style} > @@ -135,6 +147,7 @@ export const ToggleSwitch = memo( {helperText}

)} + ); }) diff --git a/src/Tooltip.tsx b/src/Tooltip.tsx index 29b14d716..68f43acb7 100644 --- a/src/Tooltip.tsx +++ b/src/Tooltip.tsx @@ -1,9 +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 { HydrationRelease } from "./tools/HydrationRelease"; import { createComponentI18nApi } from "./i18n"; import { fr } from "./fr"; import { cx } from "./tools/cx"; @@ -42,6 +43,8 @@ export const Tooltip = memo( "explicitlyProvidedId": id_prop }); + const hydrationId = useId(); + return ( <> {kind === "click" ? ( @@ -69,11 +72,13 @@ export const Tooltip = memo( {title} + ); diff --git a/src/blocks/PasswordInput.tsx b/src/blocks/PasswordInput.tsx index d6e7c3031..a83abc7cd 100644 --- a/src/blocks/PasswordInput.tsx +++ b/src/blocks/PasswordInput.tsx @@ -18,6 +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 { HydrationRelease } from "../tools/HydrationRelease"; export type PasswordInputProps = Omit< InputProps.Common, @@ -72,6 +73,8 @@ export const PasswordInput = memo( "defaultIdPrefix": "password-input" }); + const hydrationId = useId(); + const inputId = (function useClosure() { const id = useId(); @@ -133,6 +136,7 @@ export const PasswordInput = memo( )} id={id} style={style} + data-fr-hydrating={hydrationId} ref={ref} {...rest} > @@ -209,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 a1d6799b7..0c6d417f8 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, which the + // DSFR engine leaves alone until they release it once hydrated (see + // HydrationRelease). 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/src/shared/Fieldset.tsx b/src/shared/Fieldset.tsx index 56d01967d..cb8c0ec1a 100644 --- a/src/shared/Fieldset.tsx +++ b/src/shared/Fieldset.tsx @@ -12,6 +12,7 @@ import type { Equals } from "tsafe"; import { cx } from "../tools/cx"; import { fr } from "../fr"; import { useAnalyticsId } from "../tools/useAnalyticsId"; +import { HydrationRelease } from "../tools/HydrationRelease"; export type FieldsetProps = FieldsetProps.Radio | FieldsetProps.Checkbox; @@ -92,6 +93,8 @@ export const Fieldset = memo( "explicitlyProvidedId": id_props }); + const hydrationId = useId(); + const getInputId = (i: number) => `${id}-${i}`; const legendId = `${id}-legend`; @@ -129,6 +132,7 @@ export const Fieldset = memo( isRoot ? className : undefined, classes.inputGroup )} + data-fr-hydrating={isRoot ? hydrationId : undefined} key={i} > @@ -141,6 +145,7 @@ export const Fieldset = memo( {"illustration" in rest && (
{rest.illustration}
)} + {isRoot && } ); }; @@ -187,6 +192,7 @@ export const Fieldset = memo( aria-labelledby={cx(legend !== undefined && legendId, messagesWrapperId)} role={state === "default" ? undefined : "group"} {...rest} + data-fr-hydrating={hydrationId} ref={ref} > {legend !== undefined && ( @@ -233,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/test/runtime/lib/dsfrHydrationGate.test.ts b/test/runtime/lib/dsfrHydrationGate.test.ts new file mode 100644 index 000000000..dd0a64b9c --- /dev/null +++ b/test/runtime/lib/dsfrHydrationGate.test.ts @@ -0,0 +1,232 @@ +// @vitest-environment node +import { describe, it, expect } from "vitest"; +import type { DOMWindow } from "jsdom"; +import { loadEngine, nextFrames } from "./loadDsfrEngine"; + +const accordionMarkup = (id: string) => ` +
+

+ +

+
Content
+
+`; + +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(); + }); +}); + +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(); + }); +}); 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/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\(\)/); + }); +}); 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/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));