diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a47f5a66..86d1dea8 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: @@ -322,11 +332,13 @@ jobs: # version numbers, then npm publish handles OIDC trusted publishing. # npm publish alone does NOT understand catalog: and would ship broken deps. # No --provenance flag yet: the repository is public now, so it can be added. + # A version can't be changed once it's on npm: the tarball must have its license (see test.yml's pack step). - name: Publish to npm (trusted publishing) working-directory: packages/react run: | pnpm pack --pack-destination /tmp TARBALL=$(ls /tmp/unlayer-react-elements-*.tgz) + tar tzf "$TARBALL" | grep -qx package/LICENSE || { echo "::error::$TARBALL has no LICENSE"; exit 1; } npm publish "$TARBALL" --access public - name: Tag release @@ -498,6 +510,7 @@ jobs: run: | pnpm pack --pack-destination /tmp TARBALL=$(ls /tmp/unlayer-migrate-*.tgz) + tar tzf "$TARBALL" | grep -qx package/LICENSE || { echo "::error::$TARBALL has no LICENSE"; exit 1; } npm publish "$TARBALL" --access public - name: Tag release 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..721e0ab2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,9 +58,135 @@ 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 221KB. + # Current size: about 225KB (0.1.22: about 76KB). Raised from 224.5KB + # (about 1.1KB with the round before): rich text's stray brackets kept as + # text, a root's ids kept to what they hold, object children failing the + # render, style values' double quotes written as references, Tailwind + # widths on images with a natural size, and a Heading's level checked. + # Raised from 222.5KB + # (about 1.7KB): an email service's tags kept whole in background URLs + # with their encoding checked, rich text's comments left out and its + # double quotes as written, a lone Link's tags kept, a Table's cells from + # data written as text, and plain text's heading tags kept as written. + # Raised from 221KB + # (about 1.1KB): display conditions written around rows and blocks, an + # email service's tags kept whole in rich text and background URLs, a tag + # that never ends left out of rich text, and a Table's typed props named. + # Raised from 219.5KB + # (about 1.8KB): a render's work shared across copies of Elements and + # with all the code it runs, plain text's placeholders kept from text + # that decodes to them and blocked URLs left out, and text placed + # straight in a Row or Column said once per render. Raised from 218KB + # (about 1.5KB): a render's shared work kept to its own render (template + # wrappers, nested renders), code themes read by their own names, and + # plain text's skip names read as written. Raised from 216KB + # (about 1.9KB): Markdown's and code's work is shared across a render, + # not counted per block, plain text's tag names are indexed by their + # parts, and a rich text block's markup is given to with the + # render's other classes. Raised from 207KB + # (about 8.7KB): HTML's plain text (htmlToPlainText) is read in time + # linear in its size by scanners of its own, where its regexes read + # unclosed tags again from each place they could start (40KB took a + # minute); a content prop given as null renders nothing, a Video's id + # can't end its player's URL, and rich text's markup is read in time + # linear in its size. Raised from 206KB + # (about 1KB): a Row's anchor, a Video's link and thumbnail and a + # Social icon's image are kept from adding markup, a Font's + # javascript: URL loads nothing, Tailwind classes in a Heading's or a + # Button's children and on a lone are inlined, a prop given as + # null is one not given, and a "}'), + h("script", null, 1), + h("script", null, ["a", "b"]), + h("script", null, ["a<"]), + h("script", null, h("b", null, "x")), + h("script", null, h(Css)), + // What would end one, from a component, a list, or where it is (react-dom's, outside a : see head.test.tsx). + h("style", null, h(Hostile, { text: "red}"]), + h("script", { type: "application/ld+json" }, h(Hostile, { text: '{"name":""}' })), + h("script", null, [h(Hostile, { key: "a", text: ""]), + h("svg", null, h("style", null, "")), + h("SVG", null, h("style", null, "")), + h("math", null, h("mi", null, h("style", null, ""))), + h("noscript", null, h("style", null, ""), h("script", null, "")), + h("title", null, h("style", null, "")), + h("script", { dangerouslySetInnerHTML: { __html: "a" } })), + h("noscript", null, h("title", null, "x"), h("meta", { name: "a" }), h("link", { rel: "icon", href: "x" }), h("style", { precedence: "p", href: "h" }, "a>b"), h("script", { async: true, src: "s.js" })), + h("div", null, h("title", { itemProp: "name" }, "x", "y"), h("meta", { itemProp: "x", content: "y" }), h("link", { itemProp: "x", rel: "icon", href: "x" }), h("link", { rel: "icon" }), h("link", { rel: "icon", href: "" })), + ]; + const different = trees.map((tree) => [attempt(() => renderStatic(tree)), attempt(() => reactDom(tree))]).filter(([walker, dom]) => walker !== dom); + expect(different).toEqual([]); + }); + + it("differs from react-dom only where it means to: React 19's hoisting, its document singletons and forms' actions, React 18's `javascript:` URLs", () => { + // Each tree, what the walker writes on React 19, and why react-dom 19 writes otherwise (react-dom 18 writes them as + // the walker does). + const action = () => {}; + const intended: Array<[React.ReactElement, string, string]> = [ + [h("div", null, h("title", null, "T")), "
T
", "it moves a to the start of the markup: Elements' markup is a part of a document (<Head> puts tags in the head)"], + [h("div", null, h("meta", { name: "x", content: "y" })), '<div><meta name="x" content="y"/></div>', "it moves a <meta> to the start"], + [h("div", null, h("link", { rel: "icon", href: "x.ico" })), '<div><link rel="icon" href="x.ico"/></div>', "it moves a <link> to the start"], + [h("div", null, h("style", { precedence: "default", href: "x" }, "a>b{}")), '<div><style precedence="default" href="x">a>b{}</style></div>', "it moves a <style precedence href> to the start, as `data-precedence`"], + [h("div", null, h("script", { async: true, src: "x.js" })), '<div><script async="" src="x.js"></script></div>', "it moves an async <script> to the start"], + [h("div", null, h("img", { src: "x.png" })), '<div><img src="x.png"/></div>', "it adds a <link rel=\"preload\"> for an image, which an email has no use for"], + [h("html", null, h("body", null, "x")), "<html><body>x</body></html>", "it adds a <head> to an <html> at the top, and moves what it hoists there"], + [h("form", { action }, h("button", { formAction: action }, "x")), "<form><button>x</button></form>", "it writes a function as an action as a script that runs it, which an email can't"], + ]; + // In every React: what fails in a <Suspense> fails the render (or is left out with `onError`), where react-dom renders + // its fallback (for the browser to render it again): nothing is left out without a word (ARCHITECTURE.md, Errors). + const Fails = (): React.ReactElement => { + throw new Error("fails"); + }; + const suspense = h(Suspense, { fallback: h("i", null, "…") }, h(Fails)); + expect([attempt(() => renderStatic(suspense)), renderToStaticMarkup(suspense)]).toEqual(["throws", "<i>…</i>"]); + for (const [tree, walker, why] of intended) { + const dom = renderToStaticMarkup(tree); + if (REACT_18) expect([why, renderStatic(tree)]).toEqual([why, dom]); + else { + expect([why, renderStatic(tree)]).toEqual([why, walker]); + expect([why, dom]).not.toEqual([why, walker]); + } + } + // Where it differs from react-dom 18 (react-dom 19 writes them as the walker does): a `javascript:` URL, which + // react-dom 18 writes as given, is written as one that throws, so markup from data (a link's URL) runs nothing. + const blocked: Array<[React.ReactElement, string]> = [ + [h("a", { href: "javascript:alert(1)" }, "x"), `<a href="${BLOCKED_URL}">x</a>`], + [h("a", { href: [" \u0001java\tscript:alert(1)"] }, "x"), `<a href="${BLOCKED_URL}">x</a>`], + [h("img", { src: "JavaScript:x" }), `<img src="${BLOCKED_URL}"/>`], + [h("form", { action: "javascript:x" }, h("button", { formAction: "javascript:x" }, "b")), REACT_18 ? `<form action="${BLOCKED_URL}"><button formaction="${BLOCKED_URL}">b</button></form>` : `<form action="${BLOCKED_URL}"><button formAction="${BLOCKED_URL}">b</button></form>`], + [h("svg", null, h("use", { xlinkHref: "javascript:x" })), `<svg><use xlink:href="${BLOCKED_URL}"></use></svg>`], + [h("object", { data: "javascript:x" }), `<object data="${BLOCKED_URL}"></object>`], + ]; + for (const [tree, walker] of blocked) { + const dom = renderToStaticMarkup(tree).replace(/<link rel="preload" as="image"[^>]*\/>/g, ""); + expect(renderStatic(tree)).toBe(walker); + if (REACT_18) expect(dom).not.toBe(walker); + else expect(dom).toBe(walker); + } + }); + + it("gives the same useId()s with a prefix, as React's identifierPrefix", () => { + const different: number[] = []; + for (let seed = 1; seed <= 100; seed++) { + const tree = randomTree(seed); + const html = renderToStaticMarkup(tree, { identifierPrefix: "em-" }).replace(/<link rel="preload" as="image"[^>]*\/>/g, ""); + if (renderStatic(tree, { idPrefix: "em-" }) !== html) different.push(seed); + } + expect(different.slice(0, 10)).toEqual([]); + }); + + it(`writes the same for ${SEEDS} random trees`, () => { + const different: number[] = []; + let bytes = 0; + for (let seed = 1; seed <= SEEDS; seed++) { + const tree = randomTree(seed); + const html = attempt(() => reactDom(tree)); + bytes += html.length; + if (attempt(() => renderStatic(tree)) !== html) different.push(seed); + } + expect(different.slice(0, 10)).toEqual([]); + // (Trees of some size: not mostly empty ones.) + expect(bytes / SEEDS).toBeGreaterThan(200); + }); + + it(`writes the same markup where templates have it, for ${TEMPLATE_SEEDS} random trees: a template with no root, a Page's, a component's in a Column, the async renderers'`, async () => { + const different: string[] = []; + const Holder = ({ children }: { children?: React.ReactNode }) => h("div", null, children); + for (let seed = 1; seed <= TEMPLATE_SEEDS; seed++) { + // (No useId()s: a template's start where its root's content does, not at the top.) + const tree = randomMarkup(seed); + const markup = attempt(() => reactDom(tree)); + // (Markup a component of yours renders in a Column is an Html block's; markup written in one isn't kept.) + const inDiv = attempt(() => reactDom(h("div", null, tree))); + const contexts: Array<[string, string, () => string | Promise<string>]> = [ + ["no root", markup, () => renderToHtmlParts(h(Fragment, null, tree)).body], + ["no root, async", markup, async () => (await asyncRenderers.renderToHtmlParts(h(Fragment, null, tree))).body], + ["a Page's", markup, () => between(renderToHtmlParts(h(Page, null, h("hr", { id: "start" }), tree, h("hr", { id: "end" }))).body)], + ["a component's in a Column", inDiv, () => renderToJson(h(Page, null, h(Row, null, h(Column, null, h(Holder, null, tree))))).body.rows[0].columns[0].contents[0].values.html], + ]; + for (const [where, expected, render] of contexts) { + let got: string; + try { + got = await render(); + } catch { + got = "throws"; + } + if (got !== expected) different.push(`seed ${seed}, ${where}: ${got} ≠ ${expected}`); + } + } + expect(different.slice(0, 10)).toEqual([]); + }); +}); + +/** What a render gives, or "throws". */ +function attempt(render: () => string): string { + try { + return render(); + } catch { + return "throws"; + } +} + +/** A value, shown in a label. */ +const show = (value: unknown) => (typeof value === "function" ? "fn" : typeof value === "symbol" || typeof value === "bigint" ? String(value) : React.isValidElement(value) ? "<element>" : JSON.stringify(value)); + +/** The markup between a Page's `<hr id="start"/>` and `<hr id="end"/>`. */ +const between = (body: string) => body.slice(body.indexOf('<hr id="start"/>') + 16, body.indexOf('<hr id="end"/>')); + +/** + * An element where React 19 writes it in place: a <title>, <meta>, <link>, <style> or <script> in a <noscript> (React + * 19 hoists them elsewhere: see "differs … only where it means to"), anything else in a <div> (not the document's own + * <html>, <head> or <body>). + */ +const wrapped = (node: React.ReactElement) => h(/^(title|meta|link|style|script)$/.test(String(node.type)) ? "noscript" : "div", null, node); + +/** A seeded random number generator (mulberry32). */ +function random(seed: number) { + let a = seed >>> 0; + const next = () => { + 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; + }; + return { next, pick: <T,>(list: readonly T[]) => list[Math.floor(next() * list.length)] }; +} + +const TAGS = ["div", "span", "p", "b", "a", "td", "li", "section"] as const; +const WORDS = ["Hi", "a & b", "<x>", "\"q\"", "7", "", " "]; + +/** A random tree of what templates use: markup, text, arrays, Fragments, components with ids, context, memo, forwardRef. */ +function randomTree(seed: number): React.ReactElement { + const r = random(seed); + let keys = 0; + const node = (depth: number): React.ReactNode => { + const roll = r.next(); + if (depth > 5 || roll < 0.15) return r.pick(WORDS); + if (roll < 0.26) return r.pick([null, false, 0, 42]); + const children = () => Array.from({ length: 1 + Math.floor(r.next() * 3) }, () => <Fragment key={keys++}>{node(depth + 1)}</Fragment>); + if (roll < 0.4) { + const tag = r.pick(TAGS); + const props: Record<string, unknown> = { className: r.next() < 0.3 ? `c${seed}` : undefined, style: r.next() < 0.3 ? { color: "red", paddingTop: Math.floor(r.next() * 9) } : undefined }; + if (tag === "a") props.href = "https://example.com/?a=1&b=2"; + return React.createElement(tag, props, ...children()); + } + if (roll < 0.5) return children(); + if (roll < 0.58) return <>{children()}</>; + if (roll < 0.72) return <Ids n={1 + Math.floor(r.next() * 2)}>{children()}</Ids>; + if (roll < 0.78) return <Memo>{children()}</Memo>; + if (roll < 0.82) return <Ref>{children()}</Ref>; + if (roll < 0.88) return provide(r.next() < 0.5 ? Theme : Lang, `v${Math.floor(r.next() * 9)}`, [<Themed key="t" />, ...children()]); + if (roll < 0.92) return <Classy>{children()}</Classy>; + if (roll < 0.95) return <Stateful>{children()}</Stateful>; + // (Markup React rejects fails the render, also in a <Suspense>: see "differs … only where it means to".) + if (roll < 0.97) return markup(r, depth + 1, () => <Fragment key={keys++}>{node(depth + 2)}</Fragment>, true); + return <Suspense fallback={<i>…</i>}>{children()}</Suspense>; + }; + return <div>{node(0)}</div>; +} + +const VALUES = ["a", "b", "c", 1, "1", "", "A", null, undefined, true, ["a", "c"], [1], []]; +const TEXTS = ["A", "a", "b", "B", 1, ["a", ["b"]], null, true, "", "\nx", "a > b {c: 'd'}", "</style><style", "</script><script", "x & <y>"]; +const Option = ({ value, children }: { value?: unknown; children?: React.ReactNode }) => h("option", { value }, children); +const Pass = ({ children }: { children?: React.ReactNode }) => children; + +/** A random tree of markup only (no useId()s), of every element React writes its own way. */ +function randomMarkup(seed: number): React.ReactElement { + const r = random(seed); + let keys = 0; + const node = (depth: number): React.ReactNode => (depth > 4 || r.next() < 0.2 ? r.pick(TEXTS) : markup(r, depth, () => <Fragment key={keys++}>{node(depth + 1)}</Fragment>)); + return <div>{node(0)}</div>; +} + +/** + * One of the elements React writes its own way, with random props, holding `child()`ren: form controls (a select's + * options inside optgroups, Fragments, components, tables, foreign content), raw text, titles and resources (where + * React 19 writes them in place), custom elements, preformatted text, and markup around them. `valid`: none React + * rejects (a textarea's value and children, `dangerouslySetInnerHTML` and children). + */ +function markup(r: ReturnType<typeof random>, depth: number, child: () => React.ReactNode, valid = false): React.ReactNode { + const some = (n = 3) => Array.from({ length: 1 + Math.floor(r.next() * n) }, child); + const maybe = (props: Record<string, unknown>) => Object.fromEntries(Object.entries(props).filter(() => r.next() < 0.5)); + const text = () => r.pick(TEXTS); + const option = (): React.ReactNode => { + const roll = r.next(); + const props = { key: `o${r.next()}`, ...maybe({ value: r.pick(VALUES), selected: r.pick([true, false, "x", 0]), label: "l" }) }; + if (roll < 0.55) return h("option", props, text()); + if (roll < 0.65) return h("optgroup", { key: `g${r.next()}`, label: "g" }, option(), option()); + if (roll < 0.72) return h(Fragment, { key: `f${r.next()}` }, option()); + if (roll < 0.8) return h(Option, { key: `c${r.next()}`, value: r.pick(VALUES) }, text()); + if (roll < 0.86) return h(r.pick(["div", "noscript", "picture", "td", "svg"]), { key: `w${r.next()}` }, option()); + if (roll < 0.9) return h("table", { key: `t${r.next()}` }, h("tbody", null, h("tr", null, h("td", null, option())))); + if (roll < 0.94) return h("select", { key: `s${r.next()}`, defaultValue: r.pick(VALUES) }, option(), option()); + return h("option", { ...props, dangerouslySetInnerHTML: { __html: "<b>x</b>" } }); + }; + const hoistable = { itemProp: "x" }; + switch (Math.floor(r.next() * 24)) { + case 0: + case 1: + return h("select", maybe({ value: r.pick(VALUES), defaultValue: r.pick(VALUES), multiple: r.pick([true, false]), onChange: () => {}, name: "s" }), option(), option(), option()); + case 2: + return h("textarea", maybe({ value: r.pick(VALUES), defaultValue: r.pick(VALUES), rows: 2, name: "t", onChange: () => {} }), !valid && r.next() < 0.2 ? text() : undefined); + case 3: + return h("input", maybe({ name: "n", type: r.pick(["text", "checkbox", "submit"]), value: r.pick(VALUES), defaultValue: "d", checked: r.pick([true, false]), defaultChecked: true, id: "i", formAction: "/x", formTarget: "t", formMethod: "post", formEncType: "e", onChange: () => {} })); + case 4: + return h("button", maybe({ name: "n", type: "submit", formAction: "javascript:x", formTarget: "t", id: "b" }), some()); + case 5: + return h("form", maybe({ action: "/a", target: "_blank", method: "post", encType: "x", id: "f", name: "f" }), some()); + case 6: + return h("style", maybe({ media: "print", nonce: "n", precedence: "p", type: "text/css" }), r.pick([text(), [text()], [text(), text()], 1, true, h("b", null, "x")])); + case 7: + return h("script", maybe({ type: "application/ld+json", async: true, nonce: "n" }), r.pick([text(), [text()], [text(), text()], 1, h("b", null, "x")])); + case 8: + return h(r.next() < 0.5 ? "svg" : "noscript", null, h("title", null, r.pick([text(), [text(), text()], 5])), h("style", null, text()), h("script", null, text()), some(1)); + case 9: + return h("div", null, h("title", hoistable, r.pick([text(), [text(), text()]])), h("meta", { ...hoistable, content: text() }), h("link", { ...hoistable, rel: "icon", href: "x" }), h("link", { rel: "icon" })); + case 10: + return h("object", maybe({ data: r.pick(["x.swf", "", "javascript:x", true]), type: "t" }), some(1)); + case 11: + return h("menuitem", maybe({ label: "x", disabled: true })); + case 12: { + const html = r.next() < 0.3; + return h(r.pick(["pre", "listing"]), html ? { dangerouslySetInnerHTML: { __html: r.pick(["\nx", "x", ["\nx"]]) } } : null, (valid && html) || r.next() < 0.3 ? undefined : r.pick(["\nx", ["\nx"], text()])); + } + case 13: + return h("my-el", maybe({ className: "c", obj: { a: 1 }, num: 2, t: true, f: false, onClick: () => {}, onclick: "x()", style: { color: "red" }, htmlFor: "x", list: [1, 2] }), some()); + case 14: + return h("div", maybe({ is: "x-y", className: "c", for: "f", tabIndex: 1, t: true }), some()); + case 15: + return h("svg", maybe({ viewBox: "0 0 1 1", xlinkHref: "x", strokeWidth: 2, className: "c" }), h("path", maybe({ d: "M0", fillOpacity: 0.5 })), h("foreignObject", null, some(2))); + case 16: + return h("math", null, h("mi", maybe({ mathVariant: "normal", className: "c" }), text())); + case 17: { + const html = r.next() < 0.5; + return h("div", html ? { dangerouslySetInnerHTML: { __html: "<b>\nx</b>" } } : maybe({ suppressHydrationWarning: true, suppressContentEditableWarning: true, contentEditable: true, innerHTML: "x" }), (valid && html) || r.next() < 0.5 ? undefined : some()); + } + case 18: + return h("table", null, h("tbody", null, h("tr", null, h("td", maybe({ colSpan: 2, rowSpan: "x", valign: "top" }), some())))); + case 19: + return h(r.pick(["DIV", "SELECT", "Span"]), maybe({ className: "c", defaultValue: "a" }), r.pick([some(), option()])); + case 20: + return h(Pass, null, some()); + case 21: + return h(r.pick(["img", "br", "hr", "wbr", "col"]), maybe({ src: "data:image/png;base64,x", loading: "lazy", alt: text(), width: 2, className: "c" })); + default: + return h(r.pick(["div", "span", "p", "b", "a", "td", "section", "font", "center", "html", "head", "body"]), maybe({ className: "c", style: { paddingTop: 2, "--x": "y" }, href: "https://x.com/?a=1&b=2", title: text() }), some()); + } +} diff --git a/packages/react/src/engine/dispatcher.test.tsx b/packages/react/src/engine/dispatcher.test.tsx new file mode 100644 index 00000000..ff74ddfe --- /dev/null +++ b/packages/react/src/engine/dispatcher.test.tsx @@ -0,0 +1,124 @@ +// @vitest-environment node +/** + * Elements' hooks dispatcher, on the installed React (CI runs it on each version it supports): every hook React + * exports works in a template's component as on a server, and a hook it doesn't know fails with a named error. + */ +import { describe, expect, it } from "vitest"; +import React, { createContext, useState } from "react"; +import * as ReactDom from "react-dom"; +import { renderToStaticMarkup } from "react-dom/server"; +import { renderStatic } from "./index"; +import { isIdleDispatcher, theSlot } from "./dispatcher"; + +const R = React as unknown as Record<string, any>; +const has = (name: string) => typeof R[name] === "function"; +const Theme = createContext("default"); + +/** What a component gives with the walker and with react-dom/server: the same, where react-dom can render it. */ +const both = (element: React.ReactElement) => ({ walker: renderStatic(element), reactDom: renderToStaticMarkup(element) }); + +describe("an idle dispatcher (React's between its client renders: no render running, where react-dom puts a context's default back first)", () => { + const invalid = () => { + throw new Error("Invalid hook call"); + }; + const hooks = ["useState", "useEffect", "useContext", "useCallback", "useMemo", "useReducer", "useRef"]; + it("is none, or one whose hooks are one function (React's ContextOnlyDispatcher), compared without calling anything", () => { + expect(isIdleDispatcher(null)).toBe(true); + expect(isIdleDispatcher(undefined)).toBe(true); + const calls: string[] = []; + const contextOnly = new Proxy(Object.fromEntries(hooks.map((hook) => [hook, invalid])), { get: (target, key) => (calls.push(String(key)), target[key as string]) }); + expect(isIdleDispatcher(contextOnly)).toBe(true); + expect(calls.every((key) => hooks.includes(key))).toBe(true); + }); + + it("isn't a running render's: React Server Components' shares one function for most hooks, but not useContext", () => { + const unsupported = () => { + throw new Error("This Hook is not supported in Server Components."); + }; + const flight = { ...Object.fromEntries(hooks.map((hook) => [hook, unsupported])), useContext: () => { + throw new Error("Cannot read a Client Context from a Server Component."); + } }; + expect(isIdleDispatcher(flight)).toBe(false); + // A server's or a client's, with a function each, and a shape this doesn't know: hosts (the safe side, nothing put + // back). + expect(isIdleDispatcher(Object.fromEntries(hooks.map((hook) => [hook, () => hook])))).toBe(false); + expect(isIdleDispatcher({})).toBe(false); + expect(isIdleDispatcher({ useState: 1, useEffect: 1, useContext: 1, useCallback: 1 })).toBe(false); + }); +}); + +describe("the hooks dispatcher", () => { + it("implements every hook this React exports, as its server renderer does", () => { + const store = { subscribe: () => () => {}, get: () => "client", server: () => "server" }; + function Everything() { + const out: unknown[] = []; + const [state, setState] = R.useState(() => "state"); + const [reduced] = R.useReducer((s: number, a: number) => s + a, 1, (n: number) => n + 1); + out.push(state, reduced, R.useMemo(() => "memo", []), R.useCallback(() => "cb", [])(), R.useRef("ref").current); + out.push(R.useContext(Theme), R.useSyncExternalStore(store.subscribe, store.get, store.server), R.useDeferredValue("deferred")); + const [pending] = R.useTransition(); + out.push(pending, typeof R.useId()); + // Effects never run on a server. + for (const effect of ["useEffect", "useLayoutEffect", "useInsertionEffect"]) R[effect](() => out.push(`ran ${effect}`)); + R.useImperativeHandle(null, () => ({})); + R.useDebugValue("debug"); + if (has("use")) out.push(R.use(Theme)); + if (has("useOptimistic")) out.push(R.useOptimistic("optimistic")[0]); + if (has("useActionState")) out.push(R.useActionState(() => {}, "action")[0]); + if (has("useEffectEvent")) out.push(typeof R.useEffectEvent(() => {})); + if (typeof (ReactDom as Record<string, any>).useFormStatus === "function") out.push((ReactDom as Record<string, any>).useFormStatus().pending); + void setState; + return <p>{out.map(String).join(",")}</p>; + } + const element = <Theme.Provider value="provided"><Everything /></Theme.Provider>; + const { walker, reactDom } = both(element); + expect(walker).toBe(reactDom); + expect(walker).not.toContain("ran "); + // Each hook this React has: the list above covers them (a new one needs a line there and in dispatcher.ts). + const covered = ["useState", "useReducer", "useMemo", "useCallback", "useRef", "useContext", "useSyncExternalStore", "useDeferredValue", "useTransition", "useId", "useEffect", "useLayoutEffect", "useInsertionEffect", "useImperativeHandle", "useDebugValue", "use", "useOptimistic", "useActionState", "useEffectEvent"]; + expect(Object.keys(R).filter((key) => /^use([A-Z]|$)/.test(key) && has(key)).filter((key) => !covered.includes(key))).toEqual([]); + }); + + it("runs a component again when it sets its state while rendering, as React does", () => { + function Counter() { + const [n, setN] = useState(0); + if (n < 3) setN(n + 1); + return <b>{n}</b>; + } + expect(renderStatic(<Counter />)).toBe("<b>3</b>"); + }); + + it("says what a function from a hook can't do while rendering (useEffectEvent, useOptimistic, useActionState)", () => { + for (const [hook, call] of [ + ["useEffectEvent", () => R.useEffectEvent(() => {})()], + ["useOptimistic", () => R.useOptimistic(1)[1](2)], + ["useActionState", () => R.useActionState(() => {}, 1)[1]()], + ] as const) { + if (!has(hook)) continue; + const Component = () => (call(), null); + expect(() => renderStatic(<Component />), hook).toThrow(/while rendering/); + } + }); + + it("fails a hook it doesn't know (a newer React's) with a named error, and puts React's dispatcher back", () => { + const slot = theSlot()!; + const before = slot.get(); + // What a newer React's `useFuture` would do: call it on the dispatcher in its slot. + const useFuture = () => (slot.get() as Record<string, () => unknown>).useFuture(); + function Futuristic() { + useFuture(); + return null; + } + expect(() => renderStatic(<Futuristic />)).toThrow(`[Unlayer] <Futuristic> calls useFuture, which Elements' renderer doesn't support (React ${React.version}).`); + expect(slot.get()).toBe(before); + // Not every property: one React reads for something else isn't a hook. + expect((slot.get() as Record<string, unknown> | null)?.then).toBeUndefined(); + }); + + it("needs getServerSnapshot for useSyncExternalStore, as React's server renderer does", () => { + const Store = () => <i>{R.useSyncExternalStore(() => () => {}, () => "client")}</i>; + expect(() => renderStatic(<Store />)).toThrow(/getServerSnapshot/); + // Said once as Elements' (it was `[Unlayer] [Unlayer] Missing getServerSnapshot…`). + expect(() => renderStatic(<Store />)).toThrow(/^\[Unlayer\] Missing getServerSnapshot, which useSyncExternalStore needs on a server\. \(in <Store>/); + }); +}); diff --git a/packages/react/src/engine/dispatcher.ts b/packages/react/src/engine/dispatcher.ts new file mode 100644 index 00000000..335bdc2f --- /dev/null +++ b/packages/react/src/engine/dispatcher.ts @@ -0,0 +1,337 @@ +/** + * Elements' hooks dispatcher: what React's hooks do while the walker (walk.ts) calls a component. React's hook + * functions call the dispatcher in a slot of React's internals (see react-compat.ts). The walker puts this one there + * synchronously around each component call, and the one that was there back after (in `finally`, never across an + * await), so a render it runs inside (the app's react-dom render, Next.js's Server Components) keeps its own. + * + * Server semantics, as react-dom's static render has them: context from the walker's provider stack (else its + * default, whatever renders before it provided, or inside an app's render what's there: see `defaultOf`), `useId` + * from the tree position, initial state (a state set while rendering renders the component again, its last render + * counts), effects never run, `use()` of a pending thenable suspends. A hook it doesn't know (a newer React's) throws a + * named error, instead of React's "dispatcher.useX is not a function". + */ +import React from "react"; +import { cleanUp } from "@unlayer/react-elements/fizz-cleanup"; +import { slotOf, type Slot } from "./react-compat"; +import { makeId, type TreeContext } from "./tree-id"; + +// ── Context: a linked stack the walker passes down (immutable, so a resumed component keeps the one it had) ── + +export interface ContextStack { + context: object; + value: unknown; + next: ContextStack | null; +} + +const CONTEXT = Symbol.for("react.context"); +const CONSUMER = Symbol.for("react.consumer"); + +/** The context a `useContext` argument names: React 19's Consumer, React 18's development Consumer, or the context. */ +export const contextOf = (usable: any): any => (usable?.$$typeof === CONSUMER || (usable?._context && usable.$$typeof === CONTEXT) ? usable._context : usable); + +/** What a render reads for each context no provider in it gives, worked out at its first read (see `readContext`). */ +export type Fallbacks = Map<object, unknown>; + +/** + * A context's value: the nearest provider's in the walk (or around the component that started it), else its fallback + * (see `defaultOf`), worked out once per render (`fallbacks`, the top-level render's, which the renders it starts + * share: see the engine's Walker), so a render that starts inside an app's render and goes on after it (an async one) + * reads one value for it throughout, and react-dom's static renderer renders at most once per context and render. + */ +export function readContext(stack: ContextStack | null, usable: unknown, fallbacks?: Fallbacks): unknown { + const context = contextOf(usable); + const kept = fallbacks && typeof context === "object" && context !== null; + // (In a render, worked out where it's provided too, so it's seen while React's values still hold it: see + // `defaultOf`. Outside one, as the self-test reads it, only where no provider gives it.) + if (kept && !fallbacks.has(context)) fallbacks.set(context, defaultOf(context)); + for (let s = stack; s; s = s.next) if (s.context === context) return s.value; + return kept ? fallbacks.get(context) : defaultOf(context); +} + +/** + * A context's default. React keeps it only in two values, which its renders set while they render and don't always + * put back: `_currentValue` (react-dom's streaming render's, and its client's: after a stream, what the last part it + * waited for was given) and `_currentValue2` (renderToString's and renderToStaticMarkup's, and other secondary + * renderers': after one that failed or suspended inside a provider, what it was given there, till react-dom's static + * renderer's next render starts and puts it back). So while no render of React's is running, that renderer renders + * nothing first (`@unlayer/react-elements/fizz-cleanup`), and the second is then the default. Inside an app's render it's what's there: + * inside its renderToString, that render's value; inside its stream or client render, the default, unless a + * renderToString that failed inside a provider left another (that renderer can't be asked there: inside the app's + * renderToString, it would put back the app's own providers). Where react-dom/server isn't loaded for it (an edge + * runtime, a Worker, a browser, React Server Components), the second as it is. Nothing is kept from one render to the + * next: where the app's react-dom isn't the one loaded, the cleanup does nothing, and a default kept wrong would stay + * wrong. (React 18's `_defaultValue` is a server context's, null for these.) + */ +function defaultOf(context: any): unknown { + if (!hostRunning()) { + const s = theSlot(); + const held = s?.get() === DISPATCHER; + try { + cleanUp(); + } catch { + // It can't render here: the value as it is. + } finally { + // (react-dom puts back the dispatcher it found; this one stays in the slot for the component being called.) + if (held) s!.set(DISPATCHER); + } + } + return context._currentValue2; +} + +// ── The component being called ── + +export interface HookState { + /** Its name, for messages. */ + name: string; + tree: TreeContext; + contexts: ContextStack | null; + /** What its `useId()`s start with (the render's `idPrefix`). */ + idPrefix: string; + /** What its render reads for contexts no provider gives (see `readContext`). */ + fallbacks?: Fallbacks; + /** useId calls so far (React's localIdCounter): more than 0 gives its children a fork of the tree. */ + localId: number; + /** Thenables `use()` was given, by call order, kept when it's called again (React's thenableState). */ + thenables: Tracked<unknown>[]; + thenableIndex: number; + /** The thenable a `use()` suspended on. */ + suspendedOn?: PromiseLike<unknown>; + /** Hook slots, kept across the renders a state set while rendering starts again. */ + hooks?: Record<string, any>[]; + hookIndex: number; + /** It set its state while it rendered: the walker calls it again. */ + rerender: boolean; +} + +export function hookState(name: string, tree: TreeContext, contexts: ContextStack | null, idPrefix = "", fallbacks?: Fallbacks): HookState { + return { name, tree, contexts, idPrefix, fallbacks, localId: 0, thenables: [], thenableIndex: 0, hookIndex: 0, rerender: false }; +} + +/** Thrown by `use()` of a pending thenable (the walker reads `suspendedOn`). */ +export const SUSPEND = { toString: () => "[Unlayer] suspended" }; + +let active: HookState | null = null; + +function current(): HookState { + if (!active) throw new Error("[Unlayer] Invalid hook call: hooks can only be called inside the body of a function component."); + return active; +} + +type Tracked<T> = PromiseLike<T> & { status?: "pending" | "fulfilled" | "rejected"; value?: T; reason?: unknown }; + +/** React's thenable protocol: `status`, then `value` or `reason`, set once it settles (React 19 reads them too). */ +export function track<T>(thenable: Tracked<T>): void { + if (thenable.status) return; + thenable.status = "pending"; + thenable.then( + (value) => { + if (thenable.status === "pending") Object.assign(thenable, { status: "fulfilled", value }); + }, + (reason) => { + if (thenable.status === "pending") Object.assign(thenable, { status: "rejected", reason }); + } + ); +} + +const noop = () => {}; + +function useThenable<T>(state: HookState, given: Tracked<T>): T { + const index = state.thenableIndex++; + let thenable = given; + const previous = state.thenables[index] as Tracked<T> | undefined; + if (previous === undefined) state.thenables.push(thenable); + else if (previous !== thenable) { + // Called again, it made a new promise for the same `use()` (`use(fetchData())`): the first one's, as React does. + thenable.then(noop, noop); + thenable = previous; + } + track(thenable); + if (thenable.status === "fulfilled") return thenable.value as T; + if (thenable.status === "rejected") throw thenable.reason; + state.suspendedOn = thenable; + throw SUSPEND; +} + +/** The component's next hook slot (React's work-in-progress hook list, as Fizz keeps it across render-phase updates). */ +function slotFor(state: HookState): { fresh: boolean; hook: Record<string, any> } { + const hooks = (state.hooks ??= []); + const index = state.hookIndex++; + if (index < hooks.length) return { fresh: false, hook: hooks[index] }; + const hook = {}; + hooks.push(hook); + return { fresh: true, hook }; +} + +const basicStateReducer = (state: unknown, action: unknown) => (typeof action === "function" ? (action as (s: unknown) => unknown)(state) : action); + +function useReducer(reducer: (state: unknown, action: unknown) => unknown, arg: unknown, init?: (arg: unknown) => unknown): [unknown, (action: unknown) => void] { + const state = current(); + const { fresh, hook } = slotFor(state); + if (fresh) { + hook.state = init ? init(arg) : arg; + hook.queue = []; + // A set while it renders (its own render) is applied when it's called again; any other does nothing on a server. + hook.dispatch = (action: unknown) => { + if (active !== state) return; + hook.queue.push(action); + state.rerender = true; + }; + } else if (hook.queue.length) { + for (const action of hook.queue) hook.state = reducer(hook.state, action); + hook.queue = []; + } + return [hook.state, hook.dispatch]; +} + +const sameDeps = (a: unknown[] | null | undefined, b: unknown[] | null | undefined) => !!a && !!b && a.length === b.length && a.every((v, i) => Object.is(v, b[i])); + +function useMemo(make: () => unknown, deps?: unknown[] | null): unknown { + const { fresh, hook } = slotFor(current()); + if (!fresh && deps != null && sameDeps(deps, hook.deps)) return hook.value; + hook.value = make(); + hook.deps = deps; + return hook.value; +} + +/** A function a hook gives that can't run while rendering (React's server renderer throws the same). */ +const notWhileRendering = (what: string) => () => { + throw new Error(`[Unlayer] ${what} while rendering.`); +}; +const NOT_PENDING = Object.freeze({ pending: false, data: null, method: null, action: null }); +const MEMO_CACHE_SENTINEL = Symbol.for("react.memo_cache_sentinel"); + +/** A context's value for the component being called. */ +function contextValue(context: unknown): unknown { + const state = current(); + return readContext(state.contexts, context, state.fallbacks); +} + +const HOOKS = { + readContext: contextValue, + useContext: contextValue, + use(usable: any) { + const state = current(); + if (usable !== null && typeof usable === "object") { + if (typeof usable.then === "function") return useThenable(state, usable); + if (usable.$$typeof === CONTEXT || usable.$$typeof === CONSUMER) return readContext(state.contexts, usable, state.fallbacks); + } + throw new Error("[Unlayer] An unsupported type was passed to use(): " + String(usable)); + }, + useState: (initial: unknown) => useReducer(basicStateReducer, initial, typeof initial === "function" ? (f) => (f as () => unknown)() : undefined), + useReducer, + useMemo, + useCallback: (callback: unknown, deps?: unknown[] | null) => useMemo(() => callback, deps), + useRef(value: unknown) { + const { fresh, hook } = slotFor(current()); + if (fresh) hook.ref = { current: value }; + return hook.ref; + }, + useId() { + const state = current(); + return makeId(state.tree, state.localId++, state.idPrefix); + }, + // Effects never run on a server. + useEffect: noop, + useLayoutEffect: noop, + useInsertionEffect: noop, + useImperativeHandle: noop, + useDebugValue: noop, + useEffectEvent: () => (current(), notWhileRendering("A function wrapped in useEffectEvent can't be called")), + useDeferredValue: (value: unknown, initial?: unknown) => (current(), initial !== undefined ? initial : value), + useTransition: () => (current(), [false, notWhileRendering("startTransition can't be called")]), + useSyncExternalStore(_subscribe: unknown, _get: unknown, getServerSnapshot?: () => unknown) { + current(); + if (getServerSnapshot === undefined) throw new Error("[Unlayer] Missing getServerSnapshot, which useSyncExternalStore needs on a server."); + return getServerSnapshot(); + }, + useOptimistic: (passthrough: unknown) => (current(), [passthrough, notWhileRendering("Optimistic state can't be updated")]), + useActionState: (_action: unknown, initial: unknown) => (current(), [initial, notWhileRendering("Form state can't be updated"), false]), + useFormState: (_action: unknown, initial: unknown) => (current(), [initial, notWhileRendering("Form state can't be updated"), false]), + // react-dom's useFormStatus: no form is submitting on a server. + useHostTransitionStatus: () => (current(), NOT_PENDING), + // The React Compiler's cache: a new one each render, as a server render has. + useMemoCache: (size: number) => new Array(size).fill(MEMO_CACHE_SENTINEL), + useCacheRefresh: () => notWhileRendering("The cache can't be refreshed"), +}; + +/** A hook this dispatcher doesn't have (a newer React's): a named error when it's called, instead of a crash. */ +const unknownHooks = new Proxy( + {}, + { + get: (_, key) => + typeof key === "string" && /^use[A-Z]/.test(key) + ? () => { + throw new Error(`[Unlayer] <${active?.name ?? "component"}> calls ${key}, which Elements' renderer doesn't support (React ${React.version}).`); + } + : undefined, + } +); + +/** The dispatcher the walker installs: the hooks above, unknown ones through the Proxy (lookups of known ones stay fast). */ +export const DISPATCHER: typeof HOOKS = Object.setPrototypeOf(HOOKS, unknownHooks); + +let slot: Slot | null | undefined; +/** React's slot, or null where this React has none the table knows (the self-test says so). */ +export const theSlot = (): Slot | null => (slot === undefined ? (slot = slotOf()) : slot); + +/** The dispatcher of the render the walker runs inside (the app's react-dom render, Next.js's), or null. */ +let host: unknown = null; + +/** + * Whether a dispatcher in React's slot is no running render's: none, or React's between its client renders (its + * ContextOnlyDispatcher, which React 18 and 19 leave in the slot after the first one, and hold during effects), whose + * hooks are one function that throws "Invalid hook call". Its identities are compared, nothing on it called. A running + * render's hooks differ (the server's, the client's mount and update ones; React Server Components' share one function + * for most, but not for useContext), so it's a host; and so is a shape this doesn't know, where react-dom's static + * renderer then isn't asked to put contexts' values back (the safe side: see `defaultOf`). + */ +export function isIdleDispatcher(d: any): boolean { + if (d == null) return true; + return typeof d.useState === "function" && d.useState === d.useEffect && d.useState === d.useContext && d.useState === d.useCallback; +} + +/** Whether a render other than Elements' is running around this call (the app's react-dom render, Next.js's). */ +function hostRunning(): boolean { + const current = theSlot()?.get() ?? null; + return !isIdleDispatcher(current === DISPATCHER ? host : current); +} + +/** + * Calls `fn` as the component `state` describes: this dispatcher in React's slot, and the one there before put back + * after, synchronously. Hooks called after an await in `fn` (an async component's) find whatever's there then. + */ +export function callWithHooks<T>(state: HookState, fn: () => T): T { + const s = theSlot(); + const outerState = active; + const outerHost = host; + active = state; + const previous = s?.get(); + if (previous !== DISPATCHER) host = previous ?? null; + s?.set(DISPATCHER); + try { + return fn(); + } finally { + s?.set(previous); + active = outerState; + host = outerHost; + } +} + +/** + * Runs `fn` (Elements' own markup, whose components call no hooks) with the dispatcher of the render the walker runs + * inside back in the slot, as if that render ran it: react-dom puts its context back after a render of its own + * started inside one of its renders (an exporter's) only when its own dispatcher was there. + */ +export function asHost<T>(fn: () => T): T { + const s = theSlot(); + if (!s || s.get() !== DISPATCHER) return fn(); + s.set(host); + try { + return fn(); + } finally { + s.set(DISPATCHER); + } +} + +/** Whether a React render is under way with this React (its hooks can be called): the app's, or the walker's. */ +export const rendering = (): boolean => (theSlot()?.get() ?? null) !== null; diff --git a/packages/react/src/engine/errors.ts b/packages/react/src/engine/errors.ts new file mode 100644 index 00000000..4ca2c2b6 --- /dev/null +++ b/packages/react/src/engine/errors.ts @@ -0,0 +1,88 @@ +/** + * What a render does when part of a template fails: a component that throws (or is async, or suspends, in a + * synchronous render), a Row whose Columns don't fit its layout, an exporter that fails. By default the render + * throws a RenderError naming the component and where it is; with `onError: "omit"` (or a function) that part is left + * out of the HTML, the plain text and the design alike, and each part left out is a RenderError too, listed. + */ +import React from "react"; +import { RenderError, type OnError } from "@unlayer-internal/shared-elements"; + +export { RenderError, type OnError }; + +/** Whether a policy leaves failed parts out. */ +export const omits = (policy: OnError | undefined): boolean => policy === "omit" || typeof policy === "function"; + +/** A part left out under `policy`: said (or given to its function) once. */ +export function reportOmitted(policy: OnError | undefined, error: RenderError): void { + if (typeof policy === "function") policy(error); + else { + const said = `[Unlayer] Left out: ${error.message.replace(/^\[Unlayer\] /, "")}`; + const cause = (error as { cause?: unknown }).cause; + if (cause === undefined) console.error(said); + else console.error(said, cause); + } +} + +// ── Where a component is ── + +/** A component on the walk's path: its name, its label (`Row 3`, `Hero`, `Paragraph 2`), and the counts of what it renders. */ +export interface Frame { + name: string; + label: string; + parent: Frame | null; + counts: Map<string, number> | null; +} + +/** Where a walk starts: the root (`Email`), which positions don't name. */ +export const rootFrame = (name: string): Frame => ({ name, label: name, parent: null, counts: null }); + +/** + * A frame for a `name` its parent renders: Rows and Columns numbered (`Row 3`), others when there's more than one + * (`Paragraph 2`). A component rendering one of its own name (a wrapper named like what it wraps) is the same place. + */ +export function childFrame(parent: Frame, name: string, numbered = name === "Row" || name === "Column"): Frame { + if (parent.name === name && parent.parent) return parent; + const counts = (parent.counts ??= new Map()); + const n = (counts.get(name) ?? 0) + 1; + counts.set(name, n); + return { name, label: numbered || n > 1 ? `${name} ${n}` : name, parent, counts: null }; +} + +/** A frame that isn't counted (markup's, for the position of what it rejects). */ +export const placeFrame = (parent: Frame, name: string): Frame => ({ name, label: name, parent, counts: null }); + +export function positionOf(frame: Frame | null): string { + const labels: string[] = []; + for (let f = frame; f && f.parent; f = f.parent) labels.unshift(f.label); + return labels.join(" › "); +} + +// ── Names and messages ── + +const MEMO = Symbol.for("react.memo"); +const FORWARD_REF = Symbol.for("react.forward_ref"); + +export function nameOf(type: any): string { + if (typeof type === "string") return type; + if (!type) return "Unknown"; + if (type.$$typeof === MEMO) return type.displayName || nameOf(type.type); + if (type.$$typeof === FORWARD_REF) return type.displayName || type.render?.displayName || type.render?.name || "ForwardRef"; + return type.displayName || type.name || "Anonymous"; +} + +/** + * What React Server Components' React lacks (`useState`, `useContext`, …: Next.js route handlers and Server + * Components render with it), said plainly where a template calls one (the runtime's "is not a function"). + */ +export function serverReactHint(error: unknown): string | undefined { + if (!(error instanceof TypeError)) return undefined; + const hook = /\b(use[A-Z]\w*|createContext|Component|PureComponent)\)?\s+is not a (function|constructor)/.exec(error.message)?.[1]; + if (!hook || typeof (React as unknown as Record<string, unknown>)[hook] === "function") return undefined; + return `it calls ${hook}, which React Server Components' React doesn't have (Next.js route handlers and Server Components render with it; it has use, useId, useMemo and useCallback): render this template from a Client Component or plain Node.js code, or keep ${hook} out of it`; +} + +/** What a component threw, for the RenderError that names it: its message, or what React Server Components lack. */ +export const threw = (error: unknown): string => (serverReactHint(error) ?? String((error as Error)?.message ?? error)).replace(/^\[Unlayer\] /, ""); + +export const ASYNC_IN_SYNC = + 'it is async or suspends, and this renderer is synchronous: render with "@unlayer/react-elements/async", or load its data first and pass it as props'; diff --git a/packages/react/src/engine/fizz-cleanup.d.ts b/packages/react/src/engine/fizz-cleanup.d.ts new file mode 100644 index 00000000..647e80ec --- /dev/null +++ b/packages/react/src/engine/fizz-cleanup.d.ts @@ -0,0 +1,8 @@ +/** + * The package's export that the runtime loading Elements picks (package.json's "exports"): fizz-cleanup.node.ts in + * Node, fizz-cleanup.noop.ts elsewhere. Declared, not mapped in tsconfig's `paths`, which esbuild follows from the + * built files too, to Node's module in every bundle. + */ +declare module "@unlayer/react-elements/fizz-cleanup" { + export const cleanUp: () => boolean; +} diff --git a/packages/react/src/engine/fizz-cleanup.node.ts b/packages/react/src/engine/fizz-cleanup.node.ts new file mode 100644 index 00000000..546d3c11 --- /dev/null +++ b/packages/react/src/engine/fizz-cleanup.node.ts @@ -0,0 +1,27 @@ +/** + * Has react-dom's static renderer (renderToString's and renderToStaticMarkup's) put back what a render of its own left + * in contexts' `_currentValue2`: one that failed or suspended inside a provider leaves its value there, which that + * renderer puts back when its next render starts. So this renders nothing with it (see `defaultOf` in dispatcher.ts). + * + * Built on its own, as an ES module and CommonJS (dist/fizz-cleanup.node.js and .cjs), and imported as + * `@unlayer/react-elements/fizz-cleanup`, which is this module unless a bundler or runtime names React Server + * Components, a browser, a Worker or an edge runtime, where it's fizz-cleanup.noop.ts (package.json's "exports"; + * Rollup's node-resolve at its defaults names none, bundling a server): bundled for an edge runtime, a Worker or a + * browser, react-dom/server is its browser build, and React 19's opens a MessageChannel when it loads, which keeps Node + * running; in React Server Components it throws when it loads. In Node, Elements loads react-dom/server anyway + * (@unlayer/exporters imports it), so this imports it as any module does: a bundler bundles the app's own copy, and + * Vite can run it inlined (a CommonJS file it can't). Deno gets the CommonJS one: its `import` of react-dom/server is + * the browser build, its `require` Node's. + */ +import * as server from "react-dom/server"; + +type Server = { renderToStaticMarkup?: (node: null) => string }; + +/** Whether react-dom's static renderer rendered: false where react-dom/server has no static renderer. */ +export function cleanUp(): boolean { + const loaded = server as Server & { default?: Server }; + const render = loaded.renderToStaticMarkup ?? loaded.default?.renderToStaticMarkup; + if (typeof render !== "function") return false; + render(null); + return true; +} diff --git a/packages/react/src/engine/fizz-cleanup.noop.ts b/packages/react/src/engine/fizz-cleanup.noop.ts new file mode 100644 index 00000000..f6fce19d --- /dev/null +++ b/packages/react/src/engine/fizz-cleanup.noop.ts @@ -0,0 +1,7 @@ +/** + * `@unlayer/react-elements/fizz-cleanup` where a bundler or runtime names React Server Components (`react-server`), a + * browser, a Worker or an edge runtime (`browser`, `worker`, `workerd`, `edge-light`), and where a bundler doesn't read + * package.json's "exports" (webpack 4, Jest before 28: the package's `fizz-cleanup/` folder): react-dom/server isn't + * loaded for it (see fizz-cleanup.node.ts), so contexts' values stay as React's renders left them. + */ +export const cleanUp = (): boolean => false; diff --git a/packages/react/src/engine/host.ts b/packages/react/src/engine/host.ts new file mode 100644 index 00000000..a58dfe6e --- /dev/null +++ b/packages/react/src/engine/host.ts @@ -0,0 +1,224 @@ +/** + * Host markup (`<div>`, `<p>`, …) written as react-dom's static render writes it, for the installed React: its + * attributes (attributes.ts); void, preformatted and custom elements; a `<select>`'s value marking its `<option>`s, an + * `<input>`'s, `<textarea>`'s, `<button>`'s and `<form>`'s own rules; a `<style>`'s, `<script>`'s and `<title>`'s text + * (React 18 writes it as any text, escaped; React 19 its one child as it is, but for what would end it). In a <Head>, + * a `<style>`'s and `<script>`'s text is written as it is in every React, whatever writes it (but for what would end + * it, or what it's in: see `rawText`), and a `<title>`'s children rendered: those tags are the document's head. The + * walker writes the markup of a template with it, and the string exporters (src/exporters) the markup of their + * blocks. + * + * Not written as react-dom 19 writes it, on purpose (src/engine/differential.test.tsx lists each): its hoisting (a + * `<title>`, `<meta>`, `<link>`, `<style precedence href>` or async `<script>` moved to the start of the markup, and + * image preloads added), its document singletons (an `<html>` at the top given a `<head>`), and a form's action that's + * a function (a script that runs it). An email's or a page's markup is a part of the document Elements writes, which + * has a head of its own (<Head>), and runs no script. + */ +import { attribute, customAttribute, escapeText, objectData, unrun } from "./attributes"; +import { REACT_18, REACT_18_2 } from "./react-compat"; + +export { escapeText }; + +/** + * Where markup is (react-dom's format context): the value of the `<select>` it's in, which marks its `<option>`s; + * whether it's in a table, a row or a table's section (whose children start afresh); whether it's a <Head>'s; and in + * a <Head>, whether it's the text of a `<style>` or `<script>`, written as it is (`raw`), whether it's under an `<svg>` + * or `<math>` (`foreign`), and the elements around it whose content runs to their own end tag (`ends`). + */ +export interface Format { + selected?: unknown; + table?: boolean; + head?: boolean; + raw?: boolean; + foreign?: boolean; + ends?: readonly string[]; +} +export const TOP: Format = {}; + +/** Where what's in a `type` element is, as react-dom works it out (in a <Head>, as the document's parser reads it). */ +export function childFormat(format: Format, type: string, props: Record<string, any>): Format { + const inner = markupFormat(format, type, props); + return format.head ? headFormat(format, inner, type) : inner; +} + +function markupFormat(format: Format, type: string, props: Record<string, any>): Format { + switch (type) { + case "select": + return { selected: props.value != null ? props.value : props.defaultValue }; + case "table": + case "thead": + case "tbody": + case "tfoot": + case "colgroup": + case "tr": + return { table: true }; + case "svg": + case "math": + case "foreignObject": + return {}; + case "noscript": + case "picture": + if (!REACT_18) return {}; + break; + } + return format.table ? {} : format; +} + +/** The elements whose content a parser reads to their own end tag (as text: what's in them can't be markup). */ +const TO_END_TAG = new Set(["style", "script", "title", "textarea", "noscript", "noembed", "noframes", "iframe", "xmp"]); + +/** + * In a <Head>, what's in a `type` element: a `<style>`'s or `<script>`'s content is its text as it is, markup in it + * too, to the end of the outermost (`raw`); but under an `<svg>` or `<math>`, where a parser may read it as markup, it's + * escaped as any text (`foreign`, to the end of it: what's in their HTML parts too). `ends`: the elements around it, + * outermost first, whose content runs to their own end tag, a `<title>`'s, or a `<noscript>`'s where scripts run. + */ +function headFormat(format: Format, inner: Format, type: string): Format { + if (format.raw) return format; + const tag = type.toLowerCase(); + const foreign = format.foreign || tag === "svg" || tag === "math"; + return { + ...inner, + head: true, + foreign: foreign || undefined, + ends: !foreign && TO_END_TAG.has(tag) ? [...(format.ends ?? []), tag] : format.ends, + raw: (!foreign && (type === "style" || type === "script")) || undefined, + }; +} + +export const VOID = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "keygen", "link", "meta", "param", "source", "track", "wbr"]); +const VALID_TAG = /^[a-zA-Z][a-zA-Z:_.\-\d]*$/; +/** SVG's and MathML's elements whose names have a "-", and those react-dom writes its own way: never custom elements. */ +const NOT_CUSTOM = new Set(["annotation-xml", "color-profile", "font-face", "font-face-src", "font-face-uri", "font-face-format", "font-face-name", "missing-glyph", "select", "option", "textarea", "title", "listing", "pre", "html", "menuitem"]); +/** React 19 writes these after an element's other attributes: a form's, and an input's or a button's form settings. */ +const FIELDS = ["name", "formAction", "formEncType", "formMethod", "formTarget"]; +const LATER: Record<string, string[] | undefined> = REACT_18 ? {} : { form: ["action", "encType", "method", "target"], input: FIELDS, button: FIELDS }; + +/** What react-dom throws for markup it can't write (the walker says where). */ +export function validateHost(type: string, props: Record<string, any>): void { + if (!VALID_TAG.test(type)) throw new Error("Invalid tag: " + type); + const html = props.dangerouslySetInnerHTML; + const content = props.children != null || html != null; + if (VOID.has(type) && content) throw new Error(`${type} is a self-closing tag and must neither have \`children\` nor use \`dangerouslySetInnerHTML\`.`); + if (type === "menuitem" && content) throw new Error("menuitems cannot have `children` nor `dangerouslySetInnerHTML`."); + if (html != null && (type === "textarea" || (REACT_18_2 && type === "title"))) throw new Error(`\`dangerouslySetInnerHTML\` does not make sense on <${type}>.`); + if (props.style != null && typeof props.style !== "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."); + if (html != null) { + if (props.children != null) throw new Error("Can only set one of `children` or `props.dangerouslySetInnerHTML`."); + if (typeof html !== "object" || !("__html" in html)) throw new Error("`props.dangerouslySetInnerHTML` must be in the form `{__html: ...}`."); + } +} + +/** Whether react-dom writes `type` as a custom element: a name with "-" (in React 18, also one with an `is`). */ +const isCustom = (type: string, props: Record<string, any>) => !VOID.has(type) && !NOT_CUSTOM.has(type) && (type.includes("-") || (REACT_18 && typeof props.is === "string")); + +/** + * `<type …>` with its attributes as react-dom writes them: an `<input>`'s `checked` and `value` last, an `<option>`'s + * `selected` last (where the `<select>` it's in has a value, whether that's its value), React 19's form settings after + * the rest, and none for what an element holds as its content (a `<textarea>`'s value) or gives its options (a + * `<select>`'s). + */ +export function openTag(type: string, props: Record<string, any>, format: Format = TOP): string { + const custom = isCustom(type, props); + const later = LATER[type]; + let open = "<" + type; + for (const key in props) { + if (!Object.prototype.hasOwnProperty.call(props, key)) continue; + const value = props[key]; + if (value == null || key === "children" || key === "dangerouslySetInnerHTML" || later?.includes(key)) continue; + if (custom) open += customAttribute(key, value); + else if (type === "input" ? /^(value|defaultValue|checked|defaultChecked)$/.test(key) : type === "select" || type === "textarea" ? key === "value" || key === "defaultValue" : type === "option" && key === "selected") continue; + // A link to nowhere is written (React 19 leaves out other empty URLs). + else if (value === "" && key === "href" && type === "a") open += ' href=""'; + // (An <object>'s `data` is a URL: React 18 writes it as text, a `javascript:` one as React 19 does.) + else open += type === "object" && key === "data" ? (REACT_18 ? attribute(key, unrun(value)) : objectData(value)) : attribute(key, value); + } + if (later) for (const key of later) if (props[key] != null) open += attribute(key, props[key]); + if (type === "input") { + const checked = props.checked != null ? props.checked : props.defaultChecked; + if (checked && typeof checked !== "function" && typeof checked !== "symbol") open += ' checked=""'; + const value = props.value != null ? props.value : props.defaultValue; + if (value != null) open += attribute("value", value); + } + if (type === "option" && selects(format.selected, props)) open += ' selected=""'; + return VOID.has(type) ? open + "/>" : open + ">"; +} + +/** Whether an `<option>` is selected: where its `<select>` has a value, whether that's its value (or one of them), else its `selected`. */ +function selects(selected: unknown, props: Record<string, any>): boolean { + if (selected == null) return !!props.selected; + const value = props.value != null ? "" + props.value : optionText(props.children); + return Array.isArray(selected) ? selected.some((v) => "" + v === value) : "" + selected === value; +} + +/** An option's value where it has none, as react-dom reads it: its children's text, joined (an element's is `[object Object]`). */ +function optionText(children: unknown): string { + let text = ""; + const add = (child: any): void => { + if (child == null || typeof child === "boolean" || typeof child === "function" || typeof child === "symbol" || (REACT_18 && typeof child === "bigint")) return; + if (Array.isArray(child)) child.forEach(add); + else text += child; + }; + add(children); + return text; +} + +/** + * What's in the element when its props say it (`dangerouslySetInnerHTML`, a `<textarea>`'s value, React 19's text of a + * `<style>`, `<script>` or `<title>`), else undefined: its children are walked. react-dom starts a `<pre>`, + * `<textarea>` or `<listing>` whose text starts with a newline with one more (the parser drops it). + */ +export function propsBody(type: string, props: Record<string, any>, format: Format = TOP): string | undefined { + const html = props.dangerouslySetInnerHTML; + if (type === "textarea") return textareaText(props); + if (html != null) { + const given = html.__html; + const body = given == null ? "" : "" + given; + return (type === "pre" || type === "listing") && typeof given === "string" && given[0] === "\n" ? "\n" + body : body; + } + if (type === "menuitem") return ""; + if (REACT_18 || format.head || !/^(style|script|title)$/.test(type)) return undefined; + // React 19: a script's text if it's a string; a style's or a title's one child (none for more than one). + const children = props.children; + if (type === "script") return typeof children === "string" ? escapeScript(children) : ""; + const child = Array.isArray(children) ? (children.length < 2 ? children[0] : null) : children; + if (child == null || typeof child === "function" || typeof child === "symbol") return ""; + return type === "style" ? escapeStyle("" + child) : escapeText("" + child); +} + +/** A `<textarea>`'s text: its value, else its default value, else its one child. */ +function textareaText(props: Record<string, any>): string { + let value = props.value != null ? props.value : props.defaultValue; + const children = props.children; + if (children != null) { + if (value != null) throw new Error("If you supply `defaultValue` on a <textarea>, do not pass children."); + if (Array.isArray(children) && children.length > 1) throw new Error("<textarea> can only have at most one child."); + value = "" + children; + } + return value == null ? "" : (typeof value === "string" && value[0] === "\n" ? "\n" : "") + escapeText("" + value); +} + +/** The newline react-dom adds before a `<pre>`'s or `<listing>`'s text children that start with one. */ +export const leadingNewline = (type: string, children: unknown): string => ((type === "pre" || type === "listing") && typeof children === "string" && children[0] === "\n" ? "\n" : ""); + +/** A `<style>`'s text as React 19 writes it: what would end one (`</style`) or start one escaped, as CSS reads it. */ +export const escapeStyle = (text: string) => text.replace(/(<\/|<)(s)(tyle)/gi, (_, before: string, s: string, after: string) => before + (s === "s" ? "\\73 " : "\\53 ") + after); +/** A `<script>`'s text as React 19 writes it: what would end one (`</script`) or start one escaped, as JavaScript reads it. */ +export const escapeScript = (text: string) => text.replace(/(<\/|<)(s)(cript)/gi, (_, before: string, s: string, after: string) => before + (s === "s" ? "\\u0073" : "\\u0053") + after); +/** + * A <Head>'s `<style>`'s or `<script>`'s text (`type`), written as it is, whatever wrote it: what would end it escaped, + * as React 19 escapes it, and the end tag of each element it's in that runs to one (`ends`: a `<noscript>`'s, a + * `<title>`'s), its first letter escaped as CSS or JavaScript reads it (`</\6e oscript`, `</\u006eoscript`). + */ +export function rawText(type: string, text: string, ends: readonly string[] = []): string { + let out = type === "style" ? escapeStyle(text) : escapeScript(text); + for (const end of ends) { + if (end === type) continue; + out = out.replace(new RegExp(`</(${end[0]})(?=${end.slice(1)})`, "gi"), (_, first: string) => { + const hex = first.charCodeAt(0).toString(16); + return "</" + (type === "style" ? `\\${hex} ` : `\\u${hex.padStart(4, "0")}`); + }); + } + return out; +} diff --git a/packages/react/src/engine/index.ts b/packages/react/src/engine/index.ts new file mode 100644 index 00000000..318e4148 --- /dev/null +++ b/packages/react/src/engine/index.ts @@ -0,0 +1,203 @@ +/** + * The engine's entry points: a template's content collected (its Rows, Columns and blocks recorded, see + * utils/record.tsx, as a tree of its records, markup and text), synchronously or waiting for what's async in it; + * markup rendered (Elements' own components: the root's HTML); one component called with hooks (a template wrapper). + * See ARCHITECTURE.md. + */ +import React, { cloneElement } from "react"; +import { asHost, type ContextStack, type Fallbacks, type HookState } from "./dispatcher"; +import { childFrame, nameOf, omits, positionOf, RenderError, rootFrame, threw, type OnError } from "./errors"; +import { flatten, type Flat, type Part } from "./output"; +import { checkReact } from "./self-test"; +import { pushTreeContext, ROOT_TREE, type TreeContext } from "./tree-id"; +import { TOP } from "./host"; +import { call, caller, failure, positioned, renderClass, sharedNow, sharing, suspension, thrownBy, walkNode, type Collector, type Shared, type Task, type Wait, type Walker } from "./walk"; + +export { RenderError, type OnError } from "./errors"; +export { flatten, htmlOf, PENDING, type Flat, type Item, type Markup, type Cut, type Part, type RecordItem } from "./output"; +export { errorPart, passesThrough, sharing, type Task, type Wait } from "./walk"; +export { track } from "./dispatcher"; +export type { TreeContext } from "./tree-id"; +export type { Collector, Recorded, Shared } from "./walk"; + +export interface CollectOptions { + collector: Collector; + onError?: OnError; + /** The root's name (Email, Page, …): where positions start. */ + root: string; + /** Where the content is in the tree (after the template wrappers called to reach the root: see call()). */ + tree?: TreeContext; + /** Errors passed on as they are. */ + passes?: (error: unknown) => boolean; + /** Components that are no place of their own in positions. */ + frameless?: (type: unknown) => boolean; + /** What the `useId()`s start with (React's `identifierPrefix`). */ + idPrefix?: string; + /** What the render reads for contexts no provider gives, its template's wrappers' (see unwrap-root.ts). */ + fallbacks?: Fallbacks; + /** What the render's components share (see Shared): the render's own, the same in each walk of it. */ + shared?: Shared; +} + +type WalkerOptions = Pick<CollectOptions, "onError" | "collector" | "passes" | "frameless" | "idPrefix" | "fallbacks" | "shared">; + +const walker = (options: Partial<WalkerOptions>, wait?: Wait): Walker => ({ + // (What it walks once something it waited for settles shares what the rest of it does: a Consumer's render prop.) + wait: wait && ((w, task, out, thenable, fill) => wait(w, task, out, thenable, (t, parts) => sharing(w.shared, () => fill(t, parts)))), + collector: options.collector, + passes: options.passes, + frameless: options.frameless, + idPrefix: options.idPrefix, + fallbacks: options.fallbacks ?? renderFallbacks(), + // (A walk of no render of its own, Elements' own markup, shares the one calling it.) + shared: options.shared ?? sharedNow() ?? {}, + omit: omits(options.onError), + suspense: 0, + made: new WeakSet(), + fatal: new WeakSet(), +}); + +/** + * What a render reads for contexts no provider gives: the walk's calling it (see `caller`), so one value each in a + * top-level render however many it starts, else its own. + */ +export const renderFallbacks = (): Fallbacks => caller()?.fallbacks ?? new Map(); + +/** + * What the component or template wrapper being called shares with the rest of its render (see Shared), or undefined + * where none is: outside such a call (React rendering a component, code of the user's own outside a render, an async + * component's code after an `await`). + */ +export const renderShared = (): Shared | undefined => sharedNow(); + +/** Where a walk starts: where the component that started it is, if the walker is calling one (see `caller`). */ +const task = (root: string, tree?: TreeContext): Task => ({ tree: tree ?? caller()?.tree ?? ROOT_TREE, contexts: caller()?.contexts ?? null, format: TOP, frame: rootFrame(root), watch: null }); + +/** `node` collected synchronously: what's async in it fails (or is left out, as `onError` says). */ +export function collect(node: React.ReactNode, options: CollectOptions): Flat { + checkReact(); + const w = walker(options); + const t = task(options.root, options.tree); + const out: Part[] = []; + try { + sharing(w.shared, () => walkNode(w, node, t, out)); + } catch (error) { + throw thrownBy(w, error, t.frame); + } + return flatten(out, w.omit) as Flat; +} + +/** + * `node` walked to collect it, waiting where `wait` says (the async renderers' holes: see src/async/collect.ts), its + * output as it is then (holes in it), and whether failed parts are left out: flattened once its holes settle. + */ +export function walkAsync(node: React.ReactNode, options: CollectOptions, wait: Wait): { parts: Part[]; omit: boolean } { + checkReact(); + const w = walker(options, wait); + const t = task(options.root, options.tree); + const out: Part[] = []; + try { + sharing(w.shared, () => walkNode(w, node, t, out)); + } catch (error) { + out.push({ t: "error", error: thrownBy(w, error, t.frame) as RenderError, fatal: true }); + } + return { parts: out, omit: w.omit }; +} + +let own = 0; +/** Whether Elements' own markup is rendering (see renderStatic): a root reads no context of the render around it. */ +export const ownMarkup = (): boolean => own > 0; + +/** + * Markup of Elements' own (a root's, a row's) as react-dom's static render writes it: `bare` components (Elements', + * which call no hooks) are called with the dispatcher of the render around it in the slot, as that render would call + * them (see asHost); others with hooks. A RenderError in it passes as it is. + */ +export function renderStatic(node: React.ReactNode, options: { bare?: (type: unknown) => boolean; passes?: (error: unknown) => boolean; idPrefix?: string } = {}): string { + checkReact(); + const w: Walker = { ...walker(options), passAll: true, bare: options.bare }; + const out: Part[] = []; + own++; + try { + asHost(() => walkNode(w, node, task(""), out)); + } finally { + own--; + } + return out.join(""); +} + +/** + * An element of one of Elements' own components (`bare`: a root, a Row, a block, which call no hooks), with `given` + * props, as renderStatic renders it, but the HTML it renders in its `<div dangerouslySetInnerHTML>` given as it is + * (`inner`): what renderStatic would write, without writing it out again; a root's Body called in turn, as + * renderStatic calls it. Where it isn't one, or doesn't render such a `<div>` in the end, `html` is renderStatic's. + */ +export function renderOwn(node: React.ReactElement, given: Record<string, unknown>, options: { bare?: (type: unknown) => boolean; passes?: (error: unknown) => boolean } = {}): { html: string; inner?: string } { + const bare = options.bare; + const type = node.type; + if (typeof type !== "function" || !bare?.(type)) return { html: renderStatic(cloneElement(node, given), options) }; + const props = { ...(node.props as object), ...given }; + checkReact(); + own++; + try { + return asHost(() => { + let frame = rootFrame(""); + let element: any = { type, props }; + for (;;) { + const name = nameOf(element.type); + frame = childFrame(frame, name); + let result: any; + try { + result = element.type(element.props); + } catch (error) { + if (error instanceof RenderError || options.passes?.(error)) throw error; + throw new RenderError(threw(error), name, positionOf(frame), error); + } + const html = result?.type === "div" && Object.keys(result.props).length === 1 ? result.props.dangerouslySetInnerHTML : undefined; + if (html && typeof html === "object" && "__html" in html) return { html: "", inner: html.__html == null ? "" : "" + html.__html }; + if (typeof result?.type !== "function" || !bare(result.type)) return { html: renderStatic(result, options) }; + element = result; + } + }); + } finally { + own--; + } +} + +/** + * A template wrapper (`<Welcome />`, which returns the root) called with hooks: what it returns (an async one's + * promise as it is), and where what it returns is in the tree. What it suspends on (`use()` of a pending promise, or a + * thrown one: React 18's Suspense) is thrown as it is, for its caller to call it again once it settles (see + * utils/unwrap-root.ts): `state` (hookState(), at the wrapper's place and inside the providers around it) is kept. + */ +export function callComponent(fn: (props: any, second?: unknown) => unknown, props: Record<string, unknown>, second: unknown, state: HookState): { value: unknown; tree: TreeContext } { + checkReact(); + let value: unknown; + try { + value = call(state, fn, props, second); + } catch (thrown) { + const thenable = suspension(thrown, state); + if (thenable) { + state.hooks = undefined; + throw thenable; + } + throw positioned(walker({}), failure(thrown), state.name, rootFrame(state.name)); + } + return { value, tree: state.localId > 0 ? pushTreeContext(state.tree, 1, 0) : state.tree }; +} + +/** A template wrapper that's a class (react-intl's <IntlProvider>), constructed and rendered inside `contexts`: as callComponent. */ +export function callClass(Ctor: any, props: Record<string, unknown>, contexts: ContextStack | null, fallbacks?: Fallbacks): unknown { + checkReact(); + try { + return renderClass(Ctor, props, contexts, fallbacks); + } catch (thrown) { + const thenable = suspension(thrown); + if (thenable) throw thenable; + const name = nameOf(Ctor); + throw positioned(walker({}), failure(thrown), name, rootFrame(name)); + } +} + +export { hookState, readContext, contextOf, type ContextStack, type Fallbacks, type HookState } from "./dispatcher"; +export { ROOT_TREE } from "./tree-id"; diff --git a/packages/react/src/engine/keys.ts b/packages/react/src/engine/keys.ts new file mode 100644 index 00000000..f26faece --- /dev/null +++ b/packages/react/src/engine/keys.ts @@ -0,0 +1,51 @@ +/** + * React's development warnings for keys in lists, which its renderer gave and the walker, rendering instead of it, + * gives too: an element of a list without a `key`, and two with the same one. Development builds only (their elements + * carry `_store`: React marks the children it validated there), each once per component. + */ +import { createElement } from "react"; + +/** What's been said, each once: a hundred at most, as a list's keys can come from data (then once that no more are). */ +const said = new Set<string>(); + +function warnOnce(key: string, message: string): void { + if (said.has(key) || said.size > 100) return; + said.add(key); + console.error(said.size > 100 ? "[Unlayer] More warnings about keys in lists aren't said." : message); +} + +type DevElement = { $$typeof?: unknown; key: string | null; _store?: { validated?: unknown } }; + +/** Whether React is its development build, whose elements carry `_store` (its production build checks no keys). */ +const DEVELOPMENT = "_store" in createElement("i"); + +/** Lists Elements made of the template's (already checked, as the template wrote them), which aren't checked again. */ +const made = new WeakSet<object>(); +export const checked = <T extends object>(list: T): T => (DEVELOPMENT && made.add(list), list); + +/** + * Checks a list of children (`items.map(…)`) the way React's renderer does, for the component `owner` renders it in. + * React marks the elements it validated (`_store.validated`: 1, static children and ones it warned for; 2, ones + * React.Children gave keys to that it still warns for), which the walker reads and marks the same way. + */ +export function checkKeys(list: readonly unknown[], owner: string): void { + if (!DEVELOPMENT || made.has(list)) return; + let keys: Set<string> | undefined; + for (const child of list) { + const element = child as DevElement | null; + // Only React's elements in a development build (`_store`). + if (!element || typeof element !== "object" || !element._store) continue; + const validated = element._store.validated; + if ((!validated && element.key == null) || validated === 2) { + try { + element._store.validated = 1; + } catch { + // A frozen store: said once anyway. + } + warnOnce(`missing:${owner}`, `[Unlayer] Each child in a list should have a unique "key" prop (in the list <${owner}> renders). See https://react.dev/link/warning-keys.`); + } else if (element.key != null) { + if ((keys ??= new Set()).has(element.key)) warnOnce(`duplicate:${owner}:${element.key}`, `[Unlayer] Two children in a list have the same key, "${element.key}" (in the list <${owner}> renders): keys should be unique.`); + else keys.add(element.key); + } + } +} diff --git a/packages/react/src/engine/output.ts b/packages/react/src/engine/output.ts new file mode 100644 index 00000000..580630d0 --- /dev/null +++ b/packages/react/src/engine/output.ts @@ -0,0 +1,171 @@ +/** + * A walk's output: text (escaped, as written), markup, records, and where it isn't settled yet or can fail, parts that + * say so. A hole is what a component that suspended renders once its promise settles (the walk goes on with its + * siblings meanwhile, so their promises start at once); a boundary is a part of the template that's left out when it + * fails (`onError: "omit"`); an error is where one failed. Flattening reads them in tree order, whenever they settle: + * so the content, the order of the records and the error a render throws are the same whatever settles first. + * + * A collecting walk's flattened output is the template's content as a tree (`Item`): its text, the markup it wrote + * whole, the markup a record is in (`<p>Hello <Name /></p>`: "cut" by it), and its records with what's inside them. + */ +import type { RenderError } from "./errors"; +import { rawText } from "./host"; + +export interface HostPart { + t: "host"; + tag: string; + /** Its start and end tags, as written (no end tag for a void element). */ + open: string; + close: string; + parts: Part[]; + /** + * Its content is text written as it is, whatever made it (a <Head>'s `<style>` or `<script>`: see host.ts), but for + * what would end it, or the elements it's in whose content runs to their end tag (`ends`). + */ + raw?: boolean; + ends?: readonly string[]; +} +export interface RecordPart { + t: "record"; + record: object; + parts: Part[]; + /** It writes nothing: in the records, not in the content; what's inside it is its record's own (`content`: a <Head>'s tags). */ + quiet?: boolean; +} +export interface Hole { + t: "hole"; + parts: Part[]; + settled: boolean; +} +export interface BoundaryPart { + t: "boundary"; + parts: Part[]; +} +export interface ErrorPart { + t: "error"; + error: RenderError; + /** A template that isn't one (an undefined component, an object as a child): never left out. */ + fatal: boolean; +} +export type Part = string | HostPart | RecordPart | Hole | BoundaryPart | ErrorPart; + +/** Markup written whole: no record in it. `inner` is what's between its tags (none for a void element). */ +export interface Markup { + kind: "markup"; + tag: string; + html: string; + inner?: string; +} +/** Markup a record is in, so it's cut where the record is. */ +export interface Cut { + kind: "cut"; + tag: string; + open: string; + close: string; + items: Item[]; +} +export interface RecordItem { + kind: "record"; + record: object; + items: Item[]; +} +/** The content: text (a string, escaped as written), markup, and records. */ +export type Item = string | Markup | Cut | RecordItem; + +export const PENDING = Symbol("pending"); + +export interface Flat { + content: Item[]; + /** The records, in tree order (quiet ones too). */ + recorded: object[]; + /** The parts left out (`onError: "omit"`), in tree order. */ + omitted: RenderError[]; +} + +/** The HTML of content. */ +export function htmlOf(items: readonly Item[]): string { + let html = ""; + for (const item of items) html += typeof item === "string" ? item : item.kind === "markup" ? item.html : item.kind === "cut" ? item.open + htmlOf(item.items) + item.close : htmlOf(item.items); + return html; +} + +/** Content added to `into`, text joined to the text before it, as it's written. */ +function add(into: Item[], item: Item): void { + const last = into.length - 1; + if (typeof item === "string" && typeof into[last] === "string") into[last] += item; + else into.push(item); +} + +/** Where a resolution's records and left-out parts go (a boundary's own, until it's resolved), and the policy. */ +interface Found { + recorded: object[]; + omitted: RenderError[]; + omit: boolean; +} + +/** Parts in tree order as content; the first error that isn't left out, or PENDING before an unsettled hole. */ +function resolve(parts: Part[], into: Item[], found: Found): ErrorPart | typeof PENDING | undefined { + for (const part of parts) { + if (typeof part === "string") { + if (part) add(into, part); + continue; + } + switch (part.t) { + case "host": { + const inside: Item[] = []; + const stop = resolve(part.parts, inside, found); + if (stop) return stop; + // (A raw one's text is written whole, whatever made it: what its components recorded is in the records.) + if (part.raw || inside.every((item) => typeof item === "string" || item.kind === "markup")) { + const inner = part.raw ? rawText(part.tag, htmlOf(inside), part.ends) : htmlOf(inside); + into.push({ kind: "markup", tag: part.tag, html: part.open + inner + part.close, inner: part.close ? inner : undefined }); + } else into.push({ kind: "cut", tag: part.tag, open: part.open, close: part.close, items: inside }); + break; + } + case "record": { + found.recorded.push(part.record); + const inside: Item[] = []; + const stop = resolve(part.parts, inside, found); + if (stop) return stop; + if (!part.quiet) into.push({ kind: "record", record: part.record, items: inside }); + else (part.record as { content?: Item[] }).content = inside; + break; + } + case "hole": { + if (!part.settled) return PENDING; + const stop = resolve(part.parts, into, found); + if (stop) return stop; + break; + } + case "boundary": { + const own: Item[] = []; + const ownFound: Found = { recorded: [], omitted: [], omit: found.omit }; + const stop = resolve(part.parts, own, ownFound); + if (stop === PENDING) return PENDING; + if (stop) { + // Left out whole: what it recorded too (its head tags, its scopes). + if (found.omit && !stop.fatal) found.omitted.push(stop.error); + else return stop; + } else { + for (const item of own) add(into, item); + for (const record of ownFound.recorded) found.recorded.push(record); + for (const error of ownFound.omitted) found.omitted.push(error); + } + break; + } + default: + return part; + } + } + return undefined; +} + +/** The output as content, its records in tree order; PENDING while a hole before any error is open. */ +export function flatten(parts: Part[], omit: boolean): Flat | typeof PENDING { + const content: Item[] = []; + const found: Found = { recorded: [], omitted: [], omit }; + const stop = resolve(parts, content, found); + if (stop === PENDING) return PENDING; + if (stop) throw stop.error; + return { content, recorded: found.recorded, omitted: found.omitted }; +} diff --git a/packages/react/src/engine/react-compat.test.ts b/packages/react/src/engine/react-compat.test.ts new file mode 100644 index 00000000..35e03a29 --- /dev/null +++ b/packages/react/src/engine/react-compat.test.ts @@ -0,0 +1,54 @@ +// @vitest-environment node +/** + * The React versions the walker knows, and the check that the installed one has what it relies on: CI runs this on + * each version in the table (see .github/workflows/react-versions.yml), and weekly on React's canary. + */ +import { describe, expect, it } from "vitest"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { ID_PATTERN, KNOWN_REACT, reactRow, slotOf } from "./react-compat"; +import { checkReact, probe, untested } from "./self-test"; + +describe("the React compat table", () => { + it("has the installed React's minor, or (a newer one) the nearest one before it", () => { + const { row, known } = reactRow(); + const newest = KNOWN_REACT[KNOWN_REACT.length - 1]; + const newer = Number.parseFloat(React.version) > Number.parseFloat(newest.react); + // A version the table should have (one before its newest) is in it; a newer one renders if the self-check passes. + if (!newer) expect(known, `React ${React.version} isn't in the table: add its row`).toBe(true); + if (known) expect(React.version.startsWith(row.react + ".")).toBe(true); + else expect(row).toBe(newest); + expect(reactRow("19.2.4").row.react).toBe("19.2"); + expect(reactRow("19.9.0")).toEqual({ row: KNOWN_REACT[KNOWN_REACT.length - 1], known: false }); + expect(reactRow("20.0.0-canary-abc").known).toBe(false); + // React 18's every minor (the peer range's), 18.0.0 with its build in its version. + for (const v of ["18.0.0-fc46dba67-20220329", "18.1.0", "18.2.0", "18.3.1"]) expect(reactRow(v), v).toMatchObject({ row: { react: v.slice(0, 4) }, known: true }); + expect(reactRow("17.0.2")).toEqual({ row: KNOWN_REACT[0], known: false, older: true }); + }); + + it("says a React it isn't tested with is newer, or older, than those it is", () => { + expect(untested("19.9.0")).toBe(`[Unlayer] React 19.9.0 is newer than the versions Elements is tested with (18.0–19.3); it passed Elements' check of the React it relies on.`); + expect(untested("17.0.2")).toMatch(/^\[Unlayer\] React 17\.0\.2 is older than the versions Elements is tested with \(18\.0–/); + }); + + it("gives useId's format as the installed react-dom/server writes it", () => { + const ids: string[] = []; + const Probe = () => (ids.push(React.useId(), React.useId()), null); + renderToStaticMarkup(React.createElement(React.Fragment, null, React.createElement(Probe), React.createElement(Probe))); + for (const id of ids) expect(id).toMatch(ID_PATTERN[reactRow().row.id]); + }); + + it("passes the self-check on the installed React", () => { + expect(() => checkReact()).not.toThrow(); + expect(probe(reactRow().row)).toBeUndefined(); + }); + + it("names what a React without the shapes it relies on lacks", () => { + const { row } = reactRow(); + expect(slotOf({}, row.slots)).toBeNull(); + expect(probe(row, {}, null)).toBe(`its hooks dispatcher isn't at ${row.slots.join(" or ")}`); + // A dispatcher slot that doesn't reach the walker's hooks. + const deaf = { get: () => null, set: () => {}, path: "fake.H" }; + expect(probe(row, React, deaf)).toBe("the dispatcher didn't install at fake.H"); + }); +}); diff --git a/packages/react/src/engine/react-compat.ts b/packages/react/src/engine/react-compat.ts new file mode 100644 index 00000000..c4067017 --- /dev/null +++ b/packages/react/src/engine/react-compat.ts @@ -0,0 +1,103 @@ +/** + * What the walker relies on in React, per minor version it's tested with (CI runs the suite on each: see + * .github/workflows/react-versions.yml). A version that isn't here takes the row of the nearest one before it; the + * self-test (self-test.ts) checks this React has those shapes before the first render, so an unknown version that + * differs fails with a named error instead of rendering wrong. + * + * | React | dispatcher slot (client · react-server build) | useId | context | suspends by | + * |-----------|------------------------------------------------------------------------------|---------|----------|-----------------| + * | 18.0–18.3 | __SECRET_INTERNALS….ReactCurrentDispatcher.current · (no react-server build) | `:R1:` | provider | thrown promises | + * | 19.0 | __CLIENT_INTERNALS….H · __SERVER_INTERNALS….H | `:R1:` | context | use(), thrown | + * | 19.1 | same | `«R1»` | context | use(), thrown | + * | 19.2 | same | `_R_1_` | context | use(), thrown | + * | 19.3 | same | `_R_1_` | context | use(), thrown | + * + * Context: "provider", React 18's (`Ctx.Provider` is `{ $$typeof: react.provider, _context }`; `Ctx.Consumer` the + * context itself, or in development `{ $$typeof: react.context, _context }`); "context", React 19's (`<Ctx>` is the + * provider, `Ctx.Provider === Ctx`; `Ctx.Consumer` is `{ $$typeof: react.consumer, _context }`). `use()` (19) marks a + * thenable with `status` and `value`/`reason`, which the walker reads and sets as React does. The react-server build + * (Next.js's Server Components and route handlers) has no `createContext`, `useState`, `useContext`, `useEffect`, …: + * only `use`, `useId`, `useMemo`, `useCallback` and `useDebugValue`. + * + * Errors: the walker throws its own for what React's server renderer rejects (an object as a child, React's #31; an + * invalid element type, #130; portals), and never reads React's messages. + */ +import React from "react"; + +export type IdFormat = "colon" | "guillemet" | "underscore"; +export type ContextShape = "provider" | "context"; + +export interface ReactRow { + /** major.minor */ + react: string; + /** The dispatcher slot's path, per build. */ + slots: string[]; + id: IdFormat; + context: ContextShape; + /** Whether it has `use()`. */ + use: boolean; +} + +const LEGACY = "__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentDispatcher.current"; +const CLIENT = "__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H"; +const SERVER = "__SERVER_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H"; + +export const KNOWN_REACT: readonly ReactRow[] = [ + // React 18's minors are all the same to it. + ...["18.0", "18.1", "18.2", "18.3"].map((react): ReactRow => ({ react, slots: [LEGACY], id: "colon", context: "provider", use: false })), + { react: "19.0", slots: [CLIENT, SERVER], id: "colon", context: "context", use: true }, + { react: "19.1", slots: [CLIENT, SERVER], id: "guillemet", context: "context", use: true }, + { react: "19.2", slots: [CLIENT, SERVER], id: "underscore", context: "context", use: true }, + { react: "19.3", slots: [CLIENT, SERVER], id: "underscore", context: "context", use: true }, +]; + +/** What a useId the walker gives looks like, per format (no identifierPrefix). */ +export const ID_PATTERN: Record<IdFormat, RegExp> = { + colon: /^:R[0-9a-v]+(H[0-9a-v]+)?:$/, + guillemet: /^«R[0-9a-v]+(H[0-9a-v]+)?»$/, + underscore: /^_R_[0-9a-v]+(H[0-9a-v]+)?_$/, +}; + +const version = (v: string) => v.split(/[.-]/).slice(0, 2).map((n) => Number.parseInt(n, 10) || 0); + +const [MAJOR, MINOR] = version(String(React.version)); +/** React 18 renders some elements, attributes, styles and children otherwise than React 19 (see walk.ts, attributes.ts). */ +export const REACT_18 = MAJOR < 19; +/** React 18.2 and 18.3 reject a `<title>`'s `dangerouslySetInnerHTML` (18.0, 18.1 and 19 write it). */ +export const REACT_18_2 = MAJOR === 18 && MINOR >= 2; +/** React 19.3 added `credentialless` to its boolean attributes and `maskType` to its SVG attributes. */ +export const REACT_19_3 = MAJOR > 19 || (MAJOR === 19 && MINOR >= 3); + +/** The installed React's row: its own, else the nearest before it (`known: false`), or for one older than all, the first. */ +export function reactRow(v: string = React.version): { row: ReactRow; known: boolean; older?: boolean } { + const [major, minor] = version(v); + const exact = KNOWN_REACT.find((r) => r.react === `${major}.${minor}`); + if (exact) return { row: exact, known: true }; + const before = [...KNOWN_REACT].reverse().find((r) => { + const [m, n] = version(r.react); + return m < major || (m === major && n <= minor); + }); + return before ? { row: before, known: false } : { row: KNOWN_REACT[0], known: false, older: true }; +} + +let row: ReactRow | undefined; +export const idFormat = (): IdFormat => (row ??= reactRow().row).id; + +/** React's dispatcher slot: where its hooks read the dispatcher from. */ +export interface Slot { + get(): unknown; + set(dispatcher: unknown): void; + path: string; +} + +/** The slot of `R` (the React Elements imports, the template's: `react` is a peer dependency), by the table's paths. */ +export function slotOf(R: object = React, paths: string[] = (row ??= reactRow().row).slots): Slot | null { + for (const path of paths) { + const keys = path.split("."); + const field = keys.pop()!; + let holder: any = R; + for (const key of keys) holder = holder?.[key]; + if (holder && typeof holder === "object" && field in holder) return { get: () => holder[field], set: (d) => void (holder[field] = d), path }; + } + return null; +} diff --git a/packages/react/src/engine/records.ts b/packages/react/src/engine/records.ts new file mode 100644 index 00000000..2ad12e99 --- /dev/null +++ b/packages/react/src/engine/records.ts @@ -0,0 +1,32 @@ +/** + * What a collecting walk's collector gives the walker to walk in place of an element it records (see + * utils/record.tsx): a record, whose children (a Row's Columns, a rich text block's markup) are walked inside it, or + * which writes nothing (`quiet`: a <Head>'s entry, whose children are its own content, its tags); and a boundary around + * a component of the template's own, the part left out when it fails (`onError: "omit"`). + * + * They're the walker's own nodes, never React's: what's walked inside a record is that record's content in the walk's + * output (output.ts), so the template comes out as a tree of its records, its markup and its text. + */ +import type React from "react"; + +const NODE = Symbol("@unlayer/react-elements/record"); + +export interface RecordNode { + $$typeof: typeof NODE; + record: object; + children?: React.ReactNode; + quiet?: boolean; +} + +export interface BoundaryNode { + $$typeof: typeof NODE; + /** Its record (a component's), whose content is what it renders; null for markup, which isn't recorded. */ + boundary: object | null; + child: React.ReactNode; +} + +/** (Typed as React's nodes: they're walked where React's are, and only by the walker.) */ +export const recordNode = (record: object, children?: React.ReactNode, quiet?: boolean): React.ReactElement => ({ $$typeof: NODE, record, children, quiet }) as unknown as React.ReactElement; +export const boundary = (record: object | null, child: React.ReactNode): React.ReactElement => ({ $$typeof: NODE, boundary: record, child }) as unknown as React.ReactElement; + +export const isWalkerNode = (node: any): node is RecordNode | BoundaryNode => node?.$$typeof === NODE; diff --git a/packages/react/src/engine/self-test.ts b/packages/react/src/engine/self-test.ts new file mode 100644 index 00000000..acd4b8b7 --- /dev/null +++ b/packages/react/src/engine/self-test.ts @@ -0,0 +1,63 @@ +/** + * Before the first render, a check that the installed React has the shapes the walker relies on (react-compat.ts): + * its dispatcher slot where the table says, the dispatcher installed there and the previous one put back, useId in + * its format, context and `use()` as the walker reads them. A version the table doesn't know renders if it passes + * (with a warning in development); one that fails throws a named error, unless UNLAYER_SKIP_REACT_CHECK=1. + */ +import React from "react"; +import { callWithHooks, DISPATCHER, hookState, theSlot } from "./dispatcher"; +import { ID_PATTERN, KNOWN_REACT, reactRow, type ReactRow, type Slot } from "./react-compat"; +import { ROOT_TREE } from "./tree-id"; + +const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env ?? {}; + +/** What's wrong with this React (`R`, whose dispatcher slot is `slot`) for the walker, or undefined. */ +export function probe(row: ReactRow, R: any = React, slot: Slot | null = theSlot()): string | undefined { + if (!slot) return `its hooks dispatcher isn't at ${row.slots.join(" or ")}`; + const before = slot.get(); + const context = typeof R.createContext === "function" ? R.createContext("default") : undefined; + const fulfilled = { status: "fulfilled", value: "used", then() {} }; + const seen: Record<string, unknown> = {}; + const state = hookState("Probe", ROOT_TREE, context ? { context, value: "provided", next: null } : null); + try { + callWithHooks(state, () => { + seen.installed = slot.get() === DISPATCHER; + seen.memo = R.useMemo(() => "memo", []); + if (typeof R.useId === "function") seen.id = R.useId(); + if (context && typeof R.useContext === "function") seen.context = R.useContext(context); + if (row.use && typeof R.use === "function") seen.use = R.use(fulfilled); + }); + } catch (error) { + return `a component's hooks failed (${String((error as Error)?.message ?? error)})`; + } + if (!seen.installed) return `the dispatcher didn't install at ${slot.path}`; + if (slot.get() !== before) return `the dispatcher at ${slot.path} wasn't put back`; + if (seen.memo !== "memo") return "useMemo didn't reach the dispatcher"; + if ("id" in seen && !ID_PATTERN[row.id].test(String(seen.id))) return `useId gave ${String(seen.id)}, not React ${row.react}'s format`; + if (context) { + const shape = row.context === "context" ? context.Provider === context && context.Consumer?.$$typeof === Symbol.for("react.consumer") : context.Provider?.$$typeof === Symbol.for("react.provider"); + if (!shape) return `its context isn't shaped as React ${row.react}'s`; + if ("context" in seen && seen.context !== "provided") return "useContext didn't read the walker's provider"; + } + if ("use" in seen && seen.use !== "used") return "use() didn't read a fulfilled thenable's value"; + return undefined; +} + +/** What development says of a React the table doesn't have (`v`) that passed the check. */ +export const untested = (v: string): string => + `[Unlayer] React ${v} is ${reactRow(v).older ? "older" : "newer"} than the versions Elements is tested with (${KNOWN_REACT[0].react}–${KNOWN_REACT[KNOWN_REACT.length - 1].react}); it passed Elements' check of the React it relies on.`; + +let verdict: string | null | undefined; + +/** Throws, on every render, if this React fails the check (run once per process). */ +export function checkReact(): void { + if (verdict === undefined) { + if (env.UNLAYER_SKIP_REACT_CHECK === "1") verdict = null; + else { + const { row, known } = reactRow(); + verdict = probe(row) ?? null; + if (!verdict && !known && env.NODE_ENV !== "production") console.warn(untested(React.version)); + } + } + if (verdict) throw new Error(`[Unlayer] unsupported React ${React.version}: ${verdict}. Set UNLAYER_SKIP_REACT_CHECK=1 to render anyway.`); +} diff --git a/packages/react/src/engine/tree-id.ts b/packages/react/src/engine/tree-id.ts new file mode 100644 index 00000000..3358493f --- /dev/null +++ b/packages/react/src/engine/tree-id.ts @@ -0,0 +1,54 @@ +/** + * Where a component is in the tree, as React's server renderer numbers it (Fizz's tree context): `useId()` is made + * from it, so the walker's ids are what react-dom would give the same tree, in the installed React's format. + */ +import { idFormat } from "./react-compat"; + +export interface TreeContext { + id: number; + overflow: string; +} + +export const ROOT_TREE: TreeContext = { id: 1, overflow: "" }; + +const clz32 = Math.clz32; + +/** The tree context of child `index` of `total` (React's pushTreeContext). */ +export function pushTreeContext(base: TreeContext, total: number, index: number): TreeContext { + let baseId = base.id; + let baseLength = 32 - clz32(baseId) - 1; + baseId &= ~(1 << baseLength); + const slot = index + 1; + const length = 32 - clz32(total) + baseLength; + if (length > 30) { + // The id would overflow 32 bits: the bits so far go to `overflow`, in base 32. + const overflowBits = baseLength - (baseLength % 5); + const overflow = (baseId & ((1 << overflowBits) - 1)).toString(32); + baseId >>= overflowBits; + baseLength -= overflowBits; + return { id: (1 << (32 - clz32(total) + baseLength)) | (slot << baseLength) | baseId, overflow: overflow + base.overflow }; + } + return { id: (1 << length) | (slot << baseLength) | baseId, overflow: base.overflow }; +} + +function treeId(tree: TreeContext): string { + const id = tree.id; + return (id & ~(1 << (32 - clz32(id) - 1))).toString(32) + tree.overflow; +} + +/** + * The `local`-th `useId()` of the component at `tree`, after `prefix` as React writes its `identifierPrefix` (the + * render's `idPrefix`; Elements' ids carry nothing of its own). + */ +export function makeId(tree: TreeContext, local: number, prefix = ""): string { + const tid = treeId(tree); + const h = local > 0 ? "H" + local.toString(32) : ""; + switch (idFormat()) { + case "underscore": + return `_${prefix}R_${tid}${h}_`; + case "guillemet": + return `«${prefix}R${tid}${h}»`; + default: + return `:${prefix}R${tid}${h}:`; + } +} diff --git a/packages/react/src/engine/walk.ts b/packages/react/src/engine/walk.ts new file mode 100644 index 00000000..dc9d19a1 --- /dev/null +++ b/packages/react/src/engine/walk.ts @@ -0,0 +1,633 @@ +/** + * The walker: renders a React element tree as react-dom's static server render would, without react-dom. Function + * components are called with Elements' hooks dispatcher (dispatcher.ts), class components constructed and rendered, + * Context, Fragments, memo, forwardRef, lazy and Suspense handled here, host markup written by host.ts. + * + * One synchronous walk serves both renderers. Where a component suspends (an async component, `use()` of a pending + * promise, a thrown promise, a lazy component, a promise as a child), the synchronous renderer throws (to the nearest + * <Suspense>, whose fallback it renders, as react-dom does); the async one (`wait`: the async entry's, see + * src/async/collect.ts) leaves a hole in the output, walks on (so siblings start their own work at once) and fills the + * hole when the promise settles, with the tree position and context it had (output.ts puts it all in tree order). + * + * A collecting walk (the template's, see utils/record.tsx) asks its collector about each element first: Elements' + * Rows, Columns and blocks are recorded instead of called, and what's inside them walked inside their records; its + * output is the template's content as a tree (output.ts). + */ +import type React from "react"; +import { callWithHooks, contextOf, hookState, readContext, SUSPEND, track, type ContextStack, type Fallbacks, type HookState } from "./dispatcher"; +import { ASYNC_IN_SYNC, childFrame, nameOf, placeFrame, positionOf, RenderError, threw, type Frame } from "./errors"; +import { childFormat, escapeText, leadingNewline, openTag, propsBody, validateHost, VOID, type Format } from "./host"; +import { REACT_18 } from "./react-compat"; +import { checkKeys } from "./keys"; +import { isWalkerNode, type BoundaryNode, type RecordNode } from "./records"; +import type { BoundaryPart, ErrorPart, HostPart, Part, RecordPart } from "./output"; +import { pushTreeContext, type TreeContext } from "./tree-id"; + +const ELEMENT = Symbol.for("react.element"); +const TRANSITIONAL = Symbol.for("react.transitional.element"); +const PORTAL = Symbol.for("react.portal"); +const FRAGMENT = Symbol.for("react.fragment"); +const SUSPENSE = Symbol.for("react.suspense"); +const PROVIDER = Symbol.for("react.provider"); +const CONTEXT = Symbol.for("react.context"); +const CONSUMER = Symbol.for("react.consumer"); +const FORWARD_REF = Symbol.for("react.forward_ref"); +const MEMO = Symbol.for("react.memo"); +const LAZY = Symbol.for("react.lazy"); +const CLIENT_REF = Symbol.for("react.client.reference"); +const ACTIVITY = Symbol.for("react.activity"); +/** What renders its children as they are on a server. */ +const PASSTHROUGH = new Set<unknown>([FRAGMENT, Symbol.for("react.strict_mode"), Symbol.for("react.profiler"), Symbol.for("react.suspense_list"), Symbol.for("react.view_transition"), Symbol.for("react.legacy_hidden"), Symbol.for("react.scope")]); + +/** + * Whether an element type renders what's in it, its children, on a server: a Fragment, StrictMode, a <Suspense> (which + * renders its fallback instead only where what's in it suspends), a visible <Activity>, … (template wrappers: see + * utils/unwrap-root.ts). + */ +export const passesThrough = (type: unknown, props: { mode?: unknown }): boolean => PASSTHROUGH.has(type) || type === SUSPENSE || (type === ACTIVITY && props.mode !== "hidden"); + +/** What a collecting walk walks for an element instead of calling it (its record), and the name it's at. */ +export interface Recorded { + name?: string; + /** (`position`, where it is: `Row 3 › Column 1 › Button`, worked out when an error asks.) */ + record(position: () => string): React.ReactNode; +} +export type Collector = (element: React.ReactElement) => Recorded | undefined; + +/** + * What the components of one render share (the work left to its Markdown and code blocks, together): the render's, + * keyed by whoever keeps something in it (see the engine's `renderShared`). + */ +export type Shared = Record<symbol, unknown>; + +/** What waits for `thenable` (an async walk's hole), and `fill`s the walk's output where it is once it settles. */ +export type Wait = (w: Walker, task: Task, out: Part[], thenable: PromiseLike<unknown>, fill: (task: Task, parts: Part[]) => void) => void; + +export interface Walker { + /** How it waits for what's async (the async renderers'); a synchronous walk throws to the nearest <Suspense>. */ + wait?: Wait; + /** Pass on any RenderError (a render of Elements' own markup: its components' errors are placed already). */ + passAll?: boolean; + /** Components called as they are, with whatever dispatcher is there (Elements' own, which call no hooks). */ + bare?: (type: unknown) => boolean; + /** Errors passed on as they are (a <Tailwind>'s, which says what to do itself). */ + passes?: (error: unknown) => boolean; + /** Components that are no place of their own in positions (an <UnlayerProvider>: its children are where it is). */ + frameless?: (type: unknown) => boolean; + /** What the `useId()`s start with (the render's `idPrefix`). */ + idPrefix?: string; + /** What its top-level render reads for contexts no provider gives, once each (see the dispatcher's `readContext`). */ + fallbacks: Fallbacks; + /** What the components it calls share with the rest of their render (see Shared). */ + shared: Shared; + collector?: Collector; + /** Boundaries leave out what fails (`onError: "omit"`); an async walk's always catch, flattening decides. */ + omit: boolean; + /** The <Suspense>s open where a synchronous walk is: what suspends goes to the nearest, whatever `onError` says. */ + suspense: number; + /** The RenderErrors this walk made (one from a render inside a component is that component's error). */ + made: WeakSet<object>; + /** RenderErrors for a template that isn't one: never left out. */ + fatal: WeakSet<object>; +} + +export interface Task { + tree: TreeContext; + contexts: ContextStack | null; + /** Where markup is (a `<select>` its options are in, a <Head>): see host.ts. */ + format: Format; + frame: Frame; + /** A boundary's own component, whose return value its record keeps (`output`: see src/model/elements.ts's keptMarkup). */ + watch: { element: unknown; record: Record<string, any> } | null; +} + +/** + * Where the component the walker is calling is, for a walk that component starts (a root's content, rendered by + * Body; a render the template's code starts): its providers are around that walk, as React's nested renders read the + * values of the render they're in, and its place in the tree is where that walk's ids start, so they're the document's. + * By design, the one thing outside a render's scope its output depends on (ARCHITECTURE.md, invariant 4): `guest` + * doesn't clear it, and a render started outside any component's call starts at the top. A walk started there reads + * the contexts no provider gives as the walk calling it does (its `fallbacks`): one value each in a top-level render. + */ +let calling: { tree: TreeContext; contexts: ContextStack | null; fallbacks: Fallbacks } | null = null; +export const caller = () => calling; + +/** + * What the code running now shares with the rest of its render (see Shared): while a render runs (see the main entry's + * withScope), a component the walker calls, a template wrapper its render calls (see `sharing`), each render's own + * (one a component starts has its own). None outside a render's synchronous run: an async component's code after an + * `await` runs when none is, and can't be told from another render's. + * + * It's kept in one slot on globalThis (by Symbol.for), for every copy of Elements in the process (a package's ESM and + * CommonJS builds, a package still on another version), so a copy's Markdown and code entries read the render another + * copy runs (ARCHITECTURE.md, invariant 9). `version` is the shape of what the slot and the work in it hold: a copy + * that finds another version's keeps a slot of its own (its renders' blocks then share with each other, and another + * version's blocks have their own work, as each block once did). Bump SLOT_VERSION when what's kept in it changes. + */ +const SLOT = Symbol.for("@unlayer/react-elements/render-work"); +const SLOT_VERSION = 1; +type Slot = { version: number; shared: Shared | undefined }; +const slot: Slot = (() => { + const global = globalThis as Record<symbol, Slot | undefined>; + const found = global[SLOT]; + if (found?.version === SLOT_VERSION) return found; + const own: Slot = { version: SLOT_VERSION, shared: undefined }; + if (!found) global[SLOT] = own; + return own; +})(); +export const sharedNow = (): Shared | undefined => slot.shared; + +/** `fn`, called with what `share` is shared with (see `slot`). */ +export function sharing<T>(share: Shared | undefined, fn: () => T): T { + const outer = slot.shared; + slot.shared = share; + try { + return fn(); + } finally { + slot.shared = outer; + } +} + +/** `fn`, called as a component at `task` (see `calling`), with what its render's components share. */ +function asCaller<T>(w: Walker, task: Task, fn: () => T): T { + const outer = calling; + calling = { tree: task.tree, contexts: task.contexts, fallbacks: w.fallbacks }; + try { + return sharing(w.shared, fn); + } finally { + calling = outer; + } +} + +/** A suspension a synchronous walk can't wait for: to the nearest <Suspense>, or a RenderError that says so. */ +export class SyncSuspend { + constructor( + readonly name: string, + readonly frame: Frame + ) {} +} + +const isThenable = (value: unknown): value is PromiseLike<unknown> => typeof (value as { then?: unknown } | null)?.then === "function"; +type Thenable = PromiseLike<unknown> & { status?: "pending" | "fulfilled" | "rejected"; value?: unknown; reason?: unknown }; + +/** + * The promise a component's call suspended on, which it's called again once it settles: the one its `use()` waits for, + * or one it threw (React 18's Suspense, react-i18next's `useTranslation`). A thrown one that has rejected is what the + * call failed with (see `failure`), as `use()` of it fails: it isn't called again for it. + */ +export function suspension(thrown: unknown, state?: HookState): PromiseLike<unknown> | undefined { + if (thrown === SUSPEND) return state?.suspendedOn; + if (!isThenable(thrown)) return undefined; + track(thrown as Thenable); + return (thrown as Thenable).status === "rejected" ? undefined : thrown; +} + +/** What a component's call failed with: what it threw, or what a promise it threw rejected with (see `suspension`). */ +export const failure = (thrown: unknown): unknown => (isThenable(thrown) && (thrown as Thenable).status === "rejected" ? (thrown as Thenable).reason : thrown); + +const isAsyncFunction = (fn: unknown) => (fn as { [Symbol.toStringTag]?: string } | null)?.[Symbol.toStringTag] === "AsyncFunction"; +/** A promise nothing waits for: its rejection mustn't end the process. */ +const settle = (thenable: PromiseLike<unknown>) => void thenable.then(undefined, () => {}); + +/** `error`, thrown in `name` at `frame`, as the RenderError that says so (one this walk made already stays as it is). */ +export function positioned(w: Walker, error: unknown, name: string, frame: Frame): unknown { + if (error instanceof SyncSuspend || (error instanceof RenderError && (w.passAll || w.made.has(error))) || w.passes?.(error)) return error; + const made = new RenderError(threw(error), name, positionOf(frame), error); + w.made.add(made); + return made; +} + +/** A RenderError for a template that isn't one (thrown, whatever `onError` says). */ +function fatal(w: Walker, message: string, frame: Frame): RenderError { + const made = new RenderError(message, frame.name || "template", positionOf(frame)); + w.made.add(made); + w.fatal.add(made); + return made; +} + +/** What failed, as a RenderError: a suspension a synchronous walk couldn't wait for, too. */ +export function asRenderError(w: Walker, error: unknown, frame: Frame): RenderError { + if (error instanceof SyncSuspend) return new RenderError(ASYNC_IN_SYNC, error.name, positionOf(error.frame)); + return positioned(w, error, frame.name, frame) as RenderError; +} + +/** What a walk throws: a RenderError, or an error passed on as it is. */ +export const thrownBy = (w: Walker, error: unknown, frame: Frame): unknown => (w.passes?.(error) ? error : asRenderError(w, error, frame)); + +export const errorPart = (w: Walker, error: unknown, frame: Frame): ErrorPart => { + const made = thrownBy(w, error, frame) as RenderError; + return { t: "error", error: made, fatal: w.fatal.has(made) || !(made instanceof RenderError) }; +}; + +// ── Nodes ── + +export function walkNode(w: Walker, node: unknown, task: Task, out: Part[]): void { + if (node === null || node === undefined || typeof node === "boolean") return; + if (typeof node === "string") { + if (node) out.push(task.format.raw ? node : escapeText(node)); + return; + } + if (typeof node === "number") return void out.push("" + node); + if (typeof node === "bigint") { + // react-dom 18 writes nothing for one. + if (!REACT_18) out.push("" + node); + return; + } + if (typeof node === "function" || typeof node === "symbol") return; + if (Array.isArray(node)) return walkArray(w, node, task, out); + const object = node as Record<string | symbol, any>; + const tag = object.$$typeof; + if (tag === ELEMENT || tag === TRANSITIONAL) return walkElement(w, object as unknown as React.ReactElement, task, out); + if (isWalkerNode(object)) return "boundary" in object ? walkBoundary(w, object, task, out) : walkRecord(w, object, task, out); + if (tag === PORTAL) throw fatal(w, "Portals aren't supported by server rendering (React's server renderer has none either)", task.frame); + if (tag === LAZY) return walkLazy(w, object, task, out, (resolved, t, parts) => walkNode(w, resolved, t, parts)); + if (typeof object[Symbol.iterator] === "function") return walkArray(w, Array.from(object as Iterable<unknown>), task, out); + if (!REACT_18) { + // React 19 renders what a promise child resolves to, as `use()` would, and a context's value. + if (isThenable(object)) return walkThenable(w, object, task, out); + if (tag === CONTEXT) return walkNode(w, readContext(task.contexts, object, w.fallbacks), task, out); + } + const keys = Object.prototype.toString.call(object) === "[object Object]" ? `object with keys {${Object.keys(object).join(", ")}}` : Object.prototype.toString.call(object); + throw fatal(w, `Objects are not valid as a React child (found: ${keys}). If you meant to render a collection of children, use an array instead`, task.frame); +} + +function walkArray(w: Walker, list: unknown[], task: Task, out: Part[]): void { + checkKeys(list, task.frame.name); + const parent = task.tree; + const total = list.length; + try { + for (let i = 0; i < total; i++) { + task.tree = pushTreeContext(parent, total, i); + walkNode(w, list[i], task, out); + } + } finally { + task.tree = parent; + } +} + +/** `keep`: the record that keeps what `element` returns, when it's a boundary's own component (see `returned`). */ +function walkElement(w: Walker, element: React.ReactElement, task: Task, out: Part[], keep = watched(task, element)): void { + const type = element.type as any; + const props = element.props as Record<string, any>; + if (typeof type === "string") return walkHost(w, type, props, task, out); + const recorded = w.collector?.(element); + if (recorded) return walkRecorded(w, recorded, task, out); + if (typeof type === "function") { + if (type.$$typeof === CLIENT_REF) throw clientReference(w, type, task); + if (type.prototype?.isReactComponent) return walkClass(w, type, props, task, out, keep); + return walkFunction(w, type, type, props, undefined, task, out, keep); + } + // What a Provider, a StrictMode or a <Suspense> renders is what's in it (a component's call keeps what it returns). + returned(keep, props.children); + if (PASSTHROUGH.has(type)) return walkNode(w, props.children, task, out); + if (type === SUSPENSE) return walkSuspense(w, props, task, out); + if (type === ACTIVITY) return props.mode === "hidden" ? undefined : walkNode(w, props.children, task, out); + if (type !== null && typeof type === "object") { + switch (type.$$typeof) { + case FORWARD_REF: { + // React 19 passes `ref` in props, React 18 beside them. + let ref: unknown = null; + let own = props; + if (REACT_18) ref = (element as { ref?: unknown }).ref ?? null; + else if ("ref" in props) ({ ref, ...own } = props); + return walkFunction(w, type.render, type, own, ref, task, out, keep); + } + case MEMO: { + const inner = type.type; + const own = REACT_18 ? withDefaults(inner, props) : props; + if (typeof inner === "function" && !inner.prototype?.isReactComponent) return walkFunction(w, inner, type, own, undefined, task, out, keep); + return walkElement(w, { ...element, type: inner, props: own } as React.ReactElement, task, out, keep); + } + case LAZY: + return walkLazy(w, type, task, out, (resolved, t, parts) => walkElement(w, { ...element, type: resolved, props: withDefaults(resolved, props) } as React.ReactElement, t, parts, keep)); + case PROVIDER: + return withContext(w, type._context, props, task, out); + case CONTEXT: + // React 18: a Consumer (the context, or in development an object with `_context`). React 19: the provider. + return REACT_18 ? walkNode(w, returned(keep, props.children(readContext(task.contexts, type, w.fallbacks))), task, out) : withContext(w, type, props, task, out); + case CONSUMER: + return walkNode(w, returned(keep, props.children(readContext(task.contexts, type._context, w.fallbacks))), task, out); + case CLIENT_REF: + throw clientReference(w, type, task); + } + } + const missing = type === undefined || (typeof type === "object" && type !== null && Object.keys(type).length === 0); + throw fatal( + w, + `Element type is invalid: expected a string (for built-in components) or a class/function (for composite components) but got: ${type === null ? "null" : typeof type}.${missing ? " You likely forgot to export your component from the file it's defined in, or you might have mixed up default and named imports." : ""}`, + task.frame + ); +} + +/** The record that keeps what `element` returns, when it's a boundary's own component. */ +const watched = (task: Task, element: unknown) => (task.watch && task.watch.element === element ? task.watch.record : undefined); + +/** + * `value`, what a boundary's own component returned (a function's, a class's render, a Consumer's function's; what's + * in a Provider, a StrictMode, a <Suspense>), kept on its record (`keep`) as `output`: src/model/elements.ts's + * keptMarkup reads it, whatever kind of component it was (a memo's or a lazy one's is the component's in it). + */ +function returned<T>(keep: Record<string, any> | undefined, value: T): T { + if (keep) keep.output = { value }; + return value; +} + +const clientReference = (w: Walker, type: any, task: Task) => + fatal(w, `<${type.$$id ?? type.name ?? "component"}> is a Client Component ("use client"): it can't render in server code (a Server Component or route handler). Render it from a Client Component, or import it without "use client"`, task.frame); + +function withDefaults(type: any, props: Record<string, unknown>): Record<string, unknown> { + const defaults = type?.defaultProps; + if (!defaults) return props; + const out = { ...props }; + for (const key in defaults) if (out[key] === undefined) out[key] = defaults[key]; + return out; +} + +function withContext(w: Walker, context: object, props: Record<string, any>, task: Task, out: Part[]): void { + const outer = task.contexts; + task.contexts = { context: contextOf(context), value: props.value, next: outer }; + try { + walkNode(w, props.children, task, out); + } finally { + task.contexts = outer; + } +} + +/** What a collecting walk walks for an Elements component: its record, at the place its name takes. */ +function walkRecorded(w: Walker, recorded: Recorded, task: Task, out: Part[]): void { + const outer = task.frame; + const frame = recorded.name ? childFrame(outer, recorded.name) : outer; + let nodes: React.ReactNode; + try { + nodes = recorded.record(() => positionOf(frame)); + } catch (error) { + throw positioned(w, error, frame.name, frame); + } + task.frame = frame; + try { + walkNode(w, nodes, task, out); + } finally { + task.frame = outer; + } +} + +/** + * A record, what's inside it walked into it. Its children are where a list of three (its start, them, its end) had + * them, as the walk wrote a record's two ends before: their tree positions, so their `useId()`s, are as they were. + */ +function walkRecord(w: Walker, node: RecordNode, task: Task, out: Part[]): void { + const part: RecordPart = { t: "record", record: node.record, parts: [], quiet: node.quiet }; + out.push(part); + if (node.children === undefined) return; + const { tree, format } = task; + task.tree = pushTreeContext(tree, 3, 1); + // (A quiet record's are a <Head>'s tags: the document's head, see host.ts.) + if (node.quiet) task.format = { head: true }; + try { + walkNode(w, node.children, task, part.parts); + } finally { + task.tree = tree; + task.format = format; + } +} + +/** A part of the template left out when it fails (`onError: "omit"`): a component's, in its record. */ +function walkBoundary(w: Walker, node: BoundaryNode, task: Task, out: Part[]): void { + const part: BoundaryPart = { t: "boundary", parts: [] }; + out.push(part); + let into = part.parts; + if (node.boundary) { + const record: RecordPart = { t: "record", record: node.boundary, parts: [] }; + part.parts.push(record); + into = record.parts; + } + const watch = task.watch; + task.watch = node.boundary ? { element: node.child, record: node.boundary as Record<string, any> } : null; + try { + walkNode(w, node.child, task, into); + } catch (error) { + // (A suspension a <Suspense> around it handles isn't a part that failed: it renders the fallback.) + if ((!w.wait && !w.omit) || (error instanceof SyncSuspend && w.suspense > 0)) throw error; + part.parts.push(errorPart(w, error, task.frame)); + } finally { + task.watch = watch; + } +} + +// ── Components ── + +function walkFunction(w: Walker, fn: Function, named: unknown, props: Record<string, any>, secondArg: unknown, task: Task, out: Part[], keep?: Record<string, any>): void { + const name = nameOf(named); + const frame = w.frameless?.(named) ? task.frame : childFrame(task.frame, name); + // Not called when nothing can wait for it (and the promise it gives back could reject unhandled). + if (!w.wait && isAsyncFunction(fn)) throw new SyncSuspend(name, frame); + const state = hookState(name, task.tree, task.contexts, w.idPrefix, w.fallbacks); + if (!w.bare?.(named)) return callAndWalk(w, state, fn, props, secondArg, frame, task, out, keep); + let result: unknown; + try { + result = asCaller(w, task, () => fn(props, secondArg)); + } catch (error) { + throw positioned(w, error, name, frame); + } + walkResult(w, state, result, frame, task, out); +} + +function callAndWalk(w: Walker, state: HookState, fn: Function, props: Record<string, any>, secondArg: unknown, frame: Frame, task: Task, out: Part[], keep?: Record<string, any>): void { + let result: unknown; + try { + result = asCaller(w, task, () => call(state, fn, props, secondArg)); + } catch (thrown) { + const thenable = suspension(thrown, state); + if (!thenable) throw positioned(w, failure(thrown), state.name, frame); + // `use()` of a pending promise, or a thrown one (React 18's Suspense): called again once it settles, with fresh + // hooks and the results `use()` had, as React does. + if (!w.wait) { + settle(thenable); + throw new SyncSuspend(state.name, frame); + } + return w.wait(w, task, out, thenable, (t, parts) => { + state.hooks = undefined; + callAndWalk(w, state, fn, props, secondArg, frame, t, parts, keep); + }); + } + if (isThenable(result)) { + // An async component: what its promise resolves to (it isn't called again). + if (!w.wait) { + settle(result); + throw new SyncSuspend(state.name, frame); + } + const promise = result as PromiseLike<unknown> & { status?: string; value?: unknown; reason?: unknown }; + return w.wait(w, task, out, promise, (t, parts) => { + if (promise.status === "rejected") throw positioned(w, promise.reason, state.name, frame); + walkResult(w, state, returned(keep, promise.value), frame, t, parts); + }); + } + walkResult(w, state, returned(keep, result), frame, task, out); +} + +/** A component's own call (a template wrapper's too), with hooks: again while it sets its state as it renders (its last render counts). */ +export function call(state: HookState, fn: Function, props: Record<string, any>, secondArg: unknown): unknown { + for (let renders = 0; ; ) { + state.localId = 0; + state.thenableIndex = 0; + state.hookIndex = 0; + state.rerender = false; + const result = callWithHooks(state, () => fn(props, secondArg)); + if (!state.rerender) return result; + if (++renders > 25) throw new Error("Too many re-renders: a component sets its state every time it renders."); + } +} + +function walkResult(w: Walker, state: HookState, result: unknown, frame: Frame, task: Task, out: Part[]): void { + const tree = task.tree; + const outer = task.frame; + // A component that called useId gives its children a fork of the tree, as React does. + if (state.localId > 0) task.tree = pushTreeContext(tree, 1, 0); + task.frame = frame; + try { + walkNode(w, result, task, out); + } finally { + task.tree = tree; + task.frame = outer; + } +} + +function walkClass(w: Walker, Ctor: any, given: Record<string, any>, task: Task, out: Part[], keep?: Record<string, any>, frame = childFrame(task.frame, nameOf(Ctor))): void { + let result: unknown; + try { + result = asCaller(w, task, () => renderClass(Ctor, given, task.contexts, w.fallbacks)); + } catch (thrown) { + const thenable = suspension(thrown); + if (!thenable) throw positioned(w, failure(thrown), frame.name, frame); + // A thrown promise (React 18's Suspense): constructed and rendered again once it settles, as React does. + if (!w.wait) { + settle(thenable); + throw new SyncSuspend(frame.name, frame); + } + return w.wait(w, task, out, thenable, (t, parts) => walkClass(w, Ctor, given, t, parts, keep, frame)); + } + const outer = task.frame; + task.frame = frame; + try { + walkNode(w, returned(keep, result), task, out); + } finally { + task.frame = outer; + } +} + +/** A class component constructed and rendered as a server renders one (its state from getDerivedStateFromProps, or componentWillMount). */ +export function renderClass(Ctor: any, given: Record<string, any>, contexts: ContextStack | null, fallbacks?: Fallbacks): unknown { + const props = withDefaults(Ctor, given) as Record<string, any>; + const context = Ctor.contextType ? readContext(contexts, Ctor.contextType, fallbacks) : {}; + const queue: unknown[] = []; + const updater = { + isMounted: () => false, + enqueueSetState: (_: unknown, partial: unknown) => void queue.push(partial), + enqueueReplaceState: (_: unknown, state: unknown) => void ((queue.length = 0), queue.push(state)), + enqueueForceUpdate: () => {}, + }; + const instance = new Ctor(props, context, updater); + Object.assign(instance, { props, context, updater }); + if (instance.state === undefined) instance.state = null; + if (typeof Ctor.getDerivedStateFromProps === "function") { + const partial = Ctor.getDerivedStateFromProps(props, instance.state); + if (partial != null) instance.state = { ...instance.state, ...partial }; + } else { + const willMount = instance.UNSAFE_componentWillMount ?? instance.componentWillMount; + if (typeof willMount === "function") { + willMount.call(instance); + for (const partial of queue) { + const next = typeof partial === "function" ? partial.call(instance, instance.state, props, context) : partial; + if (next != null) instance.state = { ...instance.state, ...next }; + } + } + } + return instance.render(); +} + +function walkSuspense(w: Walker, props: Record<string, any>, task: Task, out: Part[]): void { + // The async renderer waits for what's in it. + if (w.wait) return walkNode(w, props.children, task, out); + const inside: Part[] = []; + let suspended = false; + w.suspense++; + try { + walkNode(w, props.children, task, inside); + } catch (error) { + if (!(error instanceof SyncSuspend)) throw error; + suspended = true; + } finally { + w.suspense--; + } + // What suspended can't be waited for: the fallback (outside this <Suspense>), as react-dom's synchronous render writes it. + if (suspended) return walkNode(w, props.fallback, task, out); + for (const part of inside) out.push(part); +} + +// ── What waits ── + +function walkThenable(w: Walker, thenable: Thenable, task: Task, out: Part[]): void { + track(thenable); + if (thenable.status === "fulfilled") return walkNode(w, thenable.value, task, out); + if (thenable.status === "rejected") throw positioned(w, thenable.reason, task.frame.name, task.frame); + if (!w.wait) { + settle(thenable); + throw new SyncSuspend(task.frame.name, task.frame); + } + w.wait(w, task, out, thenable, (t, parts) => walkThenable(w, thenable, t, parts)); +} + +function walkLazy(w: Walker, lazy: Record<string, any>, task: Task, out: Part[], walk: (resolved: any, task: Task, parts: Part[]) => void): void { + let resolved: unknown; + try { + resolved = lazy._init(lazy._payload); + } catch (thrown) { + if (!isThenable(thrown)) throw positioned(w, thrown, "lazy", placeFrame(task.frame, "lazy")); + if (!w.wait) { + settle(thrown); + throw new SyncSuspend("lazy", placeFrame(task.frame, "lazy")); + } + return w.wait(w, task, out, thrown, (t, parts) => walkLazy(w, lazy, t, parts, walk)); + } + walk(resolved, task, out); +} + +// ── Markup ── + +function walkHost(w: Walker, type: string, props: Record<string, any>, task: Task, out: Part[]): void { + const format = task.format; + let open: string; + let body: string | undefined; + try { + validateHost(type, props); + open = openTag(type, props, format); + body = propsBody(type, props, format); + } catch (error) { + throw positioned(w, error, type, placeFrame(task.frame, type)); + } + const close = VOID.has(type) ? "" : `</${type}>`; + const inner = childFormat(format, type, props); + // A collecting walk's markup is content (output.ts): its records are in it, where they are. + let into = out; + if (w.collector) { + const part: HostPart = { t: "host", tag: type, open, close, parts: [] }; + // (A <Head>'s `<style>` or `<script>`: its text as it is, whatever writes it, but for what would end it or what + // it's in. Markup in it is that text.) + if (inner.raw && !format.raw) Object.assign(part, { raw: true, ends: format.ends }); + out.push(part); + into = part.parts; + } else out.push(open); + if (!close) return; + if (body !== undefined) into.push(body); + else { + const lead = leadingNewline(type, props.children); + if (lead) into.push(lead); + task.format = inner; + try { + walkNode(w, props.children, task, into); + } finally { + task.format = format; + } + } + if (!w.collector) out.push(close); +} diff --git a/packages/react/src/exporters/__fixtures__/react-19.json b/packages/react/src/exporters/__fixtures__/react-19.json new file mode 100644 index 00000000..d67ea856 --- /dev/null +++ b/packages/react/src/exporters/__fixtures__/react-19.json @@ -0,0 +1,259 @@ +{"attributes":[["data-track","x"," data-track=\"x\""], +["data-track",1," data-track=\"1\""], +["aria-hidden","x"," aria-hidden=\"x\""], +["aria-hidden",1," aria-hidden=\"1\""], +["rel","x"," rel=\"x\""], +["rel",1," rel=\"1\""], +["title","x"," title=\"x\""], +["title",1," title=\"1\""], +["class","x"," class=\"x\""], +["class",1," class=\"1\""], +["className","x"," class=\"x\""], +["className",1," class=\"1\""], +["href","x"," href=\"x\""], +["href",1," href=\"1\""], +["href","javascript:alert(1)"," href=\"javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')\""], +["src","",""], +["src","x"," src=\"x\""], +["src",1," src=\"1\""], +["src","javascript:alert(1)"," src=\"javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')\""], +["action","x"," action=\"x\""], +["action",1," action=\"1\""], +["action","javascript:alert(1)"," action=\"javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')\""], +["formAction",""," formAction=\"\""], +["formAction","x"," formAction=\"x\""], +["formAction",0," formAction=\"0\""], +["formAction",1," formAction=\"1\""], +["formAction",2.5," formAction=\"2.5\""], +["formAction",-1," formAction=\"-1\""], +["formAction","javascript:alert(1)"," formAction=\"javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')\""], +["formAction","<&\"'>"," formAction=\"<&"'>\""], +["xlinkHref","x"," xlink:href=\"x\""], +["xlinkHref",1," xlink:href=\"1\""], +["xlinkHref","javascript:alert(1)"," xlink:href=\"javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')\""], +["target","x"," target=\"x\""], +["target",1," target=\"1\""], +["onClick","x",""], +["onClick",1,""], +["onclick","x",""], +["onclick",1,""], +["tabIndex","x"," tabindex=\"x\""], +["tabIndex",1," tabindex=\"1\""], +["htmlFor","x"," for=\"x\""], +["htmlFor",1," for=\"1\""], +["hidden","x"," hidden=\"\""], +["hidden",1," hidden=\"\""], +["download","x"," download=\"x\""], +["download",1," download=\"1\""], +["capture","x"," capture=\"x\""], +["capture",1," capture=\"1\""], +["rowSpan",true," rowSpan=\"true\""], +["rowSpan",false," rowSpan=\"false\""], +["rowSpan",""," rowSpan=\"\""], +["rowSpan","x",""], +["rowSpan",0," rowSpan=\"0\""], +["rowSpan",1," rowSpan=\"1\""], +["rowSpan",2.5," rowSpan=\"2.5\""], +["rowSpan",-1," rowSpan=\"-1\""], +["start",true," start=\"true\""], +["start",false," start=\"false\""], +["start","x",""], +["start",1," start=\"1\""], +["cols",true," cols=\"true\""], +["cols","x",""], +["cols",1," cols=\"1\""], +["span",true," span=\"true\""], +["span","x",""], +["span",1," span=\"1\""], +["contentEditable",true," contentEditable=\"true\""], +["contentEditable",false," contentEditable=\"false\""], +["contentEditable",""," contentEditable=\"\""], +["contentEditable","x"," contentEditable=\"x\""], +["contentEditable",0," contentEditable=\"0\""], +["contentEditable",1," contentEditable=\"1\""], +["contentEditable",2.5," contentEditable=\"2.5\""], +["contentEditable",-1," contentEditable=\"-1\""], +["contentEditable","javascript:alert(1)"," contentEditable=\"javascript:alert(1)\""], +["contentEditable","<&\"'>"," contentEditable=\"<&"'>\""], +["spellCheck",true," spellCheck=\"true\""], +["spellCheck",false," spellCheck=\"false\""], +["spellCheck",""," spellCheck=\"\""], +["spellCheck","x"," spellCheck=\"x\""], +["spellCheck",0," spellCheck=\"0\""], +["spellCheck",1," spellCheck=\"1\""], +["spellCheck",2.5," spellCheck=\"2.5\""], +["spellCheck",-1," spellCheck=\"-1\""], +["spellCheck","javascript:alert(1)"," spellCheck=\"javascript:alert(1)\""], +["spellCheck","<&\"'>"," spellCheck=\"<&"'>\""], +["draggable","x"," draggable=\"x\""], +["draggable",1," draggable=\"1\""], +["value","x"," value=\"x\""], +["value",1," value=\"1\""], +["focusable","x"," focusable=\"x\""], +["focusable",1," focusable=\"1\""], +["allowFullScreen",true," allowFullScreen=\"\""], +["allowFullScreen","x"," allowFullScreen=\"\""], +["allowFullScreen",1," allowFullScreen=\"\""], +["allowFullScreen",2.5," allowFullScreen=\"\""], +["allowFullScreen",-1," allowFullScreen=\"\""], +["allowFullScreen","javascript:alert(1)"," allowFullScreen=\"\""], +["allowFullScreen","<&\"'>"," allowFullScreen=\"\""], +["autoFocus","x"," autofocus=\"\""], +["autoFocus",1," autofocus=\"\""], +["itemScope",true," itemScope=\"\""], +["itemScope","x"," itemScope=\"\""], +["itemScope",1," itemScope=\"\""], +["itemScope",2.5," itemScope=\"\""], +["itemScope",-1," itemScope=\"\""], +["itemScope","javascript:alert(1)"," itemScope=\"\""], +["itemScope","<&\"'>"," itemScope=\"\""], +["inert",true," inert=\"\""], +["inert","",""], +["inert","x"," inert=\"\""], +["inert",0,""], +["inert",1," inert=\"\""], +["inert",2.5," inert=\"\""], +["inert",-1," inert=\"\""], +["inert","javascript:alert(1)"," inert=\"\""], +["inert","<&\"'>"," inert=\"\""], +["credentialless",true,""," credentialless=\"\""], +["credentialless",""," credentialless=\"\"",""], +["credentialless","x"," credentialless=\"x\""," credentialless=\"\""], +["credentialless",0," credentialless=\"0\"",""], +["credentialless",1," credentialless=\"1\""," credentialless=\"\""], +["credentialless",2.5," credentialless=\"2.5\""," credentialless=\"\""], +["credentialless",-1," credentialless=\"-1\""," credentialless=\"\""], +["credentialless","javascript:alert(1)"," credentialless=\"javascript:alert(1)\""," credentialless=\"\""], +["credentialless","<&\"'>"," credentialless=\"<&"'>\""," credentialless=\"\""], +["checked",true,""], +["checked",""," checked=\"\""], +["checked","x"," checked=\"x\""], +["checked",0," checked=\"0\""], +["checked",1," checked=\"1\""], +["checked",2.5," checked=\"2.5\""], +["checked",-1," checked=\"-1\""], +["checked","javascript:alert(1)"," checked=\"javascript:alert(1)\""], +["checked","<&\"'>"," checked=\"<&"'>\""], +["selected",true,""], +["selected",""," selected=\"\""], +["selected","x"," selected=\"x\""], +["selected",0," selected=\"0\""], +["selected",1," selected=\"1\""], +["selected",2.5," selected=\"2.5\""], +["selected",-1," selected=\"-1\""], +["selected","javascript:alert(1)"," selected=\"javascript:alert(1)\""], +["selected","<&\"'>"," selected=\"<&"'>\""], +["multiple","x"," multiple=\"\""], +["multiple",1," multiple=\"\""], +["readOnly",true," readOnly=\"\""], +["readOnly","x"," readOnly=\"\""], +["readOnly",1," readOnly=\"\""], +["readOnly",2.5," readOnly=\"\""], +["readOnly",-1," readOnly=\"\""], +["readOnly","javascript:alert(1)"," readOnly=\"\""], +["readOnly","<&\"'>"," readOnly=\"\""], +["strokeWidth","x"," stroke-width=\"x\""], +["strokeWidth",1," stroke-width=\"1\""], +["transformOrigin",""," transform-origin=\"\""], +["transformOrigin","x"," transform-origin=\"x\""], +["transformOrigin",0," transform-origin=\"0\""], +["transformOrigin",1," transform-origin=\"1\""], +["transformOrigin",2.5," transform-origin=\"2.5\""], +["transformOrigin",-1," transform-origin=\"-1\""], +["transformOrigin","javascript:alert(1)"," transform-origin=\"javascript:alert(1)\""], +["transformOrigin","<&\"'>"," transform-origin=\"<&"'>\""], +["maskType",""," maskType=\"\""," mask-type=\"\""], +["maskType","x"," maskType=\"x\""," mask-type=\"x\""], +["maskType",0," maskType=\"0\""," mask-type=\"0\""], +["maskType",1," maskType=\"1\""," mask-type=\"1\""], +["maskType",2.5," maskType=\"2.5\""," mask-type=\"2.5\""], +["maskType",-1," maskType=\"-1\""," mask-type=\"-1\""], +["maskType","javascript:alert(1)"," maskType=\"javascript:alert(1)\""," mask-type=\"javascript:alert(1)\""], +["maskType","<&\"'>"," maskType=\"<&"'>\""," mask-type=\"<&"'>\""], +["crossOrigin","x"," crossorigin=\"x\""], +["crossOrigin",1," crossorigin=\"1\""], +["acceptCharset","x"," accept-charset=\"x\""], +["acceptCharset",1," accept-charset=\"1\""], +["httpEquiv","x"," http-equiv=\"x\""], +["httpEquiv",1," http-equiv=\"1\""], +["xlinkTitle","x"," xlink:title=\"x\""], +["xlinkTitle",1," xlink:title=\"1\""], +["xmlLang","x"," xml:lang=\"x\""], +["xmlLang",1," xml:lang=\"1\""], +["xmlnsXlink","x"," xmlns:xlink=\"x\""], +["xmlnsXlink",1," xmlns:xlink=\"1\""], +["panose-1","x"," panose-1=\"x\""], +["panose-1",1," panose-1=\"1\""], +["dir","x"," dir=\"x\""], +["dir",1," dir=\"1\""], +["role","x"," role=\"x\""], +["role",1," role=\"1\""], +["width","x"," width=\"x\""], +["width",1," width=\"1\""], +["defaultValue","x",""], +["defaultValue",1,""], +["innerHTML","x",""], +["innerHTML",1,""], +["suppressHydrationWarning","x",""], +["suppressHydrationWarning",1,""], +["x y","x",""], +["x y",1,""], +["1abc","x",""], +["1abc",1,""]],"styles":[["lineHeight",1," style=\"line-height:1\""], +["lineHeight"," 2px "," style=\"line-height:2px\""], +["width",1," style=\"width:1px\""], +["width"," 2px "," style=\"width:2px\""], +["scale",1," style=\"scale:1\""], +["scale",2.5," style=\"scale:2.5\""], +["scale",-2," style=\"scale:-2\""], +["scale"," 2px "," style=\"scale:2px\""], +["aspectRatio",1," style=\"aspect-ratio:1\""], +["aspectRatio"," 2px "," style=\"aspect-ratio:2px\""], +["zoom",1," style=\"zoom:1\""], +["zoom"," 2px "," style=\"zoom:2px\""], +["msTransform",1," style=\"-ms-transform:1px\""], +["msTransform"," 2px "," style=\"-ms-transform:2px\""], +["OFlex",1," style=\"-o-flex:1px\""], +["OFlex",2.5," style=\"-o-flex:2.5px\""], +["OFlex",-2," style=\"-o-flex:-2px\""], +["OFlex"," 2px "," style=\"-o-flex:2px\""], +["MozOpacity",1," style=\"-moz-opacity:1px\""], +["MozOpacity",2.5," style=\"-moz-opacity:2.5px\""], +["MozOpacity",-2," style=\"-moz-opacity:-2px\""], +["MozOpacity"," 2px "," style=\"-moz-opacity:2px\""], +["WebkitAspectRatio",1," style=\"-webkit-aspect-ratio:1px\""], +["WebkitAspectRatio",2.5," style=\"-webkit-aspect-ratio:2.5px\""], +["WebkitAspectRatio",-2," style=\"-webkit-aspect-ratio:-2px\""], +["WebkitAspectRatio"," 2px "," style=\"-webkit-aspect-ratio:2px\""], +["msOrder",1," style=\"-ms-order:1px\""], +["msOrder",2.5," style=\"-ms-order:2.5px\""], +["msOrder",-2," style=\"-ms-order:-2px\""], +["msOrder"," 2px "," style=\"-ms-order:2px\""], +["WebkitBorderImageWidth",1," style=\"-webkit-border-image-width:1px\""], +["WebkitBorderImageWidth",2.5," style=\"-webkit-border-image-width:2.5px\""], +["WebkitBorderImageWidth",-2," style=\"-webkit-border-image-width:-2px\""], +["WebkitBorderImageWidth"," 2px "," style=\"-webkit-border-image-width:2px\""], +["WebKitBoxFlexGroup",1," style=\"-web-kit-box-flex-group:1\""], +["WebKitBoxFlexGroup",2.5," style=\"-web-kit-box-flex-group:2.5\""], +["WebKitBoxFlexGroup",-2," style=\"-web-kit-box-flex-group:-2\""], +["WebKitBoxFlexGroup"," 2px "," style=\"-web-kit-box-flex-group:2px\""], +["MozBoxFlex",1," style=\"-moz-box-flex:1\""], +["MozBoxFlex"," 2px "," style=\"-moz-box-flex:2px\""], +["msZoom",1," style=\"-ms-zoom:1\""], +["msZoom"," 2px "," style=\"-ms-zoom:2px\""], +["WebkitLineClamp",1," style=\"-webkit-line-clamp:1\""], +["WebkitLineClamp"," 2px "," style=\"-webkit-line-clamp:2px\""], +["--custom",1," style=\"--custom:1\""], +["--custom"," 2px "," style=\"--custom:2px\""], +["flexGrow",1," style=\"flex-grow:1\""], +["flexGrow"," 2px "," style=\"flex-grow:2px\""], +["strokeWidth",1," style=\"stroke-width:1\""], +["strokeWidth"," 2px "," style=\"stroke-width:2px\""]],"children":[["","<span></span>"], +["text","<span>text</span>"], +["<&\"'>","<span><&"'></span>"], +[0,"<span>0</span>"], +[7,"<span>7</span>"], +[true,"<span></span>"], +[false,"<span></span>"], +[null,"<span></span>"], +[["a",1,null,["b",2]],"<span>a1b2</span>"]]} diff --git a/packages/react/src/exporters/__snapshots__/pages.test.tsx.snap b/packages/react/src/exporters/__snapshots__/pages.test.tsx.snap new file mode 100644 index 00000000..04792de6 --- /dev/null +++ b/packages/react/src/exporters/__snapshots__/pages.test.tsx.snap @@ -0,0 +1,393 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`as a document > renders every template with these blocks as 0.3.0 did 1`] = ` +{ + "SpotifyWelcome: Default": "ed3682c9ef8607744169", + "SpotifyWelcome: SpotifyPremiumWelcome": "ed3682c9ef8607744169", + "a CDN for the icons": "0e3a4a7a8b9298991e6a", + "components/Body: Ecommerce": "1d1fdf9bb06f536d82b2", + "components/Body: Magazine": "bd69b286e036642ad31f", + "components/Body: Newsletter": "d83a68db1a219a6a509c", + "components/Body: Receipt": "a63d2ce8e2abadd35909", + "components/Body: SaaSLanding": "a378d84518f9d1d7b996", + "components/Column: SidebarArticle": "c945b2ec92f70914f442", + "components/Column: TestimonialCards": "9efb2021244aac041988", + "components/CompletePixelParityTest: CompleteEmailTemplate": "f459b1203b79d13c7198", + "components/Divider: CustomBorder": "dfd93c8d117cdf44ab08", + "components/Divider: Dashed": "2aed7f061db23193bf54", + "components/Divider: Default": "7c1b7809381774212415", + "components/Divider: DividerShowcase": "dea72adbb162a97f588c", + "components/Divider: EmailMode": "72146102bdf2cc8aa9e0", + "components/Divider: ThickAccent": "de7d6d3402b18747133d", + "components/Html: CallToAction": "2b6a2265f59bd96a7292", + "components/Html: Default": "6d8c588bdc87cb7ca5ca", + "components/Html: EmailHtml": "db0030ebbd9c34c5233e", + "components/Html: HtmlShowcase": "e4a62337f9a786b28872", + "components/Html: ProgressBars": "1f4a004ae4d5c9d2867f", + "components/Html: Statistics": "66eb55ee53eaec65fbdb", + "components/Html: StyledCard": "613c048f7b966c1d6e5d", + "components/Html: TestimonialCard": "4598723c9f7163ee989b", + "components/Menu: Default": "40774842574cf42a603b", + "components/Menu: EmailHeader": "6fa0f46b69f32b900c72", + "components/Menu: FooterLinks": "f44bfa1da2b9e83bfc96", + "components/Menu: FullPropsAPI": "9451f2c517a462967d48", + "components/Menu: StyledNavigation": "2eead493dfef469498b8", + "components/Row: ProductShowcase": "e1390f3354a4a6140489", + "components/Row: ServiceCards": "5562f68c439943ff84f5", + "components/Social: ColorfulSocialIcons": "21d94f8090be9b027297", + "components/Social: Default": "38e4b389bda59d6af21b", + "components/Social: EmailSocialIcons": "e7529d1e6771195978ce", + "components/Social: RoundedSocialIcons": "b5bf4ca8951ddf0aba8e", + "components/Social: SocialMediaBar": "fd4c48c02f2f9ca42a05", + "components/Social: SquaredSocialIcons": "534631481f59624f63c6", + "context/UnlayerProvider: FullEmailTemplate": "cf65f92fba06716610e7", + "context/UnlayerProvider: SocialWithCDN": "ea4dc47febc6c0afc4b1", + "defaults": "fa29d33eceeea7bb752c", + "defaults, bare in the root": "51ff39df2462dcbf67cf", + "defaults, two columns": "bd0698548be4cd0fa2f6", + "design: device: a phone setting the block's values type doesn't have": "590b4c45e725a255fb86", + "design: divider: border and width": "72f1c9a60376c195023a", + "design: editor kitchen sink": "5b98e76985707dc293e2", + "design: html: as given": "2b0e2f4cb445946ae0e0", + "design: menu: an item that calls a phone number": "3b372e2dcaff02918837", + "design: menu: items": "6f896cb0088bb9adfee0", + "design: menu: the editor's item keys, kept, and its links as their URLs": "195ee78d63482de2a398", + "design: social: an icon set Elements' type doesn't list": "3a5d37cf73b2fd1056ce", + "design: social: icons": "e2af43244fe95e32f834", + "divider: dashed, 100%": "cf15a3461afe63796dbb", + "divider: dashed, 240px": "c8351e476da11976ad08", + "divider: dashed, 50%": "1c0dd2f8e1db5e744e0c", + "divider: dotted, 100%": "c07200b1d393c35d750e", + "divider: dotted, 240px": "2a67393a1b5082198a8b", + "divider: dotted, 50%": "7f5299353984c55db15e", + "divider: double, 100%": "20e109b4db9043bbdecb", + "divider: double, 240px": "b848aea42a0d5a9e8168", + "divider: double, 50%": "ec7990eab201f3dc9c3b", + "divider: none, 100%": "b8106218b2591a593f5f", + "divider: none, 240px": "2227f166e83aaab0133e", + "divider: none, 50%": "0b9f0bbb544c6718e26e", + "divider: solid, 100%": "533336b2f94dc5fcf190", + "divider: solid, 240px": "592e12e0c5d90d196720", + "divider: solid, 50%": "f6da1f0f8cb814a5c524", + "editor links": "23d9a212b71c21995e2f", + "examples/LumaEventInvite: Default": "258478860a5a5986aa0a", + "examples/LumaEventInvite: LumaEventInvite": "258478860a5a5986aa0a", + "examples/MercuryReceipt: Default": "8d4ce1c1a7f98344270a", + "examples/MercuryReceipt: MercuryReceipt": "8d4ce1c1a7f98344270a", + "examples/MorningBrewDigest: Default": "2ccfffc7b217a5d9b233", + "examples/MorningBrewDigest: MorningBrewDigest": "2ccfffc7b217a5d9b233", + "examples/NikeDrop: Default": "b290e93f43ac97b8c3de", + "examples/NikeDrop: NikeDrop": "b290e93f43ac97b8c3de", + "examples/NotionWelcome: Default": "3a793de5c539895f05e1", + "examples/NotionWelcome: NotionWelcome": "3a793de5c539895f05e1", + "examples/ProductDigest: Default": "ab8460537de9356724c0", + "examples/ProductDigest: ProductDigest": "ab8460537de9356724c0", + "examples/StripeReceipt: Default": "a9b938a1e07f0b7b798f", + "examples/StripeReceipt: StripeReceipt": "a9b938a1e07f0b7b798f", + "link 0: "https://example.com/"": "2727d376be3cb0012f3f", + "link 10: "https://example.com/?u={{user_id}}&t=*|TOKEN|*"": "b2fb79c0f04967994816", + "link 11: "<%asm_group_unsubscribe_raw_url%>"": "f6d2bce2e3598c9664e7", + "link 12: """: "54ee1e3330f68f4e996e", + "link 1: "https://example.com/path?a=1&b=2#hash"": "19fa8e9d2b15570dbd62", + "link 2: "http://example.com/a b"": "d49e4bbef2d445fc8413", + "link 3: "/relative/path"": "0045870d940cc782b4fb", + "link 4: "#anchor"": "57853ee02fb247d9aa4a", + "link 5: "mailto:hi@example.com?subject=Hi there"": "15c40ca79e40fff89168", + "link 6: "tel:+15550100"": "cfdd8816b42c08bafb97", + "link 7: "sms:+15550100"": "9f7dc3dffbdbc9164222", + "link 8: "javascript:alert(1)"": "0107f525ae7ac69fda05", + "link 9: "{{unsubscribe_url}}"": "86955cf075181838e739", + "menu: horizontal, center": "0f4d2a85cf2e3cbbebae", + "menu: horizontal, left": "ce963cf2cb97499ec34c", + "menu: horizontal, right": "1077a13034232ff6e968", + "menu: vertical, center": "0ec6ed14927ad8e8b67f", + "menu: vertical, left": "cf9570f2808e0e9cd7d4", + "menu: vertical, right": "34292e49e2e5cd288b47", + "merge tags": "d0d4f036c1b789541437", + "phone settings": "f795758e6020359b40ec", + "right-to-left": "cf2d6949578bbcef8879", + "social: circle, 16, center": "f9f433789505546da28f", + "social: circle, 16, left": "71927f16e271b88c2536", + "social: circle, 16, right": "161aa66f812f6aa674c5", + "social: circle, 32, center": "a922d739e99666ecc1e6", + "social: circle, 32, left": "9fd36a64480e7d159b24", + "social: circle, 32, right": "bb127ed1d1ef4864d48a", + "social: circle, 48px, center": "b8dddbc12270c93451e2", + "social: circle, 48px, left": "eccd0e8e51ff247fabbf", + "social: circle, 48px, right": "01a97f72a5cc25377e19", + "social: circle-black, 16, center": "afbdacafce4c6c799df6", + "social: circle-black, 16, left": "bc13441f196a843af7be", + "social: circle-black, 16, right": "c4e4e158c2691adb1daf", + "social: circle-black, 32, center": "ee32b7e70754aee628b2", + "social: circle-black, 32, left": "de70a4ea32ce3cc82717", + "social: circle-black, 32, right": "e4dc3bda003958f7c02a", + "social: circle-black, 48px, center": "bcdd8dfbe36d79ff663b", + "social: circle-black, 48px, left": "f6b3d49daa72444fe249", + "social: circle-black, 48px, right": "c8d3f0925139b86f31f0", + "social: circle-white, 16, center": "3d02cf186d70152518e7", + "social: circle-white, 16, left": "03e5267f8b2f76764ed0", + "social: circle-white, 16, right": "55b5e5faa33f7aa5974f", + "social: circle-white, 32, center": "550baef7a13b5e9865d2", + "social: circle-white, 32, left": "9b9816bf831a25952058", + "social: circle-white, 32, right": "c4c169c5a7e16edd64ac", + "social: circle-white, 48px, center": "f7cf2426d030d8173762", + "social: circle-white, 48px, left": "4a27d8e8ab5521e6fb7c", + "social: circle-white, 48px, right": "d3b027f7922ea0f2266e", + "social: custom icons": "edbce64a6f434a04c907", + "social: no icons": "71a4bbcce4fd7dd293d2", + "social: rounded, 16, center": "7b4dc6b2a49a20f1c857", + "social: rounded, 16, left": "7bf012981008873501b3", + "social: rounded, 16, right": "2acc2f0275c5a638847c", + "social: rounded, 32, center": "d9a540f0f30371e85165", + "social: rounded, 32, left": "59c2446ee88e91d6464e", + "social: rounded, 32, right": "6308118cf192df80160c", + "social: rounded, 48px, center": "b0f7d2b7e6d9cb9c4825", + "social: rounded, 48px, left": "f690aae882c1c0d60620", + "social: rounded, 48px, right": "02929762be4e90208f35", + "social: rounded-black, 16, center": "0e8347e04a5cdae9603f", + "social: rounded-black, 16, left": "3ecd8957d113a795597e", + "social: rounded-black, 16, right": "b266fddbb80e6bc90a78", + "social: rounded-black, 32, center": "a58570fae4144bf9a59e", + "social: rounded-black, 32, left": "1b28391b348aa80a3d88", + "social: rounded-black, 32, right": "deedb23710a2fcf09c66", + "social: rounded-black, 48px, center": "3778f616934c8b6bcafe", + "social: rounded-black, 48px, left": "2cdb42e0a38c3bcd7321", + "social: rounded-black, 48px, right": "023e983137eb431d1a25", + "social: squared, 16, center": "21a95b1be421c611c9eb", + "social: squared, 16, left": "1717a112245e80959e9b", + "social: squared, 16, right": "464e7bb0a1ca1ce199ca", + "social: squared, 32, center": "67e632856dd6f2eace69", + "social: squared, 32, left": "d2b7360dd3b5910ee2df", + "social: squared, 32, right": "228bea8a590bcc346a56", + "social: squared, 48px, center": "ea4e7845d92bbb098d89", + "social: squared, 48px, left": "a599f52b0a54ab688a58", + "social: squared, 48px, right": "1e796591db6fa3ca73f0", + "social: squared-black, 16, center": "6f0413e224d332b9dfbb", + "social: squared-black, 16, left": "57495720657324370b6b", + "social: squared-black, 16, right": "ea44bacea898fe389dc7", + "social: squared-black, 32, center": "af958826b5715080e88a", + "social: squared-black, 32, left": "a156774061b1c32c851c", + "social: squared-black, 32, right": "3183ee79903bbba36398", + "social: squared-black, 48px, center": "04b7f174f6c3543ff1a3", + "social: squared-black, 48px, left": "9db6317f26019c76c276", + "social: squared-black, 48px, right": "00960a6baa274f70c7d6", + "toSafeHtml": "b431fc70b647052545e5", + "user text 0: "Home"": "fa73a322dbeb9543490a", + "user text 10: "<%a%>\\" x-pwn=\\"1"": "9f09a24c8c1bab2df776", + "user text 11: "Fish & chips & <b>bold</b>"": "d1090521cfea2361c6e6", + "user text 12: "“Quotes” ‘single’ — dash…"": "b3717a9fc1e254b7ebbe", + "user text 13: "مرحبا بالعالم"": "c72ee597f3d3ebe4b486", + "user text 14: "שלום עולם"": "c3db2120a182470a02b4", + "user text 15: "日本語のテキスト"": "bd02252ffa392b946753", + "user text 16: "😀 emoji 👍🏽"": "c59b384c642b53c2cd81", + "user text 17: "{{first_name}}"": "871c0eb86e6bfc682224", + "user text 18: "*|FNAME|*"": "fd1696638baf58d7687f", + "user text 19: "%%name%%"": "4391864f73e320f9d53c", + "user text 1: """: "82cec3abd5da9bb11b8b", + "user text 20: "line\\nbreak\\ttab"": "6a611d87cf09c91d408c", + "user text 2: " padded "": "284711b7e6199b038820", + "user text 3: "\\"><x-pwn></x-pwn>"": "891e8ef88ecf9c72bc4e", + "user text 4: "'><x-pwn></x-pwn>"": "8dfa4b574e856d09a2c0", + "user text 5: "\\" x-pwn=\\"1"": "25a5836d3ba29dd9c82a", + "user text 6: "' x-pwn='1"": "56bfd847be158ea5a3e4", + "user text 7: "</p></div></td></tr></table><x-pwn></x-pwn>"": "8b0a20bd86bcdca9dc7d", + "user text 8: "{{ \\" }} x-pwn=1 y"": "f8613a45bd32fc16a351", + "user text 9: "{{ x\\" x-pwn }}"": "794beb4005bf86b59a1c", +} +`; + +exports[`as a web page > renders every template with these blocks as 0.3.0 did 1`] = ` +{ + "SpotifyWelcome: Default": "4e61c8fde449a09151cf", + "SpotifyWelcome: SpotifyPremiumWelcome": "4e61c8fde449a09151cf", + "a CDN for the icons": "0660f7b7fcdf9c7fc7f9", + "components/Body: Ecommerce": "4bb21aa39fd244b9c115", + "components/Body: Magazine": "499aba3d0b789cff27c5", + "components/Body: Newsletter": "1bd4f327dc27d76e76c1", + "components/Body: Receipt": "92bc43ea8e35fb353eb1", + "components/Body: SaaSLanding": "ac31daf558a1b02aefbb", + "components/Column: SidebarArticle": "efef937f2c39a4292e72", + "components/Column: TestimonialCards": "2159ca20f80d517c716a", + "components/CompletePixelParityTest: CompleteEmailTemplate": "4b2cc0db58361258f972", + "components/Divider: CustomBorder": "df8eff96480629a6525d", + "components/Divider: Dashed": "73fee244a41596faa8b3", + "components/Divider: Default": "8fd91d37ca9fbde2f88c", + "components/Divider: DividerShowcase": "6a772ae413a01c28a7f1", + "components/Divider: EmailMode": "d457d65b10bf9d421fbc", + "components/Divider: ThickAccent": "a46640b94d1932085aca", + "components/Html: CallToAction": "499f17408526a19411fe", + "components/Html: Default": "10b80318d65255878119", + "components/Html: EmailHtml": "5d5272be68adfd470060", + "components/Html: HtmlShowcase": "ef62f4cb83182ed87172", + "components/Html: ProgressBars": "e9a2af563c11e6696c1b", + "components/Html: Statistics": "f969bb0d84fa42348f99", + "components/Html: StyledCard": "6bd4084ac5834a5e952d", + "components/Html: TestimonialCard": "1c2e3ba84507c626e612", + "components/Menu: Default": "9438e80bcc4834d39302", + "components/Menu: EmailHeader": "cd093c69fd9e4ffc438e", + "components/Menu: FooterLinks": "9472eb6b39eb270d4de8", + "components/Menu: FullPropsAPI": "2d762da87efa7293737d", + "components/Menu: StyledNavigation": "6296a9027d09c67be80e", + "components/Row: ProductShowcase": "ee6b25eab649d9e99d48", + "components/Row: ServiceCards": "79af2e195bc50860b336", + "components/Social: ColorfulSocialIcons": "bcdcdc0bfdc9a0779067", + "components/Social: Default": "798f98ff62a32d288c13", + "components/Social: EmailSocialIcons": "67638fa18c175bc0e873", + "components/Social: RoundedSocialIcons": "2c934a78310e0bba895b", + "components/Social: SocialMediaBar": "91dd0ea6547de6dc5ec9", + "components/Social: SquaredSocialIcons": "2684a6e0b423d3b5fddf", + "context/UnlayerProvider: FullEmailTemplate": "2496057e3916bcf849a1", + "context/UnlayerProvider: SocialWithCDN": "b2d954927fd7d445bda5", + "defaults": "743b86aa48932dc3a89e", + "defaults, bare in the root": "10a44952f41980aa7816", + "defaults, two columns": "bddba025792243931a22", + "design: device: a phone setting the block's values type doesn't have": "f185a2d59e82e9f3817c", + "design: divider: border and width": "218851537a56191d3f44", + "design: editor kitchen sink": "22f8911451b78517fbe4", + "design: html: as given": "9baace75fb81bd3b2264", + "design: menu: an item that calls a phone number": "51dadcf33e9310d91755", + "design: menu: items": "e1d3a5e76d7f715cc20e", + "design: menu: the editor's item keys, kept, and its links as their URLs": "d21b05a81197e269e7fd", + "design: social: an icon set Elements' type doesn't list": "bc92998e4205552a13dd", + "design: social: icons": "c50056eed323ee4a391d", + "divider: dashed, 100%": "15c4efbfe8e01b51ed4f", + "divider: dashed, 240px": "d04d9601d7269523101f", + "divider: dashed, 50%": "230e6e851958f2d50de7", + "divider: dotted, 100%": "7d441d480761ab3aa0f9", + "divider: dotted, 240px": "2072114bea909183b433", + "divider: dotted, 50%": "1b17eb579f501a1b7eb3", + "divider: double, 100%": "c99f40910895bd924e33", + "divider: double, 240px": "25f5d2d0e44b0de7b4ca", + "divider: double, 50%": "04f9f95727073698eb66", + "divider: none, 100%": "c0e6af2f478b0faadceb", + "divider: none, 240px": "a2fb1503cfa53589408e", + "divider: none, 50%": "af2d1d6bf7f96b608dc6", + "divider: solid, 100%": "4ae4bb21cd8b4b6d0f98", + "divider: solid, 240px": "feb4ba3a23d7dc1fd8ce", + "divider: solid, 50%": "243b981ca22f5374f109", + "editor links": "406a746eecbd8bf77c6c", + "examples/LumaEventInvite: Default": "896a652b54c8d245b55d", + "examples/LumaEventInvite: LumaEventInvite": "896a652b54c8d245b55d", + "examples/MercuryReceipt: Default": "910e2b64fb26b2249a25", + "examples/MercuryReceipt: MercuryReceipt": "910e2b64fb26b2249a25", + "examples/MorningBrewDigest: Default": "a4fda7a0f35233e50567", + "examples/MorningBrewDigest: MorningBrewDigest": "a4fda7a0f35233e50567", + "examples/NikeDrop: Default": "063cf4ff801d3c232898", + "examples/NikeDrop: NikeDrop": "063cf4ff801d3c232898", + "examples/NotionWelcome: Default": "317e049b907d92618302", + "examples/NotionWelcome: NotionWelcome": "317e049b907d92618302", + "examples/ProductDigest: Default": "d3ac8d2ee6dc9f82cbe9", + "examples/ProductDigest: ProductDigest": "d3ac8d2ee6dc9f82cbe9", + "examples/StripeReceipt: Default": "16f3ca0d0f1fefdb9404", + "examples/StripeReceipt: StripeReceipt": "16f3ca0d0f1fefdb9404", + "link 0: "https://example.com/"": "6dd404a99beb1473c1e0", + "link 10: "https://example.com/?u={{user_id}}&t=*|TOKEN|*"": "39c23e4b15f1539d9d75", + "link 11: "<%asm_group_unsubscribe_raw_url%>"": "fd1ce7491282150f5820", + "link 12: """: "8bf1f3ccc4733d828c73", + "link 1: "https://example.com/path?a=1&b=2#hash"": "e8ad468757b85c822863", + "link 2: "http://example.com/a b"": "10141ac8282c9ca6cf0d", + "link 3: "/relative/path"": "7efebb4fa46c42a8420f", + "link 4: "#anchor"": "ca5261edc23cd68f9c7d", + "link 5: "mailto:hi@example.com?subject=Hi there"": "4cf11cd1edcd0003a7b4", + "link 6: "tel:+15550100"": "a16645b57041d9082308", + "link 7: "sms:+15550100"": "0a9dad0f68632b448226", + "link 8: "javascript:alert(1)"": "cb5c73844884eb5f608c", + "link 9: "{{unsubscribe_url}}"": "65aa393d5732548c2047", + "menu: horizontal, center": "32b2f74fa3fe6536046c", + "menu: horizontal, left": "96e11f4c7beb15f3eef6", + "menu: horizontal, right": "bf0674c86179e9f007ee", + "menu: vertical, center": "ec643bb1479027afbbe5", + "menu: vertical, left": "080bafada690dbc8e2e8", + "menu: vertical, right": "e7b5b4b9b173ca261d6c", + "merge tags": "5480835d7a4745631c69", + "phone settings": "7a273f90f95ad9ec51fa", + "right-to-left": "5a6358c452aaa59b02c7", + "social: circle, 16, center": "ed11168c9ea306d85624", + "social: circle, 16, left": "fd4e7f21fdab77d12e98", + "social: circle, 16, right": "1f0b7225d2211e53861c", + "social: circle, 32, center": "9032970e7c21f124cdda", + "social: circle, 32, left": "636c37ed14ddcf34b8c0", + "social: circle, 32, right": "a8a83d593a6c4eb174b2", + "social: circle, 48px, center": "fbb3a983676d15d8d4eb", + "social: circle, 48px, left": "72b1062752cb77e17f5e", + "social: circle, 48px, right": "c5fc2b57e4cc4bdcd58e", + "social: circle-black, 16, center": "0ab8c596df7c17e02458", + "social: circle-black, 16, left": "1db7d97faea3781ee530", + "social: circle-black, 16, right": "24b9a6e3a294e91e179c", + "social: circle-black, 32, center": "34de26b6762b9124d26a", + "social: circle-black, 32, left": "7ee383e59a49638e544f", + "social: circle-black, 32, right": "c50c4a9c57cd0af262f6", + "social: circle-black, 48px, center": "ee4085e7546bd2e0dbc2", + "social: circle-black, 48px, left": "d6a76cb842fb1ca0fcc8", + "social: circle-black, 48px, right": "4aedeb9af29b5969add9", + "social: circle-white, 16, center": "d608b7bfae6ce891653a", + "social: circle-white, 16, left": "aaa96eaf0d798fffa2ce", + "social: circle-white, 16, right": "4cbe08edefd374b960e0", + "social: circle-white, 32, center": "aedfe1aef307ac11edf7", + "social: circle-white, 32, left": "478e4e4ada7bceefa935", + "social: circle-white, 32, right": "4733dba27a7cd0d7beb9", + "social: circle-white, 48px, center": "69c399e892957e5c2cce", + "social: circle-white, 48px, left": "ea99f44ea43dcb4aa3ee", + "social: circle-white, 48px, right": "ac3ccee680779d436504", + "social: custom icons": "e676f0de31c69fa29baa", + "social: no icons": "058d8413e11899d73d7f", + "social: rounded, 16, center": "cd53ae131ec0b3aa3f65", + "social: rounded, 16, left": "3953cb464bd8c5fbfedc", + "social: rounded, 16, right": "dbc195a4c991a4873460", + "social: rounded, 32, center": "64529e9886cffb8daa67", + "social: rounded, 32, left": "b48aad61c2721dfd0ae3", + "social: rounded, 32, right": "262aa4bd767dc4ec2896", + "social: rounded, 48px, center": "0bc7eb819459bea86d71", + "social: rounded, 48px, left": "7932408d20f639544d4f", + "social: rounded, 48px, right": "c38b7655d9df5b59e268", + "social: rounded-black, 16, center": "7340a9c0fa766c20ad66", + "social: rounded-black, 16, left": "6bf7102a82275dcb8ef9", + "social: rounded-black, 16, right": "ab092b0db65f847e51f2", + "social: rounded-black, 32, center": "4b7555808d56b76aad85", + "social: rounded-black, 32, left": "d094d544d6570727337c", + "social: rounded-black, 32, right": "5db4c4ca39352d8291e8", + "social: rounded-black, 48px, center": "ce4a6399d30854095dd7", + "social: rounded-black, 48px, left": "bb2d65f4c1a4894f34b8", + "social: rounded-black, 48px, right": "b5404fbf7d8cef5455a6", + "social: squared, 16, center": "006b634939cee999e003", + "social: squared, 16, left": "225f06ec0c96cd367052", + "social: squared, 16, right": "3ff68f37e121385824fd", + "social: squared, 32, center": "d978ccc584a55bd2d646", + "social: squared, 32, left": "f1b2ac7524d091da0b43", + "social: squared, 32, right": "3c3da2e12cc442d52602", + "social: squared, 48px, center": "aec4becce2e296838dff", + "social: squared, 48px, left": "31a3c9ff21b3d1924a13", + "social: squared, 48px, right": "2717197d9f5ca95ea719", + "social: squared-black, 16, center": "f82efeecd649ad7e6705", + "social: squared-black, 16, left": "10877a72f31066f687c5", + "social: squared-black, 16, right": "10d0758f7ef43aba7f44", + "social: squared-black, 32, center": "de58d89dab9f168c307c", + "social: squared-black, 32, left": "88a7599e5328cb198d34", + "social: squared-black, 32, right": "edaa307b39e8a7d02438", + "social: squared-black, 48px, center": "f2483cf1a9abacb092fd", + "social: squared-black, 48px, left": "4b9d1add65d910f4ed02", + "social: squared-black, 48px, right": "368adec94b372b7bf7f8", + "toSafeHtml": "a7d2c850aa2c03de2bac", + "user text 0: "Home"": "91b4bed4eb702a0aac8d", + "user text 10: "<%a%>\\" x-pwn=\\"1"": "3777d9a52800992b0ecd", + "user text 11: "Fish & chips & <b>bold</b>"": "9e556173725c7f2f000c", + "user text 12: "“Quotes” ‘single’ — dash…"": "8f878df564e707e31915", + "user text 13: "مرحبا بالعالم"": "14a74ce37f684dfc7c7b", + "user text 14: "שלום עולם"": "b20b763509358f88f9b9", + "user text 15: "日本語のテキスト"": "22fe8795c34212eb8636", + "user text 16: "😀 emoji 👍🏽"": "e628248998ea3b6e608a", + "user text 17: "{{first_name}}"": "24a962757c0ff36807e6", + "user text 18: "*|FNAME|*"": "ec989525d2e57725cc49", + "user text 19: "%%name%%"": "b85578a453916e5399b2", + "user text 1: """: "b5936a19b49144d1c4ea", + "user text 20: "line\\nbreak\\ttab"": "59c0441a59bb0e2b7481", + "user text 2: " padded "": "e1bd5f6a3aac3f9c8bc8", + "user text 3: "\\"><x-pwn></x-pwn>"": "145f08ee7ae18fd5b5b4", + "user text 4: "'><x-pwn></x-pwn>"": "a0cb315b7a2703207443", + "user text 5: "\\" x-pwn=\\"1"": "bfda6ea714e181a6769a", + "user text 6: "' x-pwn='1"": "97f2f447a90a45185633", + "user text 7: "</p></div></td></tr></table><x-pwn></x-pwn>"": "752f0ff560e10238e0d5", + "user text 8: "{{ \\" }} x-pwn=1 y"": "825a90094bbe818b35b0", + "user text 9: "{{ x\\" x-pwn }}"": "71b62234de031bf1d252", +} +`; diff --git a/packages/react/src/exporters/base64.ts b/packages/react/src/exporters/base64.ts new file mode 100644 index 00000000..721c3789 --- /dev/null +++ b/packages/react/src/exporters/base64.ts @@ -0,0 +1,15 @@ +/** + * UTF-8 text as base64 and back, as @unlayer/exporters' Menu does it: it writes a link's click handler as a + * `data-onclick-encoded` attribute (React leaves out an `onclick` given as a string), then as `onclick` decoded. + * With TextEncoder, TextDecoder, btoa and atob, which every runtime Elements runs in has (the exporters' first choice). + */ +export function encodeBase64(text: string): string { + const bytes = new TextEncoder().encode(text); + let binary = ""; + for (let i = 0; i < bytes.length; i += 32768) binary += String.fromCharCode(...bytes.subarray(i, i + 32768)); + return btoa(binary); +} + +export function decodeBase64(base64: string): string { + return new TextDecoder().decode(Uint8Array.from(atob(base64), (char) => char.charCodeAt(0))); +} diff --git a/packages/react/src/exporters/divider.ts b/packages/react/src/exporters/divider.ts new file mode 100644 index 00000000..6f0e4b9d --- /dev/null +++ b/packages/react/src/exporters/divider.ts @@ -0,0 +1,9 @@ +import { element, staticMarkup } from "./markup"; + +/** Divider's web and document HTML, as @unlayer/exporters' DividerExporters write them with React (byte for byte). */ +export function divider(values: Record<string, any>): string { + const { border, width, textAlign } = values; + let rule: Record<string, unknown> = { width, display: "inline-block" }; + if (border) rule = { ...(typeof border == "object" ? border : { borderTop: border }), ...rule, lineHeight: border?.borderTopWidth ?? 1, height: "0px", verticalAlign: "middle" }; + return staticMarkup(element("div", { style: { textAlign, lineHeight: 0 }, "aria-label": "divider" }, element("div", { style: rule }, " "))); +} diff --git a/packages/react/src/exporters/html.ts b/packages/react/src/exporters/html.ts new file mode 100644 index 00000000..623fa529 --- /dev/null +++ b/packages/react/src/exporters/html.ts @@ -0,0 +1,10 @@ +import { element, staticMarkup } from "./markup"; + +/** + * The Html block's web and document HTML, as @unlayer/exporters' HtmlExporters write them with React (byte for + * byte): its markup in a `<div>`, through the config's `toSafeHtml` where there's one. + */ +export function html(values: Record<string, any>, ...args: any[]): string { + const { exporterConfig: config } = args[6] || {}; + return staticMarkup(element("div", { dangerouslySetInnerHTML: { __html: config?.toSafeHtml ? config.toSafeHtml(values.html) : values.html } })); +} diff --git a/packages/react/src/exporters/markup.ts b/packages/react/src/exporters/markup.ts new file mode 100644 index 00000000..b3db3c68 --- /dev/null +++ b/packages/react/src/exporters/markup.ts @@ -0,0 +1,46 @@ +/** + * HTML as react-dom/server's renderToStaticMarkup writes it, for the elements the web and document exporters of + * Divider, Html, Menu and Social render with React in @unlayer/exporters: so those blocks render without react-dom + * (an edge runtime, a React Server Components layer), the same as with it. Written by the engine's host markup + * (src/engine/host.ts), as the walker writes a template's; a child that's a React element (a Menu item's text) is + * rendered by the walker. + */ +import type React from "react"; +import { escapeText, openTag, propsBody, validateHost, VOID } from "../engine/host"; +import { REACT_18 } from "../engine/react-compat"; +import { renderStatic } from "../engine"; + +/** An element's HTML, kept apart from text (which is escaped where it's put). */ +export class Markup { + constructor(readonly html: string) {} +} + +/** Children as React renders them: text escaped, numbers as text, nothing for booleans, null and functions. */ +function children(node: unknown): string { + if (node == null || typeof node === "boolean" || typeof node === "function" || typeof node === "symbol") return ""; + if (typeof node === "string" || typeof node === "number") return escapeText(node); + // (React 18 renders no BigInt.) + if (typeof node === "bigint") return REACT_18 ? "" : escapeText(node); + if (node instanceof Markup) return node.html; + if (Array.isArray(node)) return node.map(children).join(""); + // A React element (an item's text given as JSX), as the walker renders it. + return renderStatic(node as React.ReactNode); +} + +/** `React.createElement(tag, props, ...children)`, rendered (the props React keeps for itself left out). */ +export function element(tag: "a" | "div" | "img" | "span", props: Record<string, unknown>, ...given: unknown[]): Markup { + const all: Record<string, any> = {}; + for (const name in props) { + // (`key`, `ref`, `__self` and `__source` are React's; React's element can't have its own `__proto__`.) + if (!Object.prototype.hasOwnProperty.call(props, name) || name === "key" || name === "ref" || name === "__self" || name === "__source" || name === "__proto__") continue; + Object.defineProperty(all, name, { value: props[name], enumerable: true, writable: true, configurable: true }); + } + if (given.length) all.children = given.length > 1 ? given : given[0]; + validateHost(tag, all); + const open = openTag(tag, all); + if (VOID.has(tag)) return new Markup(open); + return new Markup(`${open}${propsBody(tag, all) ?? children(all.children)}</${tag}>`); +} + +/** HTML as the exporters write what React renders: without the image preloads React 19 adds (nor any in markup given). */ +export const staticMarkup = (markup: Markup | null): string => (markup ? markup.html : "").replace(/<link rel="preload" as="image"[^>]*\/?>/g, ""); diff --git a/packages/react/src/exporters/menu.ts b/packages/react/src/exporters/menu.ts new file mode 100644 index 00000000..a2f597a6 --- /dev/null +++ b/packages/react/src/exporters/menu.ts @@ -0,0 +1,89 @@ +import { decodeBase64, encodeBase64 } from "./base64"; +import { element, staticMarkup } from "./markup"; +import { present } from "./present"; + +/** lodash's `omit(value, ["onClick"])`: its keys, own and inherited, but `onClick`. */ +function withoutOnClick(value: unknown): Record<string, unknown> { + const out: Record<string, unknown> = {}; + if (value != null) { + const from = Object(value) as Record<string, unknown>; + for (const key in from) { + if (key === "onClick" || (key === "constructor" && !Object.prototype.hasOwnProperty.call(from, key))) continue; + Object.defineProperty(out, key, { value: from[key], enumerable: true, writable: true, configurable: true }); + } + } + return out; +} + +/** The keys that aren't undefined. */ +const defined = (attrs: Record<string, unknown>) => Object.keys(attrs).reduce<Record<string, unknown>>((out, key) => (attrs[key] === undefined ? out : { ...out, [key]: attrs[key] }), {}); + +/** A link's attributes from the link editor's value (`{ url, target, onClick, class, customAttrs }`), as the exporters read it. */ +function linkAttrs(link: any, defaults: Record<string, unknown> = { href: "" }): Record<string, any> { + const attrs = { ...withoutOnClick(link?.customAttrs), href: link?.url || undefined, target: link?.target || undefined, onclick: link?.onClick || undefined, class: link?.class || undefined }; + // Custom attributes, all of them empty, on a link with no URL or click handler: no link. + const empty = + link?.customAttrs && + Object.keys(link.customAttrs).length > 0 && + Object.keys(link.customAttrs).every((key) => !link.customAttrs[key] || link.customAttrs[key].trim() === "") && + !link?.url && + !link?.onClick; + return empty ? {} : { ...defaults, ...defined(attrs) }; +} + +/** The menu's markup, as the exporters' React component renders it for an export. */ +function markup(values: Record<string, any>): string { + const { menu: { items: given }, fontFamily, fontSize, linkColor, align, separator, textColor, padding, layout, fontWeight, letterSpacing } = values; + const items = present(given); + if (items.length === 0) return ""; + return staticMarkup( + element( + "div", + { style: { textAlign: align, letterSpacing }, "aria-label": "menu" }, + items.map((item, index) => { + const attrs = linkAttrs(item.link); + // (React leaves out an `onclick` it's given as a string, so it goes in another attribute until it's rendered.) + if (typeof attrs.onclick == "string") { + attrs["data-onclick-encoded"] = encodeBase64(attrs.onclick); + delete attrs.onclick; + } + const elements = [ + element( + "a", + { + ...attrs, + "aria-label": `Menu item - ${item?.text || index}`, + key: `menu-item-${index}`, + className: "v-padding v-font-size v-layout-display v-font-weight v-letter-spacing", + style: { display: layout === "horizontal" ? "inline-block" : "block", padding, fontSize, fontFamily: fontFamily?.value, color: attrs.href ? linkColor : textColor, textDecoration: "none", fontWeight }, + }, + item.text + ), + ]; + if (separator && layout !== "vertical" && index + 1 < items.length) + elements.push( + element( + "span", + { + "aria-label": "menu separator", + className: "v-padding v-font-size v-letter-spacing hide-mobile", + key: `menu-separator-${index}`, + style: { display: "inline-block", padding, fontSize, fontFamily: fontFamily?.value, color: textColor, fontWeight }, + }, + separator + ) + ); + return elements; + }) + ) + ); +} + +/** + * Menu's web HTML (which a document uses too), as @unlayer/exporters' MenuExporters write it with React (byte for + * byte). Item text or a separator given as an element renders as the walker renders it. + */ +export function menu(values: Record<string, any>): string { + const html = markup({ ...values, menu: { ...values?.menu, items: present(values?.menu?.items || []) } }); + return html.replace(/data-onclick-encoded="([^\"]+)"/gi, (_, encoded: string) => `onclick="${decodeBase64(encoded)}"`); +} diff --git a/packages/react/src/exporters/no-react-dom.test.tsx b/packages/react/src/exporters/no-react-dom.test.tsx new file mode 100644 index 00000000..1b0d77d4 --- /dev/null +++ b/packages/react/src/exporters/no-react-dom.test.tsx @@ -0,0 +1,119 @@ +/** + * Pages and documents render without react-dom: with react-dom/server's renderers made to throw, in this test's + * modules and in the @unlayer/exporters module Node loads (where its own web and document exporters of Divider, Html, + * Menu and Social call them), each of those blocks renders on a page and in a document, and so does a whole template + * with every block, by every renderer, the same as with them. (@unlayer/exporters still imports react-dom/server, so + * it has to be installed: it isn't called.) + */ +import { createRequire } from "node:module"; +import { describe, expect, it, onTestFinished, vi } from "vitest"; +import { DividerExporters, HtmlExporters, MenuExporters, SocialExporters } from "@unlayer/exporters"; +import { Button, Column, Divider, Document, Heading, Html, Image, Menu, Page, PageBreak, Paragraph, Row, Social, Table, Video, renderToHtml, renderToHtmlParts, renderToJson, renderToPlainText } from "../index"; +import * as Async from "../async"; +import { UNLAYER_RENDER_KEY } from "../utils/create-component"; + +const { RENDERERS, poisoned } = vi.hoisted(() => ({ + RENDERERS: ["renderToString", "renderToStaticMarkup", "renderToNodeStream", "renderToStaticNodeStream", "renderToPipeableStream", "renderToReadableStream"] as const, + poisoned: () => { + throw new Error("react-dom/server can't run here"); + }, +})); + +// This test's modules (Elements' own) get a react-dom/server that can't render. +vi.mock("react-dom/server", async (actual) => ({ ...(await actual<object>()), ...Object.fromEntries(RENDERERS.map((name) => [name, poisoned])) })); + +/** The react-dom/server @unlayer/exporters renders with (Node's module, which vi.mock doesn't reach). */ +const exportersServer = createRequire(createRequire(import.meta.url).resolve("@unlayer/exporters"))("react-dom/server") as Record<string, unknown>; + +/** Its renderers made to throw, until the test ends. */ +function poisonExporters() { + const saved = RENDERERS.map((name) => [name, exportersServer[name]] as const); + for (const name of RENDERERS) if (name in exportersServer) exportersServer[name] = poisoned; + onTestFinished(() => { + for (const [name, renderer] of saved) if (renderer) exportersServer[name] = renderer; + }); +} + +const BLOCKS = { + Divider: [Divider, { width: "80%", border: { borderTopWidth: "2px", borderTopStyle: "dashed", borderTopColor: "#cccccc" }, textAlign: "left" }], + Html: [Html, { html: '<p>Hello <b>{{name}}</b> & "you"</p>' }], + Menu: [Menu, { items: [{ text: "Home", href: "https://example.com/" }, { text: "Ünï <b>", href: "javascript:alert(1)", target: "_self" }, { text: "No link" }], separator: "|", linkColor: "#2563eb" }], + Social: [Social, { icons: [{ name: "Facebook", url: "https://facebook.com/x" }, { name: "Email", url: "hi@example.com" }], iconType: "rounded", iconSize: 24, spacing: 6 }], +} as const; + +/** A block's HTML as its component renders it in a mode (the hook-free render a Column calls). */ +function blockHtml(Component: unknown, props: object, mode: "web" | "document"): string { + const element = (Component as Record<string, (props: object) => { props: { dangerouslySetInnerHTML?: { __html: string } } }>)[UNLAYER_RENDER_KEY]({ ...props, mode }); + const html = element.props.dangerouslySetInnerHTML?.__html; + if (html === undefined) throw new Error("rendered its error fallback"); + return html; +} + +describe.each(Object.entries(BLOCKS))("%s", (name, [Component, props]) => { + it.each(["web", "document"] as const)("renders in %s mode where react-dom/server can't render", (mode) => { + const before = blockHtml(Component, props, mode); + poisonExporters(); + // (@unlayer/exporters' own exporter for it fails now.) + const upstream = { Divider: DividerExporters, Html: HtmlExporters, Menu: MenuExporters, Social: SocialExporters }[name]!; + const exporter = (upstream[mode] ?? upstream.web) as (...args: unknown[]) => string; + expect(() => exporter({ menu: { items: [{ text: "a" }] }, icons: { icons: [] }, html: "" })).toThrow("react-dom/server can't run here"); + const error = vi.spyOn(console, "error"); + try { + const html = blockHtml(Component, props, mode); + expect(html).toBe(before); + expect(html.length).toBeGreaterThan(20); + expect(error).not.toHaveBeenCalled(); + } finally { + error.mockRestore(); + } + }); +}); + +describe("a whole template", () => { + const content = ( + <> + <Row cells={[1, 2]}> + <Column> + <Heading>Hello, <b>world</b></Heading> + <Paragraph> + Text with a <a href="https://example.com/">link</a> + </Paragraph> + <Button href="https://example.com/go">Go</Button> + <Image src="https://example.com/a.png" alt="A" width={200} /> + </Column> + <Column> + <Divider width="80%" /> + <Html html="<p>Raw <b>HTML</b></p>" /> + <Menu items={[{ text: "Home", href: "https://example.com/" }, { text: <b>Bold</b> as unknown as string, href: "https://example.com/b" }]} separator="|" /> + <Social icons={[{ name: "Facebook", url: "https://facebook.com/x" }]} /> + <Table columns={2} rows={1} /> + <Video videoUrl="https://www.youtube.com/watch?v=dQw4w9WgXcQ" /> + <PageBreak /> + </Column> + </Row> + </> + ); + const renders = { + renderToHtml: (root: React.ReactElement) => renderToHtml(root), + renderToHtmlParts: (root: React.ReactElement) => JSON.stringify(renderToHtmlParts(root)), + renderToPlainText: (root: React.ReactElement) => renderToPlainText(root), + renderToJson: (root: React.ReactElement) => JSON.stringify(renderToJson(root)), + "async renderToHtml": (root: React.ReactElement) => Async.renderToHtml(root), + }; + it.each(Object.entries(renders).flatMap(([name, render]) => [[name, "Page", Page, render], [name, "Document", Document, render]] as const))("%s renders a %s where react-dom/server can't render", async (_, __, Root, render) => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + onTestFinished(() => warn.mockRestore()); + const root = <Root>{content}</Root>; + const before = await render(root); + poisonExporters(); + const error = vi.spyOn(console, "error"); + try { + const after = await render(root); + expect(after).toBe(before); + expect(after).toContain("Bold"); + expect(error).not.toHaveBeenCalled(); + } finally { + error.mockRestore(); + } + }); +}); diff --git a/packages/react/src/exporters/pages.test.tsx b/packages/react/src/exporters/pages.test.tsx new file mode 100644 index 00000000..82f6e0fd --- /dev/null +++ b/packages/react/src/exporters/pages.test.tsx @@ -0,0 +1,62 @@ +/** + * The full HTML of every template with a Divider, Html, Menu or Social block (test-corpus.tsx) as a Page and as a + * Document, as a digest of each document. The snapshot was written by 0.3.0 (React 18), where these blocks' web and + * document exporters rendered with react-dom/server, so the documents must stay the same byte for byte (with React + * 19, as react-dom 19 writes them), but for a `javascript:` URL (see BLOCKED). + */ +import React from "react"; +import { createHash } from "node:crypto"; +import { describe, expect, it, vi } from "vitest"; +import { Document, Page, renderToHtml } from "../index"; +import { as, corpus } from "./test-corpus"; + +/** The blocks' HTML, or the class of their wrapper: a template with any of them. */ +const BLOCKS = /aria-label="(divider|menu|social)"|u_content_(divider|html|menu|social)\b|\/social\/icons\//; + +const digest = (html: string) => createHash("sha256").update(html).digest("hex").slice(0, 20); + +/** + * Where a document is written otherwise than react-dom 18 wrote it (the snapshot): a `javascript:` link, which + * Elements writes as react-dom 19 does, as a URL that throws, with any React (see attributes.ts), the editor's exporters' + * own links too (a Button's, an Image's: see unrunLinks). Each one's digest, the snapshot's and the one written now, by + * root. + */ +const BLOCKED: Record<string, Record<string, [string, string]>> = { + Page: { 'link 8: "javascript:alert(1)"': ["cb5c73844884eb5f608c", "0cc7c54651e42795bbee"] }, + Document: { 'link 8: "javascript:alert(1)"': ["0107f525ae7ac69fda05", "bf6a9d1c77778cdc7242"] }, +}; + +/** + * A root given the editor's default link style where it sets none, as the snapshot's documents had its rule: Elements + * writes no rule for a body's links where its template sets no `linkStyle` now (the exporters' default, for the body's + * id, won over a template's own link CSS). + */ +const LINK_STYLE = { linkColor: "#0000ee", linkHoverColor: "#0000ee", linkUnderline: true, linkHoverUnderline: true }; +const linked = (node: React.ReactElement<any>): React.ReactElement => { + if (node.type === Page || node.type === Document) return node.props.linkStyle !== undefined || node.props.values?.linkStyle !== undefined ? node : React.cloneElement(node, { linkStyle: LINK_STYLE }); + return React.isValidElement(node.props?.children) ? React.cloneElement(node, undefined, linked(node.props.children as React.ReactElement)) : node; +}; + +describe.each([ + ["a web page", Page], + ["a document", Document], +] as const)("as %s", (_, Root) => { + it("renders every template with these blocks as 0.3.0 did", () => { + const quiet = [vi.spyOn(console, "warn").mockImplementation(() => {}), vi.spyOn(console, "error").mockImplementation(() => {})]; + try { + const digests: Record<string, string> = {}; + for (const { name, element, options } of corpus()) { + const html = renderToHtml(linked(as(element, Root)), options as never); + if (BLOCKS.test(html) || /^components\/(Divider|Html|Menu|Social): /.test(name)) digests[name] = digest(html); + } + expect(Object.keys(digests).length).toBeGreaterThan(150); + for (const [name, [react18, blocked]] of Object.entries(BLOCKED[Root.displayName])) { + expect(digests[name], name).toBe(blocked); + digests[name] = react18; + } + expect(digests).toMatchSnapshot(); + } finally { + for (const spy of quiet) spy.mockRestore(); + } + }); +}); diff --git a/packages/react/src/exporters/parity-values.ts b/packages/react/src/exporters/parity-values.ts new file mode 100644 index 00000000..116346f5 --- /dev/null +++ b/packages/react/src/exporters/parity-values.ts @@ -0,0 +1,219 @@ +/** + * The values parity.test.ts gives each block's exporter: what the editor and Elements give them (links in the link + * editor's shape, icon sets, borders, markup) and past that (empty, null, numbers where text goes, markup and quotes + * in every text, `javascript:` URLs, React's own attribute names in a link's custom attributes). Every combination of + * the values that shape each block's HTML, then more combinations drawn by a seeded generator. + */ +import React from "react"; +import { LINKS, TEXTS } from "./test-corpus"; + +type Values = Record<string, any>; + +/** A seeded generator (mulberry32): the same values on every run. */ +function random(seed: number) { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const pick = <T>(next: () => number, list: readonly T[]): T => list[Math.floor(next() * list.length)]; + +/** Every combination of the lists' values, one per key. */ +function combinations(lists: Record<string, readonly unknown[]>): Values[] { + return Object.entries(lists).reduce<Values[]>((all, [key, list]) => all.flatMap((values) => list.map((value) => (value === undefined ? { ...values } : { ...values, [key]: value }))), [{}]); +} + +const ODD = [undefined, null, "", 0, 1, -1, 2.5, NaN, true, false, " 5 ", 10n] as const; +const ALIGNS = ["left", "center", "right", "justify", "", undefined, '"><x>'] as const; + +// ---------- Divider ---------- + +const BORDER_STYLES = ["solid", "dashed", "dotted", "double", "none"]; +const BORDERS = [ + undefined, + null, + "", + "2px dashed #ff0000", + {}, + ...BORDER_STYLES.flatMap((style) => ["1px", "3px", "10px", 2].map((width) => ({ borderTopWidth: width, borderTopStyle: style, borderTopColor: "#BBBBBB" }))), + { borderTopWidth: "1px" }, + { borderTopColor: "rgba(0, 0, 0, 0.5)", borderTopStyle: "solid" }, + { borderTopWidth: "2px", borderTopStyle: "solid", borderTopColor: '"><x-pwn>' }, + { borderTopWidth: 0, borderTopStyle: "", borderTopColor: null }, + { lineHeight: 3, width: "10px", display: "block", height: 4, borderTopWidth: "1px" }, + { "--rule-color": " #ccc ", msTransform: "none", WebkitLineClamp: 2, scale: 2, zIndex: 1, opacity: 0, flex: NaN, aspectRatio: 1.5 }, + { borderTop: "1px solid #000", borderBottom: { a: 1 }, 0: "zero", "a-b": [1, 2] }, + 5, + true, +]; + +/** CSS properties a border can carry into the rule's style (React writes numbers in px but for its unitless ones), and values. */ +const STYLE_NAMES = ["borderTopWidth", "borderTopStyle", "borderTopColor", "borderTop", "width", "display", "lineHeight", "height", "verticalAlign", "--custom", "msTransform", "MozOpacity", "OFlex", "WebkitLineClamp", "WebkitBoxFlex", "scale", "aspectRatio", "zIndex", "opacity", "fontWeight", "0", "a-b"]; +const STYLE_VALUES = ["1px", "solid", "#BBBBBB", "rgb(1, 2, 3)", 0, 1, 2, -3, 2.5, NaN, Infinity, "", " 3px ", "<&\"'>", null, true, [1, 2], { a: 1 }]; + +export function dividerValues(): Values[] { + const all = combinations({ width: ["100%", "50%", "33.33%", "240px", "", undefined, 0, 80, null], textAlign: ALIGNS, border: BORDERS }); + // Each property with each value, in a border. + for (const name of STYLE_NAMES) for (const value of STYLE_VALUES) all.push({ width: "100%", textAlign: "center", border: { borderTopWidth: "1px", [name]: value } }); + const next = random(1); + for (let i = 0; i < 1500; i++) all.push({ width: pick(next, [...ODD, "60%", "1px"]), textAlign: pick(next, ALIGNS), border: pick(next, [...BORDERS, ...ODD]) }); + return all; +} + +// ---------- Html ---------- + +const MARKUP = [ + ...TEXTS, + "<p>Hello <strong>world</strong></p>", + '<table role="presentation"><tr><td style="color:#333">Cell</td></tr></table>', + '<a href="https://example.com/?a=1&b=2" target="_blank">Link</a>', + "<script>alert(1)</script><p>after</p>", + '<link rel="preload" as="image" href="https://example.com/a.png"><p>preload</p>', + '<link rel="preload" as="image" href="https://example.com/a.png" /><link rel="stylesheet" href="x.css">', + "<!-- comment --><p>x</p><!--[if mso]><table><![endif]-->", + '<img src="https://example.com/a.png" alt="A & B">', + "<p>Hi {{first_name}}, *|FNAME|*</p>", + '<p dir="rtl">مرحبا <b>بالعالم</b></p>', + ...ODD, + { a: 1 }, + ["<i>a</i>", "b"], +]; + +/** The block's config (the 8th argument): none, or `toSafeHtml` of each kind. */ +const HTML_META = [ + undefined, + {}, + { exporterConfig: {} }, + { exporterConfig: { toSafeHtml: (html: string) => html } }, + { exporterConfig: { toSafeHtml: (html: string) => `${html}`.replace(/<script[\s\S]*?<\/script>/g, "") } }, + { exporterConfig: { toSafeHtml: () => undefined } }, + { exporterConfig: { toSafeHtml: () => 7 } }, + { exporterConfig: { toSafeHtml: null } }, +]; + +export function htmlCases(): [Values, unknown][] { + return MARKUP.flatMap((html) => HTML_META.map((meta): [Values, unknown] => [{ html }, meta])); +} + +// ---------- Menu ---------- + +/** Custom attributes of a link: names React writes as given, renames, writes as booleans or leaves out, and values of each kind. */ +const ATTRIBUTE_NAMES = ["data-track", "data-onclick-encoded", "aria-hidden", "rel", "title", "id", "class", "className", "style", "href", "target", "onClick", "onmouseover", "tabIndex", "htmlFor", "hidden", "download", "is", "children", "dangerouslySetInnerHTML", "key", "ref", "rowSpan", "cols", "contentEditable", "allowFullScreen", "inert", "checked", "autoFocus", "src", "action", "xlinkHref", "strokeWidth", "transformOrigin", "crossOrigin", "x y", "1abc", "__proto__", "constructor", "defaultValue", "value"]; +const ATTRIBUTE_VALUES = [...ODD, "javascript:alert(1)", "<&\"'>", " sp ", "noopener", "YWxlcnQoMSk=", { a: 1 }, [1, 2], () => 1, { __html: "<b>i</b>" }]; + +function customAttrs(next: () => number): Values { + const attrs: Values = {}; + for (let i = Math.floor(next() * 4); i > 0; i--) Object.defineProperty(attrs, pick(next, ATTRIBUTE_NAMES), { value: pick(next, ATTRIBUTE_VALUES), enumerable: true, writable: true, configurable: true }); + return attrs; +} + +/** Links as the link editor gives them (`{ url, target, onClick, class, customAttrs }`), and none. */ +const LINK_SHAPES: unknown[] = [ + undefined, + null, + "https://example.com/", + ...LINKS.map((url) => ({ url, target: "_blank" })), + ...["_self", "_parent", "_top", "", undefined, null].map((target) => ({ url: "https://example.com/t", target })), + { url: "https://example.com/attrs", customAttrs: { "data-track": "menu", rel: "noopener noreferrer", title: 'Say "hi"' } }, + { url: "", customAttrs: { "data-empty": "", rel: " " } }, + { url: "", customAttrs: { a: 5 } }, + { url: "https://example.com/click", onClick: "track('menu', \"home\"); return true;", class: "menu-link primary" }, + { url: "", onClick: "openChat()" }, + { url: "", onClick: "é ✓ \ud800" }, + { url: "https://example.com/", customAttrs: "ab" }, + { url: "https://example.com/", customAttrs: { "data-onclick-encoded": "YWxlcnQoMSk=" } }, + { url: "https://example.com/", customAttrs: { "data-onclick-encoded": "!!not base64" } }, + { url: "https://example.com/", customAttrs: { is: "x-link", className: "c", onClick: "x()", hidden: true } }, +]; + +/** Item text given as JSX (markup, a component, a Fragment): the string version renders it with the walker. */ +const Em = ({ children }: { children?: React.ReactNode }) => React.createElement("em", { className: "x" }, children); +const ELEMENT_TEXTS = [ + React.createElement("b", null, "Bold"), + React.createElement("span", { style: { color: "red", lineHeight: 1.5 }, "data-x": 1 }, "a & b"), + ["x", React.createElement("i", { key: "i" }, "y")], + React.createElement(Em, null, "e"), + React.createElement(React.Fragment, null, "f", 1), +]; +const ITEM_TEXTS = [...TEXTS, 0, 7, null, undefined, ["a", 1, null, ["b"]], ...ELEMENT_TEXTS]; + +export function menuValues(): Values[] { + const all: Values[] = []; + const item = (text: unknown, link: unknown) => ({ key: "1", text, link }); + // Each text, each link, in a menu of each layout with separators. + for (const layout of ["horizontal", "vertical", undefined]) + for (const separator of ["|", "", "•", "<b>/</b>", undefined]) + for (const [i, link] of LINK_SHAPES.entries()) all.push({ menu: { items: [item(ITEM_TEXTS[i % ITEM_TEXTS.length], link), item("Second", LINK_SHAPES[(i + 1) % LINK_SHAPES.length]), null] }, layout, separator }); + for (const text of ITEM_TEXTS) all.push({ menu: { items: [item(text, { url: `https://example.com/${text}` }), item(text, undefined)] }, separator: text, layout: "horizontal" }); + // Each custom attribute name with each value, on a link. + for (const name of ATTRIBUTE_NAMES) + for (const value of ATTRIBUTE_VALUES) { + const customAttrs = Object.defineProperty({}, name, { value, enumerable: true, writable: true, configurable: true }); + all.push({ menu: { items: [item("Home", { url: "https://example.com/", customAttrs }), item("Empty", { url: "", customAttrs })] }, layout: "horizontal" }); + } + // The style values, each combination. + for (const values of combinations({ + align: ["left", "center", "right", undefined], + fontFamily: [{ label: "Arial", value: "arial,helvetica,sans-serif" }, { value: "'Open Sans', sans-serif" }, undefined, "Georgia"], + fontWeight: [400, 700, "bold", undefined], + letterSpacing: ["0px", "1px", undefined], + })) + all.push({ ...values, menu: { items: [item("Home", { url: "https://example.com/" }), item("About", undefined)] }, layout: "horizontal", separator: "|", padding: "5px 15px", fontSize: "14px", linkColor: "#0068A5", textColor: "#444444" }); + // Empty menus, and no menu. + for (const menu of [undefined, null, {}, { items: undefined }, { items: null }, { items: [] }, { items: [null, undefined] }, { items: "x" }]) all.push({ menu }); + const next = random(2); + for (let i = 0; i < 2500; i++) { + const items = Array.from({ length: Math.floor(next() * 5) }, () => (next() < 0.07 ? null : item(next() < 0.85 ? pick(next, ITEM_TEXTS) : pick(next, ODD), next() < 0.7 ? pick(next, LINK_SHAPES) : { url: pick(next, LINKS), target: pick(next, ["_blank", "_self", undefined]), onClick: next() < 0.2 ? pick(next, ["x()", "", undefined]) : undefined, class: next() < 0.2 ? "c" : undefined, customAttrs: next() < 0.4 ? customAttrs(next) : undefined }))); + all.push({ + menu: { items }, + separator: pick(next, ["|", "", "•", undefined, 0, "<i>-</i>"]), + layout: pick(next, ["horizontal", "vertical", undefined, "grid"]), + align: pick(next, ALIGNS), + padding: pick(next, ["5px 15px", "0px", 4, 0, undefined, " 3px "]), + fontSize: pick(next, ["14px", 14, 0, undefined, NaN]), + fontWeight: pick(next, [400, 700, "bold", undefined, 0]), + fontFamily: pick(next, [{ value: "arial" }, undefined, {}, "Georgia"]), + letterSpacing: pick(next, ["1px", 2, undefined, "", 0]), + linkColor: pick(next, ["#0068A5", undefined, "", "rgb(1, 2, 3)"]), + textColor: pick(next, ["#444444", undefined, "", "<x>"]), + }); + } + return all; +} + +// ---------- Social ---------- + +const ICON_NAMES = ["Facebook", "X", "Twitter", "Instagram", "LinkedIn", "YouTube", "Pinterest", "TikTok", "WhatsApp", "Email", "Custom", "", '"><x>', "Ünï", 5, undefined]; +const ICON_TYPES = ["circle", "rounded", "squared", "circle-black", "circle-white", "rounded-black", "squared-black", "", undefined]; +const ICON_URLS = [...LINKS, "hi@example.com", undefined, 5, ["mailto"]]; +const SOCIAL_META = [undefined, {}, { exporterConfig: {} }, { exporterConfig: { cdnBaseUrl: "https://cdn.tools.unlayer.com" } }, { exporterConfig: { cdnBaseUrl: "https://cdn.example.com/a" } }, { exporterConfig: { cdnBaseUrl: '"><x>' } }]; +const CUSTOM_ICONS = { data: { customIcons: [{ name: "Custom", icons: { circle: "https://example.com/c.png", rounded: "" } }, { name: "Facebook", icons: { circle: "https://example.com/fb.png" } }, null] } }; + +export function socialCases(): [Values, unknown][] { + const all: [Values, unknown][] = []; + // (An icon without a name or image throws in both, as the exporters read its name for its image.) + const icons = ICON_NAMES.filter((name) => typeof name === "string").map((name, i) => ({ name, url: ICON_URLS[i % ICON_URLS.length] })); + // Each icon set, size, spacing and alignment, with each icon. + for (const values of combinations({ iconType: ICON_TYPES, iconSize: [32, 16, 48, "40", 0, undefined, NaN], spacing: [5, 0, 10, undefined], align: ["left", "center", "right", undefined] })) + all.push([{ icons: { iconType: values.iconType, icons }, iconSize: values.iconSize, spacing: values.spacing, align: values.align }, SOCIAL_META[all.length % SOCIAL_META.length]]); + for (const url of ICON_URLS) for (const name of ICON_NAMES) if (name !== undefined || url) all.push([{ icons: { iconType: "circle", icons: [{ name, url }, { name: "X", url, imgUrl: "https://example.com/x.png" }] }, iconSize: 32, spacing: 5, align: "center" }, undefined]); + for (const meta of SOCIAL_META) { + all.push([{ icons: { iconType: "circle", icons: [] }, iconSize: 32, align: "left" }, meta]); + all.push([{ icons: { iconType: "circle", icons: [{ name: "Custom", url: "https://example.com/" }, { name: "Facebook", url: "https://example.com/" }, null], editor: CUSTOM_ICONS }, iconSize: 24, spacing: 4, align: "right" }, meta]); + } + for (const group of [undefined, {}, { icons: null }, { icons: "x" }, { iconType: "circle" }, { editor: {} }, { editor: { data: {} }, icons: [{ name: "Facebook" }] }]) all.push([{ icons: group, iconSize: 32 }, undefined]); + const next = random(3); + for (let i = 0; i < 2500; i++) { + const set = Array.from({ length: Math.floor(next() * 5) }, () => (next() < 0.06 ? pick(next, [null, undefined, 0, ""]) : { name: pick(next, ICON_NAMES), url: pick(next, ICON_URLS), ...(next() < 0.15 ? { imgUrl: pick(next, ["https://example.com/i.png", "", "javascript:x", '"><x>']) } : {}) })); + all.push([ + { icons: { iconType: pick(next, ICON_TYPES), icons: set, ...(next() < 0.15 ? { editor: CUSTOM_ICONS } : {}) }, iconSize: pick(next, [32, 16, 0, "40", undefined, NaN, 2.5, true]), spacing: pick(next, [5, 0, 10, "8", undefined, -1]), align: pick(next, ALIGNS) }, + pick(next, SOCIAL_META), + ]); + } + return all; +} diff --git a/packages/react/src/exporters/parity.test.tsx b/packages/react/src/exporters/parity.test.tsx new file mode 100644 index 00000000..5bae5008 --- /dev/null +++ b/packages/react/src/exporters/parity.test.tsx @@ -0,0 +1,213 @@ +/** + * Divider, Html, Menu and Social write their web and document HTML as strings (divider.ts, html.ts, menu.ts, + * social.ts) where @unlayer/exporters renders it with react-dom/server. Both run here, and must write the same HTML + * byte for byte (or both throw): for every combination of the values that shape each block's HTML and thousands + * more drawn by a seeded generator (parity-values.ts), and for each block Elements renders in the test corpus + * (test-corpus.tsx: the stories, the codegen designs, the regression suite's user values) and with its defaults, as + * a Page and as a Document, with the values Elements gives the exporter. + * + * It's also the alarm for the pin: when the catalog moves @unlayer/exporters to a release whose HTML for these + * blocks differs, this test fails until the string versions write the new release's HTML (compare its compiled + * DividerExporters, HtmlExporters, MenuExporters and SocialExporters with these files). * + * Where they mean to differ: a `javascript:` URL (a link's, an icon's image), which the string versions write as + * react-dom 19 writes it, a URL that throws, with any React; react-dom 18 writes it as given (see attributes.ts). So + * the pinned exporters' HTML is compared with its `javascript:` URLs written so (`blockedUrls`). + */ +import { describe, expect, it, vi } from "vitest"; +import { JSDOM, VirtualConsole } from "jsdom"; +import { DividerExporters, HtmlExporters, MenuExporters, SocialExporters } from "@unlayer/exporters"; +import { Column, Divider, Document, Html, Menu, Page, Row, Social, renderToHtml } from "../index"; +import { divider } from "./divider"; +import { html } from "./html"; +import { menu } from "./menu"; +import { social } from "./social"; +import { dividerValues, htmlCases, menuValues, socialCases } from "./parity-values"; +import { as, corpus } from "./test-corpus"; +import { blockedUrls } from "../test-utils"; + +type Exporter = (...args: any[]) => string; + +/** The arguments Elements calls each string exporter with, while a test renders templates. */ +const { calls } = vi.hoisted(() => ({ calls: [] as { block: "divider" | "html" | "menu" | "social"; args: unknown[] }[] })); +vi.mock("./divider", async (actual) => { + const own = await actual<{ divider: Exporter }>(); + return { divider: (...args: unknown[]) => (calls.push({ block: "divider", args }), own.divider(...args)) }; +}); +vi.mock("./html", async (actual) => { + const own = await actual<{ html: Exporter }>(); + return { html: (...args: unknown[]) => (calls.push({ block: "html", args }), own.html(...args)) }; +}); +vi.mock("./menu", async (actual) => { + const own = await actual<{ menu: Exporter }>(); + return { menu: (...args: unknown[]) => (calls.push({ block: "menu", args }), own.menu(...args)) }; +}); +vi.mock("./social", async (actual) => { + const own = await actual<{ social: Exporter }>(); + return { social: (...args: unknown[]) => (calls.push({ block: "social", args }), own.social(...args)) }; +}); + +/** The string versions. */ +const OWN: Record<string, Exporter> = { divider, html, menu, social }; + +/** The upstream exporters each string version stands in for (Menu's and Social's web one renders documents too). */ +const UPSTREAM: Record<string, [string, Exporter][]> = { + divider: [["DividerExporters.web", DividerExporters.web as Exporter], ["DividerExporters.document", DividerExporters.document as Exporter]], + html: [["HtmlExporters.web", HtmlExporters.web as Exporter], ["HtmlExporters.document", HtmlExporters.document as Exporter]], + menu: [["MenuExporters.web", MenuExporters.web as Exporter]], + social: [["SocialExporters.web", SocialExporters.web as Exporter]], +}; + +const run = (exporter: Exporter, args: unknown[]): { html?: string; threw?: true } => { + try { + return { html: exporter(...args) }; + } catch { + return { threw: true }; + } +}; + +/** Where the string version and an upstream exporter write different HTML for the same arguments (one line each), and how many were compared. */ +function compare(block: string, own: Exporter, cases: unknown[][]): { compared: number; differences: string[] } { + const differences: string[] = []; + let compared = 0; + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + for (const args of cases) { + const mine = run(own, args); + for (const [name, upstream] of UPSTREAM[block]) { + const theirs = run(upstream, args); + if (theirs.html !== undefined) theirs.html = blockedUrls(theirs.html); + compared++; + if (mine.threw && theirs.threw) continue; + if (mine.html !== theirs.html) differences.push(`${name}(${describeArgs(args)}): ${theirs.threw ? "throws" : JSON.stringify(theirs.html)} but the string version ${mine.threw ? "throws" : `gives ${JSON.stringify(mine.html)}`}`); + } + } + } finally { + error.mockRestore(); + } + return { compared, differences: differences.slice(0, 5) }; +} + +const describeArgs = (args: unknown[]) => { + try { + return JSON.stringify(args[0], (_, value) => (typeof value === "function" ? "[function]" : typeof value === "bigint" ? `${value}n` : value)).slice(0, 300); + } catch { + return String(args[0]); + } +}; + +describe("for the values each block takes", () => { + it.each([ + ["Divider", "divider", divider, () => dividerValues().map((values) => [values])], + ["Html", "html", html, () => htmlCases().map(([values, meta]) => [values, 0, 0, [], {}, {}, undefined, meta])], + ["Menu", "menu", menu, () => menuValues().map((values) => [values])], + ["Social", "social", social, () => socialCases().map(([values, meta]) => [values, 0, 0, [], {}, {}, undefined, meta])], + ] as const)("%s writes the pinned exporters' HTML", (_, block, own, cases) => { + const { compared, differences } = compare(block, own as Exporter, cases()); + expect(differences).toEqual([]); + expect(compared).toBeGreaterThan(150); + }); +}); + +describe("for the values Elements gives them", () => { + /** The calls a render makes of each string exporter. */ + function rendered(render: () => void): Record<string, unknown[][]> { + calls.length = 0; + const quiet = [vi.spyOn(console, "warn").mockImplementation(() => {}), vi.spyOn(console, "error").mockImplementation(() => {})]; + try { + render(); + } finally { + for (const spy of quiet) spy.mockRestore(); + } + const byBlock: Record<string, unknown[][]> = { divider: [], html: [], menu: [], social: [] }; + for (const { block, args } of calls) byBlock[block].push(args); + return byBlock; + } + + it("each block's defaults write the pinned exporters' HTML, on a page and in a document", () => { + const template = <Row><Column><Divider /><Html /><Menu /><Social /></Column></Row>; + const byBlock = rendered(() => { + renderToHtml(<Page>{template}</Page>); + renderToHtml(<Document>{template}</Document>); + }); + for (const [block, cases] of Object.entries(byBlock)) { + expect(cases.length, block).toBe(2); + expect(compare(block, OWN[block], cases).differences).toEqual([]); + } + }); + + it("every block of the stories, codegen designs and regression values writes the pinned exporters' HTML", () => { + const templates = corpus(); + const byBlock = rendered(() => { + for (const { element, options } of templates) + for (const Root of [Page, Document]) { + try { + renderToHtml(as(element, Root), options as never); + } catch { + // (A story that doesn't render as a page has no blocks to compare.) + } + } + }); + for (const [block, cases] of Object.entries(byBlock)) { + expect(cases.length, block).toBeGreaterThan(40); + expect(compare(block, OWN[block], cases).differences).toEqual([]); + } + }); +}); + +describe("a `javascript:` URL", () => { + const BLOCKED = "javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')"; + it("is written as react-dom 19 writes it where the pinned exporters' react-dom is 18 (the values above have them)", () => { + const values = { menu: { items: [{ key: "1", text: "Go", link: { url: "javascript:alert(1)", target: "_self" } }] } }; + expect(menu(values)).toBe(blockedUrls(MenuExporters.web(values as never) as string)); + expect(menu(values)).toContain("javascript:throw new Error('React has blocked"); + }); + + // However it's written: React 19's test (C0 controls and spaces before it, tabs and line breaks in it, any case), in + // a list (written as its text), and given in a link's attributes. + const URLS: unknown[] = ["javascript:globalThis.__probe=1", " \u0001JavaScript:globalThis.__probe=1", "java\tscr\nipt:globalThis.__probe=1", ["javascript:globalThis.__probe=1"]]; + + /** + * The page's first link, clicked: its href, and whether the URL ran. JSDOM runs it a task later, later still on a + * busy machine: `until`, it's waited for (2 s at most), where it should run. + */ + async function click(html: string, until = false): Promise<{ href: string | null; ran: boolean }> { + const dom = new JSDOM(html, { url: "https://example.test/", runScripts: "dangerously", virtualConsole: new VirtualConsole() }); + try { + const link = dom.window.document.querySelector("a")!; + const ran = () => (dom.window as unknown as { __probe?: number }).__probe === 1; + link.click(); + for (let waits = 0; waits < (until ? 400 : 3) && !ran(); waits++) await new Promise((resolve) => setTimeout(resolve, 5)); + return { href: link.getAttribute("href"), ran: ran() }; + } finally { + dom.window.close(); + } + } + + it("runs when a link holds one (the check below can see one run)", async () => { + expect(await click('<a href="javascript:globalThis.__probe=1" target="_self">Go</a>', true)).toEqual({ href: "javascript:globalThis.__probe=1", ran: true }); + }); + + it.each([ + ["a web page", Page], + ["a document", Document], + ] as const)("is written as one that throws, as react-dom 19 writes it, in a Menu's and a Social's links on %s, with any React", async (_, Root) => { + for (const url of URLS) { + const blocks = { + Menu: <Menu items={[{ text: "Go", href: url as string, target: "_self" }]} />, + Social: <Social icons={[{ name: "Facebook", url: url as string }]} />, + "Menu's link attributes": <Menu values={{ menu: { items: [{ key: "1", text: "Go", link: { name: "web", values: { href: "", target: "_self" }, attrs: { href: url } } }] } } as never} />, + }; + for (const [name, block] of Object.entries(blocks)) expect([name, url, await click(renderToHtml(<Root><Row><Column>{block}</Column></Row></Root>))]).toEqual([name, url, { href: BLOCKED, ran: false }]); + } + }); + + it("is written so as an icon's image too, and as a block's link however it's written", () => { + const html = (root: React.ReactElement) => new JSDOM(renderToHtml(root)).window.document; + // A block's link written with a character reference: the editor's exporters write it as it is in an email (where a + // browser decodes it) and the design holds it so, so it's the URL that throws in every mode. + const page = html(<Page><Row><Column><Social icons={{ iconType: "circle", icons: [{ name: "Facebook", url: "javascript:x", imgUrl: "javascript:x" }] } as never} /></Column></Row></Page>); + expect(page.querySelector("img")!.getAttribute("src")).toBe(BLOCKED); + expect(page.querySelector("a")!.getAttribute("href")).toBe(BLOCKED); + // (Markup React writes escapes its `&`, so a reference in it stays text: see text-children.test.tsx.) + }); +}); diff --git a/packages/react/src/exporters/present.ts b/packages/react/src/exporters/present.ts new file mode 100644 index 00000000..59f52057 --- /dev/null +++ b/packages/react/src/exporters/present.ts @@ -0,0 +1,2 @@ +/** The list without its null and undefined items (anything but a list: none), as the exporters read their lists. */ +export const present = (list: unknown): any[] => (list ? (Array.isArray(list) ? list.filter((item) => item != null) : []) : []); diff --git a/packages/react/src/exporters/react-19.test.ts b/packages/react/src/exporters/react-19.test.ts new file mode 100644 index 00000000..d0fb97a7 --- /dev/null +++ b/packages/react/src/exporters/react-19.test.ts @@ -0,0 +1,38 @@ +/** + * The string exporters write HTML as the React that's installed does (react-version.ts), and React 19 writes some + * attributes, styles and children otherwise than React 18, which these tests run with (parity.test.tsx compares + * with react-dom/server 18). Here React 19's: an element with each attribute name and value where React 19 writes + * otherwise than React 18 (and each name with a string and a number), style properties and children, against what + * react-dom/server 19.1.0 and 19.3.1 wrote for them (`__fixtures__/react-19.json`; 19.0 and 19.2 write the same as + * 19.1, and 19.3 adds `credentialless` and `maskType`). + */ +import { describe, expect, it, vi } from "vitest"; +import fixture from "./__fixtures__/react-19.json"; + +type Entry = [string, unknown, string, string?]; + +describe.each(["19.1.0", "19.3.1"])("as React %s", (version) => { + it("writes each attribute, style and child as react-dom/server does", async () => { + vi.resetModules(); + vi.doMock("react", async () => { + const own = await vi.importActual<typeof import("react") & { default: object }>("react"); + return { ...own, version, default: { ...own.default, version } }; + }); + const { element } = await import("./markup"); + const differences: string[] = []; + const expect19 = (entry: Entry) => (version === "19.3.1" && entry[3] !== undefined ? entry[3] : entry[2]); + for (const entry of fixture.attributes as Entry[]) { + const html = element("a", { [entry[0]]: entry[1] }, "t").html; + if (html !== `<a${expect19(entry)}>t</a>`) differences.push(`${entry[0]}=${JSON.stringify(entry[1])}: ${html}`); + } + for (const entry of fixture.styles as Entry[]) { + const html = element("div", { style: { [entry[0]]: entry[1] } }, "t").html; + if (html !== `<div${entry[2]}>t</div>`) differences.push(`style ${entry[0]}: ${JSON.stringify(entry[1])}: ${html}`); + } + for (const [child, html] of fixture.children as [unknown, string][]) if (element("span", {}, child).html !== html) differences.push(`child ${JSON.stringify(child)}`); + // (A BigInt, which JSON doesn't hold: React 19 renders it as a number, React 18 not at all.) + if (element("span", {}, 10n).html !== "<span>10</span>") differences.push("child 10n"); + expect(differences).toEqual([]); + expect(fixture.attributes.length).toBeGreaterThan(200); + }); +}); diff --git a/packages/react/src/exporters/social.ts b/packages/react/src/exporters/social.ts new file mode 100644 index 00000000..9d8f2a10 --- /dev/null +++ b/packages/react/src/exporters/social.ts @@ -0,0 +1,50 @@ +import { element, staticMarkup } from "./markup"; +import { present } from "./present"; + +/** The icons, each with its image from the editor's custom icons for the icon type where it's one of them. */ +function withCustomIcons({ icons, customIcons, iconType }: { icons: any[]; customIcons?: any[]; iconType?: string }) { + return icons + .map((icon) => { + if (!icon) return; + const custom = customIcons?.find((own) => own && own.name === icon.name); + return { ...icon, imgUrl: custom ? custom.icons[iconType as string] : icon.imgUrl }; + }) + .filter(Boolean); +} + +/** An icon's link: an email address as a `mailto:` link. */ +const href = (icon: any) => (icon?.name !== "Email" || icon?.url?.includes("mailto") ? icon?.url : `mailto:${icon?.url}`); + +/** + * Social's web HTML (which a document uses too), as @unlayer/exporters' SocialExporters write it with React (byte + * for byte): a link and image for each icon, from the config's CDN. + */ +export function social(values: Record<string, any>, ...args: any[]): string { + const { exporterConfig: config } = args[6] || {}; + const { icons: { editor: { data } = {}, iconType, icons: given } = {}, align, spacing, iconSize } = values; + const cdnBaseUrl = config?.cdnBaseUrl || ""; + const icons = withCustomIcons({ icons: present(given), customIcons: data?.customIcons, iconType }).map((icon) => ({ ...icon, url: href(icon) })); + if (!icons.length) + return staticMarkup(element("div", { style: { textAlign: align, lineHeight: "0px" } }, element("img", { src: `${cdnBaseUrl}/social/icons/default.png`, height: iconSize, alt: "social icon" }))); + return staticMarkup( + element( + "div", + { style: { textAlign: align, lineHeight: "0px", direction: "ltr" }, "aria-label": "social" }, + icons.map((icon, index) => { + if (!icon) return null; + const src = icon?.imgUrl || `${cdnBaseUrl}/social/icons/${iconType}/${icon.name.toLowerCase()}.png`; + return element( + "a", + { + key: `icon-${index}`, + href: href(icon), + target: "_blank", + "aria-label": `${icon.name} icon`, + style: { display: "inline-block", width: `${iconSize}px`, height: `${iconSize}px`, marginRight: `${index == icons.length - 1 ? 0 : spacing}px` }, + }, + element("img", { alt: `${icon?.name} icon`, src, width: iconSize, height: iconSize }) + ); + }) + ) + ); +} diff --git a/packages/react/src/exporters/test-corpus.tsx b/packages/react/src/exporters/test-corpus.tsx new file mode 100644 index 00000000..fe638d08 --- /dev/null +++ b/packages/react/src/exporters/test-corpus.tsx @@ -0,0 +1,337 @@ +/** + * Templates with Divider, Html, Menu and Social blocks, for the tests of their web and document HTML (pages.test.tsx, + * parity.test.ts): every story, the codegen fixtures' designs as Elements code, the values the regression suite gives + * users' text and links, and templates for what these blocks take (links and targets, icon sets, sizes, alignment, + * borders, markup, right-to-left text, merge tags, phone settings). Each renders as a Page and as a Document. + */ +import React from "react"; +import * as Elements from "../index"; +import { Body, Column, Divider, Document, Email, Html, Menu, Page, Row, Social, UnlayerProvider } from "../index"; +import { DESIGNS } from "../codegen/__fixtures__/designs"; +import kitchenSink from "../codegen/__fixtures__/editor-kitchen-sink.json"; +import { designToElements } from "../codegen/design-to-elements"; +import { evaluate } from "../codegen/test-harness"; +import type { Plain } from "../codegen/values"; + +export interface Template { + name: string; + /** The template, with any root (Email, Page, Document, Body), which `as` replaces. */ + element: React.ReactElement; + /** renderToHtml's options. */ + options?: Record<string, unknown>; +} + +const ROOTS = new Set<unknown>([Email, Page, Document, Body]); +const OWN = new Set<unknown>(Object.values(Elements)); + +/** What a template component renders, where it can be called outside a render (no hooks), or undefined. */ +function rendered(type: (props: object) => React.ReactNode, props: object): React.ReactNode | undefined { + const error = console.error; + console.error = () => {}; + try { + return type(props); + } catch { + return undefined; + } finally { + console.error = error; + } +} + +/** The template rendered by `Root` (Page or Document): its root replaced, found through the components that render it. */ +export function as(node: React.ReactNode, Root: typeof Page | typeof Document): React.ReactElement { + if (React.isValidElement(node)) { + const { type, props } = node as React.ReactElement<Record<string, unknown>>; + if (ROOTS.has(type)) { + const { mode: _mode, ...rest } = props; + return <Root {...rest} />; + } + if (type === UnlayerProvider) return React.cloneElement(node as React.ReactElement<Record<string, unknown>>, undefined, as(props.children as React.ReactNode, Root)); + if (typeof type === "function" && !OWN.has(type) && !(type.prototype && type.prototype.isReactComponent)) { + const inner = rendered(type as (props: object) => React.ReactNode, props); + if (inner !== undefined) return as(inner, Root); + } + } + return <Root>{node}</Root>; +} + +type Story = ((args: object) => React.ReactNode) | { args?: object; render?: (args: object, context: object) => React.ReactNode }; +type StoryModule = Record<string, unknown> & { default?: { component?: React.ComponentType<object>; args?: object; render?: (args: object, context: object) => React.ReactNode } }; + +const STORY_MODULES = (import.meta as unknown as { glob: (pattern: string, options: { eager: true }) => Record<string, StoryModule> }).glob("../**/*.stories.tsx", { eager: true }); + +/** Every story, as Storybook renders it: its render function (called in a component, as one can use hooks), or its component with its args. */ +function stories(): Template[] { + const out: Template[] = []; + for (const [file, module] of Object.entries(STORY_MODULES)) { + const meta = module.default ?? {}; + for (const [name, story] of Object.entries(module)) { + if (name === "default" || !story || (typeof story !== "function" && typeof story !== "object")) continue; + const own = story as Story; + const args = { ...meta.args, ...(typeof own === "object" ? own.args : undefined) }; + const render = typeof own === "function" ? own : own.render ?? meta.render; + let element: React.ReactElement; + if (render) { + // Called here where it can be (to find its root), else in a component, where its hooks work. + const tree = rendered(() => render(args, { args }), {}); + const Story = () => <>{render(args, { args })}</>; + element = tree !== undefined && React.isValidElement(tree) ? tree : <Story />; + } else if (meta.component) element = React.createElement(meta.component, args); + else continue; + out.push({ name: `${file.replace(/^\.\.\//, "").replace(/\.stories\.tsx$/, "")}: ${name}`, element }); + } + } + return out; +} + +/** The codegen fixtures' designs (as the editor saves them) as the Elements code codegen writes for them. */ +function designs(): Template[] { + const all: [string, Plain][] = [["editor kitchen sink", kitchenSink as unknown as Plain], ...Object.entries(DESIGNS).map(([name, { design }]): [string, Plain] => [name, design])]; + return all.flatMap(([name, design]) => { + const types = JSON.stringify(design).match(/"type":"(divider|html|menu|social)"/); + if (!types) return []; + const Template = evaluate(designToElements(design).code); + return [{ name: `design: ${name}`, element: <Template /> }]; + }); +} + +/** Values from users (the regression suite's), which must render as they did: markup, quotes, entities, other scripts. */ +export const TEXTS = [ + "Home", + "", + " padded ", + '"><x-pwn></x-pwn>', + "'><x-pwn></x-pwn>", + '" x-pwn="1', + "' x-pwn='1", + "</p></div></td></tr></table><x-pwn></x-pwn>", + '{{ " }} x-pwn=1 y', + '{{ x" x-pwn }}', + '<%a%>" x-pwn="1', + "Fish & chips & <b>bold</b>", + "“Quotes” ‘single’ — dash…", + "مرحبا بالعالم", + "שלום עולם", + "日本語のテキスト", + "😀 emoji 👍🏽", + "{{first_name}}", + "*|FNAME|*", + "%%name%%", + "line\nbreak\ttab", +]; + +export const LINKS = [ + "https://example.com/", + "https://example.com/path?a=1&b=2#hash", + "http://example.com/a b", + "/relative/path", + "#anchor", + "mailto:hi@example.com?subject=Hi there", + "tel:+15550100", + "sms:+15550100", + "javascript:alert(1)", + "{{unsubscribe_url}}", + "https://example.com/?u={{user_id}}&t=*|TOKEN|*", + "<%asm_group_unsubscribe_raw_url%>", + "", +]; + +const ICON_NAMES = ["Facebook", "X", "Twitter", "Instagram", "LinkedIn", "YouTube", "Pinterest", "TikTok", "WhatsApp", "Discord", "Email", "Custom"]; +const ICON_TYPES = ["circle", "rounded", "squared", "circle-black", "circle-white", "rounded-black", "squared-black"]; + +const row = (...blocks: React.ReactNode[]) => ( + <Row> + <Column>{blocks}</Column> + </Row> +); + +/** Templates for the values these blocks take. */ +function values(): Template[] { + const out: Template[] = []; + const add = (name: string, element: React.ReactElement, options?: Record<string, unknown>) => out.push({ name, element, options }); + + add("defaults", <Email>{row(<Divider key="d" />, <Html key="h" />, <Menu key="m" />, <Social key="s" />)}</Email>); + add("defaults, bare in the root", <Email><Divider /><Html /><Menu /><Social /></Email>); + add("defaults, two columns", <Email><Row layout={Elements.ColumnLayouts.TwoEqual}><Column><Menu /><Divider /></Column><Column><Social /><Html /></Column></Row></Email>); + + TEXTS.forEach((text, i) => + add( + `user text ${i}: ${JSON.stringify(text)}`, + <Email> + {row( + <Menu key="m" items={[{ text, href: `https://example.com/${text}` }, { text: `${text} 2` }]} separator={text || "|"} />, + <Social key="s" icons={[{ name: text || "Facebook", url: `https://example.com/${text}` }, { name: "X", url: text }]} />, + <Html key="h" html={`<p>${text}</p>`} />, + <Html key="h2" html={text} />, + )} + </Email>, + ), + ); + + LINKS.forEach((href, i) => + add( + `link ${i}: ${JSON.stringify(href)}`, + <Email> + {row( + <Menu key="m" items={[{ text: "Same tab", href, target: "_self" }, { text: "New tab", href, target: "_blank" }, { text: "Parent", href, target: "_parent" }, { text: "Top", href, target: "_top" }, { text: "No link" }]} separator="•" />, + <Social key="s" icons={[{ name: "Facebook", url: href }, { name: "Email", url: href.replace(/^mailto:/, "") }, { name: "Email", url: href }]} />, + )} + </Email>, + ), + ); + + // Links in the editor's own shapes (`values`): the stored link, and the link editor's render value with + // custom attributes, a click handler and a class. + const editorLinks: Record<string, unknown>[] = [ + { name: "web", values: { href: "https://example.com/stored", target: "_self" } }, + { name: "web", values: { href: "" }, attrs: { href: "https://example.com/attrs", target: "_top", rel: "noopener" } }, + { name: "email", values: { email: "hi@example.com", subject: "Hello" } }, + { name: "phone", values: { phone: "+15550100" } }, + { url: "https://example.com/render", target: "_blank", customAttrs: { "data-track": "menu", rel: "noopener noreferrer", title: 'Say "hi"', id: "m1" } }, + { url: "https://example.com/render", customAttrs: { "data-empty": "", "aria-hidden": "true" } }, + { url: "", customAttrs: { "data-empty": " ", rel: "" } }, + { url: "https://example.com/click", onClick: "track('menu', \"home\"); return true;", class: "menu-link primary" }, + { url: "", onClick: "openChat()" }, + { url: "https://example.com/{{id}}", target: "", class: "" }, + ]; + add( + "editor links", + <Email> + {row( + <Menu + key="m" + values={{ menu: { items: editorLinks.map((link, i) => ({ key: String(i + 1), text: `Item ${i + 1}`, link })) } } as never} + separator="/" + />, + )} + </Email>, + ); + + for (const layout of ["horizontal", "vertical"] as const) + for (const align of ["left", "center", "right"] as const) + add( + `menu: ${layout}, ${align}`, + <Email> + {row( + <Menu + key="m" + items={[{ text: "Home", href: "https://example.com/" }, { text: "Shop", href: "https://example.com/shop" }, { text: "Help" }]} + layout={layout} + align={align} + separator={layout === "horizontal" ? "|" : "-"} + padding="8px 12px" + fontSize="13px" + fontWeight={700} + fontFamily="Georgia, serif" + letterSpacing="1px" + linkColor="#1a73e8" + textColor="#555555" + />, + <Menu key="m2" items={[{ text: "A", href: "https://example.com/a" }, { text: "B", href: "https://example.com/b" }]} layout={layout} textAlign={align} color="#ff0000" padding={4} />, + )} + </Email>, + ); + + ICON_TYPES.forEach((iconType) => + [16, 32, "48px"].forEach((iconSize) => + (["left", "center", "right"] as const).forEach((align) => + add( + `social: ${iconType}, ${iconSize}, ${align}`, + <Email> + {row( + <Social + key="s" + icons={ICON_NAMES.map((name) => ({ name, url: `https://example.com/${name.toLowerCase()}` }))} + iconType={iconType as "circle"} + iconSize={iconSize} + spacing={iconSize === 16 ? 0 : 10} + align={align} + />, + )} + </Email>, + ), + ), + ), + ); + add("social: no icons", <Email>{row(<Social key="s" icons={[]} />, <Social key="s2" values={{ icons: { iconType: "squared", icons: [] } } as never} iconSize={20} />)}</Email>); + add( + "social: custom icons", + <Email> + {row( + <Social + key="s" + values={{ + icons: { + iconType: "circle", + icons: [{ name: "Mastodon", url: "https://example.com/@me" }, { name: "Facebook", url: "https://facebook.com/x", imgUrl: "https://example.com/fb.png" }], + editor: { data: { customIcons: [{ name: "Mastodon", url: "https://mastodon.social", icons: { circle: "https://example.com/mastodon-circle.png" } }] } }, + }, + } as never} + />, + )} + </Email>, + ); + + for (const style of ["solid", "dashed", "dotted", "double", "none"]) + for (const width of ["100%", "50%", "240px"] as const) + add( + `divider: ${style}, ${width}`, + <Email> + {row( + <Divider key="a" width={width} borderTopWidth="3px" borderTopStyle={style} borderTopColor="#cc0000" align="left" />, + <Divider key="b" width={width} border={{ borderTopWidth: 1, borderTopStyle: style, borderTopColor: "rgb(10, 20, 30)" }} textAlign="right" />, + <Divider key="c" values={{ width, border: { borderTopWidth: "10px", borderTopStyle: style, borderTopColor: "#BBBBBB" }, textAlign: "center" } as never} />, + )} + </Email>, + ); + + add( + "right-to-left", + <Email textDirection="rtl" lang="ar"> + {row( + <Menu key="m" items={[{ text: "الرئيسية", href: "https://example.com/ar" }, { text: "اتصل بنا" }]} separator="|" />, + <Social key="s" icons={[{ name: "Facebook", url: "https://facebook.com/x" }, { name: "X", url: "https://x.com/x" }]} />, + <Divider key="d" width="60%" />, + <Html key="h" html={'<p dir="rtl">مرحبا <b>بالعالم</b></p>'} />, + )} + </Email>, + ); + add( + "merge tags", + <Email> + {row( + <Menu key="m" items={[{ text: "Hi {{first_name}}", href: "{{profile_url}}" }, { text: "Unsubscribe", href: "{{unsubscribe_url}}" }]} />, + <Social key="s" icons={[{ name: "Facebook", url: "{{facebook_url}}" }, { name: "Email", url: "{{email}}" }]} />, + <Html key="h" html="<p>Hello {{first_name}}, your code is <strong>{{code}}</strong>.</p>" />, + )} + </Email>, + { mergeTagState: { first_name: "Ada" } }, + ); + add( + "phone settings", + <Email> + {row( + <Menu key="m" items={[{ text: "Home", href: "https://example.com/" }]} mobile={{ fontSize: "12px", padding: "2px" } as never} hideOnDesktop />, + <Social key="s" icons={[{ name: "X", url: "https://x.com/x" }]} hideOnMobile />, + <Divider key="d" mobile={{ width: "40%" } as never} />, + <Html key="h" html="<p>Desktop only</p>" hideOnMobile />, + )} + </Email>, + ); + add( + "toSafeHtml", + <Email>{row(<Html key="h" html={'<p onclick="x()">Hi</p><script>alert(1)</script><link rel="preload" as="image" href="https://example.com/a.png">'} />)}</Email>, + { toSafeHtml: (html: string) => html.replace(/<script[\s\S]*?<\/script>/g, "").replace(/ on\w+="[^"]*"/g, "") }, + ); + add( + "a CDN for the icons", + <UnlayerProvider config={{ cdnBaseUrl: "https://cdn.example.com/assets" }}> + <Email>{row(<Social key="s" icons={[{ name: "Facebook", url: "https://facebook.com/x" }]} />, <Social key="s2" icons={[]} />)}</Email> + </UnlayerProvider>, + ); + return out; +} + +/** The templates, in order. */ +export function corpus(): Template[] { + return [...values(), ...designs(), ...stories()]; +} diff --git a/packages/react/src/font/font.test.tsx b/packages/react/src/font/font.test.tsx new file mode 100644 index 00000000..7aec920a --- /dev/null +++ b/packages/react/src/font/font.test.tsx @@ -0,0 +1,126 @@ +import React from "react"; +import { describe, expect, it } from "vitest"; +import { Column, Email, Heading, Page, Paragraph, Row, renderToHtml, renderToHtmlParts, renderToJson } from "../index"; +import { Font } from "./index"; + +const SHEET = "https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap"; +const content = ( + <Row> + <Column> + <Paragraph>Hello</Paragraph> + </Column> + </Row> +); + +describe("Font", () => { + it("loads a stylesheet in the head, out of Outlook's sight in an email, and is the template's font", () => { + const email = ( + <Email> + <Font family="Inter" url={SHEET} fallback="Arial, sans-serif" default /> + {content} + </Email> + ); + const html = renderToHtml(email); + const head = html.slice(0, html.indexOf("</head>")); + expect(head).toContain(`<!--[if !mso]><!--><link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet" type="text/css"><!--<![endif]-->`); + // On the content, as the root's own fontFamily is. + expect(html.slice(html.indexOf("<body"))).toContain("font-family:'Inter', Arial, sans-serif;"); + expect(renderToJson(email).body.values.fontFamily).toEqual({ label: "Inter", value: "'Inter', Arial, sans-serif", url: SHEET }); + }); + + it("declares a font file with @font-face, as written on a web page", () => { + const page = ( + <Page> + <Font family="Brand Sans" url="https://cdn.example.com/brand.woff2" weight={700} style="italic" /> + <Font family="Mono" url="https://cdn.example.com/mono" format="woff" /> + {content} + </Page> + ); + const { tags, head } = renderToHtmlParts(page); + expect(tags).toEqual([ + "<style type=\"text/css\">@font-face { font-family: 'Brand Sans'; src: url('https://cdn.example.com/brand.woff2'); font-weight: 700; font-style: italic; }</style>", + "<style type=\"text/css\">@font-face { font-family: 'Mono'; src: url('https://cdn.example.com/mono') format('woff'); font-weight: 400; font-style: normal; }</style>", + ]); + expect(head).toContain("@font-face { font-family: 'Brand Sans'"); + // Not `default`: the root's font is its own. + expect(renderToJson(page).body.values.fontFamily.label).toBe("Arial"); + }); + + it("gives way to the root's own fontFamily, and works from a component of yours", () => { + const Fonts = () => <Font family="Inter" url={SHEET} default />; + const email = ( + <Email fontFamily="Georgia, serif"> + <Fonts /> + {content} + </Email> + ); + expect(renderToJson(email).body.values.fontFamily).toEqual({ label: "Georgia, serif", value: "Georgia, serif" }); + expect(renderToHtml(email)).toContain("fonts.googleapis.com"); + const json = renderToJson( + <Email> + <Fonts /> + {content} + </Email> + ); + expect(json.body.values.fontFamily.label).toBe("Inter"); + // It adds no block to the design. + expect(json.body.rows).toHaveLength(1); + }); + + it("puts its URL on the design's fonts of its family, as the editor keeps a web font, so the editor loads it too", () => { + const FRAUNCES = "https://fonts.googleapis.com/css2?family=Fraunces:wght@500&display=swap"; + const json = renderToJson( + <Email fontFamily="'Fraunces', serif"> + <Font family="Fraunces" url={FRAUNCES} fallback="Georgia, serif" /> + <Font family="Brand Sans" url="https://cdn.example.com/brand.woff2" /> + <Row> + <Column> + <Heading fontFamily="Fraunces, Georgia, 'Times New Roman', serif">Serif</Heading> + <Paragraph fontFamily={{ label: "Fraunces", value: "fraunces, serif", url: "https://example.com/own.css" }}>Its own</Paragraph> + <Paragraph fontFamily="Georgia, serif">Another family</Paragraph> + <Paragraph fontFamily="'Brand Sans', sans-serif">A font file</Paragraph> + </Column> + </Row> + </Email> + ); + const [heading, own, georgia, file] = json.body.rows[0].columns[0].contents.map((content) => content.values.fontFamily); + expect(json.body.values.fontFamily).toEqual({ label: "'Fraunces', serif", value: "'Fraunces', serif", url: FRAUNCES }); + expect(heading).toEqual({ label: "Fraunces, Georgia, 'Times New Roman', serif", value: "Fraunces, Georgia, 'Times New Roman', serif", url: FRAUNCES }); + // A URL of its own stays; another family, and a font file (an @font-face, which the editor has no place for), get none. + expect(own.url).toBe("https://example.com/own.css"); + expect(georgia.url).toBeUndefined(); + expect(file.url).toBeUndefined(); + }); + + it("can't end its tags or CSS with what a family or URL holds", () => { + const { tags } = renderToHtmlParts( + <Page> + <Font family={"X'; } body { color: red"} url={"https://x.com/a.woff2') ; </style><script>"} format="woff2" /> + {content} + </Page> + ); + expect(tags[0]).toBe("<style type=\"text/css\">@font-face { font-family: 'X body color: red'; src: url('https://x.com/a.woff2%27%29%20;%20%3c/style%3e%3cscript%3e') format('woff2'); font-weight: 400; font-style: normal; }</style>"); + }); + + it("reads a family as a browser reads it in a style attribute: no character reference or control character in it ends its declaration", () => { + for (const family of ["X'; color: red", "X'; color: red", "X'\n; color: red", "X'\u0000; color: red"]) { + const html = renderToHtml(<Email><Font family={family} url="https://fonts.example.com/x.css" fallback={"Arial; color: red"} default />{content}</Email>); + const doc = new DOMParser().parseFromString(html, "text/html"); + expect([family, [...doc.querySelectorAll<HTMLElement>("[style]")].filter((el) => el.style.color === "red").length]).toEqual([family, 0]); + } + }); + + it("loads nothing from a URL that runs code (`javascript:`, as React tells one), a stylesheet or a font file", () => { + for (const url of ["javascript:alert(1)", " JavaScript:alert(1)", "java\tscript:alert(1)", "javascript:alert(1)//x.woff2"]) { + for (const Root of [Email, Page]) { + const { tags } = renderToHtmlParts( + <Root> + <Font family="Inter" url={url} /> + {content} + </Root> + ); + expect([url, tags.filter((tag) => /javascript/i.test(tag))]).toEqual([url, []]); + } + } + }); +}); diff --git a/packages/react/src/font/index.tsx b/packages/react/src/font/index.tsx new file mode 100644 index 00000000..8ace22ec --- /dev/null +++ b/packages/react/src/font/index.tsx @@ -0,0 +1,42 @@ +/** + * @unlayer/react-elements/font: `<Font>`, a web font for a template. + */ +import type React from "react"; +import { headEntry } from "../head/record"; +import { fontFamilyOf, fontTags, fontUrlInDesign, primaryFamily, type FontProps } from "../utils/font-design"; + +export { fontFamilyOf, fontTags, type FontProps }; + +function fontInDesign(font: FontProps, body: Parameters<typeof fontUrlInDesign>[2]): void { + const { url, label } = fontFamilyOf(font); + const family = primaryFamily(label); + if (url && family) fontUrlInDesign(family, url, body); +} + +/** + * Font - Declares a web font for the template. Place it in an Email, Page or Document (or a component of yours + * there): the document's head loads it (the stylesheet's `<link>`, or an `@font-face` for a font file), and with + * `default` it's the root's `fontFamily`, in the HTML and the design. Outlook ignores web fonts: it shows `fallback`. + * + * @example + * ```tsx + * <Email> + * <Font family="Inter" url="https://fonts.googleapis.com/css2?family=Inter:wght@400;700" fallback="Arial, sans-serif" default /> + * <Row>…</Row> + * </Email> + * ``` + */ +export function Font(_: FontProps): React.ReactElement | null { + // What it gives the head and the root is recorded where the template is collected; it renders nothing. + return null; +} + +Font.displayName = "Font"; +headEntry(Font, (props: FontProps) => ({ + kind: "head", + tags: (mode: string) => fontTags(props, mode), + root: props.default ? { fontFamily: fontFamilyOf(props) } : undefined, + design: (body: Parameters<typeof fontInDesign>[1]) => fontInDesign(props, body), +})); + +export default Font; diff --git a/packages/react/src/head/head.test.tsx b/packages/react/src/head/head.test.tsx new file mode 100644 index 00000000..49d9c8fa --- /dev/null +++ b/packages/react/src/head/head.test.tsx @@ -0,0 +1,322 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { JSDOM, VirtualConsole } from "jsdom"; +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest"; +import { Column, Document, Email, Page, Paragraph, RenderError, Row, renderToHtml, renderToHtmlParts, renderToJson, renderToPlainText } from "../index"; +import * as asyncRenderers from "../async"; +import { Head } from "./index"; + +let warn: MockInstance; +beforeEach(() => { + warn = vi.spyOn(console, "warn").mockImplementation(() => {}); +}); +afterEach(() => warn.mockRestore()); + +const content = ( + <Row> + <Column> + <Paragraph>Hello</Paragraph> + </Column> + </Row> +); +const tags = ( + <Head> + <meta name="color-scheme" content="light dark" /> + <link rel="preconnect" href="https://fonts.gstatic.com" /> + <style>{`@media (prefers-color-scheme: dark) { .x > a { color: #fff !important; } }`}</style> + <title>Hi & bye + +); +/** Random head contents the fuzz renders (`HEAD_FUZZ_RUNS=5000` for a longer run, `HEAD_FUZZ_SEED` for others). */ +const HEAD_FUZZ_RUNS = Number(process.env.HEAD_FUZZ_RUNS ?? 150); + +/** A window where scripts run, whose document a parse writes into (one: a window of jsdom's each would add up). */ +let scripted: Window | undefined; + +/** + * What `read` reads of HTML as a browser parses it: where scripts don't run, and where they do (a ", "", "", "", "", "", "", "", "", "", "", + "", "", "", "", "<", "&", "\\", "'", '"', "{", "}", "", "/", " ", "x", + ]; + const tags = ["style", "style", "script", "script", "title", "noscript", "svg", "math", "mi", "foreignObject", "desc", "b", "iframe", "xmp", "noembed", "noframes", "STYLE", "SVG"]; + type Spec = { tag: string; id: number; type?: string; children: Spec[] } | { text: number; how: "string" | "component" | "list" | "parts" }; + const Text = ({ text }: { text: string }) => text; + const node = (spec: Spec, texts: string[], key?: number): React.ReactNode => { + if ("text" in spec) { + const text = texts[spec.text]; + const half = Math.floor(text.length / 2); + if (spec.how === "string") return text; + if (spec.how === "component") return ; + if (spec.how === "parts") return {text.slice(0, half)}{text.slice(half)}; + return {[text.slice(0, half), ]}; + } + return React.createElement(spec.tag, { key, "data-t": spec.id, type: spec.tag === "script" ? spec.type : undefined }, ...spec.children.map((child, i) => node(child, texts, i))); + }; + /** Where each piece of text is (the template's element it's in, by its `data-t`), and the elements there are. */ + const layout = (document: Document) => { + const where: string[] = []; + const texts = document.createTreeWalker(document, 4 /* NodeFilter.SHOW_TEXT */); + for (let text = texts.nextNode(); text; text = texts.nextNode()) + for (const [mark] of (text as Text).data.matchAll(/([QZ])\d+\1/g)) where.push(`${mark} in ${text.parentElement?.closest("[data-t]")?.getAttribute("data-t") ?? text.parentElement?.localName}`); + const elements = [...document.querySelectorAll("*")].map((element) => element.getAttribute("data-t") ?? element.localName); + return { where: where.sort(), elements: elements.sort() }; + }; + // (The two parsers differ where they should: a