From 65e1fe8a077741d7a752efbf076b8b8ac3dfba02 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Dec 2025 09:28:25 +0000 Subject: [PATCH 1/4] Bump react and @types/react Bumps [react](https://github.com/facebook/react/tree/HEAD/packages/react) and [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react). These dependencies needed to be updated together. Updates `react` from 18.3.1 to 19.2.3 - [Release notes](https://github.com/facebook/react/releases) - [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/react/commits/v19.2.3/packages/react) Updates `@types/react` from 18.3.3 to 19.2.7 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react) --- updated-dependencies: - dependency-name: react dependency-version: 19.2.3 dependency-type: direct:production update-type: version-update:semver-major - dependency-name: "@types/react" dependency-version: 19.2.7 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- packages/alfa-react/package.json | 4 ++-- yarn.lock | 40 ++++++++++++-------------------- 2 files changed, 17 insertions(+), 27 deletions(-) diff --git a/packages/alfa-react/package.json b/packages/alfa-react/package.json index 7bb7b316..79eb9467 100644 --- a/packages/alfa-react/package.json +++ b/packages/alfa-react/package.json @@ -28,9 +28,9 @@ "@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": "^19.2.7", "@types/react-test-renderer": "^18.3.0", - "react": "^18.3.1", + "react": "^19.2.3", "react-test-renderer": "^18.3.1" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index f4eb4c29..6474deb8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3143,9 +3143,9 @@ __metadata: "@siteimprove/alfa-refinement": "npm:^0.108.2" "@siteimprove/alfa-test": "npm:^0.108.2" "@siteimprove/alfa-web": "npm:^0.108.2" - "@types/react": "npm:^18.3.3" + "@types/react": "npm:^19.2.7" "@types/react-test-renderer": "npm:^18.3.0" - react: "npm:^18.3.1" + react: "npm:^19.2.3" react-test-renderer: "npm:^18.3.1" peerDependencies: "@siteimprove/alfa-device": ^0.108.2 @@ -4136,13 +4136,6 @@ __metadata: languageName: node linkType: hard -"@types/prop-types@npm:*": - version: 15.7.5 - resolution: "@types/prop-types@npm:15.7.5" - checksum: 10/5b43b8b15415e1f298243165f1d44390403bb2bd42e662bca3b5b5633fdd39c938e91b7fce3a9483699db0f7a715d08cef220c121f723a634972fdf596aec980 - languageName: node - linkType: hard - "@types/react-test-renderer@npm:^18.3.0": version: 18.3.0 resolution: "@types/react-test-renderer@npm:18.3.0" @@ -4152,13 +4145,12 @@ __metadata: languageName: node linkType: hard -"@types/react@npm:*, @types/react@npm:^18.3.3": - version: 18.3.3 - resolution: "@types/react@npm:18.3.3" +"@types/react@npm:*, @types/react@npm:^19.2.7": + version: 19.2.7 + resolution: "@types/react@npm:19.2.7" dependencies: - "@types/prop-types": "npm:*" - csstype: "npm:^3.0.2" - checksum: 10/68e203b7f1f91d6cf21f33fc7af9d6d228035a26c83f514981e54aa3da695d0ec6af10c277c6336de1dd76c4adbe9563f3a21f80c4462000f41e5f370b46e96c + csstype: "npm:^3.2.2" + checksum: 10/dc0b756eee2c9782d282ae47eaa8d537b2a569eb889a6808c4b172d70fb690b2b1d8fe6239db451aa1c90d2a947cc21c9b537ce177ba9e6121468e403e4079c5 languageName: node linkType: hard @@ -6007,10 +5999,10 @@ __metadata: languageName: node linkType: hard -"csstype@npm:^3.0.2, csstype@npm:^3.1.3": - version: 3.1.3 - resolution: "csstype@npm:3.1.3" - checksum: 10/f593cce41ff5ade23f44e77521e3a1bcc2c64107041e1bf6c3c32adc5187d0d60983292fda326154d20b01079e24931aa5b08e4467cc488b60bb1e7f6d478ade +"csstype@npm:^3.1.3, csstype@npm:^3.2.2": + version: 3.2.3 + resolution: "csstype@npm:3.2.3" + checksum: 10/ad41baf7e2ffac65ab544d79107bf7cd1a4bb9bab9ac3302f59ab4ba655d5e30942a8ae46e10ba160c6f4ecea464cc95b975ca2fefbdeeacd6ac63f12f99fe1f languageName: node linkType: hard @@ -10273,12 +10265,10 @@ __metadata: languageName: node linkType: hard -"react@npm:^18.3.1": - version: 18.3.1 - resolution: "react@npm:18.3.1" - dependencies: - loose-envify: "npm:^1.1.0" - checksum: 10/261137d3f3993eaa2368a83110466fc0e558bc2c7f7ae7ca52d94f03aac945f45146bd85e5f481044db1758a1dbb57879e2fcdd33924e2dde1bdc550ce73f7bf +"react@npm:^19.2.3": + version: 19.2.3 + resolution: "react@npm:19.2.3" + checksum: 10/d16b7f35c0d35a56f63d9d1693819762e4abc479c57dd6310298920bed3820fcec7e17a99d44983416d8f5049143ea45b8005d3ab8324bae8973224400502b08 languageName: node linkType: hard From 95f8a8e671be49e6cad4144c7f4f66c1ff5ef2aa Mon Sep 17 00:00:00 2001 From: Jean-Yves Moyen Date: Wed, 17 Dec 2025 15:36:25 +0100 Subject: [PATCH 2/4] =?UTF-8?q?Adapt=20to=20React=E2=80=AF19?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/shaky-beans-begin.md | 7 + config/validate-structure.json | 2 +- packages/alfa-react/package.json | 14 +- packages/alfa-react/src/react.ts | 292 ++++++++------- packages/alfa-react/test/react.spec.tsx | 4 +- yarn.lock | 456 ++++++++++++++++++++---- 6 files changed, 551 insertions(+), 224 deletions(-) create mode 100644 .changeset/shaky-beans-begin.md 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/packages/alfa-react/package.json b/packages/alfa-react/package.json index 79eb9467..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": "^19.2.7", - "@types/react-test-renderer": "^18.3.0", + "jsdom": "^27.3.0", "react": "^19.2.3", - "react-test-renderer": "^18.3.1" + "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..4f7943e9 100644 --- a/packages/alfa-react/src/react.ts +++ b/packages/alfa-react/src/react.ts @@ -1,157 +1,179 @@ 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; -} +// 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(