From a32d80f448a2e898cb63c2b7d545dd5e774419bd Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 1 Mar 2026 10:45:39 +0300 Subject: [PATCH] Deduplicate render internals: shared resolveToTree, remove dead code --- packages/core/src/render/index.ts | 2 +- packages/core/src/render/render-jsx.ts | 45 +-------------- packages/core/src/render/renderer.ts | 78 ++++++-------------------- packages/core/src/render/tree.ts | 65 +++++++++++---------- 4 files changed, 51 insertions(+), 139 deletions(-) diff --git a/packages/core/src/render/index.ts b/packages/core/src/render/index.ts index 62d379e4f..ad854a1b1 100644 --- a/packages/core/src/render/index.ts +++ b/packages/core/src/render/index.ts @@ -17,7 +17,7 @@ export { INTRINSIC_ELEMENTS } from './components' -export { type TreeNode, type BaseProps, type TextProps, type StyleProps, isTreeNode, node } from './tree' +export { type TreeNode, type BaseProps, type TextProps, type StyleProps, isTreeNode, node, resolveToTree } from './tree' export { renderTree, type RenderResult } from './renderer' diff --git a/packages/core/src/render/render-jsx.ts b/packages/core/src/render/render-jsx.ts index 3d567f33b..a00e94a12 100644 --- a/packages/core/src/render/render-jsx.ts +++ b/packages/core/src/render/render-jsx.ts @@ -1,52 +1,9 @@ import * as React from './mini-react' -import { isTreeNode, type TreeNode } from './tree' +import { resolveToTree, type TreeNode } from './tree' import { renderTree, type RenderResult } from './renderer' import type { SceneGraph } from '../scene-graph' -interface ReactElement { - type: unknown - props: Record -} - -function isReactElement(x: unknown): x is ReactElement { - return x !== null && typeof x === 'object' && 'type' in x && 'props' in x -} - -function resolveToTree(element: unknown, depth = 0): TreeNode | null { - if (depth > 100) throw new Error('Component resolution depth exceeded') - if (isTreeNode(element)) return element - - if (isReactElement(element)) { - if (typeof element.type === 'function') { - return resolveToTree( - (element.type as (p: Record) => unknown)(element.props), - depth + 1 - ) - } - if (typeof element.type === 'string') { - const children: (TreeNode | string)[] = [] - const elChildren = element.props.children - if (elChildren != null) { - const childArray = Array.isArray(elChildren) ? elChildren : [elChildren] - for (const child of childArray.flat()) { - if (child == null) continue - if (typeof child === 'string' || typeof child === 'number') { - children.push(String(child)) - } else { - const resolved = resolveToTree(child, depth + 1) - if (resolved) children.push(resolved) - } - } - } - const { children: _, ...props } = element.props - return { type: element.type, props, children } - } - } - - return null -} - /** * Build a component function from a JSX string using esbuild. * Uses mini-react createElement — no React dependency. diff --git a/packages/core/src/render/renderer.ts b/packages/core/src/render/renderer.ts index f9c15ebf5..7030758f1 100644 --- a/packages/core/src/render/renderer.ts +++ b/packages/core/src/render/renderer.ts @@ -1,6 +1,6 @@ import { parseColor, colorToFill } from '../color' -import type { SceneGraph, SceneNode, NodeType, Fill, LayoutMode, Stroke } from '../scene-graph' +import type { SceneGraph, SceneNode, NodeType, LayoutMode, Stroke } from '../scene-graph' import type { TreeNode } from './tree' import { isTreeNode } from './tree' @@ -53,10 +53,6 @@ const TEXT_AUTO_RESIZE_MAP: Record = { height: 'HEIGHT' } -function parseFill(value: string): Fill { - return colorToFill(value) -} - function parseStroke(value: string, width: number): Stroke { const color = parseColor(value) return { @@ -108,7 +104,6 @@ function renderNode(graph: SceneGraph, tree: TreeNode, parentId: string): SceneN const isText = nodeType === 'TEXT' const overrides = propsToOverrides(tree.props, isText) - // Text content from children if (isText) { const textContent = tree.children .filter((c): c is string => typeof c === 'string') @@ -118,7 +113,6 @@ function renderNode(graph: SceneGraph, tree: TreeNode, parentId: string): SceneN const node = graph.createNode(nodeType, parentId, overrides) - // Render child nodes (skip string children — already handled for text) for (const child of tree.children) { if (typeof child === 'string') continue if (isTreeNode(child)) { @@ -132,17 +126,13 @@ function renderNode(graph: SceneGraph, tree: TreeNode, parentId: string): SceneN function propsToOverrides(props: Record, isText: boolean): Partial { const o: Partial = {} - // Name if (props.name) o.name = props.name as string - // Size const w = props.w ?? props.width const h = props.h ?? props.height if (typeof w === 'number') o.width = w if (typeof h === 'number') o.height = h - // "fill" and "hug" sizing handled via layout sizing - const hasFlex = props.flex !== undefined if (w === 'fill') { o.layoutGrow = 1 o.layoutAlignSelf = 'STRETCH' @@ -151,23 +141,19 @@ function propsToOverrides(props: Record, isText: boolean): Part o.layoutAlignSelf = 'STRETCH' } - // Position if (props.x !== undefined) o.x = props.x as number if (props.y !== undefined) o.y = props.y as number - // Fill / background const bg = props.bg ?? props.fill if (typeof bg === 'string') { - o.fills = [parseFill(bg)] + o.fills = [colorToFill(bg)] } - // Stroke if (typeof props.stroke === 'string') { const strokeWidth = (props.strokeWidth as number) ?? 1 o.strokes = [parseStroke(props.stroke, strokeWidth)] } - // Corner radius const rounded = props.rounded ?? props.cornerRadius if (typeof rounded === 'number') { o.cornerRadius = rounded @@ -182,57 +168,40 @@ function propsToOverrides(props: Record, isText: boolean): Part } if (props.cornerSmoothing !== undefined) o.cornerSmoothing = props.cornerSmoothing as number - // Opacity if (props.opacity !== undefined) o.opacity = props.opacity as number - - // Rotation if (props.rotate !== undefined) o.rotation = props.rotate as number - - // Blend mode if (props.blendMode !== undefined) { o.blendMode = (props.blendMode as string).toUpperCase() as SceneNode['blendMode'] } - - // Overflow / clip if (props.overflow === 'hidden') o.clipsContent = true - // Layout (auto-layout / flexbox) - if (hasFlex) { + if (props.flex !== undefined) { const dir = props.flex as string o.layoutMode = (dir === 'col' || dir === 'column' ? 'VERTICAL' : 'HORIZONTAL') as LayoutMode - // Default sizing: hug content o.primaryAxisSizing = 'HUG' o.counterAxisSizing = 'HUG' - // Override with explicit w/h if (typeof w === 'number') o.primaryAxisSizing = 'FIXED' if (typeof h === 'number') o.counterAxisSizing = 'FIXED' if (w === 'hug') o.primaryAxisSizing = 'HUG' if (h === 'hug') o.counterAxisSizing = 'HUG' } - // Gap / spacing if (props.gap !== undefined) o.itemSpacing = props.gap as number - // Wrap if (props.wrap) { o.layoutWrap = 'WRAP' if (props.rowGap !== undefined) o.counterAxisSpacing = props.rowGap as number } - // Alignment if (props.justify) { o.primaryAxisAlign = ALIGN_MAP[props.justify as string] ?? 'MIN' } if (props.items) { o.counterAxisAlign = COUNTER_ALIGN_MAP[props.items as string] ?? 'MIN' - if (props.items === 'stretch') { - // Children should stretch — handled at child level - } } - // Padding const p = props.p ?? props.padding if (typeof p === 'number') { o.paddingTop = p @@ -249,14 +218,11 @@ function propsToOverrides(props: Record, isText: boolean): Part if (props.pb !== undefined) o.paddingBottom = props.pb as number if (props.pl !== undefined) o.paddingLeft = props.pl as number - // Grow if (props.grow !== undefined) o.layoutGrow = props.grow as number - // Min/max size — stored as-is, layout engine uses them if (props.minW !== undefined) o.width = Math.max(o.width ?? 0, props.minW as number) if (props.maxW !== undefined) o.width = Math.min(o.width ?? Infinity, props.maxW as number) - // Text properties if (isText) { const fontSize = props.size ?? props.fontSize if (typeof fontSize === 'number') o.fontSize = fontSize @@ -272,50 +238,40 @@ function propsToOverrides(props: Record, isText: boolean): Part } if (typeof props.color === 'string') { - o.fills = [parseFill(props.color)] + o.fills = [colorToFill(props.color)] } if (props.textAlign) { o.textAlignHorizontal = TEXT_ALIGN_MAP[props.textAlign as string] ?? 'LEFT' } - if (props.textAutoResize) { - o.textAutoResize = TEXT_AUTO_RESIZE_MAP[props.textAutoResize as string] ?? 'NONE' - } else { - // Default: auto-resize height for text nodes - o.textAutoResize = 'HEIGHT' - } + o.textAutoResize = props.textAutoResize + ? TEXT_AUTO_RESIZE_MAP[props.textAutoResize as string] ?? 'NONE' + : 'HEIGHT' } - // Star-specific if (props.points !== undefined) o.pointCount = props.points as number if (props.innerRadius !== undefined) o.starInnerRadius = props.innerRadius as number - - // Polygon-specific if (props.pointCount !== undefined) o.pointCount = props.pointCount as number - // Stroke align - if (props.strokeAlign) { - // Stored on the stroke itself, applied during rendering - } - - // Effects: shadow and blur if (typeof props.shadow === 'string') { const parts = (props.shadow as string).split(/\s+/) if (parts.length >= 4) { - const [x, y, blur, color] = [ - parseFloat(parts[0]!), - parseFloat(parts[1]!), - parseFloat(parts[2]!), - parts.slice(3).join(' ') - ] - const c = parseColor(color) + const c = parseColor(parts.slice(3).join(' ')) o.effects = [ ...(o.effects ?? []), - { type: 'DROP_SHADOW', color: c, offset: { x, y }, radius: blur, spread: 0, visible: true } + { + type: 'DROP_SHADOW', + color: c, + offset: { x: parseFloat(parts[0]!), y: parseFloat(parts[1]!) }, + radius: parseFloat(parts[2]!), + spread: 0, + visible: true + } ] } } + if (typeof props.blur === 'number') { o.effects = [ ...(o.effects ?? []), diff --git a/packages/core/src/render/tree.ts b/packages/core/src/render/tree.ts index 1e5cffada..30ed6d8f5 100644 --- a/packages/core/src/render/tree.ts +++ b/packages/core/src/render/tree.ts @@ -19,57 +19,56 @@ function isReactElement(x: unknown): x is ReactElement { return x !== null && typeof x === 'object' && 'type' in x && 'props' in x } -function resolveElement(el: ReactElement, depth = 0): TreeNode | null { +/** + * Resolve any element-like value (ReactElement, TreeNode, function component) + * into a TreeNode. Handles recursive function components up to depth 100. + */ +export function resolveToTree(element: unknown, depth = 0): TreeNode | null { if (depth > 100) throw new Error('Component resolution depth exceeded') - if (isTreeNode(el)) return el + if (element == null) return null + if (isTreeNode(element)) return element + if (!isReactElement(element)) return null - if (typeof el.type === 'function') { - const result = (el.type as (p: Record) => unknown)(el.props) - if (isTreeNode(result)) return result - if (isReactElement(result)) return resolveElement(result, depth + 1) + if (typeof element.type === 'function') { + return resolveToTree( + (element.type as (p: Record) => unknown)(element.props), + depth + 1 + ) } - if (typeof el.type === 'string') { - return convertToTree(el) - } - - return null -} - -function convertToTree(el: ReactElement): TreeNode { - const children: (TreeNode | string)[] = [] - const elChildren = el.props.children - - if (elChildren != null) { - const childArray = Array.isArray(elChildren) ? elChildren : [elChildren] - for (const child of childArray.flat()) { - if (child == null) continue - if (typeof child === 'string' || typeof child === 'number') { - children.push(String(child)) - } else if (isReactElement(child)) { - const resolved = resolveElement(child) - if (resolved) children.push(resolved) + if (typeof element.type === 'string') { + const children: (TreeNode | string)[] = [] + const elChildren = element.props.children + if (elChildren != null) { + const childArray = Array.isArray(elChildren) ? elChildren : [elChildren] + for (const child of childArray.flat()) { + if (child == null) continue + if (typeof child === 'string' || typeof child === 'number') { + children.push(String(child)) + } else { + const resolved = resolveToTree(child, depth + 1) + if (resolved) children.push(resolved) + } } } + const { children: _, ...props } = element.props + return { type: element.type, props, children } } - const { children: _, ...props } = el.props - return { type: el.type as string, props, children } + return null } -function processChild(child: unknown): TreeNode | string | null { +function resolveChild(child: unknown): TreeNode | string | null { if (child == null) return null if (typeof child === 'string' || typeof child === 'number') return String(child) - if (isTreeNode(child)) return child - if (isReactElement(child)) return resolveElement(child) - return null + return resolveToTree(child) } export function node(type: string, props: Record): TreeNode { const { children, ...rest } = props const processed = [children] .flat(Infinity) - .map(processChild) + .map(resolveChild) .filter((c): c is TreeNode | string => c !== null) return { type, props: rest, children: processed } }