From 5df81e62c27108da99fd197575d492fb95b9dc36 Mon Sep 17 00:00:00 2001 From: Rezo Date: Wed, 11 Feb 2026 13:57:09 +0400 Subject: [PATCH 1/5] feat(core): expose internal render and layout callbacks --- packages/core/src/app/createApp.ts | 48 ++++++++++++++++++++++++- packages/core/src/app/widgetRenderer.ts | 7 ++++ packages/core/src/index.ts | 11 ++++++ 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/packages/core/src/app/createApp.ts b/packages/core/src/app/createApp.ts index 39261ad8..06fa2afb 100644 --- a/packages/core/src/app/createApp.ts +++ b/packages/core/src/app/createApp.ts @@ -29,7 +29,15 @@ import { type RuntimeBackend, } from "../backend.js"; import type { UiEvent } from "../events.js"; -import type { App, AppConfig, DrawFn, EventHandler, ViewFn } from "../index.js"; +import type { + App, + AppConfig, + AppLayoutSnapshot, + AppRenderMetrics, + DrawFn, + EventHandler, + ViewFn, +} from "../index.js"; import type { BindingMap, KeyContext, @@ -71,6 +79,8 @@ type ResolvedAppConfig = Readonly<{ drawlistReuseOutputBuffer: boolean; drawlistEncodedStringCacheCap: number; maxFramesInFlight: number; + internal_onRender?: ((metrics: AppRenderMetrics) => void) | undefined; + internal_onLayout?: ((snapshot: AppLayoutSnapshot) => void) | undefined; }>; /** Default configuration values. */ @@ -83,6 +93,8 @@ const DEFAULT_CONFIG: ResolvedAppConfig = Object.freeze({ drawlistReuseOutputBuffer: true, drawlistEncodedStringCacheCap: 1024, maxFramesInFlight: 1, + internal_onRender: undefined, + internal_onLayout: undefined, }); const SYNC_FRAME_ACK_MARKER = "__reziSyncFrameAck"; @@ -157,6 +169,10 @@ export function resolveAppConfig(config: AppConfig | undefined): ResolvedAppConf config.maxFramesInFlight === undefined ? DEFAULT_CONFIG.maxFramesInFlight : Math.min(4, Math.max(1, requirePositiveInt("maxFramesInFlight", config.maxFramesInFlight))); + const internal_onRender = + typeof config.internal_onRender === "function" ? config.internal_onRender : undefined; + const internal_onLayout = + typeof config.internal_onLayout === "function" ? config.internal_onLayout : undefined; return Object.freeze({ fpsCap, @@ -167,6 +183,8 @@ export function resolveAppConfig(config: AppConfig | undefined): ResolvedAppConf drawlistReuseOutputBuffer, drawlistEncodedStringCacheCap, maxFramesInFlight, + internal_onRender, + internal_onLayout, }); } @@ -676,6 +694,28 @@ export function createApp( } } + function emitInternalRenderMetrics(renderTime: number): boolean { + if (config.internal_onRender === undefined) return true; + try { + config.internal_onRender({ renderTime: Math.max(0, renderTime) }); + return true; + } catch (e: unknown) { + fatalNowOrEnqueue("ZRUI_USER_CODE_THROW", `onRender callback threw: ${describeThrown(e)}`); + return false; + } + } + + function emitInternalLayoutSnapshot(): boolean { + if (config.internal_onLayout === undefined) return true; + try { + config.internal_onLayout({ idRects: widgetRenderer.getRectByIdIndex() }); + return true; + } catch (e: unknown) { + fatalNowOrEnqueue("ZRUI_USER_CODE_THROW", `onLayout callback threw: ${describeThrown(e)}`); + return false; + } + } + function tryRenderOnce(): void { if (sm.state !== "Running") return; // During stop(), we may still receive a few late event batches, but we must not @@ -708,6 +748,7 @@ export function createApp( const df = drawFn; if (!df) return; + const renderStart = perfNow(); const submitToken = perfMarkStart("submit_frame"); const res = rawRenderer.submitFrame(df, hooks); perfMarkEnd("submit_frame", submitToken); @@ -715,6 +756,7 @@ export function createApp( fatalNowOrEnqueue(res.code, res.detail); return; } + if (!emitInternalRenderMetrics(perfNow() - renderStart)) return; submitFrameStartMs = PERF_ENABLED ? submitToken : null; const buildEndMs = PERF_ENABLED ? perfNow() : null; @@ -750,6 +792,7 @@ export function createApp( (pendingDirtyFlags & DIRTY_VIEW) !== 0, }; + const renderStart = perfNow(); const submitToken = perfMarkStart("submit_frame"); const res = widgetRenderer.submitFrame(vf, snapshot, viewport, theme, hooks, plan); perfMarkEnd("submit_frame", submitToken); @@ -757,6 +800,9 @@ export function createApp( fatalNowOrEnqueue(res.code, res.detail); return; } + if (!emitInternalRenderMetrics(perfNow() - renderStart)) return; + if (!emitInternalLayoutSnapshot()) return; + submitFrameStartMs = PERF_ENABLED ? submitToken : null; const buildEndMs = PERF_ENABLED ? perfNow() : null; framesInFlight++; diff --git a/packages/core/src/app/widgetRenderer.ts b/packages/core/src/app/widgetRenderer.ts index 8f5ef12a..bab3ded5 100644 --- a/packages/core/src/app/widgetRenderer.ts +++ b/packages/core/src/app/widgetRenderer.ts @@ -479,6 +479,13 @@ export class WidgetRenderer { return this.focusState.focusedId; } + /** + * Get the latest committed id->rect layout index. + */ + getRectByIdIndex(): ReadonlyMap { + return this.rectById; + } + /** * Determine whether a key event should bypass the keybinding system. * diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c8658e36..f1c637ca 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -716,6 +716,7 @@ export type { BindingMap, ModeBindingMap } from "./keybindings/index.js"; import type { DrawApi } from "./drawApi.js"; import type { UiEvent } from "./events.js"; import type { BindingMap, KeyContext, ModeBindingMap } from "./keybindings/index.js"; +import type { Rect } from "./layout/types.js"; import type { Theme } from "./theme/theme.js"; import type { ThemeDefinition } from "./theme/tokens.js"; import type { VNode } from "./widgets/types.js"; @@ -723,6 +724,8 @@ import type { VNode } from "./widgets/types.js"; export type ViewFn = (state: Readonly) => VNode; export type DrawFn = (g: DrawApi) => void; export type EventHandler = (ev: UiEvent) => void; +export type AppRenderMetrics = Readonly<{ renderTime: number }>; +export type AppLayoutSnapshot = Readonly<{ idRects: ReadonlyMap }>; export type AppConfig = Readonly<{ fpsCap?: number; @@ -750,6 +753,14 @@ export type AppConfig = Readonly<{ * Default: 1 (no pipelining). Max: 4. */ maxFramesInFlight?: number; + /** + * @internal Called after a frame is rendered/submitted. + */ + internal_onRender?: (metrics: AppRenderMetrics) => void; + /** + * @internal Called with the latest widget id->rect layout snapshot. + */ + internal_onLayout?: (snapshot: AppLayoutSnapshot) => void; }>; export interface App { From 73633436ef43ba26404c2440c861b7b1d588181c Mon Sep 17 00:00:00 2001 From: Rezo Date: Wed, 11 Feb 2026 13:57:16 +0400 Subject: [PATCH 2/5] feat(ink-compat): add committed layout measurement and render parity APIs --- .../src/context/AccessibilityContext.ts | 5 + .../src/hooks/useIsScreenReaderEnabled.ts | 6 + packages/ink-compat/src/index.ts | 3 + packages/ink-compat/src/measureElement.ts | 41 ++--- packages/ink-compat/src/measurement.ts | 169 ++++++++++++++++++ packages/ink-compat/src/reconciler/convert.ts | 11 +- .../ink-compat/src/reconciler/hostConfig.ts | 25 ++- packages/ink-compat/src/reconciler/types.ts | 46 ++++- packages/ink-compat/src/render.ts | 134 +++++++++++++- packages/ink-compat/src/resizeObserver.ts | 71 ++++++++ packages/ink-compat/src/types.ts | 20 ++- 11 files changed, 480 insertions(+), 51 deletions(-) create mode 100644 packages/ink-compat/src/context/AccessibilityContext.ts create mode 100644 packages/ink-compat/src/hooks/useIsScreenReaderEnabled.ts create mode 100644 packages/ink-compat/src/measurement.ts create mode 100644 packages/ink-compat/src/resizeObserver.ts diff --git a/packages/ink-compat/src/context/AccessibilityContext.ts b/packages/ink-compat/src/context/AccessibilityContext.ts new file mode 100644 index 00000000..66e8f665 --- /dev/null +++ b/packages/ink-compat/src/context/AccessibilityContext.ts @@ -0,0 +1,5 @@ +import React from "react"; + +const AccessibilityContext = React.createContext(false); + +export default AccessibilityContext; diff --git a/packages/ink-compat/src/hooks/useIsScreenReaderEnabled.ts b/packages/ink-compat/src/hooks/useIsScreenReaderEnabled.ts new file mode 100644 index 00000000..2bcb9768 --- /dev/null +++ b/packages/ink-compat/src/hooks/useIsScreenReaderEnabled.ts @@ -0,0 +1,6 @@ +import React from "react"; +import AccessibilityContext from "../context/AccessibilityContext.js"; + +export default function useIsScreenReaderEnabled(): boolean { + return React.useContext(AccessibilityContext); +} diff --git a/packages/ink-compat/src/index.ts b/packages/ink-compat/src/index.ts index 0d40c241..6f64cb6e 100644 --- a/packages/ink-compat/src/index.ts +++ b/packages/ink-compat/src/index.ts @@ -16,12 +16,15 @@ export { default as useStdout } from "./hooks/useStdout.js"; export { default as useStderr } from "./hooks/useStderr.js"; export { default as useFocus } from "./hooks/useFocus.js"; export { default as useFocusManager } from "./hooks/useFocusManager.js"; +export { default as useIsScreenReaderEnabled } from "./hooks/useIsScreenReaderEnabled.js"; // Render export { render } from "./render.js"; // Measurement export { default as measureElement } from "./measureElement.js"; +export { getBoundingBox, getScrollHeight, getScrollWidth } from "./measureElement.js"; +export { default as ResizeObserver, ResizeObserverEntry } from "./resizeObserver.js"; // Types export type { Instance, RenderOptions } from "./types.js"; diff --git a/packages/ink-compat/src/measureElement.ts b/packages/ink-compat/src/measureElement.ts index f5b7674f..682f0142 100644 --- a/packages/ink-compat/src/measureElement.ts +++ b/packages/ink-compat/src/measureElement.ts @@ -1,38 +1,17 @@ -import { warnOnce } from "./internal/warn.js"; +import { + getBoundingBox, + getScrollHeight, + getScrollWidth, + measureElementFromLayout, +} from "./measurement.js"; import type { DOMElement } from "./types.js"; - -type Output = { - width: number; - height: number; -}; -type MeasuredAttrs = { - width?: unknown; - height?: unknown; -}; +export { getBoundingBox, getScrollHeight, getScrollWidth } from "./measurement.js"; /** * Measure the dimensions of a `` element. * - * In Ink this reads computed layout from the Yoga node. In ink-compat, - * layout is computed by the Zireael C engine and not exposed back to JS, - * so this function returns explicitly-set `width`/`height` props when - * available, or `0` with a one-time warning. + * In ink-compat this reads committed layout from the latest renderer frame. */ -export default function measureElement(node: DOMElement): Output { - const attrs = ((node as { props?: Record }).props ?? - node.attributes) as MeasuredAttrs; - - const widthValue = attrs.width; - const heightValue = attrs.height; - const width = typeof widthValue === "number" ? widthValue : 0; - const height = typeof heightValue === "number" ? heightValue : 0; - - if (width === 0 && height === 0) { - warnOnce( - "measureElement: Rezi computes layout in the native engine. " + - "Measurements are only available when explicit width/height props are set on the .", - ); - } - - return { width, height }; +export default function measureElement(node: DOMElement): { width: number; height: number } { + return measureElementFromLayout(node); } diff --git a/packages/ink-compat/src/measurement.ts b/packages/ink-compat/src/measurement.ts new file mode 100644 index 00000000..1709e59a --- /dev/null +++ b/packages/ink-compat/src/measurement.ts @@ -0,0 +1,169 @@ +import type { HostElement, HostLayoutRect, HostRoot } from "./reconciler/types.js"; +import { ResizeObserverEntry } from "./resizeObserver.js"; +import type { DOMElement } from "./types.js"; + +type CoreRect = Readonly<{ x: number; y: number; w: number; h: number }>; + +type ResizeObserverLike = Readonly<{ + internalTrigger: (entries: ResizeObserverEntry[]) => void; +}>; + +const ZERO_LAYOUT: HostLayoutRect = Object.freeze({ x: 0, y: 0, width: 0, height: 0 }); + +function toLayout(rect: CoreRect | undefined): HostLayoutRect { + if (!rect) return ZERO_LAYOUT; + const width = Number.isFinite(rect.w) ? Math.max(0, rect.w) : 0; + const height = Number.isFinite(rect.h) ? Math.max(0, rect.h) : 0; + return { + x: Number.isFinite(rect.x) ? rect.x : 0, + y: Number.isFinite(rect.y) ? rect.y : 0, + width, + height, + }; +} + +function readLayout(node: DOMElement): HostLayoutRect { + const layout = (node as HostElement).internal_layout; + return layout ?? ZERO_LAYOUT; +} + +export function measureElementFromLayout(node: DOMElement): Readonly<{ width: number; height: number }> { + const layout = readLayout(node); + return { width: layout.width, height: layout.height }; +} + +export function getBoundingBox(node: DOMElement): Readonly<{ + x: number; + y: number; + width: number; + height: number; +}> { + const layout = readLayout(node); + return { + x: layout.x, + y: layout.y, + width: layout.width, + height: layout.height, + }; +} + +export function getScrollHeight(node: DOMElement): number { + const st = (node as HostElement).internal_scrollState; + return st?.scrollHeight ?? 0; +} + +export function getScrollWidth(node: DOMElement): number { + const st = (node as HostElement).internal_scrollState; + return st?.scrollWidth ?? 0; +} + +function collectObserverEntry( + batches: Map, + observer: ResizeObserverLike, + entry: ResizeObserverEntry, +): void { + const prev = batches.get(observer); + if (prev) { + prev.push(entry); + return; + } + batches.set(observer, [entry]); +} + +function updateNodeLayout( + node: HostElement, + idRects: ReadonlyMap, + observerBatches: Map, +): HostLayoutRect { + let layout = node.type === "ink-box" ? toLayout(idRects.get(node.internal_id)) : ZERO_LAYOUT; + + let haveChildBounds = false; + let minChildX = 0; + let minChildY = 0; + let maxChildX = 0; + let maxChildY = 0; + + let maxRelRight = 0; + let maxRelBottom = 0; + + for (const child of node.children) { + if (child.kind !== "element") continue; + + const childRect = updateNodeLayout(child, idRects, observerBatches); + + if (!haveChildBounds) { + haveChildBounds = true; + minChildX = childRect.x; + minChildY = childRect.y; + maxChildX = childRect.x + childRect.width; + maxChildY = childRect.y + childRect.height; + } else { + minChildX = Math.min(minChildX, childRect.x); + minChildY = Math.min(minChildY, childRect.y); + maxChildX = Math.max(maxChildX, childRect.x + childRect.width); + maxChildY = Math.max(maxChildY, childRect.y + childRect.height); + } + + const relRight = childRect.x - layout.x + childRect.width; + const relBottom = childRect.y - layout.y + childRect.height; + if (relRight > maxRelRight) maxRelRight = relRight; + if (relBottom > maxRelBottom) maxRelBottom = relBottom; + } + + if (node.type !== "ink-box") { + layout = haveChildBounds + ? { + x: minChildX, + y: minChildY, + width: Math.max(0, maxChildX - minChildX), + height: Math.max(0, maxChildY - minChildY), + } + : ZERO_LAYOUT; + } + + node.internal_layout = layout; + + const clientWidth = layout.width; + const clientHeight = layout.height; + node.internal_scrollState = { + scrollHeight: Math.max(clientHeight, maxRelBottom), + scrollWidth: Math.max(clientWidth, maxRelRight), + clientHeight, + clientWidth, + }; + + const nextSize = { width: layout.width, height: layout.height }; + const lastSize = node.internal_lastMeasuredSize; + if (!lastSize || lastSize.width !== nextSize.width || lastSize.height !== nextSize.height) { + node.internal_lastMeasuredSize = nextSize; + + if (node.resizeObservers && node.resizeObservers.size > 0) { + const entry = new ResizeObserverEntry(node as unknown as DOMElement, nextSize); + for (const observer of node.resizeObservers) { + collectObserverEntry(observerBatches, observer as unknown as ResizeObserverLike, entry); + } + } + } + + return layout; +} + +export function applyLayoutSnapshot( + root: HostRoot, + idRects: ReadonlyMap, +): void { + const observerBatches = new Map(); + + for (const child of root.children) { + if (child.kind !== "element") continue; + updateNodeLayout(child, idRects, observerBatches); + } + + for (const [observer, entries] of observerBatches) { + try { + observer.internalTrigger(entries); + } catch { + // ignore observer callback failures + } + } +} diff --git a/packages/ink-compat/src/reconciler/convert.ts b/packages/ink-compat/src/reconciler/convert.ts index f9735763..dee962e1 100644 --- a/packages/ink-compat/src/reconciler/convert.ts +++ b/packages/ink-compat/src/reconciler/convert.ts @@ -37,12 +37,17 @@ function convertNode(node: HostNode, ctx: ConvertCtx): VNode | null { if (isStatic && childVNodes.length === 0) return null; if (mapped.reverseChildren) childVNodes.reverse(); + const measuredId = node.internal_id; + const stackPropsWithId = mapped.wrapper + ? mapped.stackProps + : { ...mapped.stackProps, id: measuredId }; const stack = mapped.stackKind === "row" - ? ui.row(mapped.stackProps, childVNodes) - : ui.column(mapped.stackProps, childVNodes); + ? ui.row(stackPropsWithId, childVNodes) + : ui.column(stackPropsWithId, childVNodes); - const vnode = mapped.wrapper ? ui.box(mapped.wrapper, [stack]) : stack; + const wrapperWithId = mapped.wrapper ? { ...mapped.wrapper, id: measuredId } : null; + const vnode = wrapperWithId ? ui.box(wrapperWithId, [stack]) : stack; if (isStatic) { ctx.staticVNodes.push(vnode); diff --git a/packages/ink-compat/src/reconciler/hostConfig.ts b/packages/ink-compat/src/reconciler/hostConfig.ts index 81560b9b..3c082e02 100644 --- a/packages/ink-compat/src/reconciler/hostConfig.ts +++ b/packages/ink-compat/src/reconciler/hostConfig.ts @@ -3,6 +3,7 @@ import { DefaultEventPriority } from "react-reconciler/constants.js"; import { InkCompatError } from "../errors.js"; import { convertRoot } from "./convert.js"; import { + allocateNodeId, type HostContext, type HostElement, type HostNode, @@ -61,7 +62,7 @@ const reconciler = createReconciler< // ------------------- // Instance Create // ------------------- - createInstance(originalType, newProps, _root, hostContext) { + createInstance(originalType, newProps, root, hostContext) { if (hostContext.isInsideText && originalType === "ink-box") { throw new InkCompatError("INK_COMPAT_INVALID_PROPS", " can't be nested inside "); } @@ -69,7 +70,18 @@ const reconciler = createReconciler< const type: HostType = originalType === "ink-text" && hostContext.isInsideText ? "ink-virtual-text" : originalType; - return { kind: "element", type, props: { ...newProps }, children: [] }; + const props = { ...newProps }; + const children: HostNode[] = []; + return { + kind: "element", + type, + nodeName: type, + props, + attributes: props, + children, + childNodes: children, + internal_id: allocateNodeId(root), + }; }, createTextInstance(text, _root, hostContext) { @@ -79,7 +91,7 @@ const reconciler = createReconciler< `Text string "${text}" must be rendered inside component`, ); } - return { kind: "text", text }; + return { kind: "text", text, nodeName: "#text", nodeValue: text }; }, // ------------------- @@ -101,10 +113,13 @@ const reconciler = createReconciler< return newProps; }, commitUpdate(instance, updatePayload) { - instance.props = { ...updatePayload }; + const props = { ...updatePayload }; + instance.props = props; + instance.attributes = props; }, commitTextUpdate(textInstance, _oldText, newText) { textInstance.text = newText; + textInstance.nodeValue = newText; }, // ------------------- @@ -114,9 +129,11 @@ const reconciler = createReconciler< unhideInstance() {}, hideTextInstance(textInstance) { textInstance.text = ""; + textInstance.nodeValue = ""; }, unhideTextInstance(textInstance, text) { textInstance.text = text; + textInstance.nodeValue = text; }, // ------------------- diff --git a/packages/ink-compat/src/reconciler/types.ts b/packages/ink-compat/src/reconciler/types.ts index d2e7cf5b..4c915caf 100644 --- a/packages/ink-compat/src/reconciler/types.ts +++ b/packages/ink-compat/src/reconciler/types.ts @@ -2,16 +2,46 @@ import type { VNode } from "@rezi-ui/core"; export type HostType = "ink-box" | "ink-text" | "ink-virtual-text" | "ink-spacer"; +export type HostLayoutRect = Readonly<{ + x: number; + y: number; + width: number; + height: number; +}>; + +export type HostScrollState = Readonly<{ + scrollHeight: number; + scrollWidth: number; + clientHeight: number; + clientWidth: number; +}>; + +export type HostResizeObserverLike = Readonly<{ + internalTrigger: (entries: readonly unknown[]) => void; +}>; + export type HostText = { kind: "text"; text: string; + nodeName: "#text"; + nodeValue: string; + parentNode?: HostElement; }; export type HostElement = { kind: "element"; type: HostType; + nodeName: HostType; props: Record; - children: Array; + attributes: Record; + children: HostNode[]; + childNodes: HostNode[]; + parentNode?: HostElement; + internal_id: string; + internal_layout?: HostLayoutRect; + internal_scrollState?: HostScrollState; + resizeObservers?: Set; + internal_lastMeasuredSize?: Readonly<{ width: number; height: number }>; }; export type HostNode = HostElement | HostText; @@ -24,16 +54,25 @@ export type HostRoot = { * Items are appended on each commit and remain for the lifetime of the render() root. */ staticVNodes: VNode[]; + internal_nextNodeId?: number; onCommit: (vnode: VNode | null) => void; }; export type HostContext = Readonly<{ isInsideText: boolean }>; +export function allocateNodeId(root: HostRoot): string { + const next = root.internal_nextNodeId ?? 1; + root.internal_nextNodeId = next + 1; + return `ink-compat-${String(next)}`; +} + export function appendChildNode( parent: HostRoot | HostElement, child: HostElement | HostText, ): void { parent.children.push(child); + if (parent.kind === "element") child.parentNode = parent; + else delete child.parentNode; } export function insertBeforeNode( @@ -44,9 +83,13 @@ export function insertBeforeNode( const idx = parent.children.indexOf(before); if (idx < 0) { parent.children.push(child); + if (parent.kind === "element") child.parentNode = parent; + else delete child.parentNode; return; } parent.children.splice(idx, 0, child); + if (parent.kind === "element") child.parentNode = parent; + else delete child.parentNode; } export function removeChildNode( @@ -56,4 +99,5 @@ export function removeChildNode( const idx = parent.children.indexOf(child); if (idx < 0) return; parent.children.splice(idx, 1); + delete child.parentNode; } diff --git a/packages/ink-compat/src/render.ts b/packages/ink-compat/src/render.ts index 952c5f67..9fd0d827 100644 --- a/packages/ink-compat/src/render.ts +++ b/packages/ink-compat/src/render.ts @@ -1,11 +1,13 @@ import { type RuntimeBackend, type UiEvent, type VNode, createApp, ui } from "@rezi-ui/core"; import { createNodeBackend } from "@rezi-ui/node"; import React from "react"; +import AccessibilityContext from "./context/AccessibilityContext.js"; import AppContext from "./context/AppContext.js"; import FocusProvider from "./context/FocusProvider.js"; import StdioContext, { type StdioContextValue } from "./context/StdioContext.js"; import { createInputEventEmitter } from "./internal/emitter.js"; import { enableWarnOnce } from "./internal/warn.js"; +import { applyLayoutSnapshot } from "./measurement.js"; import reconciler, { type HostRoot } from "./reconciler.js"; import { createConsoleCapture } from "./render/consoleCapture.js"; import { deferred } from "./render/deferred.js"; @@ -14,6 +16,31 @@ import type { Instance, RenderOptions } from "./types.js"; type AppState = Readonly<{ vnode: VNode; consoleLines: readonly string[] }>; +const ANSI_ALTERNATE_BUFFER_ENTER = "\u001B[?1049h"; +const ANSI_ALTERNATE_BUFFER_EXIT = "\u001B[?1049l"; + +function hasRawMode( + stdin: NodeJS.ReadStream, +): stdin is NodeJS.ReadStream & { + isTTY: true; + setRawMode: (value: boolean) => void; + ref: () => void; + unref: () => void; +} { + return ( + (stdin as unknown as { isTTY?: unknown }).isTTY === true && + typeof (stdin as unknown as { setRawMode?: unknown }).setRawMode === "function" + ); +} + +function writeBestEffort(stream: NodeJS.WriteStream, data: string): void { + try { + void stream.write(data); + } catch { + // ignore + } +} + export function render( tree: React.ReactNode, options?: RenderOptions | NodeJS.WriteStream, @@ -22,23 +49,43 @@ export function render( if (opts.debug === true) enableWarnOnce(); - // We currently can't plumb stdio into the Rezi backend without core/node changes. - // We still expose the streams for compatibility with Ink hooks. const stdin = opts.stdin ?? process.stdin; const stdout = opts.stdout ?? process.stdout; const stderr = opts.stderr ?? process.stderr; const exitOnCtrlC = opts.exitOnCtrlC !== false; const maxFps = opts.maxFps ?? 60; + const isScreenReaderEnabled = + opts.isScreenReaderEnabled ?? process.env["INK_SCREEN_READER"] === "true"; + + let alternateBufferActive = opts.alternateBufferAlreadyActive === true; + const canUseAlternateBuffer = + opts.alternateBuffer === true && (stdout as unknown as { isTTY?: unknown }).isTTY === true; + + if (canUseAlternateBuffer && !alternateBufferActive) { + writeBestEffort(stdout, ANSI_ALTERNATE_BUFFER_ENTER); + alternateBufferActive = true; + } const eventEmitter = createInputEventEmitter(); + let rootRef: HostRoot | null = null; + const backend = ((opts as { internal_backend?: unknown }).internal_backend ?? createNodeBackend({ fpsCap: maxFps })) as RuntimeBackend; const app = createApp({ backend, initialState: { vnode: ui.text(""), consoleLines: [] }, - config: { fpsCap: maxFps }, + config: { + fpsCap: maxFps, + internal_onRender: (metrics) => { + opts.onRender?.(metrics); + }, + internal_onLayout: (snapshot) => { + if (!rootRef) return; + applyLayoutSnapshot(rootRef, snapshot.idRects); + }, + }, }); app.view((s) => { if (s.consoleLines.length === 0) return s.vnode; @@ -54,6 +101,10 @@ export function render( let restoreConsole: (() => void) | null = null; let unsubEvents: (() => void) | null = null; + const supportsRawMode = hasRawMode(stdin); + const backendOwnsRawMode = stdin === process.stdin; + let rawModeEnabledCount = 0; + const cleanupPatchedConsole = () => { if (restoreConsole === null) return; try { @@ -74,6 +125,29 @@ export function render( unsubEvents = null; }; + const cleanupRawMode = () => { + if (rawModeEnabledCount <= 0) return; + if (supportsRawMode && !backendOwnsRawMode) { + try { + stdin.setRawMode(false); + } catch { + // ignore + } + try { + stdin.unref(); + } catch { + // ignore + } + } + rawModeEnabledCount = 0; + }; + + const cleanupAlternateBuffer = () => { + if (!canUseAlternateBuffer || !alternateBufferActive) return; + writeBestEffort(stdout, ANSI_ALTERNATE_BUFFER_EXIT); + alternateBufferActive = false; + }; + // Console patching: best-effort Ink compatibility. // We intentionally disable in debug mode and in Node's test runner. const shouldPatchConsole = @@ -90,6 +164,8 @@ export function render( exited = true; cleanupPatchedConsole(); cleanupEventSubscription(); + cleanupRawMode(); + cleanupAlternateBuffer(); void Promise.resolve() .then(() => app.stop()) .catch(() => { @@ -121,9 +197,42 @@ export function render( stdin, stdout, stderr, - // Rezi owns terminal mode; Ink-style raw mode toggling is intentionally a no-op. - setRawMode: () => {}, - isRawModeSupported: false, + setRawMode: (enabled: boolean) => { + if (!supportsRawMode) { + if (stdin === process.stdin) { + throw new Error( + "Raw mode is not supported on the current process.stdin, which Ink uses as input stream by default.", + ); + } + throw new Error("Raw mode is not supported on the stdin provided to Ink."); + } + + if (enabled) { + if (rawModeEnabledCount === 0 && !backendOwnsRawMode) { + stdin.setEncoding("utf8"); + try { + stdin.ref(); + } catch { + // ignore + } + stdin.setRawMode(true); + } + rawModeEnabledCount++; + return; + } + + if (rawModeEnabledCount <= 0) return; + rawModeEnabledCount--; + if (rawModeEnabledCount === 0 && !backendOwnsRawMode) { + stdin.setRawMode(false); + try { + stdin.unref(); + } catch { + // ignore + } + } + }, + isRawModeSupported: supportsRawMode, internal_exitOnCtrlC: exitOnCtrlC, internal_eventEmitter: eventEmitter, }); @@ -133,9 +242,13 @@ export function render( AppContext.Provider, { value: { exit: requestExit } }, React.createElement( - StdioContext.Provider, - { value: stdioValue }, - React.createElement(FocusProvider, null, node), + AccessibilityContext.Provider, + { value: isScreenReaderEnabled }, + React.createElement( + StdioContext.Provider, + { value: stdioValue }, + React.createElement(FocusProvider, null, node), + ), ), ); @@ -147,6 +260,7 @@ export function render( app.update((prev) => ({ ...prev, vnode: vnode ?? ui.text("") })); }, }; + rootRef = root; const container = reconciler.createContainer(root, 0, null, false, null, "id", () => {}, null); @@ -156,6 +270,8 @@ export function render( } catch (error) { cleanupPatchedConsole(); cleanupEventSubscription(); + cleanupRawMode(); + cleanupAlternateBuffer(); try { app.dispose(); } catch { diff --git a/packages/ink-compat/src/resizeObserver.ts b/packages/ink-compat/src/resizeObserver.ts new file mode 100644 index 00000000..64dcd6ab --- /dev/null +++ b/packages/ink-compat/src/resizeObserver.ts @@ -0,0 +1,71 @@ +import type { DOMElement } from "./types.js"; + +export type ResizeObserverCallback = ( + entries: ResizeObserverEntry[], + observer: ResizeObserver, +) => void; + +export class ResizeObserverEntry { + readonly target: DOMElement; + readonly contentRect: Readonly<{ width: number; height: number }>; + + constructor(target: DOMElement, contentRect: Readonly<{ width: number; height: number }>) { + this.target = target; + this.contentRect = contentRect; + } +} + +export default class ResizeObserver { + private readonly callback: ResizeObserverCallback; + private readonly observedElements = new Set(); + + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + } + + observe(element: DOMElement): void { + if (this.observedElements.has(element)) return; + + this.observedElements.add(element); + const target = element as DOMElement & { + resizeObservers?: Set; + internal_lastMeasuredSize?: Readonly<{ width: number; height: number }>; + }; + if (!target.resizeObservers) target.resizeObservers = new Set(); + target.resizeObservers.add(this as unknown); + + const size = target.internal_lastMeasuredSize; + if (size) { + this.safeTrigger([new ResizeObserverEntry(element, size)]); + } + } + + unobserve(element: DOMElement): void { + this.observedElements.delete(element); + + const target = element as DOMElement & { resizeObservers?: Set }; + target.resizeObservers?.delete(this as unknown); + } + + disconnect(): void { + for (const element of this.observedElements) { + const target = element as DOMElement & { resizeObservers?: Set }; + target.resizeObservers?.delete(this as unknown); + } + this.observedElements.clear(); + } + + internalTrigger(entries: ResizeObserverEntry[]): void { + this.safeTrigger(entries); + } + + private safeTrigger(entries: ResizeObserverEntry[]): void { + try { + this.callback(entries, this); + } catch (error) { + // Keep observer failures isolated, matching Ink's behavior. + // eslint-disable-next-line no-console + console.error(error); + } + } +} diff --git a/packages/ink-compat/src/types.ts b/packages/ink-compat/src/types.ts index 065f95f2..fb3b4d88 100644 --- a/packages/ink-compat/src/types.ts +++ b/packages/ink-compat/src/types.ts @@ -116,6 +116,11 @@ export type RenderOptions = Readonly<{ exitOnCtrlC?: boolean; patchConsole?: boolean; maxFps?: number; + onRender?: (metrics: Readonly<{ renderTime: number }>) => void; + isScreenReaderEnabled?: boolean; + alternateBuffer?: boolean; + alternateBufferAlreadyActive?: boolean; + incrementalRendering?: boolean; /** * @internal Test-only hook. When provided, `render()` uses this backend instead of * `createNodeBackend()`. @@ -181,12 +186,21 @@ export type NewlineProps = Readonly<{ * Opaque handle returned by a Box `ref`. * * In Ink this is a full DOM node with a yogaNode for layout queries. - * In ink-compat it wraps the reconciler's HostElement — layout is - * computed by the Zireael C engine, so `measureElement` returns - * explicitly-set dimensions or zero with a warning. + * In ink-compat it wraps the reconciler's HostElement and carries + * committed layout metadata populated after each render frame. */ export type DOMElement = { nodeName: string; attributes: Record; childNodes: readonly DOMElement[]; + parentNode?: DOMElement; + internal_layout?: Readonly<{ x: number; y: number; width: number; height: number }>; + internal_scrollState?: Readonly<{ + scrollHeight: number; + scrollWidth: number; + clientHeight: number; + clientWidth: number; + }>; + resizeObservers?: Set; + internal_lastMeasuredSize?: Readonly<{ width: number; height: number }>; }; From cfcec706002b450cc09e13af87e3ead801f8ad7f Mon Sep 17 00:00:00 2001 From: Rezo Date: Wed, 11 Feb 2026 13:57:23 +0400 Subject: [PATCH 3/5] test(ink-compat): harden parity coverage and add compat docs --- INK_COMPAT_PLAN.md | 54 ++++++ INK_COMPAT_REPORT.md | 82 +++++++++ .../src/__tests__/api.surface.test.ts | 30 ++++ .../src/__tests__/compat.thirdparty.test.tsx | 81 +++++++++ .../src/__tests__/measurement.test.tsx | 113 ++++++++++++ .../src/__tests__/render.parity.test.tsx | 163 ++++++++++++++++++ .../src/__tests__/resizeObserver.test.tsx | 97 +++++++++++ 7 files changed, 620 insertions(+) create mode 100644 INK_COMPAT_PLAN.md create mode 100644 INK_COMPAT_REPORT.md create mode 100644 packages/ink-compat/src/__tests__/api.surface.test.ts create mode 100644 packages/ink-compat/src/__tests__/compat.thirdparty.test.tsx create mode 100644 packages/ink-compat/src/__tests__/measurement.test.tsx create mode 100644 packages/ink-compat/src/__tests__/render.parity.test.tsx create mode 100644 packages/ink-compat/src/__tests__/resizeObserver.test.tsx diff --git a/INK_COMPAT_PLAN.md b/INK_COMPAT_PLAN.md new file mode 100644 index 00000000..33d4871f --- /dev/null +++ b/INK_COMPAT_PLAN.md @@ -0,0 +1,54 @@ +# INK_COMPAT_PLAN + +## Scope +Finish `@rezi-ui/ink-compat` toward near drop-in Ink parity for missing APIs/behaviors needed by downstream apps. + +## Gaps Identified +1. Missing API exports: `getBoundingBox`, `getScrollHeight`, `ResizeObserver`. +2. `measureElement` used props fallback, not committed layout. +3. No layout-change observer plumbing across rerenders/unmount. +4. Render options parity gaps: `onRender`, `isScreenReaderEnabled`, `alternateBuffer`, `incrementalRendering`. +5. `useStdin` raw-mode semantics were always no-op/unsupported. +6. No explicit smoke coverage for `ink-spinner`/`ink-gradient` usage patterns. + +## Design +1. Core->compat layout bridge (minimal internal plumbing) +- Add internal `createApp` callbacks: + - `internal_onRender(metrics)` for per-frame render timing. + - `internal_onLayout(snapshot)` for committed widget `id -> rect` map. +- Expose `WidgetRenderer.getRectByIdIndex()` to provide latest committed layout index. + +2. Host measurement model in ink-compat +- Add stable internal IDs on host elements in reconciler. +- Propagate IDs into converted Rezi VNodes (on the outer measurable box node). +- Maintain per-element committed layout metadata (`internal_layout`, `internal_scrollState`, size cache). +- Replace props-only measurement path with committed-layout reads. + +3. Resize observer semantics +- Add Ink-shaped `ResizeObserver`/`ResizeObserverEntry`. +- Support `observe`, `unobserve`, `disconnect`. +- Immediate callback on `observe()` when size is already known. +- Batch observer notifications after each committed layout snapshot. + +4. Render/useStdin parity +- Extend render options surface with Ink-like fields. +- Wire `onRender` callback to render timing from core submit pipeline. +- Add best-effort alternate buffer enter/exit sequences. +- Add screen-reader context + `useIsScreenReaderEnabled` hook. +- Implement meaningful `isRawModeSupported` and ref-counted `setRawMode`. +- Respect backend ownership model (avoid toggling `process.stdin` raw mode when backend owns it). + +## Sequence +1. Core internal callback plumbing. +2. Reconciler host metadata + ID propagation. +3. Measurement/scroll API implementation. +4. Resize observer implementation. +5. Render options + stdin raw-mode parity. +6. API/tests hardening (including spinner/gradient pattern smoke tests). +7. Document deviations and Gemini integration next step. + +## Risks +1. Ink scroll semantics are richer than current Rezi layout clipping behavior. +2. Alternate buffer behavior is best-effort because node backend stdio routing is backend-owned. +3. Raw-mode parity is constrained by backend ownership and non-plumbed custom stdin event path. +4. Observer ordering is close to Ink but not Yoga-identical for all edge cases. diff --git a/INK_COMPAT_REPORT.md b/INK_COMPAT_REPORT.md new file mode 100644 index 00000000..197604e9 --- /dev/null +++ b/INK_COMPAT_REPORT.md @@ -0,0 +1,82 @@ +# INK_COMPAT_REPORT + +## Implemented + +### 1) Export/API surface parity (G1) +- Added and exported: + - `getBoundingBox` + - `getScrollHeight` (plus `getScrollWidth` helper) + - `ResizeObserver` + - `ResizeObserverEntry` +- Added `useIsScreenReaderEnabled` export to support Ink-like screen-reader option usage. + +### 2) Real measurement pipeline (G2) +- Replaced props-only `measureElement` fallback with committed layout metadata. +- Added host-element internal layout state fed from current committed layout (`id -> rect`) after each frame. +- `measureElement` and `getBoundingBox` now read committed geometry. + +### 3) Observer + layout notifications (G3) +- Implemented Ink-shaped resize observer behavior: + - `observe`/`unobserve`/`disconnect` + - immediate callback on `observe` when size exists + - batched callbacks on committed size changes + - stable behavior across rerenders/unmounted subtree removal + +### 4) Render option parity (G4) +- Extended `RenderOptions` with Ink-like fields: + - `onRender` + - `isScreenReaderEnabled` + - `alternateBuffer` + - `alternateBufferAlreadyActive` + - `incrementalRendering` +- Implemented behavior: + - `onRender`: wired to core per-frame render timing callback. + - `isScreenReaderEnabled`: plumbed via context + `useIsScreenReaderEnabled`. + - `alternateBuffer`: best-effort ANSI enter/exit handling. + - `incrementalRendering`: accepted and typed; currently no backend-level incremental diffing. + +### 5) Stdin/raw-mode behavior (G5) +- `useStdin` now exposes meaningful `isRawModeSupported` (TTY + `setRawMode` capability). +- `setRawMode` now: + - throws on unsupported stdin (Ink-like behavior) + - reference-counts enable/disable calls + - avoids toggling raw mode on `process.stdin` when backend ownership applies + +### 6) ink-spinner / ink-gradient compatibility (G6) +- Verified against package surfaces (`ink-spinner@5.0.0`, `ink-gradient@3.0.0`) and added smoke coverage for their runtime patterns: + - spinner-style interval state updates with `` + - gradient-style `` text transformation +- No third-party package patches were required. + +### 7) Testing hardening (G7) +Added tests for: +- API presence/types +- committed measurement + bounding box updates +- resize observer semantics +- scroll metric behavior from committed layout +- render option hooks/alternate buffer/onRender +- raw-mode support and behavior +- spinner/gradient usage-pattern smoke tests + +## Core/runtime plumbing added +- `packages/core/src/index.ts` + - added internal app callback types on `AppConfig` (`internal_onRender`, `internal_onLayout`) +- `packages/core/src/app/createApp.ts` + - emits internal render metrics/layout snapshots +- `packages/core/src/app/widgetRenderer.ts` + - exposes latest committed `id -> rect` map + +## Remaining deviations from Ink +1. `incrementalRendering` is currently type/surface-compatible only (no true terminal diff pipeline). +2. `alternateBuffer` is best-effort and depends on backend/stdio ownership constraints. +3. Scroll metrics are based on current Rezi committed layout behavior; Yoga-specific scroll edge semantics may differ. +4. Raw-mode behavior intentionally preserves backend ownership for `process.stdin`, so exact Ink internals differ in that path. + +## Validation +Executed successfully: +- `npm run build` +- `npm run typecheck` +- `npm run test` + +## Recommended next step for Gemini integration +1. In Gemini, alias/import `ink` usage to `@rezi-ui/ink-compat` in one vertical slice (including one spinner/gradient view), then run its e2e rendering flow and catalog only remaining app-level incompatibilities. diff --git a/packages/ink-compat/src/__tests__/api.surface.test.ts b/packages/ink-compat/src/__tests__/api.surface.test.ts new file mode 100644 index 00000000..7bc59ead --- /dev/null +++ b/packages/ink-compat/src/__tests__/api.surface.test.ts @@ -0,0 +1,30 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import { + ResizeObserver, + getBoundingBox, + getScrollHeight, + render, + type RenderOptions, +} from "../index.js"; + +describe("api surface", () => { + test("exports measurement and observer APIs", () => { + assert.equal(typeof getBoundingBox, "function"); + assert.equal(typeof getScrollHeight, "function"); + assert.equal(typeof ResizeObserver, "function"); + }); + + test("RenderOptions includes Ink-like parity fields", () => { + const opts: RenderOptions = { + onRender: (metrics) => { + assert.ok(metrics.renderTime >= 0); + }, + isScreenReaderEnabled: true, + alternateBuffer: true, + incrementalRendering: true, + }; + + assert.equal(opts.isScreenReaderEnabled, true); + assert.equal(typeof render, "function"); + }); +}); diff --git a/packages/ink-compat/src/__tests__/compat.thirdparty.test.tsx b/packages/ink-compat/src/__tests__/compat.thirdparty.test.tsx new file mode 100644 index 00000000..ce487ff3 --- /dev/null +++ b/packages/ink-compat/src/__tests__/compat.thirdparty.test.tsx @@ -0,0 +1,81 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import React, { useEffect, useState } from "react"; +import { Text, Transform, render } from "../index.js"; +import { + StubBackend, + encodeZrevBatchV1, + flushMicrotasks, + makeBackendBatch, +} from "./testBackend.js"; + +async function pushInitialResize(backend: StubBackend): Promise { + backend.pushBatch( + makeBackendBatch( + encodeZrevBatchV1({ events: [{ kind: "resize", timeMs: 1, cols: 80, rows: 24 }] }), + ), + ); + await flushMicrotasks(10); +} + +describe("third-party compatibility smoke", () => { + test("ink-spinner pattern (stateful interval + ) triggers render updates", async () => { + function SpinnerLike() { + const frames = ["-", "\\", "|", "/"] as const; + const [frame, setFrame] = useState(0); + + useEffect(() => { + const timer = setInterval(() => { + setFrame((prev) => (prev + 1) % frames.length); + }, 10); + return () => clearInterval(timer); + }, []); + + return {frames[frame]}; + } + + const backend = new StubBackend(); + const inst = render(, { internal_backend: backend, exitOnCtrlC: false }); + + await flushMicrotasks(10); + await pushInitialResize(backend); + + const initialFrames = backend.requestedFrames.length; + await new Promise((resolve) => setTimeout(resolve, 40)); + await flushMicrotasks(10); + + assert.ok(backend.requestedFrames.length > initialFrames); + + inst.unmount(); + await inst.waitUntilExit(); + }); + + test("ink-gradient pattern (Transform transform callback) receives flattened text", async () => { + let lastInput = ""; + + function GradientLike(props: Readonly<{ children: React.ReactNode }>) { + const transform = (children: string) => { + lastInput = children; + return `\u001B[31m${children}\u001B[39m`; + }; + + return {props.children}; + } + + const backend = new StubBackend(); + const inst = render( + + rainbow + , + { internal_backend: backend, exitOnCtrlC: false }, + ); + + await flushMicrotasks(10); + await pushInitialResize(backend); + + assert.equal(lastInput, "rainbow"); + assert.ok(backend.requestedFrames.length >= 1); + + inst.unmount(); + await inst.waitUntilExit(); + }); +}); diff --git a/packages/ink-compat/src/__tests__/measurement.test.tsx b/packages/ink-compat/src/__tests__/measurement.test.tsx new file mode 100644 index 00000000..70daab36 --- /dev/null +++ b/packages/ink-compat/src/__tests__/measurement.test.tsx @@ -0,0 +1,113 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import React from "react"; +import { + Box, + Text, + getBoundingBox, + getScrollHeight, + measureElement, + render, + type DOMElement, +} from "../index.js"; +import { + StubBackend, + encodeZrevBatchV1, + flushMicrotasks, + makeBackendBatch, +} from "./testBackend.js"; + +async function pushInitialResize(backend: StubBackend): Promise { + backend.pushBatch( + makeBackendBatch( + encodeZrevBatchV1({ events: [{ kind: "resize", timeMs: 1, cols: 80, rows: 24 }] }), + ), + ); + await flushMicrotasks(10); +} + +describe("measurement", () => { + test("measureElement/getBoundingBox use committed layout and update on rerender", async () => { + const backend = new StubBackend(); + const ref = React.createRef(); + + const inst = render( + + + x + + , + { internal_backend: backend, exitOnCtrlC: false }, + ); + + await flushMicrotasks(10); + await pushInitialResize(backend); + + const node = ref.current; + assert.ok(node); + + const measured = measureElement(node); + assert.equal(measured.width, 4); + assert.equal(measured.height, 2); + + const box = getBoundingBox(node); + assert.equal(box.width, 4); + assert.equal(box.height, 2); + assert.ok(box.x >= 0); + assert.ok(box.y >= 0); + + inst.rerender( + + + x + + , + ); + + await flushMicrotasks(10); + + const measured2 = measureElement(node); + assert.equal(measured2.width, 7); + assert.equal(measured2.height, 3); + + inst.unmount(); + await inst.waitUntilExit(); + }); + + test("getScrollHeight reflects committed layout and updates across rerenders", async () => { + const backend = new StubBackend(); + const parentRef = React.createRef(); + + const inst = render( + + + content + + , + { internal_backend: backend, exitOnCtrlC: false }, + ); + + await flushMicrotasks(10); + await pushInitialResize(backend); + + const parent = parentRef.current; + assert.ok(parent); + + const measured = measureElement(parent); + assert.equal(measured.height, 2); + assert.equal(getScrollHeight(parent), 2); + + inst.rerender( + + + content + + , + ); + await flushMicrotasks(10); + + assert.equal(getScrollHeight(parent), 4); + + inst.unmount(); + await inst.waitUntilExit(); + }); +}); diff --git a/packages/ink-compat/src/__tests__/render.parity.test.tsx b/packages/ink-compat/src/__tests__/render.parity.test.tsx new file mode 100644 index 00000000..c99274c3 --- /dev/null +++ b/packages/ink-compat/src/__tests__/render.parity.test.tsx @@ -0,0 +1,163 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import React from "react"; +import { Text, render, useIsScreenReaderEnabled, useStdin } from "../index.js"; +import { + StubBackend, + encodeZrevBatchV1, + flushMicrotasks, + makeBackendBatch, +} from "./testBackend.js"; + +async function pushInitialResize(backend: StubBackend): Promise { + backend.pushBatch( + makeBackendBatch( + encodeZrevBatchV1({ events: [{ kind: "resize", timeMs: 1, cols: 80, rows: 24 }] }), + ), + ); + await flushMicrotasks(10); +} + +describe("render parity", () => { + test("onRender metrics + alternateBuffer + screen-reader context", async () => { + const backend = new StubBackend(); + const metrics: number[] = []; + const writes: string[] = []; + let isScreenReader: boolean | null = null; + + const stdout = { + isTTY: true, + columns: 80, + rows: 24, + write(data: string) { + writes.push(data); + return true; + }, + on() {}, + off() {}, + } as unknown as NodeJS.WriteStream; + + function Probe() { + isScreenReader = useIsScreenReaderEnabled(); + return probe; + } + + const inst = render(, { + internal_backend: backend, + exitOnCtrlC: false, + patchConsole: false, + stdout, + onRender: (m) => metrics.push(m.renderTime), + isScreenReaderEnabled: true, + alternateBuffer: true, + incrementalRendering: true, + }); + + await flushMicrotasks(10); + await pushInitialResize(backend); + + assert.equal(isScreenReader, true); + assert.ok(metrics.length >= 1); + assert.ok(metrics[0] !== undefined && metrics[0] >= 0); + assert.ok(writes.includes("\u001B[?1049h")); + + inst.unmount(); + await inst.waitUntilExit(); + + assert.ok(writes.includes("\u001B[?1049l")); + }); + + test("useStdin exposes meaningful raw-mode support and ref-counted toggles", async () => { + const backend = new StubBackend(); + const calls: string[] = []; + let stdinHook: { + setRawMode: (enabled: boolean) => void; + isRawModeSupported: boolean; + } | undefined; + + const stdin = { + isTTY: true, + setRawMode(value: boolean) { + calls.push(`setRawMode:${String(value)}`); + }, + setEncoding(value: string) { + calls.push(`setEncoding:${value}`); + }, + ref() { + calls.push("ref"); + }, + unref() { + calls.push("unref"); + }, + } as unknown as NodeJS.ReadStream; + + function Probe() { + stdinHook = useStdin(); + return stdin; + } + + const inst = render(, { + internal_backend: backend, + exitOnCtrlC: false, + stdin, + patchConsole: false, + }); + + await flushMicrotasks(10); + await pushInitialResize(backend); + + const hook = stdinHook as { + setRawMode: (enabled: boolean) => void; + isRawModeSupported: boolean; + }; + assert.equal(hook.isRawModeSupported, true); + + hook.setRawMode(true); + hook.setRawMode(true); + hook.setRawMode(false); + hook.setRawMode(false); + + assert.deepEqual(calls, ["setEncoding:utf8", "ref", "setRawMode:true", "setRawMode:false", "unref"]); + + inst.unmount(); + await inst.waitUntilExit(); + }); + + test("useStdin throws on unsupported raw mode", async () => { + const backend = new StubBackend(); + let stdinHook: { + setRawMode: (enabled: boolean) => void; + isRawModeSupported: boolean; + } | undefined; + + const stdin = { + isTTY: false, + } as unknown as NodeJS.ReadStream; + + function Probe() { + stdinHook = useStdin(); + return stdin; + } + + const inst = render(, { + internal_backend: backend, + exitOnCtrlC: false, + stdin, + patchConsole: false, + }); + + await flushMicrotasks(10); + await pushInitialResize(backend); + + const hook = stdinHook as { + setRawMode: (enabled: boolean) => void; + isRawModeSupported: boolean; + }; + assert.equal(hook.isRawModeSupported, false); + assert.throws(() => { + hook.setRawMode(true); + }, /Raw mode is not supported/); + + inst.unmount(); + await inst.waitUntilExit(); + }); +}); diff --git a/packages/ink-compat/src/__tests__/resizeObserver.test.tsx b/packages/ink-compat/src/__tests__/resizeObserver.test.tsx new file mode 100644 index 00000000..b9dd38e7 --- /dev/null +++ b/packages/ink-compat/src/__tests__/resizeObserver.test.tsx @@ -0,0 +1,97 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import React from "react"; +import { Box, ResizeObserver, Text, render, type DOMElement } from "../index.js"; +import { + StubBackend, + encodeZrevBatchV1, + flushMicrotasks, + makeBackendBatch, +} from "./testBackend.js"; + +async function pushInitialResize(backend: StubBackend): Promise { + backend.pushBatch( + makeBackendBatch( + encodeZrevBatchV1({ events: [{ kind: "resize", timeMs: 1, cols: 80, rows: 24 }] }), + ), + ); + await flushMicrotasks(10); +} + +describe("ResizeObserver", () => { + test("observe/unobserve/disconnect on committed layout changes", async () => { + const backend = new StubBackend(); + const ref = React.createRef(); + const seen: Array> = []; + + const inst = render( + + + x + + , + { internal_backend: backend, exitOnCtrlC: false }, + ); + + await flushMicrotasks(10); + await pushInitialResize(backend); + + const node = ref.current; + assert.ok(node); + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + seen.push(entry.contentRect); + } + }); + + observer.observe(node); + assert.equal(seen.length, 1); + assert.equal(seen[0]?.width, 4); + assert.equal(seen[0]?.height, 2); + + inst.rerender( + + + x + + , + ); + await flushMicrotasks(10); + + assert.equal(seen.length, 2); + assert.equal(seen[1]?.width, 8); + assert.equal(seen[1]?.height, 3); + + observer.unobserve(node); + inst.rerender( + + + x + + , + ); + await flushMicrotasks(10); + + assert.equal(seen.length, 2); + + observer.observe(node); + assert.equal(seen.length, 3); + assert.equal(seen[2]?.width, 10); + assert.equal(seen[2]?.height, 4); + + observer.disconnect(); + inst.rerender( + + + x + + , + ); + await flushMicrotasks(10); + + assert.equal(seen.length, 3); + + inst.unmount(); + await inst.waitUntilExit(); + }); +}); From 5d3a30b175ff430417b3216d2a41608b84ca5c14 Mon Sep 17 00:00:00 2001 From: Rezo Date: Wed, 11 Feb 2026 13:58:17 +0400 Subject: [PATCH 4/5] chore: remove temporary ink compat docs from tracked commits --- INK_COMPAT_PLAN.md | 54 ----------------------------- INK_COMPAT_REPORT.md | 82 -------------------------------------------- 2 files changed, 136 deletions(-) delete mode 100644 INK_COMPAT_PLAN.md delete mode 100644 INK_COMPAT_REPORT.md diff --git a/INK_COMPAT_PLAN.md b/INK_COMPAT_PLAN.md deleted file mode 100644 index 33d4871f..00000000 --- a/INK_COMPAT_PLAN.md +++ /dev/null @@ -1,54 +0,0 @@ -# INK_COMPAT_PLAN - -## Scope -Finish `@rezi-ui/ink-compat` toward near drop-in Ink parity for missing APIs/behaviors needed by downstream apps. - -## Gaps Identified -1. Missing API exports: `getBoundingBox`, `getScrollHeight`, `ResizeObserver`. -2. `measureElement` used props fallback, not committed layout. -3. No layout-change observer plumbing across rerenders/unmount. -4. Render options parity gaps: `onRender`, `isScreenReaderEnabled`, `alternateBuffer`, `incrementalRendering`. -5. `useStdin` raw-mode semantics were always no-op/unsupported. -6. No explicit smoke coverage for `ink-spinner`/`ink-gradient` usage patterns. - -## Design -1. Core->compat layout bridge (minimal internal plumbing) -- Add internal `createApp` callbacks: - - `internal_onRender(metrics)` for per-frame render timing. - - `internal_onLayout(snapshot)` for committed widget `id -> rect` map. -- Expose `WidgetRenderer.getRectByIdIndex()` to provide latest committed layout index. - -2. Host measurement model in ink-compat -- Add stable internal IDs on host elements in reconciler. -- Propagate IDs into converted Rezi VNodes (on the outer measurable box node). -- Maintain per-element committed layout metadata (`internal_layout`, `internal_scrollState`, size cache). -- Replace props-only measurement path with committed-layout reads. - -3. Resize observer semantics -- Add Ink-shaped `ResizeObserver`/`ResizeObserverEntry`. -- Support `observe`, `unobserve`, `disconnect`. -- Immediate callback on `observe()` when size is already known. -- Batch observer notifications after each committed layout snapshot. - -4. Render/useStdin parity -- Extend render options surface with Ink-like fields. -- Wire `onRender` callback to render timing from core submit pipeline. -- Add best-effort alternate buffer enter/exit sequences. -- Add screen-reader context + `useIsScreenReaderEnabled` hook. -- Implement meaningful `isRawModeSupported` and ref-counted `setRawMode`. -- Respect backend ownership model (avoid toggling `process.stdin` raw mode when backend owns it). - -## Sequence -1. Core internal callback plumbing. -2. Reconciler host metadata + ID propagation. -3. Measurement/scroll API implementation. -4. Resize observer implementation. -5. Render options + stdin raw-mode parity. -6. API/tests hardening (including spinner/gradient pattern smoke tests). -7. Document deviations and Gemini integration next step. - -## Risks -1. Ink scroll semantics are richer than current Rezi layout clipping behavior. -2. Alternate buffer behavior is best-effort because node backend stdio routing is backend-owned. -3. Raw-mode parity is constrained by backend ownership and non-plumbed custom stdin event path. -4. Observer ordering is close to Ink but not Yoga-identical for all edge cases. diff --git a/INK_COMPAT_REPORT.md b/INK_COMPAT_REPORT.md deleted file mode 100644 index 197604e9..00000000 --- a/INK_COMPAT_REPORT.md +++ /dev/null @@ -1,82 +0,0 @@ -# INK_COMPAT_REPORT - -## Implemented - -### 1) Export/API surface parity (G1) -- Added and exported: - - `getBoundingBox` - - `getScrollHeight` (plus `getScrollWidth` helper) - - `ResizeObserver` - - `ResizeObserverEntry` -- Added `useIsScreenReaderEnabled` export to support Ink-like screen-reader option usage. - -### 2) Real measurement pipeline (G2) -- Replaced props-only `measureElement` fallback with committed layout metadata. -- Added host-element internal layout state fed from current committed layout (`id -> rect`) after each frame. -- `measureElement` and `getBoundingBox` now read committed geometry. - -### 3) Observer + layout notifications (G3) -- Implemented Ink-shaped resize observer behavior: - - `observe`/`unobserve`/`disconnect` - - immediate callback on `observe` when size exists - - batched callbacks on committed size changes - - stable behavior across rerenders/unmounted subtree removal - -### 4) Render option parity (G4) -- Extended `RenderOptions` with Ink-like fields: - - `onRender` - - `isScreenReaderEnabled` - - `alternateBuffer` - - `alternateBufferAlreadyActive` - - `incrementalRendering` -- Implemented behavior: - - `onRender`: wired to core per-frame render timing callback. - - `isScreenReaderEnabled`: plumbed via context + `useIsScreenReaderEnabled`. - - `alternateBuffer`: best-effort ANSI enter/exit handling. - - `incrementalRendering`: accepted and typed; currently no backend-level incremental diffing. - -### 5) Stdin/raw-mode behavior (G5) -- `useStdin` now exposes meaningful `isRawModeSupported` (TTY + `setRawMode` capability). -- `setRawMode` now: - - throws on unsupported stdin (Ink-like behavior) - - reference-counts enable/disable calls - - avoids toggling raw mode on `process.stdin` when backend ownership applies - -### 6) ink-spinner / ink-gradient compatibility (G6) -- Verified against package surfaces (`ink-spinner@5.0.0`, `ink-gradient@3.0.0`) and added smoke coverage for their runtime patterns: - - spinner-style interval state updates with `` - - gradient-style `` text transformation -- No third-party package patches were required. - -### 7) Testing hardening (G7) -Added tests for: -- API presence/types -- committed measurement + bounding box updates -- resize observer semantics -- scroll metric behavior from committed layout -- render option hooks/alternate buffer/onRender -- raw-mode support and behavior -- spinner/gradient usage-pattern smoke tests - -## Core/runtime plumbing added -- `packages/core/src/index.ts` - - added internal app callback types on `AppConfig` (`internal_onRender`, `internal_onLayout`) -- `packages/core/src/app/createApp.ts` - - emits internal render metrics/layout snapshots -- `packages/core/src/app/widgetRenderer.ts` - - exposes latest committed `id -> rect` map - -## Remaining deviations from Ink -1. `incrementalRendering` is currently type/surface-compatible only (no true terminal diff pipeline). -2. `alternateBuffer` is best-effort and depends on backend/stdio ownership constraints. -3. Scroll metrics are based on current Rezi committed layout behavior; Yoga-specific scroll edge semantics may differ. -4. Raw-mode behavior intentionally preserves backend ownership for `process.stdin`, so exact Ink internals differ in that path. - -## Validation -Executed successfully: -- `npm run build` -- `npm run typecheck` -- `npm run test` - -## Recommended next step for Gemini integration -1. In Gemini, alias/import `ink` usage to `@rezi-ui/ink-compat` in one vertical slice (including one spinner/gradient view), then run its e2e rendering flow and catalog only remaining app-level incompatibilities. From a085c8b9d4619e307058b573bb9c70d3b1496114 Mon Sep 17 00:00:00 2001 From: Rezo Date: Wed, 11 Feb 2026 14:22:02 +0400 Subject: [PATCH 5/5] chore(ink-compat): fix biome lint --- .../src/__tests__/api.surface.test.ts | 2 +- .../src/__tests__/compat.thirdparty.test.tsx | 5 ++-- .../src/__tests__/measurement.test.tsx | 2 +- .../src/__tests__/render.parity.test.tsx | 28 +++++++++++++------ .../src/__tests__/resizeObserver.test.tsx | 2 +- packages/ink-compat/src/measurement.ts | 9 +++--- .../ink-compat/src/reconciler/hostConfig.ts | 2 +- packages/ink-compat/src/reconciler/types.ts | 8 +++--- packages/ink-compat/src/render.ts | 8 +++--- 9 files changed, 38 insertions(+), 28 deletions(-) diff --git a/packages/ink-compat/src/__tests__/api.surface.test.ts b/packages/ink-compat/src/__tests__/api.surface.test.ts index 7bc59ead..b1765595 100644 --- a/packages/ink-compat/src/__tests__/api.surface.test.ts +++ b/packages/ink-compat/src/__tests__/api.surface.test.ts @@ -1,10 +1,10 @@ import { assert, describe, test } from "@rezi-ui/testkit"; import { + type RenderOptions, ResizeObserver, getBoundingBox, getScrollHeight, render, - type RenderOptions, } from "../index.js"; describe("api surface", () => { diff --git a/packages/ink-compat/src/__tests__/compat.thirdparty.test.tsx b/packages/ink-compat/src/__tests__/compat.thirdparty.test.tsx index ce487ff3..b5567cad 100644 --- a/packages/ink-compat/src/__tests__/compat.thirdparty.test.tsx +++ b/packages/ink-compat/src/__tests__/compat.thirdparty.test.tsx @@ -1,5 +1,6 @@ import { assert, describe, test } from "@rezi-ui/testkit"; -import React, { useEffect, useState } from "react"; +import type React from "react"; +import { useEffect, useState } from "react"; import { Text, Transform, render } from "../index.js"; import { StubBackend, @@ -28,7 +29,7 @@ describe("third-party compatibility smoke", () => { setFrame((prev) => (prev + 1) % frames.length); }, 10); return () => clearInterval(timer); - }, []); + }, [frames.length]); return {frames[frame]}; } diff --git a/packages/ink-compat/src/__tests__/measurement.test.tsx b/packages/ink-compat/src/__tests__/measurement.test.tsx index 70daab36..90f79cf6 100644 --- a/packages/ink-compat/src/__tests__/measurement.test.tsx +++ b/packages/ink-compat/src/__tests__/measurement.test.tsx @@ -2,12 +2,12 @@ import { assert, describe, test } from "@rezi-ui/testkit"; import React from "react"; import { Box, + type DOMElement, Text, getBoundingBox, getScrollHeight, measureElement, render, - type DOMElement, } from "../index.js"; import { StubBackend, diff --git a/packages/ink-compat/src/__tests__/render.parity.test.tsx b/packages/ink-compat/src/__tests__/render.parity.test.tsx index c99274c3..0e773e2b 100644 --- a/packages/ink-compat/src/__tests__/render.parity.test.tsx +++ b/packages/ink-compat/src/__tests__/render.parity.test.tsx @@ -69,10 +69,12 @@ describe("render parity", () => { test("useStdin exposes meaningful raw-mode support and ref-counted toggles", async () => { const backend = new StubBackend(); const calls: string[] = []; - let stdinHook: { - setRawMode: (enabled: boolean) => void; - isRawModeSupported: boolean; - } | undefined; + let stdinHook: + | { + setRawMode: (enabled: boolean) => void; + isRawModeSupported: boolean; + } + | undefined; const stdin = { isTTY: true, @@ -116,7 +118,13 @@ describe("render parity", () => { hook.setRawMode(false); hook.setRawMode(false); - assert.deepEqual(calls, ["setEncoding:utf8", "ref", "setRawMode:true", "setRawMode:false", "unref"]); + assert.deepEqual(calls, [ + "setEncoding:utf8", + "ref", + "setRawMode:true", + "setRawMode:false", + "unref", + ]); inst.unmount(); await inst.waitUntilExit(); @@ -124,10 +132,12 @@ describe("render parity", () => { test("useStdin throws on unsupported raw mode", async () => { const backend = new StubBackend(); - let stdinHook: { - setRawMode: (enabled: boolean) => void; - isRawModeSupported: boolean; - } | undefined; + let stdinHook: + | { + setRawMode: (enabled: boolean) => void; + isRawModeSupported: boolean; + } + | undefined; const stdin = { isTTY: false, diff --git a/packages/ink-compat/src/__tests__/resizeObserver.test.tsx b/packages/ink-compat/src/__tests__/resizeObserver.test.tsx index b9dd38e7..c6c80913 100644 --- a/packages/ink-compat/src/__tests__/resizeObserver.test.tsx +++ b/packages/ink-compat/src/__tests__/resizeObserver.test.tsx @@ -1,6 +1,6 @@ import { assert, describe, test } from "@rezi-ui/testkit"; import React from "react"; -import { Box, ResizeObserver, Text, render, type DOMElement } from "../index.js"; +import { Box, type DOMElement, ResizeObserver, Text, render } from "../index.js"; import { StubBackend, encodeZrevBatchV1, diff --git a/packages/ink-compat/src/measurement.ts b/packages/ink-compat/src/measurement.ts index 1709e59a..da5b96f1 100644 --- a/packages/ink-compat/src/measurement.ts +++ b/packages/ink-compat/src/measurement.ts @@ -27,7 +27,9 @@ function readLayout(node: DOMElement): HostLayoutRect { return layout ?? ZERO_LAYOUT; } -export function measureElementFromLayout(node: DOMElement): Readonly<{ width: number; height: number }> { +export function measureElementFromLayout( + node: DOMElement, +): Readonly<{ width: number; height: number }> { const layout = readLayout(node); return { width: layout.width, height: layout.height }; } @@ -148,10 +150,7 @@ function updateNodeLayout( return layout; } -export function applyLayoutSnapshot( - root: HostRoot, - idRects: ReadonlyMap, -): void { +export function applyLayoutSnapshot(root: HostRoot, idRects: ReadonlyMap): void { const observerBatches = new Map(); for (const child of root.children) { diff --git a/packages/ink-compat/src/reconciler/hostConfig.ts b/packages/ink-compat/src/reconciler/hostConfig.ts index 3c082e02..0689e0dc 100644 --- a/packages/ink-compat/src/reconciler/hostConfig.ts +++ b/packages/ink-compat/src/reconciler/hostConfig.ts @@ -3,13 +3,13 @@ import { DefaultEventPriority } from "react-reconciler/constants.js"; import { InkCompatError } from "../errors.js"; import { convertRoot } from "./convert.js"; import { - allocateNodeId, type HostContext, type HostElement, type HostNode, type HostRoot, type HostText, type HostType, + allocateNodeId, appendChildNode, insertBeforeNode, removeChildNode, diff --git a/packages/ink-compat/src/reconciler/types.ts b/packages/ink-compat/src/reconciler/types.ts index 4c915caf..3f4e179e 100644 --- a/packages/ink-compat/src/reconciler/types.ts +++ b/packages/ink-compat/src/reconciler/types.ts @@ -72,7 +72,7 @@ export function appendChildNode( ): void { parent.children.push(child); if (parent.kind === "element") child.parentNode = parent; - else delete child.parentNode; + else Reflect.deleteProperty(child, "parentNode"); } export function insertBeforeNode( @@ -84,12 +84,12 @@ export function insertBeforeNode( if (idx < 0) { parent.children.push(child); if (parent.kind === "element") child.parentNode = parent; - else delete child.parentNode; + else Reflect.deleteProperty(child, "parentNode"); return; } parent.children.splice(idx, 0, child); if (parent.kind === "element") child.parentNode = parent; - else delete child.parentNode; + else Reflect.deleteProperty(child, "parentNode"); } export function removeChildNode( @@ -99,5 +99,5 @@ export function removeChildNode( const idx = parent.children.indexOf(child); if (idx < 0) return; parent.children.splice(idx, 1); - delete child.parentNode; + Reflect.deleteProperty(child, "parentNode"); } diff --git a/packages/ink-compat/src/render.ts b/packages/ink-compat/src/render.ts index 9fd0d827..892bae8c 100644 --- a/packages/ink-compat/src/render.ts +++ b/packages/ink-compat/src/render.ts @@ -19,9 +19,7 @@ type AppState = Readonly<{ vnode: VNode; consoleLines: readonly string[] }>; const ANSI_ALTERNATE_BUFFER_ENTER = "\u001B[?1049h"; const ANSI_ALTERNATE_BUFFER_EXIT = "\u001B[?1049l"; -function hasRawMode( - stdin: NodeJS.ReadStream, -): stdin is NodeJS.ReadStream & { +function hasRawMode(stdin: NodeJS.ReadStream): stdin is NodeJS.ReadStream & { isTTY: true; setRawMode: (value: boolean) => void; ref: () => void; @@ -56,7 +54,9 @@ export function render( const exitOnCtrlC = opts.exitOnCtrlC !== false; const maxFps = opts.maxFps ?? 60; const isScreenReaderEnabled = - opts.isScreenReaderEnabled ?? process.env["INK_SCREEN_READER"] === "true"; + opts.isScreenReaderEnabled ?? + // biome-ignore lint/complexity/useLiteralKeys: process.env is typed with an index signature under our TS config. + process.env["INK_SCREEN_READER"] === "true"; let alternateBufferActive = opts.alternateBufferAlreadyActive === true; const canUseAlternateBuffer =