From 3fe2a3f01220bef180064792bb338a6638a06089 Mon Sep 17 00:00:00 2001 From: Patryk Zdunowski Date: Sun, 26 Apr 2026 18:17:07 +0200 Subject: [PATCH 1/3] fix(source-react-runtime): handle React types in props without skipping components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a component's props contain types typia can't resolve (React.ReactNode, React.ReactElement, etc.), fall back to the TS type checker to extract prop schemas instead of skipping the component entirely. This ensures all components get __xydUniform metadata — React-typed props show their actual type name (e.g. "ReactNode") instead of being lost. - Add per-component retry with TS type checker fallback - Add fallbackExtractSchema and tsTypeToJsonSchema helpers - Fix boolean detection (TS represents boolean as true|false union) - Add -2.vite-lib.react-types-resilience test fixture --- .../input/package.json | 8 + .../input/src/CardWrapper.tsx | 31 +++ .../input/src/StatusBadge.tsx | 21 ++ .../input/src/index.ts | 2 + .../input/tsconfig.json | 12 + .../input/vite.config.ts | 21 ++ .../output.js | 15 ++ .../__tests__/source-react-runtime.test.ts | 4 + .../xyd-source-react-runtime/src/index.ts | 225 +++++++++++++++++- 9 files changed, 333 insertions(+), 6 deletions(-) create mode 100644 packages/xyd-source-react-runtime/__fixtures__/-2.vite-lib.react-types-resilience/input/package.json create mode 100644 packages/xyd-source-react-runtime/__fixtures__/-2.vite-lib.react-types-resilience/input/src/CardWrapper.tsx create mode 100644 packages/xyd-source-react-runtime/__fixtures__/-2.vite-lib.react-types-resilience/input/src/StatusBadge.tsx create mode 100644 packages/xyd-source-react-runtime/__fixtures__/-2.vite-lib.react-types-resilience/input/src/index.ts create mode 100644 packages/xyd-source-react-runtime/__fixtures__/-2.vite-lib.react-types-resilience/input/tsconfig.json create mode 100644 packages/xyd-source-react-runtime/__fixtures__/-2.vite-lib.react-types-resilience/input/vite.config.ts create mode 100644 packages/xyd-source-react-runtime/__fixtures__/-2.vite-lib.react-types-resilience/output.js diff --git a/packages/xyd-source-react-runtime/__fixtures__/-2.vite-lib.react-types-resilience/input/package.json b/packages/xyd-source-react-runtime/__fixtures__/-2.vite-lib.react-types-resilience/input/package.json new file mode 100644 index 00000000..a2ad61c6 --- /dev/null +++ b/packages/xyd-source-react-runtime/__fixtures__/-2.vite-lib.react-types-resilience/input/package.json @@ -0,0 +1,8 @@ +{ + "name": "xyd-fixture-rt-9-vite-lib-react-types-resilience", + "private": true, + "type": "module", + "scripts": { + "build": "vite build" + } +} diff --git a/packages/xyd-source-react-runtime/__fixtures__/-2.vite-lib.react-types-resilience/input/src/CardWrapper.tsx b/packages/xyd-source-react-runtime/__fixtures__/-2.vite-lib.react-types-resilience/input/src/CardWrapper.tsx new file mode 100644 index 00000000..5bade1da --- /dev/null +++ b/packages/xyd-source-react-runtime/__fixtures__/-2.vite-lib.react-types-resilience/input/src/CardWrapper.tsx @@ -0,0 +1,31 @@ +import React from "react"; + +interface CardWrapperProps { + /** Card title */ + title: string; + + /** Card content — React types that typia cannot resolve */ + children: React.ReactNode; + + /** Optional icon element */ + icon?: React.ReactElement; + + /** Optional footer element */ + footer?: React.ReactNode; + + /** Whether the card has a border */ + bordered?: boolean; +} + +export function CardWrapper({ title, children, icon, footer, bordered }: CardWrapperProps) { + return ( +
+
+ {icon && {icon}} +

{title}

+
+
{children}
+ {footer &&
{footer}
} +
+ ); +} diff --git a/packages/xyd-source-react-runtime/__fixtures__/-2.vite-lib.react-types-resilience/input/src/StatusBadge.tsx b/packages/xyd-source-react-runtime/__fixtures__/-2.vite-lib.react-types-resilience/input/src/StatusBadge.tsx new file mode 100644 index 00000000..2ec7f1d4 --- /dev/null +++ b/packages/xyd-source-react-runtime/__fixtures__/-2.vite-lib.react-types-resilience/input/src/StatusBadge.tsx @@ -0,0 +1,21 @@ +interface StatusBadgeProps { + /** Current status label */ + label: string; + + /** Visual variant */ + variant: "success" | "warning" | "error" | "info"; + + /** Size of the badge */ + size?: "small" | "medium" | "large"; + + /** Whether the badge should pulse */ + animated?: boolean; +} + +export function StatusBadge({ label, variant, size = "medium", animated }: StatusBadgeProps) { + return ( + + {label} + + ); +} diff --git a/packages/xyd-source-react-runtime/__fixtures__/-2.vite-lib.react-types-resilience/input/src/index.ts b/packages/xyd-source-react-runtime/__fixtures__/-2.vite-lib.react-types-resilience/input/src/index.ts new file mode 100644 index 00000000..448dbaeb --- /dev/null +++ b/packages/xyd-source-react-runtime/__fixtures__/-2.vite-lib.react-types-resilience/input/src/index.ts @@ -0,0 +1,2 @@ +export { StatusBadge } from "./StatusBadge"; +export { CardWrapper } from "./CardWrapper"; diff --git a/packages/xyd-source-react-runtime/__fixtures__/-2.vite-lib.react-types-resilience/input/tsconfig.json b/packages/xyd-source-react-runtime/__fixtures__/-2.vite-lib.react-types-resilience/input/tsconfig.json new file mode 100644 index 00000000..9f8bc05b --- /dev/null +++ b/packages/xyd-source-react-runtime/__fixtures__/-2.vite-lib.react-types-resilience/input/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "outDir": "./dist", + "jsx": "react-jsx", + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "target": "ES2020", + "strict": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/packages/xyd-source-react-runtime/__fixtures__/-2.vite-lib.react-types-resilience/input/vite.config.ts b/packages/xyd-source-react-runtime/__fixtures__/-2.vite-lib.react-types-resilience/input/vite.config.ts new file mode 100644 index 00000000..a683fe16 --- /dev/null +++ b/packages/xyd-source-react-runtime/__fixtures__/-2.vite-lib.react-types-resilience/input/vite.config.ts @@ -0,0 +1,21 @@ +import {defineConfig} from 'vite'; +import react from '@vitejs/plugin-react'; +import {xydSourceReactRuntime} from '@xyd-js/source-react-runtime'; + +export default defineConfig({ + plugins: [ + xydSourceReactRuntime(), + react(), + ], + build: { + lib: { + entry: 'src/index.ts', + formats: ['es'], + fileName: 'index', + }, + rollupOptions: { + external: ['react', 'react/jsx-runtime', 'react/jsx-dev-runtime', 'react-dom'], + }, + minify: false, + }, +}); diff --git a/packages/xyd-source-react-runtime/__fixtures__/-2.vite-lib.react-types-resilience/output.js b/packages/xyd-source-react-runtime/__fixtures__/-2.vite-lib.react-types-resilience/output.js new file mode 100644 index 00000000..de11d6f6 --- /dev/null +++ b/packages/xyd-source-react-runtime/__fixtures__/-2.vite-lib.react-types-resilience/output.js @@ -0,0 +1,15 @@ +// === index.js === +import { jsx, jsxs } from "react/jsx-runtime"; +function StatusBadge({ label, variant, size = "medium", animated }) { + return jsx("span", { className: `badge badge-${variant} badge-${size} ${animated ? "pulse" : ""}`, children: label }); +} +StatusBadge.__xydUniform = JSON.parse('{"title":"StatusBadge","canonical":"","description":"","definitions":[{"title":"Props","properties":[{"name":"label","type":"string","description":"Current status label","meta":[{"name":"required","value":"true"}]},{"name":"variant","type":"$xor","description":"Visual variant","properties":[{"name":"variant","type":"object","description":"","meta":[{"name":"required","value":"true"}]},{"name":"variant","type":"object","description":"","meta":[{"name":"required","value":"true"}]},{"name":"variant","type":"object","description":"","meta":[{"name":"required","value":"true"}]},{"name":"variant","type":"object","description":"","meta":[{"name":"required","value":"true"}]}],"meta":[{"name":"required","value":"true"}]},{"name":"size","type":"$xor","description":"Size of the badge","properties":[{"name":"size","type":"object","description":"","meta":[]},{"name":"size","type":"object","description":"","meta":[]},{"name":"size","type":"object","description":"","meta":[]}],"meta":[]},{"name":"animated","type":"boolean","description":"Whether the badge should pulse","meta":[]}],"meta":[{"name":"type","value":"parameters"}]}],"examples":{"groups":[]}}'); +function CardWrapper({ title, children, icon, footer, bordered }) { + return jsxs("div", { className: `card ${bordered ? "bordered" : ""}`, children: [jsxs("div", { className: "card-header", children: [icon && jsx("span", { className: "card-icon", children: icon }), jsx("h3", { children: title })] }), jsx("div", { className: "card-body", children }), footer && jsx("div", { className: "card-footer", children: footer })] }); +} +CardWrapper.__xydUniform = JSON.parse('{"title":"CardWrapper","canonical":"","description":"","definitions":[{"title":"Props","properties":[{"name":"title","type":"string","description":"Card title","meta":[{"name":"required","value":"true"}]},{"name":"children","type":"ReactNode","description":"Card content — React types that typia cannot resolve","meta":[{"name":"required","value":"true"}]},{"name":"icon","type":"ReactElement> | undefined","description":"Optional icon element","meta":[]},{"name":"footer","type":"ReactNode","description":"Optional footer element","meta":[]},{"name":"bordered","type":"boolean","description":"Whether the card has a border","meta":[]}],"meta":[{"name":"type","value":"parameters"}]}],"examples":{"groups":[]}}'); +export { + CardWrapper, + StatusBadge +}; + diff --git a/packages/xyd-source-react-runtime/__tests__/source-react-runtime.test.ts b/packages/xyd-source-react-runtime/__tests__/source-react-runtime.test.ts index a82c720e..fee0e5bc 100644 --- a/packages/xyd-source-react-runtime/__tests__/source-react-runtime.test.ts +++ b/packages/xyd-source-react-runtime/__tests__/source-react-runtime.test.ts @@ -50,6 +50,10 @@ const tests: { name: '8.tanstack-router.app', description: 'tanstack router: file-based routing with createFileRoute, Link, useNavigate', }, + { + name: '-2.vite-lib.react-types-resilience', + description: 'vite lib: components with React types in props do not break sibling components', + }, ]; describe('xyd-source-react-runtime', () => { diff --git a/packages/xyd-source-react-runtime/src/index.ts b/packages/xyd-source-react-runtime/src/index.ts index 19108ef2..21f4f331 100644 --- a/packages/xyd-source-react-runtime/src/index.ts +++ b/packages/xyd-source-react-runtime/src/index.ts @@ -137,7 +137,99 @@ async function buildTypiaSchemas(tsconfigPath: string): Promise !c.propsType.includes('React.')); + if (supported.length === 0) continue; + + const original = ts.sys.readFile(fileName)!; + let mergedOutput = ''; + + for (const comp of supported) { + const singleModified = new Map(modifiedSources); + // Replace this file's modified source with one that only has this component's typia call + const singleSource = `import typia from "typia";\n${original}\n${comp.name}.__xydSchema = typia.json.schemas<[${comp.propsType}]>();`; + singleModified.set(fileName, singleSource); + + const singleResult = emitWithTypia(ts, parsedConfig, singleModified, typiaTransformModule, new Map([[fileName, [comp]]])); + const singleOutput = singleResult.get(path.resolve(fileName)); + + if (singleOutput) { + // Extract the __xydSchema assignment line from successful output + const schemaMatch = singleOutput.match(new RegExp(`${comp.name}\\.__xydSchema\\s*=\\s*\\{[\\s\\S]*?\\n\\};`)); + if (schemaMatch) { + if (!mergedOutput) { + // Use the first successful output as the base (it has the full compiled file) + mergedOutput = singleOutput; + } else { + // Append the schema assignment to the base output + mergedOutput += `\n${schemaMatch[0]}`; + } + } + } else { + // Typia can't handle this component's props (e.g. React types) — + // fall back to TS type checker to extract the schema manually + if (!cleanProgram) { + cleanProgram = ts.createProgram({ + rootNames: parsedConfig.fileNames, + options: {...parsedConfig.options, noEmit: false, declaration: false, sourceMap: false}, + }); + } + + // Get base compiled output if we don't have one yet + if (!mergedOutput) { + const sf = cleanProgram.getSourceFile(fileName); + if (sf) { + cleanProgram.emit(sf, (_name, text) => { + if (!_name.endsWith('.d.ts')) mergedOutput = text; + }); + } + } + + const schema = fallbackExtractSchema(ts, cleanProgram, fileName, comp); + if (schema) { + const schemaStr = JSON.stringify(schema, null, 2); + mergedOutput += `\n${comp.name}.__xydSchema = ${schemaStr};`; + } else { + console.warn(`[xyd-source-react-runtime] could not extract schema for ${comp.name} in ${fileName}, skipping component`); + } + } + } + + if (mergedOutput) { + result.set(path.resolve(fileName), mergedOutput); + } + } + + return result; +} + +/** + * Creates a TS program with modified sources and emits typia-transformed output. + * Returns a map of resolved file paths to their transformed output. + * Files that fail during transform are silently skipped (returns empty map for them). + */ +function emitWithTypia( + ts: typeof import('typescript'), + parsedConfig: import('typescript').ParsedCommandLine, + modifiedSources: Map, + typiaTransformModule: any, + componentsByFile: Map, +): Map { + const result = new Map(); + const compilerHost = ts.createCompilerHost(parsedConfig.options); const origGetSourceFile = compilerHost.getSourceFile; const origFileExists = compilerHost.fileExists; @@ -174,7 +266,6 @@ async function buildTypiaSchemas(tsconfigPath: string): Promise | null { + const checker = program.getTypeChecker(); + const sourceFile = program.getSourceFile(fileName); + if (!sourceFile) return null; + + let propsType: import('typescript').Type | undefined; + + const findPropsInNode = (node: import('typescript').Node) => { + if (propsType) return; + + if (ts.isFunctionDeclaration(node) && node.name?.text === comp.name) { + const param = node.parameters[0]; + if (param?.type) propsType = checker.getTypeAtLocation(param.type); + } + + if (ts.isVariableStatement(node)) { + for (const decl of node.declarationList.declarations) { + if (!ts.isIdentifier(decl.name) || decl.name.text !== comp.name) continue; + if (decl.initializer && (ts.isArrowFunction(decl.initializer) || ts.isFunctionExpression(decl.initializer))) { + const param = decl.initializer.parameters[0]; + if (param?.type) propsType = checker.getTypeAtLocation(param.type); + } + } + } + }; + + ts.forEachChild(sourceFile, findPropsInNode); + if (!propsType) return null; + + const properties: Record = {}; + const required: string[] = []; + + for (const symbol of checker.getPropertiesOfType(propsType)) { + const name = symbol.getName(); + const memberType = checker.getTypeOfSymbolAtLocation(symbol, sourceFile); + const description = ts.displayPartsToString(symbol.getDocumentationComment(checker)); + const isOptional = (symbol.flags & ts.SymbolFlags.Optional) !== 0; + + const schema = tsTypeToJsonSchema(ts, checker, memberType, sourceFile); + if (description) schema.description = description; + + properties[name] = schema; + if (!isOptional) required.push(name); + } + + const rootSchema: any = { type: 'object', properties }; + if (required.length > 0) rootSchema.required = required; + + return { + version: '3.1', + components: { schemas: { [comp.propsType]: rootSchema } }, + schemas: [{ $ref: `#/components/schemas/${comp.propsType}` }], + }; +} + +/** + * Converts a TS type to a JSON Schema object using the type checker. + * Handles primitives, string literal unions, arrays, and falls back to + * { type: "object", description } for complex types (React, DOM, etc.). + */ +function tsTypeToJsonSchema( + ts: typeof import('typescript'), + checker: import('typescript').TypeChecker, + type: import('typescript').Type, + location: import('typescript').Node, +): any { + const typeStr = checker.typeToString(type); + + if (type.flags & ts.TypeFlags.String) return {type: 'string'}; + if (type.flags & ts.TypeFlags.Number) return {type: 'number'}; + if (type.flags & (ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral)) return {type: 'boolean'}; + if (type.flags & ts.TypeFlags.Null) return {type: 'null'}; + if (type.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) return {}; + if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) return {}; + + if (type.isStringLiteral()) return {type: 'string', const: type.value}; + if (type.isNumberLiteral()) return {type: 'number', const: type.value}; + + if (type.isUnion()) { + const types = type.types.filter(t => !(t.flags & ts.TypeFlags.Undefined)); + // boolean is internally `true | false` — detect and collapse it + if (types.length <= 2 && types.every(t => !!(t.flags & ts.TypeFlags.BooleanLiteral))) { + return {type: 'boolean'}; + } + if (types.every(t => t.isStringLiteral())) { + return { + oneOf: types.map(t => ({type: 'string', const: (t as import('typescript').StringLiteralType).value})), + }; + } + // Simple unions with only primitives/literals + if (types.length <= 4 && types.every(t => + (t.flags & (ts.TypeFlags.String | ts.TypeFlags.Number | ts.TypeFlags.Boolean | + ts.TypeFlags.BooleanLiteral | ts.TypeFlags.Null)) !== 0 || + t.isStringLiteral() || t.isNumberLiteral() + )) { + if (types.length === 1) return tsTypeToJsonSchema(ts, checker, types[0], location); + return {oneOf: types.map(t => tsTypeToJsonSchema(ts, checker, t, location))}; + } + return {type: typeStr}; + } + + // Simple array types + if (typeStr === 'string[]') return {type: 'array', items: {type: 'string'}}; + if (typeStr === 'number[]') return {type: 'array', items: {type: 'number'}}; + if (typeStr === 'boolean[]') return {type: 'array', items: {type: 'boolean'}}; + if (typeStr.endsWith('[]') || typeStr.startsWith('Array<')) return {type: 'array'}; + + // Function types — use the signature as the type + const signatures = checker.getSignaturesOfType(type, ts.SignatureKind.Call); + if (signatures.length > 0) return {type: typeStr}; + + // Everything else — React types, DOM, Map, Set, Date, Record, etc. + return {type: typeStr}; +} + /** * Detects exported React components and their props type names using TS AST. */ From 02b2eae0e6458497d625317c04bd9eff643cf1a4 Mon Sep 17 00:00:00 2001 From: Patryk Zdunowski Date: Sun, 26 Apr 2026 18:35:47 +0200 Subject: [PATCH 2/3] test(source-react-runtime): add multi-entry iframe fixture Adds -3.vite-app.iframe-multi-entry test: a Vite app with two HTML entries where the main app loads an iframe pointing to a separate React app entry. Verifies __xydUniform is injected across both entries, including components with React types in props (UserCard with ReactElement/ReactNode) via the type checker fallback. --- .../input/index.html | 12 ++ .../input/package.json | 8 ++ .../input/sample-app.html | 10 ++ .../input/src/main.tsx | 38 +++++++ .../input/src/sample-app-entry.ts | 7 ++ .../input/src/sample-app.tsx | 105 ++++++++++++++++++ .../input/tsconfig.json | 12 ++ .../input/vite.config.ts | 20 ++++ .../-3.vite-app.iframe-multi-entry/output.js | 48 ++++++++ .../__tests__/source-react-runtime.test.ts | 4 + 10 files changed, 264 insertions(+) create mode 100644 packages/xyd-source-react-runtime/__fixtures__/-3.vite-app.iframe-multi-entry/input/index.html create mode 100644 packages/xyd-source-react-runtime/__fixtures__/-3.vite-app.iframe-multi-entry/input/package.json create mode 100644 packages/xyd-source-react-runtime/__fixtures__/-3.vite-app.iframe-multi-entry/input/sample-app.html create mode 100644 packages/xyd-source-react-runtime/__fixtures__/-3.vite-app.iframe-multi-entry/input/src/main.tsx create mode 100644 packages/xyd-source-react-runtime/__fixtures__/-3.vite-app.iframe-multi-entry/input/src/sample-app-entry.ts create mode 100644 packages/xyd-source-react-runtime/__fixtures__/-3.vite-app.iframe-multi-entry/input/src/sample-app.tsx create mode 100644 packages/xyd-source-react-runtime/__fixtures__/-3.vite-app.iframe-multi-entry/input/tsconfig.json create mode 100644 packages/xyd-source-react-runtime/__fixtures__/-3.vite-app.iframe-multi-entry/input/vite.config.ts create mode 100644 packages/xyd-source-react-runtime/__fixtures__/-3.vite-app.iframe-multi-entry/output.js diff --git a/packages/xyd-source-react-runtime/__fixtures__/-3.vite-app.iframe-multi-entry/input/index.html b/packages/xyd-source-react-runtime/__fixtures__/-3.vite-app.iframe-multi-entry/input/index.html new file mode 100644 index 00000000..fd01349d --- /dev/null +++ b/packages/xyd-source-react-runtime/__fixtures__/-3.vite-app.iframe-multi-entry/input/index.html @@ -0,0 +1,12 @@ + + + + + + Main App + + +
+ + + diff --git a/packages/xyd-source-react-runtime/__fixtures__/-3.vite-app.iframe-multi-entry/input/package.json b/packages/xyd-source-react-runtime/__fixtures__/-3.vite-app.iframe-multi-entry/input/package.json new file mode 100644 index 00000000..a9c7b371 --- /dev/null +++ b/packages/xyd-source-react-runtime/__fixtures__/-3.vite-app.iframe-multi-entry/input/package.json @@ -0,0 +1,8 @@ +{ + "name": "xyd-fixture-rt-neg3-vite-app-iframe-multi-entry", + "private": true, + "type": "module", + "scripts": { + "build": "vite build" + } +} diff --git a/packages/xyd-source-react-runtime/__fixtures__/-3.vite-app.iframe-multi-entry/input/sample-app.html b/packages/xyd-source-react-runtime/__fixtures__/-3.vite-app.iframe-multi-entry/input/sample-app.html new file mode 100644 index 00000000..af385a83 --- /dev/null +++ b/packages/xyd-source-react-runtime/__fixtures__/-3.vite-app.iframe-multi-entry/input/sample-app.html @@ -0,0 +1,10 @@ + + + + + + +
+ + + diff --git a/packages/xyd-source-react-runtime/__fixtures__/-3.vite-app.iframe-multi-entry/input/src/main.tsx b/packages/xyd-source-react-runtime/__fixtures__/-3.vite-app.iframe-multi-entry/input/src/main.tsx new file mode 100644 index 00000000..51c6f446 --- /dev/null +++ b/packages/xyd-source-react-runtime/__fixtures__/-3.vite-app.iframe-multi-entry/input/src/main.tsx @@ -0,0 +1,38 @@ +import { createRoot } from "react-dom/client"; + +interface NavBarProps { + /** Application title */ + title: string; + /** Navigation links */ + links: string[]; + /** Whether to use dark theme */ + dark?: boolean; +} + +export function NavBar({ title, links, dark }: NavBarProps) { + return ( + + ); +} + +function App() { + return ( +
+ +