diff --git a/.changeset/shaky-beans-begin.md b/.changeset/shaky-beans-begin.md new file mode 100644 index 00000000..55ef73d2 --- /dev/null +++ b/.changeset/shaky-beans-begin.md @@ -0,0 +1,7 @@ +--- +"@siteimprove/alfa-react": minor +--- + +**Breaking:** `React.toPage` is now `async`. A new `React.toNode` function is available to return the (Alfa) JSON representation of the React root of a React node. + +This adapts to React 19, mostly getting rid of `react-test-renderer` to render the React node before serializing them. We now use React renderer directly. diff --git a/config/validate-structure.json b/config/validate-structure.json index 1a08c5dd..9c9f5ea3 100644 --- a/config/validate-structure.json +++ b/config/validate-structure.json @@ -26,7 +26,7 @@ "@siteimprove/alfa-jquery": ["jquery"], "@siteimprove/alfa-playwright": ["playwright"], "@siteimprove/alfa-puppeteer": ["puppeteer"], - "@siteimprove/alfa-react": ["react", "react-test-renderer"], + "@siteimprove/alfa-react": ["jsdom","react", "react-dom"], "@siteimprove/alfa-selenium": ["selenium-webdriver"], "@siteimprove/alfa-scraper": ["puppeteer"], "@siteimprove/alfa-test-utils": ["axios", "chalk", "simple-git"], diff --git a/docs/review/api/alfa-react.api.md b/docs/review/api/alfa-react.api.md index bf295eaa..b9808a48 100644 --- a/docs/review/api/alfa-react.api.md +++ b/docs/review/api/alfa-react.api.md @@ -4,15 +4,17 @@ ```ts +import type { Node } from '@siteimprove/alfa-dom'; import { Page } from '@siteimprove/alfa-web'; import type { ReactElement } from 'react'; // @public (undocumented) namespace React_2 { + function toNode(value: Type): Promise; // (undocumented) - function toPage(value: Type): Page; + function toPage(value: Type): Promise; // (undocumented) - type Type = ReactElement; + type Type = ReactElement; } export { React_2 as React } diff --git a/packages/alfa-react/package.json b/packages/alfa-react/package.json index 7bb7b316..3b7108d4 100644 --- a/packages/alfa-react/package.json +++ b/packages/alfa-react/package.json @@ -25,23 +25,21 @@ "@siteimprove/alfa-device": "^0.108.2", "@siteimprove/alfa-dom": "^0.108.2", "@siteimprove/alfa-http": "^0.108.2", - "@siteimprove/alfa-option": "^0.108.2", - "@siteimprove/alfa-refinement": "^0.108.2", "@siteimprove/alfa-web": "^0.108.2", - "@types/react": "^18.3.3", - "@types/react-test-renderer": "^18.3.0", - "react": "^18.3.1", - "react-test-renderer": "^18.3.1" + "jsdom": "^27.3.0", + "react": "^19.2.3", + "react-dom": "^19.2.3" }, "devDependencies": { - "@siteimprove/alfa-test": "^0.108.2" + "@siteimprove/alfa-test": "^0.108.2", + "@types/jsdom": "^27", + "@types/react": "^19.2.7", + "@types/react-dom": "^19" }, "peerDependencies": { "@siteimprove/alfa-device": "^0.108.2", "@siteimprove/alfa-dom": "^0.108.2", "@siteimprove/alfa-http": "^0.108.2", - "@siteimprove/alfa-option": "^0.108.2", - "@siteimprove/alfa-refinement": "^0.108.2", "@siteimprove/alfa-web": "^0.108.2" }, "publishConfig": { diff --git a/packages/alfa-react/src/react.ts b/packages/alfa-react/src/react.ts index 42d6d1c8..e630941d 100644 --- a/packages/alfa-react/src/react.ts +++ b/packages/alfa-react/src/react.ts @@ -1,157 +1,61 @@ import { Device } from "@siteimprove/alfa-device"; -import type { Attribute, Node, Text } from "@siteimprove/alfa-dom"; -import { Document, Element, Namespace } from "@siteimprove/alfa-dom"; +import type { Node } from "@siteimprove/alfa-dom"; +import { Document, Element } from "@siteimprove/alfa-dom"; +import { Native } from "@siteimprove/alfa-dom/native"; import { Request, Response } from "@siteimprove/alfa-http"; -import { None, Option } from "@siteimprove/alfa-option"; -import { Refinement } from "@siteimprove/alfa-refinement"; import { Page } from "@siteimprove/alfa-web"; +import { JSDOM } from "jsdom"; import type { ReactElement } from "react"; -import * as TestRenderer from "react-test-renderer"; +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; -const { keys } = Object; -const { isBoolean, isObject, isString } = Refinement; +// Setup JSDOM environment, so we can have DOM bindings for React to use. +const { window } = new JSDOM(``); +const { document } = window; +(global as any).window = window; +(global as any).document = document; /** * @public */ export namespace React { - export type Type = ReactElement; - - export function toPage(value: Type): Page { - const tree = TestRenderer.create(value).toJSON(); + export type Type = ReactElement; + + /** + * Returns an Alfa JSON representation of the React root around the rendered + * element(s). + * + * @remarks + * There may be several children in the root in case of React fragments. + */ + export async function toNode(value: Type): Promise { + // Create a new React root for each render to avoid conflicts. + const div = document.createElement("div"); + const root = createRoot(div); + + // flushSync is needed to wait for the render to complete before proceeding. + flushSync(() => root.render(value)); + + return Native.fromNode(div); + } - if (tree === null) { - throw new Error("Could not render React element"); - } + export async function toPage(value: Type): Promise { + const reactRoot = await toNode(value); - const children = (Array.isArray(tree) ? tree : [tree]).map((element) => - Element.from(toElement(element)) + // 1. We do not convert the React root since we need the children to be + // orphaned in order for the document to adopt them. + // 2. We do not have a device in this context, so we drop the meaningless + // layout information. + const elements = (reactRoot.children ?? []).map((json) => + Element.from(json), ); return Page.of( Request.empty(), Response.empty(), - Document.of(children), - Device.standard() + Document.of(elements), + Device.standard(), ); } } - -type TestNode = TestElement | string; - -type TestElement = TestRenderer.ReactTestRendererJSON; - -function toNode(node: TestNode): Node.JSON { - return isString(node) ? toText(node) : toElement(node); -} - -function toElement(element: TestElement): Element.JSON { - const { type: name, props, children } = element; - - const attributes = keys(props).reduce>( - (attributes, prop) => { - for (const attribute of toAttribute(prop, props[prop])) { - attributes.push(attribute); - } - - return attributes; - }, - [] - ); - - return { - type: "element", - namespace: Namespace.HTML, - prefix: null, - name, - attributes, - style: null, - children: children?.map(toNode) ?? [], - shadow: null, - content: null, - box: null, - }; -} - -function toAttribute(name: string, value: unknown): Option { - switch (value) { - // Attributes that are either `null` or `undefined` are always ignored. - case null: - case undefined: - return None; - } - - name = toAttributeName(name); - - return toAttributeValue(name, value).map((value) => { - return { - type: "attribute", - namespace: null, - prefix: null, - name, - value, - }; - }); -} - -function toAttributeName(name: string): string { - switch (name) { - case "className": - return "class"; - - case "htmlFor": - return "for"; - } - - return name; -} - -function toAttributeValue(name: string, value: unknown): Option { - switch (name) { - case "style": - if (isObject(value)) { - return Option.of(toInlineStyle(value)); - } - } - - if (name.startsWith("aria-") && isBoolean(value)) { - return Option.of(String(value)); - } - - switch (value) { - case false: - return None; - - case true: - return Option.of(name); - } - - return Option.of(String(value)); -} - -function toText(data: string): Text.JSON { - return { - type: "text", - data, - box: null, - }; -} - -function toInlineStyle(props: { [key: string]: unknown }): string { - let style = ""; - let delimiter = ""; - - for (const prop of keys(props)) { - if (props[prop]) { - style += prop.replace(/([A-Z])/g, "-$1").toLowerCase(); - style += ": "; - style += String(props[prop]).trim(); - style += delimiter; - - delimiter = ";"; - } - } - - return style; -} diff --git a/packages/alfa-react/test/react.spec.tsx b/packages/alfa-react/test/react.spec.tsx index 755527a8..5ddcc17a 100644 --- a/packages/alfa-react/test/react.spec.tsx +++ b/packages/alfa-react/test/react.spec.tsx @@ -12,8 +12,8 @@ const Button: FunctionComponent = ({ children }) => ( ); -test(`.toPage() creates an Alfa page`, (t) => { - const actual = React.toPage(