Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/shaky-beans-begin.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion config/validate-structure.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
6 changes: 4 additions & 2 deletions docs/review/api/alfa-react.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Node.JSON>;
// (undocumented)
function toPage(value: Type): Page;
function toPage(value: Type): Promise<Page>;
// (undocumented)
type Type = ReactElement<unknown>;
type Type = ReactElement;
}
export { React_2 as React }

Expand Down
16 changes: 7 additions & 9 deletions packages/alfa-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
176 changes: 40 additions & 136 deletions packages/alfa-react/src/react.ts
Original file line number Diff line number Diff line change
@@ -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(`<!DOCTYPE html><body></body>`);
const { document } = window;
(global as any).window = window;
(global as any).document = document;

/**
* @public
*/
export namespace React {
export type Type = ReactElement<unknown>;

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<Node.JSON> {
// 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<Page> {
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<Array<Attribute.JSON>>(
(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<Attribute.JSON> {
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<string> {
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;
}
4 changes: 2 additions & 2 deletions packages/alfa-react/test/react.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ const Button: FunctionComponent<PropsWithChildren> = ({ children }) => (
<button className="btn">{children}</button>
);

test(`.toPage() creates an Alfa page`, (t) => {
const actual = React.toPage(<Button />);
test(`.toPage() creates an Alfa page`, async (t) => {
const actual = await React.toPage(<Button />);

const expected = Page.of(
Request.empty(),
Expand Down
Loading