From 0f284a23c8009178260d5b3531da8fadec0e74a2 Mon Sep 17 00:00:00 2001 From: Eugenio Paluello Date: Tue, 4 Aug 2026 18:17:19 +0200 Subject: [PATCH 01/11] chore: scaffold @parity/truapi-react-renderer workspace package --- js/packages/truapi-react-renderer/.gitignore | 3 + js/packages/truapi-react-renderer/LICENSE | 21 +++++ .../truapi-react-renderer/package.json | 46 +++++++++++ .../truapi-react-renderer/src/helpers.ts | 3 + .../truapi-react-renderer/tsconfig.json | 21 +++++ package-lock.json | 80 +++++++++++++++++++ 6 files changed, 174 insertions(+) create mode 100644 js/packages/truapi-react-renderer/.gitignore create mode 100644 js/packages/truapi-react-renderer/LICENSE create mode 100644 js/packages/truapi-react-renderer/package.json create mode 100644 js/packages/truapi-react-renderer/src/helpers.ts create mode 100644 js/packages/truapi-react-renderer/tsconfig.json diff --git a/js/packages/truapi-react-renderer/.gitignore b/js/packages/truapi-react-renderer/.gitignore new file mode 100644 index 000000000..4d6f8eee0 --- /dev/null +++ b/js/packages/truapi-react-renderer/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*.tsbuildinfo +dist/ diff --git a/js/packages/truapi-react-renderer/LICENSE b/js/packages/truapi-react-renderer/LICENSE new file mode 100644 index 000000000..ad207e8ab --- /dev/null +++ b/js/packages/truapi-react-renderer/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Parity Technologies + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/js/packages/truapi-react-renderer/package.json b/js/packages/truapi-react-renderer/package.json new file mode 100644 index 000000000..321ff187b --- /dev/null +++ b/js/packages/truapi-react-renderer/package.json @@ -0,0 +1,46 @@ +{ + "name": "@parity/truapi-react-renderer", + "version": "0.0.0", + "description": "React renderer for TrUAPI custom chat messages: maps a React component tree to CustomRendererNode widget trees", + "license": "MIT", + "author": "Parity Technologies ", + "repository": { + "type": "git", + "url": "git+https://github.com/paritytech/truapi.git", + "directory": "js/packages/truapi-react-renderer" + }, + "homepage": "https://github.com/paritytech/truapi#readme", + "bugs": { + "url": "https://github.com/paritytech/truapi/issues" + }, + "type": "module", + "sideEffects": false, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": ["dist", "README.md", "LICENSE"], + "scripts": { + "build": "tsc -b", + "typecheck": "npm run build", + "test": "bun test" + }, + "dependencies": { + "@parity/truapi": "^0.7.0", + "react-reconciler": "0.33.0" + }, + "peerDependencies": { + "react": "^19.0.0" + }, + "devDependencies": { + "@types/bun": "^1.3.0", + "@types/react": "^19.0.0", + "@types/react-reconciler": "0.33.0", + "react": "^19.0.0", + "typescript": "^5.7" + } +} diff --git a/js/packages/truapi-react-renderer/src/helpers.ts b/js/packages/truapi-react-renderer/src/helpers.ts new file mode 100644 index 000000000..6af62335d --- /dev/null +++ b/js/packages/truapi-react-renderer/src/helpers.ts @@ -0,0 +1,3 @@ +export const noop = (): void => { + /* empty */ +}; diff --git a/js/packages/truapi-react-renderer/tsconfig.json b/js/packages/truapi-react-renderer/tsconfig.json new file mode 100644 index 000000000..98ea26d8d --- /dev/null +++ b/js/packages/truapi-react-renderer/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "composite": true, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "lib": ["ES2022", "DOM"] + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], + "references": [{ "path": "../truapi" }] +} diff --git a/package-lock.json b/package-lock.json index 920b8a36f..d8682d6a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,25 @@ "typescript": "^5.7" } }, + "js/packages/truapi-react-renderer": { + "name": "@parity/truapi-react-renderer", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@parity/truapi": "^0.7.0", + "react-reconciler": "0.33.0" + }, + "devDependencies": { + "@types/bun": "^1.3.0", + "@types/react": "^19.0.0", + "@types/react-reconciler": "0.33.0", + "react": "^19.0.0", + "typescript": "^5.7" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, "js/packages/truapi/node_modules/typescript": { "version": "6.0.3", "dev": true, @@ -431,6 +450,10 @@ "resolved": "js/packages/truapi-host", "link": true }, + "node_modules/@parity/truapi-react-renderer": { + "resolved": "js/packages/truapi-react-renderer", + "link": true + }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", @@ -467,6 +490,26 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/ansi-colors": { "version": "4.1.3", "dev": true, @@ -546,6 +589,13 @@ "node": ">= 8" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/detect-indent": { "version": "6.1.0", "dev": true, @@ -1004,6 +1054,30 @@ ], "license": "MIT" }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, "node_modules/read-yaml-file": { "version": "1.1.0", "dev": true, @@ -1086,6 +1160,12 @@ "version": "1.6.1", "license": "MIT" }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.8.0", "dev": true, From 14b4072a3c1ee24e8ef41f75bb63e5553b637f1f Mon Sep 17 00:00:00 2001 From: Eugenio Paluello Date: Tue, 4 Aug 2026 18:19:18 +0200 Subject: [PATCH 02/11] test: port renderer and component specs to bun:test with TrUAPI shapes --- .../src/components.test.tsx | 340 ++++++++++++++++++ .../src/renderer.test.tsx | 287 +++++++++++++++ 2 files changed, 627 insertions(+) create mode 100644 js/packages/truapi-react-renderer/src/components.test.tsx create mode 100644 js/packages/truapi-react-renderer/src/renderer.test.tsx diff --git a/js/packages/truapi-react-renderer/src/components.test.tsx b/js/packages/truapi-react-renderer/src/components.test.tsx new file mode 100644 index 000000000..e767e23a7 --- /dev/null +++ b/js/packages/truapi-react-renderer/src/components.test.tsx @@ -0,0 +1,340 @@ +// @ts-expect-error Untyped +globalThis["IS_REACT_ACT_ENVIRONMENT"] = true; + +import { str } from "@parity/truapi/scale"; +import { describe, expect, it, mock } from "bun:test"; +import type { ReactNode } from "react"; +import { act } from "react"; + +import { + Box, + Button, + Column, + Row, + Spacer, + Text, + TextField, +} from "./components.js"; +import type { ActionCallback } from "./context.js"; +import { createRenderer } from "./renderer.js"; + +function makeActionBus() { + let listener: ActionCallback | undefined; + + const subscribeActions = mock((cb: ActionCallback) => { + listener = cb; + return () => { + listener = undefined; + }; + }); + + function dispatch(actionId: string, payload?: Uint8Array) { + listener?.(actionId, payload); + } + + return { subscribeActions, dispatch }; +} + +/** + * Mounts a single element and returns the serialized root node plus a dispatch helper. + */ +async function mount(element: ReactNode) { + const bus = makeActionBus(); + const onRender = mock((_node: unknown) => {}); + const renderer = createRenderer({ + onRender, + subscribeActions: bus.subscribeActions, + }); + + await act(async () => { + renderer.mount(element); + }); + + function lastNode() { + return onRender.mock.calls[onRender.mock.calls.length - 1]![0] as any; + } + + async function dispatchAction(actionId: string, payload?: Uint8Array) { + await act(async () => { + bus.dispatch(actionId, payload); + }); + } + + return { node: lastNode(), lastNode, dispatchAction, onRender, renderer }; +} + +describe("custom components", () => { + describe("Box", () => { + it("serializes contentAlignment prop", async () => { + const { node } = await mount(); + expect(node.tag).toBe("Box"); + expect(node.value.props.contentAlignment).toBe("Center"); + }); + + it("passes children through", async () => { + const { node } = await mount( + + child + , + ); + expect(node.value.children).toHaveLength(1); + expect(node.value.children[0].tag).toBe("Text"); + }); + }); + + describe("Column", () => { + it("serializes horizontalAlignment and verticalArrangement props", async () => { + const { node } = await mount( + , + ); + expect(node.tag).toBe("Column"); + expect(node.value.props.horizontalAlignment).toBe("Center"); + expect(node.value.props.verticalArrangement).toBe("SpaceBetween"); + }); + + it("passes children through", async () => { + const { node } = await mount( + + + + , + ); + expect(node.value.children).toHaveLength(2); + }); + }); + + describe("Row", () => { + it("serializes verticalAlignment and horizontalArrangement props", async () => { + const { node } = await mount( + , + ); + expect(node.tag).toBe("Row"); + expect(node.value.props.verticalAlignment).toBe("Center"); + expect(node.value.props.horizontalArrangement).toBe("End"); + }); + }); + + describe("Spacer", () => { + it("serializes as a Spacer node with no props", async () => { + const { node } = await mount(); + expect(node.tag).toBe("Spacer"); + }); + + it("forwards layout modifiers", async () => { + const { node } = await mount(); + const heightMod = (node.value.modifiers as any[]).find( + (m: any) => m.tag === "Height", + ); + expect(heightMod.value).toEqual({ height: 16 }); + }); + }); + + describe("Text", () => { + it("serializes style and color props", async () => { + const { node } = await mount( + , + ); + expect(node.tag).toBe("Text"); + expect(node.value.props.style).toBe("HeadlineLarge"); + expect(node.value.props.color).toBe("FgPrimary"); + }); + + it("renders text content as a String child", async () => { + const { node } = await mount(Hello world); + expect(node.value.children[0]).toEqual({ + tag: "String", + value: { text: "Hello world" }, + }); + }); + + it("forwards layout modifiers", async () => { + const { node } = await mount(hi); + const paddingMod = (node.value.modifiers as any[]).find( + (m: any) => m.tag === "Padding", + ); + expect(paddingMod.value).toEqual({ top: 8, end: 8 }); + }); + }); + + describe("Button", () => { + it("serializes text, variant, enabled and loading props", async () => { + const { node } = await mount( +