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 (
-
+
{buttons.map((buttonProps, i) => (
-
))}
+
);
})
diff --git a/src/Card.tsx b/src/Card.tsx
index 804ef5433..2614bf3e8 100644
--- a/src/Card.tsx
+++ b/src/Card.tsx
@@ -1,6 +1,7 @@
import React, {
memo,
forwardRef,
+ useId,
type ReactNode,
type CSSProperties,
type DetailedHTMLProps,
@@ -14,6 +15,7 @@ import type { RegisteredLinkProps } from "./link";
import { getLink } from "./link";
import { cx } from "./tools/cx";
import { useAnalyticsId } from "./tools/useAnalyticsId";
+import { HydrationRelease } from "./tools/HydrationRelease";
import { assert, type Equals } from "tsafe/assert";
//https://main--ds-gouv.netlify.app/example/component/card/
@@ -156,6 +158,8 @@ export const Card = memo(
"explicitlyProvidedId": props_id
});
+ const hydrationId = useId();
+
const { Link } = getLink();
return (
@@ -190,6 +194,7 @@ export const Card = memo(
className
)}
style={style}
+ data-fr-hydrating={hydrationId}
ref={ref}
{...rest}
>
@@ -265,6 +270,7 @@ export const Card = memo(
)}
)}
+
);
})
diff --git a/src/Header/Header.tsx b/src/Header/Header.tsx
index 4920249c3..20cff8d96 100644
--- a/src/Header/Header.tsx
+++ b/src/Header/Header.tsx
@@ -3,6 +3,7 @@ import type { JSX } from "../tools/JSX";
import React, {
memo,
forwardRef,
+ useId,
cloneElement,
type ReactNode,
type CSSProperties,
@@ -24,6 +25,7 @@ import { setBrandTopAndHomeLinkProps } from "../zz_internal/brandTopAndHomeLinkP
import { typeGuard } from "tsafe/typeGuard";
import { SearchButton } from "../SearchBar/SearchButton";
import { useTranslation as useSearchBarTranslation } from "../SearchBar/SearchBar";
+import { HydrationRelease } from "../tools/HydrationRelease";
export type HeaderProps = {
className?: string;
@@ -158,6 +160,8 @@ export const Header = memo(
const { t } = useTranslation();
const { t: tSearchBar } = useSearchBarTranslation();
+ const hydrationId = useId();
+
const { Link } = getLink();
const getQuickAccessNode = (usecase: "mobile" | "desktop") => (
@@ -204,6 +208,7 @@ export const Header = memo(
role="banner"
id={id}
className={cx(fr.cx("fr-header"), classes.root, className)}
+ data-fr-hydrating={hydrationId}
ref={ref}
style={style}
{...rest}
@@ -471,6 +476,7 @@ export const Header = memo(
)}
+
>
);
diff --git a/src/MainNavigation/MainNavigation.tsx b/src/MainNavigation/MainNavigation.tsx
index 35c45a9e6..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,6 +12,7 @@ import { Menu } from "./Menu";
import type { MegaMenuProps } from "./MegaMenu";
import { MegaMenu } from "./MegaMenu";
import { useAnalyticsId } from "../tools/useAnalyticsId";
+import { HydrationRelease } from "../tools/HydrationRelease";
export type MainNavigationProps = {
className?: string;
@@ -89,6 +90,8 @@ export const MainNavigation = memo(
"defaultIdPrefix": "main-navigation"
});
+ const hydrationId = useId();
+
const getMenuId = (i: number) => `${id}-menu-${i}`;
return (
@@ -98,6 +101,7 @@ export const MainNavigation = memo(
style={style}
role="navigation"
aria-label={t("main menu")}
+ data-fr-hydrating={hydrationId}
ref={ref}
{...rest}
>
@@ -180,6 +184,7 @@ export const MainNavigation = memo(
)
)}
+
);
})
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 (
);
})
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) => `
+
+`;
+
+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 }) => `
+
+
+`;
+
+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));