diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a47f5a66..1f5a3378 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,7 +1,8 @@ # Release & Publish # # Gated publish flow, one package at a time: -# 1. PR merged to main → triggers build & test +# 1. PR merged to main → triggers build & test, and the react package's +# suite on each React it supports (react-versions.yml) # 2. Each package publishes only when its code changed since its last # release tag (Elements: v*, migrate: migrate-v*) # 3. A reviewer approves each publish in the Actions UI (environment gate) @@ -160,9 +161,18 @@ jobs: echo "migrate=$MIGRATE" >> $GITHUB_OUTPUT echo "Elements changed: $ELEMENTS, migrate changed: $MIGRATE" + # The react package's suite on each React it supports, for this run's commit: Elements publishes only when every + # one passes (test.yml runs it too, but nothing waits for that run). + react-versions: + name: React versions + needs: changes + if: needs.changes.outputs.elements == 'true' + uses: ./.github/workflows/react-versions.yml + secrets: inherit + publish: name: Publish @unlayer/react-elements - needs: [build-and-test, changes] + needs: [build-and-test, changes, react-versions] if: needs.changes.outputs.elements == 'true' runs-on: ubuntu-latest outputs: diff --git a/.github/workflows/react-canary.yml b/.github/workflows/react-canary.yml new file mode 100644 index 00000000..35759c01 --- /dev/null +++ b/.github/workflows/react-canary.yml @@ -0,0 +1,53 @@ +name: React canary + +# Weekly, the react package's renderer (src/engine) against React's canary: what changes in React before it ships. +# It reports and doesn't block: a failure here is a heads-up to update the React version table +# (packages/react/src/engine/react-compat.ts) or the walker before that React is released. +on: + schedule: + - cron: '0 6 * * 1' # Mondays at 6am UTC + workflow_dispatch: + +jobs: + canary: + name: React canary (fuzz) + runs-on: ubuntu-latest + continue-on-error: true + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.7.0 + + - name: Authenticate npm registry + run: echo "//registry.npmjs.org/:_authToken=${{ secrets.TEST_NPM_TOKEN }}" > ~/.npmrc + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install React's canary + run: | + pnpm --filter @unlayer/react-elements add -D react@canary react-dom@canary + node -e "console.log('React', require('./packages/react/node_modules/react/package.json').version)" + + - name: Build + run: pnpm --filter @unlayer-internal/shared-elements build && pnpm --filter @unlayer/react-elements build + + # The walker against react-dom/server on random trees (useId()s included) and on random markup where templates + # have it, random templates against the last release, and the rest of the engine's tests (the self-check of an + # unknown React version among them). + - name: Fuzz + env: + WALKER_FUZZ_SEEDS: 5000 + TEMPLATE_SEEDS: 1000 + GENERATED_SEEDS: 3000 + run: pnpm --filter @unlayer/react-elements exec vitest run src/engine src/regression/generated.test.tsx diff --git a/.github/workflows/react-versions.yml b/.github/workflows/react-versions.yml new file mode 100644 index 00000000..7ec2d501 --- /dev/null +++ b/.github/workflows/react-versions.yml @@ -0,0 +1,69 @@ +# React versions +# +# Elements renders templates with its own renderer (src/engine), which must +# render as each React it supports does: the react package's suite on each +# one (its react and react-dom swapped in), in React's development build +# (the full suite) and its production build (the engine's, the renderers' +# and the regression tests: React's act(), which the component tests need, +# is in its development build only). It holds the walker against +# react-dom/server on hand-written and random trees, useId()s included +# (src/engine/differential.test.tsx), React's react-server build with a +# hook it lacks (src/regression/react-server-build.test.ts, React 19), and +# a hook the dispatcher doesn't know (src/engine/dispatcher.test.tsx). +# +# A workflow of its own, called by test.yml (pushes and pull requests) and by +# publish.yml, whose publish job waits for it: a release ships only a commit +# every React passed. `github.sha` is the caller's, so it tests the commit the +# caller tests or publishes. +name: React versions + +on: + workflow_call: + +jobs: + react-versions: + name: React ${{ matrix.react }} (${{ matrix.build }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + react: ["18.0.0", "18.2.0", "18.3.1", "19.1", "19.2", "latest"] + build: [development, production] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.7.0 + + - name: Authenticate npm registry + run: echo "//registry.npmjs.org/:_authToken=${{ secrets.TEST_NPM_TOKEN }}" > ~/.npmrc + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install React ${{ matrix.react }} + run: | + pnpm --filter @unlayer/react-elements add -D react@${{ matrix.react }} react-dom@${{ matrix.react }} + node -e "console.log('React', require('./packages/react/node_modules/react/package.json').version)" + + - name: Build + run: pnpm --filter @unlayer-internal/shared-elements build && pnpm --filter @unlayer/react-elements build + + - name: Tests (React's development build) + if: matrix.build == 'development' + run: pnpm --filter @unlayer/react-elements exec vitest run + + - name: Tests (React's production build) + if: matrix.build == 'production' + env: + NODE_ENV: production + run: pnpm --filter @unlayer/react-elements exec vitest run src/engine src/async src/regression src/utils/render-errors.test.tsx src/utils/nested-renders.test.tsx --exclude src/regression/client-render.test.tsx diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b18a61e8..2168541c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,9 +58,71 @@ jobs: run: pnpm --filter @unlayer/from-react-email typecheck && pnpm --filter @unlayer/migrate typecheck # ── Bundle size budget ────────────────────────────────────── - # Fail if the react-elements ESM bundle exceeds 118KB. - # Current size: about 113.4KB (0.1.22: about 76KB). Most of the growth is - # the render that collects a template's rows and blocks (one React render, + # Fail if the react-elements ESM bundle exceeds 198.5KB. + # Current size: about 198.3KB (0.1.22: about 76KB). Raised from 198KB + # (about 0.3KB): the plain text reads links before headings and + # preformatted text, each attribute whole, with their URLs set aside + # till the end, and doesn't wrap the URLs listed after the text. + # Raised from 194.5KB + # (about 3.4KB): markup is written as react-dom writes it for the + # installed React, a `'s value marking its options, the format context the walker carries for it); a ` + + … + +``` + +### style, className and skipInText +Every component takes these. +- `style?: CSSProperties` — inlined on the element it renders, after the renderer's own styles: a Paragraph's or Heading's text, a Button's link, an Image's `img`, a Column's cell, a Row's container, the root's body (the wrapper in it, on a web page). A number is px (but `lineHeight`, `fontWeight`, `opacity`, `zIndex`, `flex`, `order`). A prop given wins over the same property in `style`, and a value that could end the attribute or the rule is left out. +- `className?: string` — class names on the same element, before the renderer's own, for a stylesheet of yours (a ``'s `", fontWeight: 700 }} className={'a"b '}>Hi); + expect(out).not.toContain('onclick="x'); + expect(out).not.toContain("\""); + expect(out).toMatch(/class="ab c v-/); + expect(out).toMatch(/;font-weight:700"/); + }); + + it("keeps a `$` in a value as it is, on a tag without the attribute", () => { + const out = html(); + expect(out).toContain(`
Hi
`); + }); + + it("output without them is the same as before", () => { + const plain = (props: Record) => html(Hi); + expect(plain({ style: {}, className: "" })).toBe(plain({})); + expect(plain({ style: { color: undefined } })).toBe(plain({})); + }); + + it("the design leaves them out, and says so once per render", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const design = renderToJson(template(Email)); + for (let i = 0; i < 2; i++) renderToJson(template(Email)); + const said = warn.mock.calls.map((call) => String(call[0])); + expect(said.slice(0, 7)).toEqual([ + "[Unlayer] Email `style.outline`, `className` are kept in HTML but not in the design for the visual editor.", + "[Unlayer] Row `style.borderTop`, `className` are kept in HTML but not in the design for the visual editor.", + "[Unlayer] Column `style.verticalAlign`, `className` are kept in HTML but not in the design for the visual editor.", + "[Unlayer] Paragraph `style.textTransform`, `style.color`, `className` are kept in HTML but not in the design for the visual editor.", + "[Unlayer] Heading `style.letterSpacing`, `style.lineHeight`, `className` are kept in HTML but not in the design for the visual editor.", + "[Unlayer] Button `style.boxShadow`, `className` are kept in HTML but not in the design for the visual editor.", + "[Unlayer] Image `style.borderRadius`, `className` are kept in HTML but not in the design for the visual editor.", + ]); + // Once per render: three renders, each says it once. + expect(said).toHaveLength(21); + const values = JSON.stringify(design); + expect(values).not.toContain("textTransform"); + expect(values).not.toContain('"lead"'); + } finally { + warn.mockRestore(); + } + }); +}); + +describe("DX: what a block can't use is said, not dropped quietly", () => { + it("a prop a block has no use for (a link's `target` on a block without one)", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + renderToHtml( + + + + Hi + + A + Title + + + + ); + expect(warn.mock.calls.map((call) => String(call[0]))).toEqual([ + "[Unlayer] Paragraph ignores the prop `textTransfrom`.", + "[Unlayer] Button ignores the prop `onClick`.", + "[Unlayer] Image ignores the prop `height`.", + "[Unlayer] Heading ignores the prop `target`.", + ]); + } finally { + warn.mockRestore(); + } + }); + + it("a prop a slip away from one it takes: the warning names it", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const slips = { textColour: "#f00", fontsize: "20px", textAling: "left", lineHieght: "140%" }; + renderToHtml( + + + + + Hi + Title + + + + + ); + expect(warn.mock.calls.map((call) => String(call[0]))).toEqual([ + "[Unlayer] Menu ignores the prop `textColour`: did you mean `textColor`?", + "[Unlayer] Paragraph ignores the prop `fontsize`: did you mean `fontSize`?", + "[Unlayer] Heading ignores the prop `textAling`: did you mean `textAlign`?", + "[Unlayer] Heading ignores the prop `lineHieght`: did you mean `lineHeight`?", + "[Unlayer] Button ignores the prop `backgroundColour`: did you mean `backgroundColor`?", + ]); + } finally { + warn.mockRestore(); + } + }); +}); + +describe("DX: the names authors reach for (CSS's, React Email's) stand for the component's own prop", () => { + const menuItems = [{ text: "Home", href: "https://example.com" }]; + const json = (el: React.ReactElement) => itemValues(el) as Record; + + it("Menu: `color` is its items' color (linked or not), `textAlign` its alignment; its own props win", () => { + expect(json()).toMatchObject({ textColor: "#ff0000", linkColor: "#ff0000", align: "center" }); + expect(json()).toMatchObject({ textColor: "#00ff00", linkColor: "#ff0000", align: "right" }); + expect(json()).toMatchObject({ textColor: "#ff0000", linkColor: "#0000ff" }); + expect(json()).not.toHaveProperty("color"); + // The same in the HTML: a linked item is in the color too. + expect(html()).toBe(html()); + expect(html()).toMatch(/]*color: ?#ff0000[^>]*>\s*Home/); + }); + + it("Social: `textAlign` is its alignment", () => { + const icons = [{ name: "Facebook", url: "https://facebook.com/x" }]; + expect(json()).toMatchObject({ align: "right" }); + expect(html()).toBe(html()); + }); + + it("text blocks: `align` is `textAlign`, `textColor` is `color` (a Button's text color)", () => { + expect(json(Hi)).toMatchObject({ textAlign: "center", color: "#333333" }); + expect(json(Hi)).toMatchObject({ textAlign: "right", color: "#111111" }); + expect(json()).toMatchObject({ textAlign: "left", buttonColors: { color: "#ffffff" } }); + expect(json(Hi)).toMatchObject({ textAlign: "right" }); + expect(json()).toMatchObject({ textAlign: "right" }); + expect(json()).toMatchObject({ textAlign: "left" }); + // On a phone too. + expect(json()._override.mobile).toMatchObject({ align: "center" }); + }); + + it("Heading: React Email's `as` is its level; `headingType`, then `level`, win over it", () => { + expect(html(Hi)).toMatch(/]/); + expect(json(Hi)).toMatchObject({ headingType: "h2" }); + expect(json(Hi)).not.toHaveProperty("as"); + expect(json(Hi)).toMatchObject({ headingType: "h3" }); + expect(json(Hi)).toMatchObject({ headingType: "h4" }); + // A flat prop wins over the `values` escape hatch, as everywhere. + expect(json(Hi)).toMatchObject({ headingType: "h5" }); + expect(html(Hi)).toBe(html(Hi)); + }); + + it("an alias isn't said to be ignored", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + html(); + html(Hi); + html(Hi); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); +}); diff --git a/packages/react/src/dx-types.test-d.tsx b/packages/react/src/dx-types.test-d.tsx index c97710d2..b609783b 100644 --- a/packages/react/src/dx-types.test-d.tsx +++ b/packages/react/src/dx-types.test-d.tsx @@ -129,3 +129,42 @@ export const _phone_reject_visibility: RowProps = { hideOnMobile: "yes" }; // A custom tool's props are its own values, whatever their names (as in 0.1.22): a phone number called `mobile`. export const _tool_own_values: ElementsToolProps = { name: "Ada", mobile: "+1 555", hideOnMobile: "yes", hideOnDesktop: 0 }; + +// ── `values`: as many as you set, keys checked, values as the builder stores them ── +import type { EmailProps, SocialProps, ValuesInput, ParagraphValues } from "./index"; +export const _values_partial: ParagraphProps = { values: { anchor: "intro" } }; +export const _values_partial_nested: ButtonProps = { values: { buttonColors: { backgroundColor: "#000" } } }; +export const _values_device: DividerProps = { values: { _override: { mobile: { textAlign: "center", hideMobile: true } }, hideDesktop: true } }; +export const _values_column: ColumnProps = { values: { backgroundColor: "#fff", _meta: { htmlID: "x" } } }; +export const _values_root: EmailProps = { values: { popupWidth: "400px" } }; +// What a design may hold that its type doesn't say (a number padding, a weight as text): passed on as it is. +export const _values_loose_leaf: ParagraphProps = { values: { lineHeight: 1.5, fontWeight: "700", containerPadding: 12 } }; +export const _values_input: ValuesInput = { textAlign: "center" }; +// @ts-expect-error a misspelled key is caught (every component, Button and Image included) +export const _values_reject_key: ButtonProps = { values: { buttonColour: { color: "#fff" } } }; +// @ts-expect-error a misspelled key in a nested object too +export const _values_reject_nested: ButtonProps = { values: { buttonColors: { backgroundColour: "#000" } } }; +// @ts-expect-error and in a device's settings +export const _values_reject_device: ImageProps = { values: { _override: { mobile: { textAling: "left" } } } }; +// @ts-expect-error a nested object holds objects only where its type has one +export const _values_reject_shape: ParagraphProps = { values: { textAlign: { value: "left" } } }; + +// ── names authors reach for: aliases of a component's own prop ────────────── +// (The props the components take in JSX, not only the exported interfaces.) +import type { ComponentProps } from "react"; +import type { Heading as HeadingComponent, Menu as MenuComponent, Social as SocialComponent } from "./index"; +export const _alias_menu_color: ComponentProps = { color: "#ff0000", textAlign: "center" }; +export const _alias_social_align: SocialProps = { textAlign: "right" }; +export const _alias_social_component: ComponentProps = { textAlign: "right" }; +export const _alias_text: ParagraphProps = { align: "center", textColor: "#333" }; +export const _alias_heading: HeadingProps = { align: "right", textColor: "#111" }; +// React Email's ``: a heading level, as `headingType` and `level` take one. +export const _alias_heading_as: HeadingProps = { as: "h2" }; +export const _alias_heading_as_component: ComponentProps = { as: "h3", level: "h4" }; +// @ts-expect-error `as` takes a heading level, not any element +export const _alias_heading_as_reject: HeadingProps = { as: "div" }; +export const _alias_button: ButtonProps = { align: "left", textColor: "#fff" }; +export const _alias_image: ImageProps = { align: "center" }; +export const _alias_divider: DividerProps = { align: "center" }; +// @ts-expect-error an alias takes what its prop takes +export const _alias_reject: ParagraphProps = { align: "middle" }; diff --git a/packages/react/src/engine/async.test.tsx b/packages/react/src/engine/async.test.tsx new file mode 100644 index 00000000..ff5be22d --- /dev/null +++ b/packages/react/src/engine/async.test.tsx @@ -0,0 +1,151 @@ +// @vitest-environment node +/** + * The async walk: siblings' promises start at once, the output is in tree order whatever settles first, ids follow + * the tree, the error a render fails with is the first in tree order, and React's hooks dispatcher is the walker's + * only while it calls a component (never across an await). + */ +import { describe, expect, it, vi } from "vitest"; +import React, { useId } from "react"; +import { Column, Email, Paragraph, RenderError, Row, renderToHtml as renderToHtmlSync } from "../index"; +import { renderToHtml, renderToHtmlParts, renderToJson } from "../async"; +import { theSlot } from "./dispatcher"; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** A seeded random number generator (mulberry32), for delays. */ +function random(seed: number) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** A template whose parts load at random: async blocks, an async component of Rows, nested ones, all with ids. */ +function template(name: string, delay: () => number) { + const Block = async ({ text }: { text: string }) => { + const id = useId(); + await sleep(delay()); + return {`${name} ${text} ${id}`}; + }; + const Nested = async () => { + await sleep(delay()); + return <>; + }; + const Rows = async () => { + await sleep(delay()); + return [1, 2].map((n) => ); + }; + return ( + + + + {`${name} last`} + + ); +} + +describe("the async walk", () => { + it("starts siblings at once, and writes them in tree order", async () => { + const started: string[] = []; + const Late = async ({ text, ms }: { text: string; ms: number }) => { + started.push(text); + await sleep(ms); + return {text}; + }; + const t0 = Date.now(); + const html = await renderToHtml(); + // At once: about the slowest one's time, not the three's. + expect(Date.now() - t0).toBeLessThan(110); + expect(started).toEqual(["slow", "medium", "fast"]); + expect(html.indexOf("slow")).toBeLessThan(html.indexOf("medium")); + expect(html.indexOf("medium")).toBeLessThan(html.indexOf("fast")); + }); + + it("gives byte-identical output whatever order things settle in, ids by their place in the tree", async () => { + const outputs = new Set(); + const designs = new Set(); + for (let seed = 1; seed <= 40; seed++) { + const delay = random(seed); + const ms = () => Math.floor(delay() * 8); + outputs.add(await renderToHtml(template("A", ms))); + designs.add(JSON.stringify(await renderToJson(template("A", ms)))); + } + expect(outputs.size).toBe(1); + expect(designs.size).toBe(1); + expect([...outputs][0]).toMatch(/A nested two (:R\w+:|«R\w+»|_R_\w+_)/); + }); + + it("keeps two renders under way at once apart, with random delays", async () => { + const expected = { A: await renderToHtml(template("A", () => 0)), B: await renderToHtml(template("B", () => 0)) }; + for (let seed = 1; seed <= 20; seed++) { + const delay = random(seed); + const ms = () => Math.floor(delay() * 10); + const [a, b] = await Promise.all([renderToHtml(template("A", ms)), renderToHtml(template("B", ms))]); + expect(a).toBe(expected.A); + expect(b).toBe(expected.B); + } + }); + + it("fails with the first error in tree order, whatever fails first, without waiting for what comes after it", async () => { + const Fails = async ({ text, ms }: { text: string; ms: number }) => { + await sleep(ms); + throw new Error(text); + }; + const Never = () => { + throw new Promise(() => {}); + }; + for (const [first, second] of [[5, 30], [30, 5]]) { + const email = ; + await expect(renderToHtml(email)).rejects.toMatchObject({ message: "[Unlayer] first (in at Row 1 › Column 1 › Fails)" }); + } + }); + + it("leaves failed parts out (onError: \"omit\"), listed in tree order whatever fails first", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const Fails = async ({ text, ms }: { text: string; ms: number }) => { + await sleep(ms); + throw new Error(text); + }; + for (const [first, second] of [[5, 30], [30, 5]]) { + const email = Kept; + const parts = await renderToHtmlParts(email, { onError: "omit" }); + expect(parts.body).toContain("Kept"); + expect(parts.omitted.map((e) => [e.message, e.position])).toEqual([ + ["[Unlayer] first (in at Row 1 › Column 1 › Fails)", "Row 1 › Column 1 › Fails"], + ["[Unlayer] second (in at Row 1 › Column 1 › Fails 2)", "Row 1 › Column 1 › Fails 2"], + ]); + expect(parts.omitted.every((e) => e instanceof RenderError)).toBe(true); + } + } finally { + error.mockRestore(); + } + }); + + it("puts React's dispatcher back after each component, never holding it across an await", async () => { + const slot = theSlot()!; + const before = slot.get(); + const seen: unknown[] = []; + const Watch = async () => { + const id = useId(); // the walker's, while it calls this + await sleep(1); + seen.push(slot.get()); // after an await: not the walker's + return {id}; + }; + const render = renderToHtml(); + // While the render waits. + seen.push(slot.get()); + await render; + expect(seen).toEqual([before, before, before]); + expect(slot.get()).toBe(before); + // The synchronous renderers too, and one inside another. + const Inner = () => {renderToHtmlSync(inner).length > 0 ? "nested" : ""}; + expect(renderToHtmlSync()).toContain("nested"); + expect(slot.get()).toBe(before); + expect(React.isValidElement()).toBe(true); + }); +}); diff --git a/packages/react/src/engine/attributes.ts b/packages/react/src/engine/attributes.ts new file mode 100644 index 00000000..b9ed0f60 --- /dev/null +++ b/packages/react/src/engine/attributes.ts @@ -0,0 +1,150 @@ +/** + * An element's attributes as react-dom/server writes them (React 18's property table, React 19's attribute switch): + * which props it writes, under which name, and how it writes their values, its `style` included; and text, escaped as + * React escapes it. The walker's host markup (host.ts) and the string exporters (src/exporters) both write with these. + */ +import { REACT_18, REACT_19_3 } from "./react-compat"; + +const MATCH_HTML = /["'&<>]/; +const ESCAPES: Record = { '"': """, "&": "&", "'": "'", "<": "<", ">": ">" }; + +/** Text or an attribute's value, escaped as React escapes it. */ +export function escapeText(value: unknown): string { + const text = "" + value; + return MATCH_HTML.test(text) ? text.replace(/["'&<>]/g, (c) => ESCAPES[c]) : text; +} + +const NAME_START = ":A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD"; +/** An attribute name React writes (others it leaves out). */ +const SAFE_NAME = new RegExp(`^[${NAME_START}][${NAME_START}\\-.0-9\\u00B7\\u0300-\\u036F\\u203F-\\u2040]*$`); + +const JAVASCRIPT_URL = /^[\u0000-\u001F ]*j[\r\n\t]*a[\r\n\t]*v[\r\n\t]*a[\r\n\t]*s[\r\n\t]*c[\r\n\t]*r[\r\n\t]*i[\r\n\t]*p[\r\n\t]*t[\r\n\t]*:/i; +/** A URL attribute's value: React 19 writes a `javascript:` URL as one that throws. */ +const url = (value: string) => (JAVASCRIPT_URL.test(value) ? "javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')" : value); + +/** + * How React writes a prop it knows: as a string (S), as a boolean attribute (B: present or not), as either (O), as a + * number (N), a positive number (P), "true" or "false" (Y), a URL (U), or not at all (X). + */ +type Kind = "S" | "B" | "O" | "N" | "P" | "Y" | "U" | "X"; + +/** The props React writes otherwise than as a string under their own name: the attribute's name, and how. */ +const RULES = new Map(); +const rule = (names: string, kind: Kind, attribute: (name: string) => string = (name) => name) => { + for (const name of names.split(" ")) RULES.set(name, [attribute(name), kind]); +}; +const lower = (name: string) => name.toLowerCase(); +// SVG's attributes, XML's and two of HTML's, which their props name in camel case (React's list, and its camel case). +const CAMEL_CASED = "accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type xml:base xml:lang xml:space accept-charset http-equiv"; +for (const attribute of `${CAMEL_CASED}${REACT_18 ? "" : " transform-origin"}${REACT_19_3 ? " mask-type" : ""}`.split(" ")) { + RULES.set(attribute.replace(/[-:]([a-z])/g, (_, letter: string) => letter.toUpperCase()), [attribute, "S"]); +} +rule("className htmlFor tabIndex crossOrigin", "S", (name) => (name === "className" ? "class" : name === "htmlFor" ? "for" : lower(name))); +rule("defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning ref key", "X"); +rule("capture download", "O"); +rule("cols rows size span", "P"); +const BOOLEANS = "allowFullScreen async autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope"; +if (REACT_18) { + rule(`${BOOLEANS} autoFocus checked multiple muted selected`, "B", lower); + rule("contentEditable draggable spellCheck value", "Y", lower); + rule("autoReverse externalResourcesRequired focusable preserveAlpha", "Y"); + rule("rowSpan start", "N", lower); + rule("src href action formAction", "S", lower); + RULES.set("xlinkHref", ["xlink:href", "S"]); +} else { + rule(`${BOOLEANS} inert${REACT_19_3 ? " credentialless" : ""}`, "B"); + rule("autoFocus multiple muted", "B", lower); + rule("contentEditable draggable spellCheck value autoReverse externalResourcesRequired focusable preserveAlpha", "Y"); + rule("rowSpan start", "N"); + rule("src href action formAction", "U"); + RULES.set("xlinkHref", ["xlink:href", "U"]); +} + +const written = (name: string, value: unknown) => ` ${name}="${escapeText(value)}"`; + +/** An ``'s `data` as React 19 writes it: a URL, any value as text (an empty one left out). */ +export function objectData(value: unknown): string { + const data = url("" + value); + return data ? written("data", data) : ""; +} + +/** A prop of an element as React writes it (` name="value"`), or "" where it writes none. */ +export function attribute(name: string, value: unknown): string { + if (name === "style") return style(value); + const known = RULES.get(name); + // An event handler's prop (`onClick`, `onclick`). + if (!known && name.length > 2 && (name[0] === "o" || name[0] === "O") && (name[1] === "n" || name[1] === "N")) return ""; + const [attribute, kind] = known ?? [name, undefined]; + if (kind === "X" || typeof value === "function" || typeof value === "symbol") return ""; + switch (kind) { + case "B": + return value ? ` ${attribute}=""` : ""; + case "O": + return value === true ? ` ${attribute}=""` : value === false ? "" : written(attribute, value); + case "Y": + return written(attribute, value); + case "N": + case "P": + // (React 18 leaves a boolean out; React 19 writes it, as a number would be.) + return (REACT_18 && typeof value === "boolean") || isNaN(value as number) || (kind === "P" && !((value as number) >= 1)) ? "" : written(attribute, value); + case "U": + return typeof value === "boolean" || (value === "" && (name === "src" || name === "href")) ? "" : written(attribute, url("" + value)); + case "S": + return typeof value === "boolean" ? "" : written(attribute, value); + default: + // Any other: written as given, where it's a name an attribute can have (and a boolean only in data-* and aria-*). + return !SAFE_NAME.test(attribute) || (typeof value === "boolean" && !/^(data|aria)-/i.test(attribute)) ? "" : written(attribute, value); + } +} + +/** + * A prop of a custom element (``; in React 18 also one with an `is`): React 18 writes each as it's given + * (`className` too, a boolean as "true" or "false"), React 19 `className` as `class`, `true` as an empty value, and + * neither `false` nor an object. (A string handler, `onclick="…"`, is written as any other.) + */ +export function customAttribute(name: string, value: unknown): string { + if (name === "style") return style(value); + if (name === "suppressContentEditableWarning" || name === "suppressHydrationWarning" || typeof value === "function" || typeof value === "symbol") return ""; + if (REACT_18) return SAFE_NAME.test(name) ? written(name, value) : ""; + if (name === "className") name = "class"; + if (name === "ref" || name === "key" || value === false || (typeof value === "object" && value !== null) || !SAFE_NAME.test(name)) return ""; + return value === true ? ` ${name}=""` : written(name, value); +} + +// The CSS properties whose numbers have no unit: React 18's list, each with the vendor prefixes it adds; React 19's, +// which adds `scale` and lists its prefixed ones. +const UNITLESS = "animationIterationCount aspectRatio borderImageOutset borderImageSlice borderImageWidth boxFlex boxFlexGroup boxOrdinalGroup columnCount columns flex flexGrow flexPositive flexShrink flexNegative flexOrder gridArea gridRow gridRowEnd gridRowSpan gridRowStart gridColumn gridColumnEnd gridColumnSpan gridColumnStart fontWeight lineClamp lineHeight opacity order orphans tabSize widows zIndex zoom fillOpacity floodOpacity stopOpacity strokeDasharray strokeDashoffset strokeMiterlimit strokeOpacity strokeWidth".split(" "); +const UNITLESS_NUMBERS = new Set( + REACT_18 + ? UNITLESS.flatMap((name) => [name, ...["Webkit", "ms", "Moz", "O"].map((prefix) => prefix + name[0].toUpperCase() + name.slice(1))]) + : [ + ...UNITLESS, + ..."scale MozAnimationIterationCount MozBoxFlex MozBoxFlexGroup MozLineClamp msAnimationIterationCount msFlex msZoom msFlexGrow msFlexNegative msFlexOrder msFlexPositive msFlexShrink msGridColumn msGridColumnSpan msGridRow msGridRowSpan WebkitAnimationIterationCount WebkitBoxFlex WebKitBoxFlexGroup WebkitBoxOrdinalGroup WebkitColumnCount WebkitColumns WebkitFlex WebkitFlexGrow WebkitFlexPositive WebkitFlexShrink WebkitLineClamp".split(" "), + ] +); + +/** Style properties' names as CSS writes them (`marginTop`: `margin-top`), worked out once each: a thousand kept at most (a style's keys can come from data). */ +export const cssNames = new Map(); + +/** A `style` prop as React writes it: its declarations in order, names in kebab case, numbers in px where they take a unit. */ +export function style(value: unknown): string { + if (typeof value !== "object") throw new Error("The `style` prop expects a mapping from style properties to values, not a string. For example, style={{marginRight: spacing + 'em'}} when using JSX."); + let out = ""; + for (const name in value) { + if (!Object.prototype.hasOwnProperty.call(value, name)) continue; + const css = (value as Record)[name]; + if (css == null || typeof css === "boolean" || css === "") continue; + let declaration: string; + if (name.indexOf("--") === 0) declaration = `${escapeText(name)}:${escapeText(("" + css).trim())}`; + else { + let property = cssNames.get(name); + if (property === undefined) { + property = escapeText(name.replace(/([A-Z])/g, "-$1").toLowerCase().replace(/^ms-/, "-ms-")); + if (cssNames.size < 1000) cssNames.set(name, property); + } + declaration = `${property}:${typeof css === "number" ? (css !== 0 && !UNITLESS_NUMBERS.has(name) ? `${css}px` : "" + css) : escapeText(("" + css).trim())}`; + } + out += out ? ";" + declaration : declaration; + } + return out ? ` style="${out}"` : ""; +} diff --git a/packages/react/src/engine/differential.test.tsx b/packages/react/src/engine/differential.test.tsx new file mode 100644 index 00000000..1f7cf655 --- /dev/null +++ b/packages/react/src/engine/differential.test.tsx @@ -0,0 +1,486 @@ +// @vitest-environment node +/** + * The walker against react-dom/server: the same tree, through `renderToStaticMarkup` and through the engine, must give + * the same markup, `useId()`s included (in the installed React's format). Hand-written trees for what templates do, + * every element and kind of attribute React writes its own way, then random ones, alone and where templates have + * markup (`WALKER_FUZZ_SEEDS=5000` for a longer run, `TEMPLATE_SEEDS` for the templates'; CI's weekly canary runs it on + * React's canary). Where the walker means to differ (React 19's hoisting, its document singletons, forms' function + * actions, an error in a ), the differences are listed, each with why. + */ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import React, { Component, createContext, forwardRef, Fragment, memo, Suspense, useContext, useId, useMemo, useReducer, useRef, useState } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { renderStatic } from "./index"; +import { cssNames } from "./attributes"; +import { REACT_18 } from "./react-compat"; +import { Column, Page, Row, renderToHtmlParts, renderToJson } from "../index"; +import * as asyncRenderers from "../async"; + +const SEEDS = Number(process.env.WALKER_FUZZ_SEEDS ?? 300); +const TEMPLATE_SEEDS = Number(process.env.TEMPLATE_SEEDS ?? 40); +/** createElement, for props no element's types take (a number as a title, a function as a value). */ +const h = React.createElement as (type: any, props?: any, ...children: any[]) => React.ReactElement; +const Theme = createContext("light"); +const Lang = createContext("en"); +const provide = (context: React.Context, value: string, children: React.ReactNode) => React.createElement(context.Provider, { value }, children); + +function Ids({ n = 1, children }: { n?: number; children?: React.ReactNode }) { + const ids = Array.from({ length: n }, () => useId()); + return {children}; +} +const Themed = () => {`${useContext(Theme)}/${useContext(Lang)}`}; +const Memo = memo(({ children }: { children?: React.ReactNode }) => {children}); +const Ref = forwardRef(({ children }, _ref) => {children}); +class Classy extends Component<{ children?: React.ReactNode }> { + static contextType = Theme; + render() { + return {this.props.children}; + } +} +function Stateful({ children }: { children?: React.ReactNode }) { + const [count, setCount] = useState(0); + const [reduced] = useReducer((s: number, a: number) => s + a, 2); + const ref = useRef("ref"); + const memo = useMemo(() => count * 10, [count]); + if (count < 2) setCount(count + 1); + return

{children}

; +} + +/** react-dom's markup, but for React 19's image preloads (hoisted resource hints), which the walker doesn't write. */ +const reactDom = (node: React.ReactElement) => renderToStaticMarkup(node).replace(/]*\/>/g, ""); +const same = (node: React.ReactElement) => expect(renderStatic(node)).toBe(reactDom(node)); + +// React's development warnings for these trees (an attribute it doesn't know, a list without keys), and the walker's. +beforeAll(() => void vi.spyOn(console, "error").mockImplementation(() => {})); +afterAll(() => vi.restoreAllMocks()); + +describe("the walker against react-dom/server", () => { + it("writes the same markup: attributes, styles, escaping, void and preformatted elements", () => { + same( +