diff --git a/packages/bench/src/io.ts b/packages/bench/src/io.ts index f4eb0f07..5cefd459 100644 --- a/packages/bench/src/io.ts +++ b/packages/bench/src/io.ts @@ -119,9 +119,8 @@ export async function createBenchBackend(): Promise { } const NodeBackend = await import("@rezi-ui/node"); - const executionModeEnv = ( - process.env as Readonly<{ REZI_BENCH_REZI_EXECUTION_MODE?: string }> - ).REZI_BENCH_REZI_EXECUTION_MODE; + const executionModeEnv = (process.env as Readonly<{ REZI_BENCH_REZI_EXECUTION_MODE?: string }>) + .REZI_BENCH_REZI_EXECUTION_MODE; const executionMode = executionModeEnv === "worker" ? "worker" : "inline"; const inner = NodeBackend.createNodeBackend({ // PTY mode already runs in a dedicated process, so prefer inline execution diff --git a/packages/bench/src/reziProfile.ts b/packages/bench/src/reziProfile.ts index 18cf3bca..c29956d3 100644 --- a/packages/bench/src/reziProfile.ts +++ b/packages/bench/src/reziProfile.ts @@ -63,4 +63,3 @@ export function emitReziPerfSnapshot( // Profiling is optional and must never affect benchmark execution. } } - diff --git a/packages/bench/src/scenarios/terminalVirtualList.ts b/packages/bench/src/scenarios/terminalVirtualList.ts index f9cd8727..25ace20f 100644 --- a/packages/bench/src/scenarios/terminalVirtualList.ts +++ b/packages/bench/src/scenarios/terminalVirtualList.ts @@ -135,7 +135,13 @@ async function runRezi( metrics.framesProduced = backend.frameCount - frameBase; metrics.bytesProduced = backend.totalFrameBytes - bytesBase; - emitReziPerfSnapshot(core, "terminal-virtual-list", { items: totalItems, viewport }, config, metrics); + emitReziPerfSnapshot( + core, + "terminal-virtual-list", + { items: totalItems, viewport }, + config, + metrics, + ); return metrics; } finally { await app.stop(); diff --git a/packages/core/src/app/widgetRenderer/submitFramePipeline.ts b/packages/core/src/app/widgetRenderer/submitFramePipeline.ts index 5777fba0..bfcd3c28 100644 --- a/packages/core/src/app/widgetRenderer/submitFramePipeline.ts +++ b/packages/core/src/app/widgetRenderer/submitFramePipeline.ts @@ -12,9 +12,8 @@ const HASH_FNV_PRIME = 0x01000193; const EMPTY_INSTANCE_IDS: readonly InstanceId[] = Object.freeze([]); const LAYOUT_SIG_INCLUDE_TEXT_WIDTH = (() => { try { - const raw = ( - globalThis as { process?: { env?: { REZI_LAYOUT_SIG_TEXT_WIDTH?: string } } } - ).process?.env?.REZI_LAYOUT_SIG_TEXT_WIDTH; + const raw = (globalThis as { process?: { env?: { REZI_LAYOUT_SIG_TEXT_WIDTH?: string } } }) + .process?.env?.REZI_LAYOUT_SIG_TEXT_WIDTH; // Default: treat plain (non-wrapped, unconstrained) text width changes as // paint-only, not layout-affecting. This avoids full relayout churn for // high-frequency text updates. diff --git a/packages/core/src/renderer/__tests__/renderPackets.test.ts b/packages/core/src/renderer/__tests__/renderPackets.test.ts index 5908cb15..e2602b39 100644 --- a/packages/core/src/renderer/__tests__/renderPackets.test.ts +++ b/packages/core/src/renderer/__tests__/renderPackets.test.ts @@ -229,11 +229,7 @@ describe("render packet retention", () => { firstKey, "key should remain stable when visual fields are unchanged despite new object identity", ); - assert.equal( - root.renderPacket, - firstPacket, - "packet should be reused when key matches", - ); + assert.equal(root.renderPacket, firstPacket, "packet should be reused when key matches"); }); test("packet invalidates when visual field changes", () => { diff --git a/packages/core/src/renderer/renderToDrawlist/renderPackets.ts b/packages/core/src/renderer/renderToDrawlist/renderPackets.ts index 14ebf23c..ec9eb32f 100644 --- a/packages/core/src/renderer/renderToDrawlist/renderPackets.ts +++ b/packages/core/src/renderer/renderToDrawlist/renderPackets.ts @@ -116,7 +116,8 @@ function hashPropsShallow(hash: number, props: Readonly> const keys = Object.keys(props); out = mixHash(out, keys.length); for (let i = 0; i < keys.length; i++) { - const key = keys[i]!; + const key = keys[i]; + if (key === undefined) continue; out = mixHash(out, hashString(key)); out = hashPropValue(out, props[key]); } @@ -250,12 +251,21 @@ function isTickDrivenKind(kind: RuntimeInstance["vnode"]["kind"]): boolean { * The text content itself is already hashed separately. */ function hashTextProps(hash: number, props: Readonly>): number { - const style = props["style"]; - const maxWidth = props["maxWidth"]; - const wrap = props["wrap"]; - const variant = props["variant"]; - const dim = props["dim"]; - const textOverflow = props["textOverflow"]; + const textProps = props as Readonly<{ + style?: unknown; + maxWidth?: unknown; + wrap?: unknown; + variant?: unknown; + dim?: unknown; + textOverflow?: unknown; + }>; + + const style = textProps.style; + const maxWidth = textProps.maxWidth; + const wrap = textProps.wrap; + const variant = textProps.variant; + const dim = textProps.dim; + const textOverflow = textProps.textOverflow; // Common case for plain text nodes with no explicit props. if ( diff --git a/packages/ink-compat/src/__tests__/integration/basic.test.ts b/packages/ink-compat/src/__tests__/integration/basic.test.ts index 32b471d8..4d7eafc2 100644 --- a/packages/ink-compat/src/__tests__/integration/basic.test.ts +++ b/packages/ink-compat/src/__tests__/integration/basic.test.ts @@ -577,6 +577,100 @@ test("runtime render resolves nested percent sizing from resolved parent layout" } }); +test("runtime render re-resolves percent sizing when parent layout changes (no frame lag)", async () => { + const stdin = new PassThrough() as PassThrough & { setRawMode: (enabled: boolean) => void }; + stdin.setRawMode = () => {}; + + const stdout = new PassThrough() as PassThrough & { + columns?: number; + rows?: number; + }; + stdout.columns = 80; + stdout.rows = 24; + + const stderr = new PassThrough(); + + let parentNode: InkHostNode | null = null; + let childNode: InkHostNode | null = null; + + function App(props: { parentWidth: number }): React.ReactElement { + const parentRef = React.useRef(null); + const childRef = React.useRef(null); + + useEffect(() => { + parentNode = parentRef.current; + childNode = childRef.current; + }); + + return React.createElement( + Box, + { ref: parentRef, width: props.parentWidth, flexDirection: "row" }, + React.createElement( + Box, + { ref: childRef, width: "50%" }, + React.createElement(Text, null, "Child"), + ), + ); + } + + const instance = runtimeRender(React.createElement(App, { parentWidth: 20 }), { + stdin, + stdout, + stderr, + }); + try { + await new Promise((resolve) => setTimeout(resolve, 60)); + assert.ok(parentNode != null, "parent ref should be set"); + assert.ok(childNode != null, "child ref should be set"); + assert.equal(measureElement(parentNode).width, 20); + assert.equal(measureElement(childNode).width, 10); + + instance.rerender(React.createElement(App, { parentWidth: 30 })); + await new Promise((resolve) => setTimeout(resolve, 60)); + assert.equal(measureElement(parentNode).width, 30); + assert.equal(measureElement(childNode).width, 15); + } finally { + instance.unmount(); + instance.cleanup(); + } +}); + +test("runtime render layout generations hide stale layout for removed nodes", async () => { + const stdin = new PassThrough() as PassThrough & { setRawMode: (enabled: boolean) => void }; + stdin.setRawMode = () => {}; + const stdout = new PassThrough(); + const stderr = new PassThrough(); + + let removedNode: InkHostNode | null = null; + + function Before(): React.ReactElement { + const removedRef = React.useRef(null); + useEffect(() => { + removedNode = removedRef.current; + }); + return React.createElement( + Box, + { ref: removedRef, width: 22 }, + React.createElement(Text, null, "Before"), + ); + } + + const instance = runtimeRender(React.createElement(Before), { stdin, stdout, stderr }); + try { + await new Promise((resolve) => setTimeout(resolve, 40)); + assert.ok(removedNode != null, "removed node ref should be set"); + assert.equal(measureElement(removedNode).width, 22); + + instance.rerender(React.createElement(Text, null, "After")); + await new Promise((resolve) => setTimeout(resolve, 40)); + + assert.deepEqual(measureElement(removedNode), { width: 0, height: 0 }); + } finally { + instance.unmount(); + instance.cleanup(); + } +}); + test("render option isScreenReaderEnabled flows to hook context", async () => { const stdin = new PassThrough() as PassThrough & { setRawMode: (enabled: boolean) => void }; stdin.setRawMode = () => {}; @@ -948,6 +1042,40 @@ test("rerender updates output", () => { assert.match(result.lastFrame(), /New/); }); +test("rendering identical tree keeps ANSI frame bytes stable", async () => { + const element = React.createElement( + Box, + { flexDirection: "row" }, + React.createElement(Text, { color: "green", bold: true }, "Left"), + React.createElement(Text, null, " "), + React.createElement(Text, null, "\u001b[31mRight\u001b[0m"), + ); + + const captureFrame = async (): Promise => { + const stdin = new PassThrough() as PassThrough & { setRawMode: (enabled: boolean) => void }; + stdin.setRawMode = () => {}; + const stdout = new PassThrough(); + const stderr = new PassThrough(); + let writes = ""; + stdout.on("data", (chunk) => { + writes += chunk.toString("utf-8"); + }); + + const instance = runtimeRender(element, { stdin, stdout, stderr }); + try { + await new Promise((resolve) => setTimeout(resolve, 30)); + return latestFrameFromWrites(writes); + } finally { + instance.unmount(); + instance.cleanup(); + } + }; + + const firstFrame = await captureFrame(); + const secondFrame = await captureFrame(); + assert.equal(secondFrame, firstFrame); +}); + test("runtime Static emits only new items on rerender", async () => { interface Item { id: string; @@ -1138,6 +1266,45 @@ test("ANSI output resets attributes between differently-styled cells", () => { // ─── Regression: text inherits background from underlying fillRect ─── +test("nested non-overlapping clips do not leak text", async () => { + const stdin = new PassThrough() as PassThrough & { setRawMode: (enabled: boolean) => void }; + stdin.setRawMode = () => {}; + const stdout = new PassThrough(); + const stderr = new PassThrough(); + let writes = ""; + stdout.on("data", (chunk) => { + writes += chunk.toString("utf-8"); + }); + + const instance = runtimeRender( + React.createElement( + Box, + { width: 4, height: 1, overflow: "hidden" }, + React.createElement( + Box, + { position: "absolute", left: 10, top: 0, width: 4, height: 1, overflow: "hidden" }, + React.createElement(Text, null, "LEAK"), + ), + ), + { stdin, stdout, stderr }, + ); + + try { + await new Promise((resolve) => { + if (writes.length > 0) { + resolve(); + return; + } + stdout.once("data", () => resolve()); + }); + const latest = stripTerminalEscapes(latestFrameFromWrites(writes)); + assert.equal(latest.includes("LEAK"), false, `unexpected clipped leak in output: ${latest}`); + } finally { + instance.unmount(); + instance.cleanup(); + } +}); + test("text over backgroundColor box preserves box background in ANSI output", () => { const previousNoColor = process.env["NO_COLOR"]; const previousForceColor = process.env["FORCE_COLOR"]; diff --git a/packages/ink-compat/src/__tests__/perf/bottleneck-profile.test.ts b/packages/ink-compat/src/__tests__/perf/bottleneck-profile.test.ts new file mode 100644 index 00000000..e4da372d --- /dev/null +++ b/packages/ink-compat/src/__tests__/perf/bottleneck-profile.test.ts @@ -0,0 +1,1106 @@ +import assert from "node:assert/strict"; +/** + * Micro-benchmarks proving the identified bottlenecks in ink-compat. + * + * Run with: npx tsx --test packages/ink-compat/src/__tests__/perf/bottleneck-profile.test.ts + */ +import { describe, it } from "node:test"; +import { type VNode, createTestRenderer } from "@rezi-ui/core"; +import { + type InkHostContainer, + type InkHostNode, + appendChild, + createHostContainer, + createHostNode, + setNodeProps, + setNodeTextContent, +} from "../../reconciler/types.js"; +import { + advanceLayoutGeneration, + readCurrentLayout, + writeCurrentLayout, +} from "../../runtime/layoutState.js"; +import { + __inkCompatTranslationTestHooks, + translateDynamicTreeWithMetadata, + translateTree, +} from "../../translation/propsToVNode.js"; + +// ─── Bottleneck 1: stylesEqual with JSON.stringify ─── + +interface CellStyle { + fg?: { r: number; g: number; b: number }; + bg?: { r: number; g: number; b: number }; + bold?: boolean; + dim?: boolean; + italic?: boolean; + underline?: boolean; + strikethrough?: boolean; + inverse?: boolean; +} + +// Current implementation (from render.ts:1203) +function stylesEqual_CURRENT(a: CellStyle | undefined, b: CellStyle | undefined): boolean { + if (a === b) return true; + if (!a || !b) return false; + + const keysA = Object.keys(a).sort(); + const keysB = Object.keys(b).sort(); + if (keysA.length !== keysB.length) return false; + + for (let i = 0; i < keysA.length; i += 1) { + const key = keysA[i]!; + if (key !== keysB[i]) return false; + if ( + JSON.stringify((a as Record)[key]) !== + JSON.stringify((b as Record)[key]) + ) { + return false; + } + } + return true; +} + +// Proposed fix: direct field comparison +function rgbEqual( + a: { r: number; g: number; b: number } | undefined, + b: { r: number; g: number; b: number } | undefined, +): boolean { + if (a === b) return true; + if (!a || !b) return false; + return a.r === b.r && a.g === b.g && a.b === b.b; +} + +function stylesEqual_FIXED(a: CellStyle | undefined, b: CellStyle | undefined): boolean { + if (a === b) return true; + if (!a || !b) return false; + return ( + a.bold === b.bold && + a.dim === b.dim && + a.italic === b.italic && + a.underline === b.underline && + a.strikethrough === b.strikethrough && + a.inverse === b.inverse && + rgbEqual(a.fg, b.fg) && + rgbEqual(a.bg, b.bg) + ); +} + +// ─── Bottleneck 2: stylesEqual in propsToVNode ─── + +interface TextStyleMap { + fg?: { r: number; g: number; b: number }; + bg?: { r: number; g: number; b: number }; + bold?: boolean; + italic?: boolean; + underline?: boolean; + strikethrough?: boolean; + dim?: boolean; + inverse?: boolean; + [key: string]: unknown; +} + +function textStylesEqual_CURRENT(a: TextStyleMap, b: TextStyleMap): boolean { + const keysA = Object.keys(a).sort(); + const keysB = Object.keys(b).sort(); + if (keysA.length !== keysB.length) return false; + for (let i = 0; i < keysA.length; i += 1) { + const key = keysA[i]!; + if (key !== keysB[i]) return false; + if (JSON.stringify(a[key]) !== JSON.stringify(b[key])) return false; + } + return true; +} + +function textStylesEqual_FIXED(a: TextStyleMap, b: TextStyleMap): boolean { + if (a === b) return true; + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + for (const key of keysA) { + if (!(key in b)) return false; + if (key === "fg" || key === "bg") { + if ( + !rgbEqual( + a[key] as { r: number; g: number; b: number } | undefined, + b[key] as { r: number; g: number; b: number } | undefined, + ) + ) { + return false; + } + continue; + } + if (!Object.is(a[key], b[key])) return false; + } + return true; +} + +// ─── Bottleneck 3: Grid allocation ─── + +interface StyledCell { + char: string; + style: CellStyle | undefined; +} + +function allocateGrid_CURRENT(cols: number, rows: number): StyledCell[][] { + const grid: StyledCell[][] = []; + for (let y = 0; y < rows; y++) { + const row: StyledCell[] = []; + for (let x = 0; x < cols; x++) { + row.push({ char: " ", style: undefined }); + } + grid.push(row); + } + return grid; +} + +let reusableGrid: StyledCell[][] = []; +let reusableCols = 0; +let reusableRows = 0; + +function allocateGrid_REUSE(cols: number, rows: number): StyledCell[][] { + if (cols === reusableCols && rows === reusableRows) { + for (let y = 0; y < rows; y++) { + const row = reusableGrid[y]!; + for (let x = 0; x < cols; x++) { + const cell = row[x]!; + cell.char = " "; + cell.style = undefined; + } + } + return reusableGrid; + } + reusableGrid = []; + for (let y = 0; y < rows; y++) { + const row: StyledCell[] = []; + for (let x = 0; x < cols; x++) { + row.push({ char: " ", style: undefined }); + } + reusableGrid.push(row); + } + reusableCols = cols; + reusableRows = rows; + return reusableGrid; +} + +// ─── Bottleneck 7: mergeCellStyles ─── + +function mergeCellStyles_CURRENT( + base: CellStyle | undefined, + overlay: CellStyle | undefined, +): CellStyle | undefined { + if (!overlay && !base) return undefined; + if (!overlay) return base; + if (!base) return overlay; + + const merged: CellStyle = {}; + const bg = overlay.bg ?? base.bg; + const fg = overlay.fg ?? base.fg; + if (bg) merged.bg = bg; + if (fg) merged.fg = fg; + if (overlay.bold ?? base.bold) merged.bold = true; + if (overlay.dim ?? base.dim) merged.dim = true; + if (overlay.italic ?? base.italic) merged.italic = true; + if (overlay.underline ?? base.underline) merged.underline = true; + if (overlay.strikethrough ?? base.strikethrough) merged.strikethrough = true; + if (overlay.inverse ?? base.inverse) merged.inverse = true; + return Object.keys(merged).length > 0 ? merged : undefined; +} + +// ─── Benchmarking harness ─── + +const FIXED_WARMUP_ITERATIONS = 500; + +function bench(name: string, fn: () => void, iterations: number): number { + // Warmup + for (let i = 0; i < Math.min(FIXED_WARMUP_ITERATIONS, iterations); i++) fn(); + + const start = performance.now(); + for (let i = 0; i < iterations; i++) fn(); + const elapsed = performance.now() - start; + const perOp = (elapsed / iterations) * 1_000_000; // nanoseconds + return perOp; +} + +describe("ink-compat bottleneck profiling", () => { + it("Bottleneck 1: stylesEqual — JSON.stringify vs direct comparison", () => { + const a: CellStyle = { + fg: { r: 255, g: 0, b: 0 }, + bg: { r: 0, g: 0, b: 0 }, + bold: true, + }; + const b: CellStyle = { + fg: { r: 255, g: 0, b: 0 }, + bg: { r: 0, g: 0, b: 0 }, + bold: true, + }; + const N = 100_000; + + const currentNs = bench("current", () => stylesEqual_CURRENT(a, b), N); + const fixedNs = bench("fixed", () => stylesEqual_FIXED(a, b), N); + const speedup = currentNs / fixedNs; + + console.log(" stylesEqual (render.ts):"); + console.log(` CURRENT (JSON.stringify): ${currentNs.toFixed(0)} ns/op`); + console.log(` FIXED (direct fields): ${fixedNs.toFixed(0)} ns/op`); + console.log(` Speedup: ${speedup.toFixed(1)}x`); + console.log( + ` Per-frame savings (1920 cells): ${(((currentNs - fixedNs) * 1920) / 1_000_000).toFixed(2)} ms`, + ); + + // The fixed version must produce the same result + assert.equal(stylesEqual_CURRENT(a, b), stylesEqual_FIXED(a, b)); + assert.equal(stylesEqual_CURRENT(a, undefined), stylesEqual_FIXED(a, undefined)); + assert.equal(stylesEqual_CURRENT(undefined, b), stylesEqual_FIXED(undefined, b)); + assert.equal( + stylesEqual_CURRENT(undefined, undefined), + stylesEqual_FIXED(undefined, undefined), + ); + + const c: CellStyle = { fg: { r: 0, g: 255, b: 0 } }; + assert.equal(stylesEqual_CURRENT(a, c), stylesEqual_FIXED(a, c)); + + assert.ok(speedup > 1.1, `Expected at least 1.1x speedup, got ${speedup.toFixed(1)}x`); + }); + + it("Bottleneck 1b: stylesEqual — undefined vs undefined (common case)", () => { + const N = 100_000; + + const currentNs = bench("current", () => stylesEqual_CURRENT(undefined, undefined), N); + const fixedNs = bench("fixed", () => stylesEqual_FIXED(undefined, undefined), N); + const speedup = currentNs / fixedNs; + + console.log(" stylesEqual (undefined vs undefined):"); + console.log(` CURRENT: ${currentNs.toFixed(0)} ns/op`); + console.log(` FIXED: ${fixedNs.toFixed(0)} ns/op`); + console.log(` Speedup: ${speedup.toFixed(1)}x`); + }); + + it("Bottleneck 2: textStylesEqual — same pattern in translation", () => { + const a: TextStyleMap = { + fg: { r: 255, g: 128, b: 0 }, + bold: true, + dim: false, + }; + const b: TextStyleMap = { + fg: { r: 255, g: 128, b: 0 }, + bold: true, + dim: false, + }; + const N = 100_000; + + const currentNs = bench("current", () => textStylesEqual_CURRENT(a, b), N); + const fixedNs = bench("fixed", () => textStylesEqual_FIXED(a, b), N); + const speedup = currentNs / fixedNs; + + console.log(" textStylesEqual (propsToVNode.ts):"); + console.log(` CURRENT (JSON.stringify): ${currentNs.toFixed(0)} ns/op`); + console.log(` FIXED (direct fields): ${fixedNs.toFixed(0)} ns/op`); + console.log(` Speedup: ${speedup.toFixed(1)}x`); + + assert.equal(textStylesEqual_CURRENT(a, b), textStylesEqual_FIXED(a, b)); + assert.ok(speedup > 1.1, `Expected at least 1.1x speedup, got ${speedup.toFixed(1)}x`); + }); + + it("Bottleneck 3: grid allocation — new objects vs reuse", () => { + const cols = 120; + const rows = 40; + const N = 1_000; + + const currentNs = bench("current", () => allocateGrid_CURRENT(cols, rows), N); + const fixedNs = bench("fixed", () => allocateGrid_REUSE(cols, rows), N); + const speedup = currentNs / fixedNs; + + console.log(` Grid allocation (${cols}x${rows} = ${cols * rows} cells):`); + console.log(` CURRENT (new objects): ${(currentNs / 1000).toFixed(0)} µs/frame`); + console.log(` FIXED (reuse): ${(fixedNs / 1000).toFixed(0)} µs/frame`); + console.log(` Speedup: ${speedup.toFixed(1)}x`); + + assert.ok(speedup > 1.1, `Expected at least 1.1x speedup, got ${speedup.toFixed(1)}x`); + }); + + it("Bottleneck 7: mergeCellStyles — fast path when base is undefined", () => { + const overlay: CellStyle = { fg: { r: 255, g: 0, b: 0 }, bold: true }; + const N = 100_000; + + // Common case: drawing text on blank cell (base = undefined) + const currentNs = bench("current", () => mergeCellStyles_CURRENT(undefined, overlay), N); + + // With the fast path, !base returns overlay directly + const fastPathNs = bench( + "fast-path", + () => { + // This is what the fix does: + const base = undefined; + if (!base) return overlay; // fast path + return mergeCellStyles_CURRENT(base, overlay); + }, + N, + ); + + console.log(" mergeCellStyles (base=undefined, common case):"); + console.log(` CURRENT: ${currentNs.toFixed(0)} ns/op`); + console.log(` FAST: ${fastPathNs.toFixed(0)} ns/op`); + + // When base IS present + const base: CellStyle = { bg: { r: 0, g: 0, b: 40 } }; + const mergeNs = bench("merge", () => mergeCellStyles_CURRENT(base, overlay), N); + console.log(` MERGE (base+overlay): ${mergeNs.toFixed(0)} ns/op`); + }); + + it("Bottleneck 8: inClipStack per-cell vs pre-computed clip rect", () => { + interface ClipRect { + x: number; + y: number; + w: number; + h: number; + } + + function inClipStack_CURRENT(x: number, y: number, clipStack: readonly ClipRect[]): boolean { + for (const clip of clipStack) { + if (x < clip.x || x >= clip.x + clip.w || y < clip.y || y >= clip.y + clip.h) return false; + } + return true; + } + + function computeEffectiveClip(clipStack: readonly ClipRect[]): ClipRect | null { + if (clipStack.length === 0) return null; + let x1 = clipStack[0]!.x; + let y1 = clipStack[0]!.y; + let x2 = x1 + clipStack[0]!.w; + let y2 = y1 + clipStack[0]!.h; + for (let i = 1; i < clipStack.length; i++) { + const c = clipStack[i]!; + x1 = Math.max(x1, c.x); + y1 = Math.max(y1, c.y); + x2 = Math.min(x2, c.x + c.w); + y2 = Math.min(y2, c.y + c.h); + } + if (x1 >= x2 || y1 >= y2) return null; + return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 }; + } + + const clips: ClipRect[] = [ + { x: 0, y: 0, w: 120, h: 40 }, + { x: 5, y: 2, w: 100, h: 30 }, + { x: 10, y: 5, w: 80, h: 20 }, + ]; + const W = 80; + const H = 20; + const N = 500; + + const currentNs = bench( + "current", + () => { + let count = 0; + for (let y = 0; y < H; y++) { + for (let x = 0; x < W; x++) { + if (inClipStack_CURRENT(x + 10, y + 5, clips)) count++; + } + } + return count; + }, + N, + ); + + const fixedNs = bench( + "fixed", + () => { + const eff = computeEffectiveClip(clips); + if (!eff) return 0; + let count = 0; + for (let y = 0; y < H; y++) { + for (let x = 0; x < W; x++) { + const px = x + 10; + const py = y + 5; + if (px >= eff.x && px < eff.x + eff.w && py >= eff.y && py < eff.y + eff.h) count++; + } + } + return count; + }, + N, + ); + + const speedup = currentNs / fixedNs; + console.log(` inClipStack (${W}x${H} = ${W * H} cells, ${clips.length} clips):`); + console.log(` CURRENT (loop per cell): ${(currentNs / 1000).toFixed(0)} µs/frame`); + console.log(` FIXED (pre-computed): ${(fixedNs / 1000).toFixed(0)} µs/frame`); + console.log(` Speedup: ${speedup.toFixed(1)}x`); + }); + + it("Bottleneck 9: styleToSgr — cached vs uncached", () => { + type Rgb = { r: number; g: number; b: number }; + type ColorSupport = { level: 0 | 1 | 2 | 3; noColor: boolean }; + + function clampByte(v: number): number { + return Math.max(0, Math.min(255, Math.round(v))); + } + + function styleToSgr_CURRENT(style: CellStyle | undefined, cs: ColorSupport): string { + if (!style) return "\u001b[0m"; + const codes: string[] = []; + if (style.bold) codes.push("1"); + if (style.dim) codes.push("2"); + if (style.italic) codes.push("3"); + if (style.underline) codes.push("4"); + if (style.inverse) codes.push("7"); + if (style.strikethrough) codes.push("9"); + if (cs.level > 0 && style.fg) { + codes.push( + `38;2;${clampByte(style.fg.r)};${clampByte(style.fg.g)};${clampByte(style.fg.b)}`, + ); + } + if (cs.level > 0 && style.bg) { + codes.push( + `48;2;${clampByte(style.bg.r)};${clampByte(style.bg.g)};${clampByte(style.bg.b)}`, + ); + } + if (codes.length === 0) return "\u001b[0m"; + return `\u001b[0;${codes.join(";")}m`; + } + + // Identity cache by style object reference. This only helps when callers + // reuse CellStyle objects; creating fresh style objects per cell will miss. + // That tradeoff is acceptable for this benchmark's demonstration. + const sgrCache = new Map(); + function styleToSgr_CACHED(style: CellStyle | undefined, cs: ColorSupport): string { + if (!style) return "\u001b[0m"; + const cached = sgrCache.get(style); + if (cached !== undefined) return cached; + const result = styleToSgr_CURRENT(style, cs); + sgrCache.set(style, result); + return result; + } + + const cs: ColorSupport = { level: 3, noColor: false }; + const style: CellStyle = { fg: { r: 255, g: 0, b: 0 }, bold: true }; + const N = 100_000; + + const currentNs = bench("current", () => styleToSgr_CURRENT(style, cs), N); + sgrCache.clear(); + const cachedNs = bench("cached", () => styleToSgr_CACHED(style, cs), N); + const speedup = currentNs / cachedNs; + + console.log(" styleToSgr (truecolor, bold+fg):"); + console.log(` CURRENT (rebuild): ${currentNs.toFixed(0)} ns/op`); + console.log(` CACHED (identity): ${cachedNs.toFixed(0)} ns/op`); + console.log(` Speedup: ${speedup.toFixed(1)}x`); + }); + + it("Combined: estimated per-frame savings (80x24 viewport)", () => { + // Informational single-pass estimate (not a strict benchmark assertion). + // Simulate a typical frame with 1920 cells + const CELLS = 80 * 24; + const style: CellStyle = { fg: { r: 255, g: 128, b: 0 }, bold: true }; + const style2: CellStyle = { fg: { r: 255, g: 128, b: 0 }, bold: true }; + + // Current: stylesEqual with JSON.stringify for each cell + const t1 = performance.now(); + for (let i = 0; i < CELLS; i++) { + stylesEqual_CURRENT(style, style2); + } + const currentStyleMs = performance.now() - t1; + + // Fixed: direct comparison + const t2 = performance.now(); + for (let i = 0; i < CELLS; i++) { + stylesEqual_FIXED(style, style2); + } + const fixedStyleMs = performance.now() - t2; + + // Current: grid allocation + const t3 = performance.now(); + allocateGrid_CURRENT(80, 24); + const currentGridMs = performance.now() - t3; + + // Fixed: grid reuse + reusableCols = 0; // force first allocation + allocateGrid_REUSE(80, 24); + const t4 = performance.now(); + allocateGrid_REUSE(80, 24); // second call — reuse + const fixedGridMs = performance.now() - t4; + + console.log("\n === Estimated per-frame savings (80x24) ==="); + console.log( + ` stylesEqual: ${currentStyleMs.toFixed(3)} ms → ${fixedStyleMs.toFixed(3)} ms (saved ${(currentStyleMs - fixedStyleMs).toFixed(3)} ms)`, + ); + console.log( + ` grid alloc: ${currentGridMs.toFixed(3)} ms → ${fixedGridMs.toFixed(3)} ms (saved ${(currentGridMs - fixedGridMs).toFixed(3)} ms)`, + ); + console.log( + ` total saved: ~${(currentStyleMs - fixedStyleMs + currentGridMs - fixedGridMs).toFixed(3)} ms/frame`, + ); + console.log( + ` at 30fps, that's ${((currentStyleMs - fixedStyleMs + currentGridMs - fixedGridMs) * 30).toFixed(1)} ms/sec overhead eliminated`, + ); + }); +}); + +interface LegacyTextSpan { + text: string; + style: Record; +} + +const LEGACY_ANSI_SGR_REGEX = /\u001b\[([0-9:;]*)m/g; + +function sanitizeAnsiInputLegacy(input: string): string { + const ESC = 0x1b; + let output: string[] | null = null; + let runStart = 0; + let index = 0; + + while (index < input.length) { + const code = input.charCodeAt(index); + + if (code === ESC) { + const next = input[index + 1]; + if (next === "[") { + const csiEnd = findCsiEndIndexLegacy(input, index + 2); + if (csiEnd === -1) { + if (!output) { + output = []; + if (index > 0) output.push(input.slice(0, index)); + } else if (runStart < index) { + output.push(input.slice(runStart, index)); + } + index = input.length; + runStart = index; + break; + } + + const keep = input[csiEnd] === "m"; + if (output) { + if (runStart < index) output.push(input.slice(runStart, index)); + if (keep) output.push(input.slice(index, csiEnd + 1)); + } else if (!keep) { + output = []; + if (index > 0) output.push(input.slice(0, index)); + } + + index = csiEnd + 1; + runStart = index; + continue; + } + + if (next === "]") { + const oscEnd = findOscEndIndexLegacy(input, index + 2); + if (oscEnd === -1) { + if (!output) { + output = []; + if (index > 0) output.push(input.slice(0, index)); + } else if (runStart < index) { + output.push(input.slice(runStart, index)); + } + index = input.length; + runStart = index; + break; + } + + if (output) { + if (runStart < index) output.push(input.slice(runStart, index)); + output.push(input.slice(index, oscEnd)); + } + + index = oscEnd; + runStart = index; + continue; + } + + if (!output) { + output = []; + if (index > 0) output.push(input.slice(0, index)); + } else if (runStart < index) { + output.push(input.slice(runStart, index)); + } + index += next == null ? 1 : 2; + runStart = index; + continue; + } + + if (code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d) { + if (!output) { + output = []; + if (index > 0) output.push(input.slice(0, index)); + } else if (runStart < index) { + output.push(input.slice(runStart, index)); + } + index += 1; + runStart = index; + continue; + } + + index += 1; + } + + if (!output) return input; + if (runStart < input.length) output.push(input.slice(runStart)); + return output.join(""); +} + +function findCsiEndIndexLegacy(input: string, start: number): number { + for (let index = start; index < input.length; index += 1) { + const code = input.charCodeAt(index); + if (code >= 0x40 && code <= 0x7e) { + return index; + } + } + return -1; +} + +function findOscEndIndexLegacy(input: string, start: number): number { + for (let index = start; index < input.length; index += 1) { + const code = input.charCodeAt(index); + if (code === 0x07) { + return index + 1; + } + if (code === 0x1b && input[index + 1] === "\\") { + return index + 2; + } + } + return -1; +} + +function appendStyledTextLegacy( + spans: LegacyTextSpan[], + text: string, + style: Record, +): void { + if (text.length === 0) return; + const previous = spans[spans.length - 1]; + if (previous && JSON.stringify(previous.style) === JSON.stringify(style)) { + previous.text += text; + return; + } + spans.push({ text, style: { ...style } }); +} + +function parseAnsiTextLegacy( + text: string, + baseStyle: Record, +): { + spans: LegacyTextSpan[]; + fullText: string; +} { + if (text.length === 0) return { spans: [], fullText: "" }; + + const sanitized = sanitizeAnsiInputLegacy(text); + if (sanitized.length === 0) return { spans: [], fullText: "" }; + + const spans: LegacyTextSpan[] = []; + let fullText = ""; + let lastIndex = 0; + let hadAnsiMatch = false; + const activeStyle: Record = { ...baseStyle }; + + LEGACY_ANSI_SGR_REGEX.lastIndex = 0; + for (const match of sanitized.matchAll(LEGACY_ANSI_SGR_REGEX)) { + const index = match.index; + if (index == null) continue; + hadAnsiMatch = true; + + const plain = sanitized.slice(lastIndex, index); + if (plain.length > 0) { + appendStyledTextLegacy(spans, plain, activeStyle); + fullText += plain; + } + + // This perf baseline only targets the no-ANSI path, so code application is omitted. + lastIndex = index + match[0].length; + } + + const trailing = sanitized.slice(lastIndex); + if (trailing.length > 0) { + appendStyledTextLegacy(spans, trailing, activeStyle); + fullText += trailing; + } + + if (spans.length === 0 && !hadAnsiMatch) { + appendStyledTextLegacy(spans, sanitized, baseStyle); + fullText = sanitized; + } + + return { spans, fullText }; +} + +function collectHostNodes(container: InkHostContainer): InkHostNode[] { + const out: InkHostNode[] = []; + const stack = [...container.children]; + while (stack.length > 0) { + const node = stack.pop(); + if (!node) continue; + out.push(node); + for (let index = node.children.length - 1; index >= 0; index -= 1) { + const child = node.children[index]; + if (child) stack.push(child); + } + } + return out; +} + +function buildLargeHostTree( + rows: number, + cols: number, +): { + container: InkHostContainer; + leaves: InkHostNode[]; +} { + const container = createHostContainer(); + const root = createHostNode("ink-box", { flexDirection: "column" }); + appendChild(container, root); + + const leaves: InkHostNode[] = []; + for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) { + const row = createHostNode("ink-box", { flexDirection: "row" }); + appendChild(root, row); + for (let colIndex = 0; colIndex < cols; colIndex += 1) { + const textNode = createHostNode("ink-text", {}); + const leaf = createHostNode("ink-text", {}); + setNodeTextContent(leaf, `cell-${rowIndex}-${colIndex}`); + appendChild(textNode, leaf); + appendChild(row, textNode); + leaves.push(leaf); + } + } + + return { container, leaves }; +} + +function legacyScanStaticAndAnsi(rootNode: InkHostContainer): { + hasStaticNodes: boolean; + hasAnsiSgr: boolean; +} { + const ANSI_DETECT = /\u001b\[[0-9:;]*m/; + let hasStaticNodes = false; + let hasAnsiSgr = false; + const stack = [...rootNode.children]; + while (stack.length > 0) { + const node = stack.pop(); + if (!node) continue; + if (!hasStaticNodes && node.type === "ink-box" && node.props["__inkStatic"] === true) { + hasStaticNodes = true; + } + if (!hasAnsiSgr && typeof node.textContent === "string" && ANSI_DETECT.test(node.textContent)) { + hasAnsiSgr = true; + } + if (hasStaticNodes && hasAnsiSgr) break; + for (let index = node.children.length - 1; index >= 0; index -= 1) { + const child = node.children[index]; + if (child) stack.push(child); + } + } + return { hasStaticNodes, hasAnsiSgr }; +} + +function clearHostLayoutsLegacy(container: InkHostContainer): void { + const stack = [...container.children]; + while (stack.length > 0) { + const node = stack.pop(); + if (!node) continue; + delete (node as InkHostNode & { __inkLayout?: unknown }).__inkLayout; + delete (node as InkHostNode & { __inkLayoutGen?: unknown }).__inkLayoutGen; + for (let index = node.children.length - 1; index >= 0; index -= 1) { + const child = node.children[index]; + if (child) stack.push(child); + } + } +} + +function fillRowLoop(row: T[], start: number, end: number, value: T): void { + for (let index = start; index < end; index += 1) { + row[index] = value; + } +} + +describe("ink-compat bottleneck profiling (A-E)", () => { + it("A: parseAnsiText fast-path avoids sanitize+matchAll overhead", () => { + const baseStyle = { bold: true, dim: false }; + const text = "Simple plain text without any ANSI controls."; + const N = 200_000; + + const fastNs = bench( + "fast-path", + () => { + __inkCompatTranslationTestHooks.parseAnsiText(text, baseStyle); + }, + N, + ); + const legacyNs = bench( + "legacy-path", + () => { + parseAnsiTextLegacy(text, baseStyle); + }, + N, + ); + const speedup = legacyNs / fastNs; + + const fastResult = __inkCompatTranslationTestHooks.parseAnsiText(text, baseStyle); + const legacyResult = parseAnsiTextLegacy(text, baseStyle); + assert.deepEqual(fastResult, legacyResult); + + console.log(" A) parseAnsiText no-ANSI fast-path:"); + console.log(` Legacy sanitize+matchAll: ${legacyNs.toFixed(0)} ns/op`); + console.log(` Fast-path parseAnsiText: ${fastNs.toFixed(0)} ns/op`); + console.log(` Speedup: ${speedup.toFixed(2)}x`); + }); + + it("B: incremental translation cache speeds small leaf mutations", () => { + const rows = 80; + const cols = 8; + const iterations = 250; + + const cachedTree = buildLargeHostTree(rows, cols); + const baselineTree = buildLargeHostTree(rows, cols); + const cachedTarget = cachedTree.leaves[Math.floor(cachedTree.leaves.length / 2)]!; + const baselineTarget = baselineTree.leaves[Math.floor(baselineTree.leaves.length / 2)]!; + + __inkCompatTranslationTestHooks.setCacheEnabled(true); + __inkCompatTranslationTestHooks.clearCache(); + __inkCompatTranslationTestHooks.resetStats(); + translateTree(cachedTree.container); + + let cachedFlip = false; + let cachedLast: unknown = null; + const cachedNs = bench( + "cached", + () => { + cachedFlip = !cachedFlip; + setNodeTextContent(cachedTarget, cachedFlip ? "hot-A" : "hot-B"); + cachedLast = translateTree(cachedTree.container); + }, + iterations, + ); + const cachedStats = __inkCompatTranslationTestHooks.getStats(); + + __inkCompatTranslationTestHooks.setCacheEnabled(false); + __inkCompatTranslationTestHooks.clearCache(); + __inkCompatTranslationTestHooks.resetStats(); + translateTree(baselineTree.container); + + let baselineFlip = false; + let baselineLast: unknown = null; + const baselineNs = bench( + "baseline", + () => { + baselineFlip = !baselineFlip; + setNodeTextContent(baselineTarget, baselineFlip ? "hot-A" : "hot-B"); + baselineLast = translateTree(baselineTree.container); + }, + iterations, + ); + const baselineStats = __inkCompatTranslationTestHooks.getStats(); + + const renderer = createTestRenderer({ viewport: { cols: 160, rows: 120 } }); + assert.equal( + renderer.render(cachedLast as VNode).toText(), + renderer.render(baselineLast as VNode).toText(), + ); + assert.ok(cachedStats.cacheHits > 0); + assert.ok(cachedStats.translatedNodes < baselineStats.translatedNodes); + + const speedup = baselineNs / cachedNs; + console.log( + ` B) incremental translation (${rows * cols} text leaves, 1 leaf mutation/frame):`, + ); + console.log(` Baseline (cache OFF): ${(baselineNs / 1000).toFixed(1)} µs/update`); + console.log(` Cached (cache ON): ${(cachedNs / 1000).toFixed(1)} µs/update`); + console.log(` Speedup: ${speedup.toFixed(2)}x`); + console.log( + ` Node translations: cache=${cachedStats.translatedNodes} baseline=${baselineStats.translatedNodes}`, + ); + + __inkCompatTranslationTestHooks.setCacheEnabled(true); + }); + + it("C: root static/ANSI marker detection is O(1) vs DFS scan", () => { + const tree = buildLargeHostTree(160, 12); + const root = tree.container.children[0]!; + const targetLeaf = tree.leaves[tree.leaves.length - 1]!; + setNodeTextContent(targetLeaf, "X\u001b[32mY\u001b[0m"); + setNodeProps(root, { ...root.props, __inkStatic: true }); + + const legacyScan = legacyScanStaticAndAnsi(tree.container); + const flaggedScan = { + hasStaticNodes: tree.container.__inkSubtreeHasStatic, + hasAnsiSgr: tree.container.__inkSubtreeHasAnsiSgr, + }; + assert.deepEqual(flaggedScan, legacyScan); + + const N = 80_000; + const legacyNs = bench( + "legacy-dfs", + () => { + legacyScanStaticAndAnsi(tree.container); + }, + N, + ); + const fastNs = bench( + "root-flags", + () => { + void tree.container.__inkSubtreeHasStatic; + void tree.container.__inkSubtreeHasAnsiSgr; + }, + N, + ); + const speedup = legacyNs / fastNs; + + console.log(" C) root hasStatic/hasAnsi detection:"); + console.log(` Legacy DFS scan: ${legacyNs.toFixed(0)} ns/op`); + console.log(` Root O(1) flags: ${fastNs.toFixed(0)} ns/op`); + console.log(` Speedup: ${speedup.toFixed(2)}x`); + }); + + it("D: layout generation avoids full clear traversal", () => { + const treeLegacy = buildLargeHostTree(180, 8); + const treeGeneration = buildLargeHostTree(180, 8); + const legacyNodes = collectHostNodes(treeLegacy.container); + const generationNodes = collectHostNodes(treeGeneration.container); + const viewportWidth = 120; + + const legacyAssign = (): void => { + for (let index = 0; index < legacyNodes.length; index += 1) { + const node = legacyNodes[index]!; + ( + node as InkHostNode & { __inkLayout?: { x: number; y: number; w: number; h: number } } + ).__inkLayout = { + x: 0, + y: index, + w: viewportWidth, + h: 1, + }; + } + }; + + const generationAssign = (): void => { + const generation = advanceLayoutGeneration(treeGeneration.container); + for (let index = 0; index < generationNodes.length; index += 1) { + writeCurrentLayout( + generationNodes[index]!, + { x: 0, y: index, w: viewportWidth, h: 1 }, + generation, + ); + } + }; + + legacyAssign(); + generationAssign(); + + const staleProbe = generationNodes[generationNodes.length - 1]!; + const generation = advanceLayoutGeneration(treeGeneration.container); + writeCurrentLayout(generationNodes[0]!, { x: 0, y: 0, w: viewportWidth, h: 1 }, generation); + assert.equal(readCurrentLayout(staleProbe), undefined); + + const iterations = 200; + const legacyNs = bench( + "legacy-clear+assign", + () => { + clearHostLayoutsLegacy(treeLegacy.container); + legacyAssign(); + }, + iterations, + ); + const generationNs = bench( + "generation-assign", + () => { + generationAssign(); + }, + iterations, + ); + const speedup = legacyNs / generationNs; + + console.log(" D) layout invalidation:"); + console.log(` Legacy clearHostLayouts + assign: ${(legacyNs / 1000).toFixed(1)} µs/frame`); + console.log( + ` Generation assign only: ${(generationNs / 1000).toFixed(1)} µs/frame`, + ); + console.log(` Speedup: ${speedup.toFixed(2)}x`); + }); + + it("E: adaptive fill threshold favors loop for small spans, fill for large", () => { + const rowLength = 2048; + const N = 300_000; + const fillValue = { char: " ", style: undefined }; + + const smallStart = 32; + const smallEnd = 40; + const largeStart = 256; + const largeEnd = 1280; + + const rowForLoop = new Array(rowLength).fill(null); + const rowForFill = new Array(rowLength).fill(null); + fillRowLoop(rowForLoop, smallStart, smallEnd, fillValue); + rowForFill.fill(fillValue, smallStart, smallEnd); + assert.deepEqual(rowForLoop, rowForFill); + + const loopSmallNs = bench( + "small-loop", + () => { + fillRowLoop(rowForLoop, smallStart, smallEnd, fillValue); + }, + N, + ); + const fillSmallNs = bench( + "small-fill", + () => { + rowForFill.fill(fillValue, smallStart, smallEnd); + }, + N, + ); + const smallSpeedup = fillSmallNs / loopSmallNs; + + const rowForLoopLarge = new Array(rowLength).fill(null); + const rowForFillLarge = new Array(rowLength).fill(null); + fillRowLoop(rowForLoopLarge, largeStart, largeEnd, fillValue); + rowForFillLarge.fill(fillValue, largeStart, largeEnd); + assert.deepEqual(rowForLoopLarge, rowForFillLarge); + + const loopLargeNs = bench( + "large-loop", + () => { + fillRowLoop(rowForLoopLarge, largeStart, largeEnd, fillValue); + }, + N, + ); + const fillLargeNs = bench( + "large-fill", + () => { + rowForFillLarge.fill(fillValue, largeStart, largeEnd); + }, + N, + ); + const largeSpeedup = loopLargeNs / fillLargeNs; + + console.log(" E) adaptive fill strategy:"); + console.log( + ` Small span (${smallEnd - smallStart} cells): loop=${loopSmallNs.toFixed(0)} ns fill=${fillSmallNs.toFixed(0)} ns`, + ); + console.log(` loop advantage: ${smallSpeedup.toFixed(2)}x`); + console.log( + ` Large span (${largeEnd - largeStart} cells): loop=${loopLargeNs.toFixed(0)} ns fill=${fillLargeNs.toFixed(0)} ns`, + ); + console.log(` fill advantage: ${largeSpeedup.toFixed(2)}x`); + }); + + it("B/C correctness: metadata and output equivalence remain stable", () => { + const tree = buildLargeHostTree(20, 6); + const staticNode = tree.container.children[0]!; + setNodeProps(staticNode, { ...staticNode.props, __inkStatic: true }); + setNodeTextContent(tree.leaves[0]!, "Z\u001b[31mR\u001b[0m"); + + __inkCompatTranslationTestHooks.setCacheEnabled(true); + __inkCompatTranslationTestHooks.clearCache(); + const cached = translateTree(tree.container); + const metaCached = translateDynamicTreeWithMetadata(tree.container).meta; + + __inkCompatTranslationTestHooks.setCacheEnabled(false); + __inkCompatTranslationTestHooks.clearCache(); + const baseline = translateTree(tree.container); + const metaBaseline = translateDynamicTreeWithMetadata(tree.container).meta; + + assert.deepEqual(cached, baseline); + assert.deepEqual(metaCached, metaBaseline); + assert.equal(metaCached.hasStaticNodes, true); + assert.equal(metaCached.hasAnsiSgr, true); + + __inkCompatTranslationTestHooks.setCacheEnabled(true); + }); +}); diff --git a/packages/ink-compat/src/__tests__/reconciler/types.test.ts b/packages/ink-compat/src/__tests__/reconciler/types.test.ts index f7fc4568..96e6c835 100644 --- a/packages/ink-compat/src/__tests__/reconciler/types.test.ts +++ b/packages/ink-compat/src/__tests__/reconciler/types.test.ts @@ -7,6 +7,8 @@ import { createHostNode, insertBefore, removeChild, + setNodeProps, + setNodeTextContent, } from "../../reconciler/types.js"; /** @@ -116,3 +118,39 @@ test("appendChild detaches from previous non-container parent", () => { assert.deepEqual(parentB.children, [child]); assert.equal(child.parent, parentB); }); + +test("container ANSI subtree flag tracks deep leaf add/remove", () => { + const container = createHostContainer(); + const outer = createHostNode("ink-box", {}); + const inner = createHostNode("ink-box", {}); + const text = createHostNode("ink-text", {}); + + setNodeTextContent(text, "plain \u001b[31mred\u001b[0m"); + appendChild(inner, text); + appendChild(outer, inner); + appendChild(container, outer); + + assert.equal(container.__inkSubtreeHasAnsiSgr, true); + + removeChild(inner, text); + assert.equal(container.__inkSubtreeHasAnsiSgr, false); +}); + +test("container static subtree flag tracks prop updates and removal", () => { + const container = createHostContainer(); + const dynamicBox = createHostNode("ink-box", {}); + appendChild(container, dynamicBox); + assert.equal(container.__inkSubtreeHasStatic, false); + + setNodeProps(dynamicBox, { __inkStatic: true }); + assert.equal(container.__inkSubtreeHasStatic, true); + + setNodeProps(dynamicBox, {}); + assert.equal(container.__inkSubtreeHasStatic, false); + + const staticChild = createHostNode("ink-box", { __inkStatic: true }); + appendChild(container, staticChild); + assert.equal(container.__inkSubtreeHasStatic, true); + removeChild(container, staticChild); + assert.equal(container.__inkSubtreeHasStatic, false); +}); diff --git a/packages/ink-compat/src/__tests__/runtime/newApis.test.ts b/packages/ink-compat/src/__tests__/runtime/newApis.test.ts index 03c8b695..84e62f78 100644 --- a/packages/ink-compat/src/__tests__/runtime/newApis.test.ts +++ b/packages/ink-compat/src/__tests__/runtime/newApis.test.ts @@ -12,7 +12,7 @@ import React from "react"; import { useIsScreenReaderEnabled } from "../../hooks/useIsScreenReaderEnabled.js"; import { kittyFlags, kittyModifiers, useCursor } from "../../index.js"; import { reconciler } from "../../reconciler/reconciler.js"; -import { createHostContainer, createHostNode } from "../../reconciler/types.js"; +import { appendChild, createHostContainer, createHostNode } from "../../reconciler/types.js"; import { InkResizeObserver } from "../../runtime/ResizeObserver.js"; import { InkContext, type InkContextValue } from "../../runtime/context.js"; import { getInnerHeight, getScrollHeight } from "../../runtime/domHelpers.js"; @@ -114,6 +114,25 @@ test("getBoundingBox reads __inkLayout", () => { assert.deepEqual(box, { x: 5, y: 10, width: 40, height: 20 }); }); +test("layout readers ignore stale generation-tagged layouts", () => { + type LayoutNode = ReturnType & { + __inkLayout?: { x: number; y: number; w: number; h: number }; + __inkLayoutGen?: number; + }; + + const container = createHostContainer(); + const node = createHostNode("ink-box", {}) as LayoutNode; + appendChild(container, node); + + node.__inkLayout = { x: 1, y: 2, w: 30, h: 10 }; + node.__inkLayoutGen = 1; + container.__inkLayoutGeneration = 2; + + assert.deepEqual(getBoundingBox(node), { x: 0, y: 0, width: 0, height: 0 }); + assert.equal(getInnerHeight(node), 0); + assert.equal(getScrollHeight(node), 0); +}); + // --- getInnerHeight --- test("getInnerHeight returns 0 for node without layout", () => { @@ -208,6 +227,42 @@ test("ResizeObserver fires on size change via check()", () => { observer.disconnect(); }); +test("ResizeObserver reports zero size for stale generation-tagged layout", () => { + const entries: Array<{ width: number; height: number }> = []; + const observer = new InkResizeObserver((e) => { + entries.push(e[0]!.contentRect); + }); + + type LayoutNode = ReturnType & { + __inkLayout?: { x: number; y: number; w: number; h: number }; + __inkLayoutGen?: number; + }; + + const container = createHostContainer(); + const node = createHostNode("ink-box", {}) as LayoutNode; + appendChild(container, node); + + node.__inkLayout = { x: 0, y: 0, w: 80, h: 24 }; + node.__inkLayoutGen = container.__inkLayoutGeneration; + + observer.observe(node); + assert.equal(entries.length, 1); + assert.deepEqual(entries[0], { width: 80, height: 24 }); + + container.__inkLayoutGeneration += 1; + observer.check(); + assert.equal(entries.length, 2); + assert.deepEqual(entries[1], { width: 0, height: 0 }); + + node.__inkLayout = { x: 0, y: 0, w: 90, h: 30 }; + node.__inkLayoutGen = container.__inkLayoutGeneration; + observer.check(); + assert.equal(entries.length, 3); + assert.deepEqual(entries[2], { width: 90, height: 30 }); + + observer.disconnect(); +}); + test("ResizeObserver does not fire after disconnect", () => { let callCount = 0; const observer = new InkResizeObserver(() => { diff --git a/packages/ink-compat/src/__tests__/translation/propsToVNode.test.ts b/packages/ink-compat/src/__tests__/translation/propsToVNode.test.ts index 72888f2e..0cacfab3 100644 --- a/packages/ink-compat/src/__tests__/translation/propsToVNode.test.ts +++ b/packages/ink-compat/src/__tests__/translation/propsToVNode.test.ts @@ -8,9 +8,14 @@ import { appendChild, createHostContainer, createHostNode, + insertBefore, + removeChild, + setNodeTextContent, } from "../../reconciler/types.js"; import { + __inkCompatTranslationTestHooks, translateDynamicTree, + translateDynamicTreeWithMetadata, translateStaticTree, translateTree, } from "../../translation/propsToVNode.js"; @@ -235,6 +240,39 @@ test("ANSI truecolor colon form maps to RGB style", () => { assert.deepEqual(vnode.props.spans[0]?.style?.fg, rgb(255, 120, 40)); }); +test("plain text without ANSI/control keeps single text vnode shape", () => { + const node = textNode("Hello plain text"); + const vnode = translateTree(containerWith(node)) as any; + + assert.equal(vnode.kind, "text"); + assert.equal(vnode.text, "Hello plain text"); + assert.equal(vnode.props?.spans, undefined); +}); + +test("disallowed control characters are sanitized from raw text", () => { + const node = createHostNode("ink-text", {}); + appendChild(node, textLeaf("A\x01B\x02C")); + + const vnode = translateTree(containerWith(node)) as any; + + assert.equal(vnode.kind, "text"); + assert.equal(vnode.text, "ABC"); +}); + +test("text containing ESC still sanitizes + parses ANSI SGR", () => { + const node = createHostNode("ink-text", {}); + appendChild(node, textLeaf("A\u001b[31mB\u001b[0m\u001b[2KZ")); + + const vnode = translateTree(containerWith(node)) as any; + + assert.equal(vnode.kind, "richText"); + assert.equal(vnode.props.spans.length, 3); + assert.equal(vnode.props.spans[0]?.text, "A"); + assert.equal(vnode.props.spans[1]?.text, "B"); + assert.deepEqual(vnode.props.spans[1]?.style?.fg, rgb(205, 0, 0)); + assert.equal(vnode.props.spans[2]?.text, "Z"); +}); + test("spacer virtual node maps to ui.spacer", () => { const spacer = createHostNode("ink-virtual", { __inkType: "spacer" }); const vnode = translateTree(containerWith(spacer)) as any; @@ -289,7 +327,7 @@ test("flexShrink defaults to 1 when not set", () => { assert.equal(vnode.props.flexShrink, 1); }); -test("percent dimensions map to native percent strings or markers", () => { +test("percent dimensions map to percent marker props", () => { const node = boxNode( { width: "100%", @@ -302,13 +340,11 @@ test("percent dimensions map to native percent strings or markers", () => { ); const vnode = translateTree(containerWith(node)) as any; - // width, height, flexBasis: passed as native percent strings (layout engine resolves them) - assert.equal(vnode.props.width, "100%"); - assert.equal(vnode.props.height, "50%"); - assert.equal(vnode.props.flexBasis, "40%"); - // minWidth, minHeight: still use markers (layout engine only accepts numbers) + assert.equal(vnode.props.__inkPercentWidth, 100); + assert.equal(vnode.props.__inkPercentHeight, 50); assert.equal(vnode.props.__inkPercentMinWidth, 25); assert.equal(vnode.props.__inkPercentMinHeight, 75); + assert.equal(vnode.props.__inkPercentFlexBasis, 40); }); test("wrap-reverse is approximated as wrap + reverse", () => { @@ -568,3 +604,116 @@ test("static translation preserves static style props except absolute positionin assert.equal("top" in vnode.props, false); assert.equal("left" in vnode.props, false); }); + +test("translation cache preserves deep-equal output across repeated renders", () => { + const container = createHostContainer(); + const root = boxNode({ flexDirection: "column" }, [ + textNode("Header"), + boxNode({ flexDirection: "row" }, [textNode("A"), textNode("B"), textNode("C")]), + ]); + appendChild(container, root); + + __inkCompatTranslationTestHooks.setCacheEnabled(true); + __inkCompatTranslationTestHooks.clearCache(); + __inkCompatTranslationTestHooks.resetStats(); + + const first = translateTree(container); + const firstStats = __inkCompatTranslationTestHooks.getStats(); + const second = translateTree(container); + const secondStats = __inkCompatTranslationTestHooks.getStats(); + + assert.deepEqual(second, first); + assert.ok(firstStats.translatedNodes > 0); + assert.ok(secondStats.cacheHits > firstStats.cacheHits); + assert.ok( + secondStats.translatedNodes - firstStats.translatedNodes < firstStats.translatedNodes, + "second translation should execute fewer uncached node translations", + ); +}); + +test("leaf text mutation updates output and matches no-cache baseline", () => { + const leafA = textLeaf("left"); + const leafB = textLeaf("right"); + + const textA = createHostNode("ink-text", {}); + appendChild(textA, leafA); + const textB = createHostNode("ink-text", {}); + appendChild(textB, leafB); + + const root = boxNode({ flexDirection: "row" }, [textA, textB]); + const container = containerWith(root); + + __inkCompatTranslationTestHooks.clearCache(); + __inkCompatTranslationTestHooks.setCacheEnabled(true); + const beforeCached = translateTree(container); + + setNodeTextContent(leafB, "RIGHT!"); + const afterCached = translateTree(container); + + __inkCompatTranslationTestHooks.clearCache(); + __inkCompatTranslationTestHooks.setCacheEnabled(false); + const afterBaseline = translateTree(container); + + assert.deepEqual(afterCached, afterBaseline); + const beforeRow = beforeCached as any; + const afterRow = afterCached as any; + assert.equal(beforeRow.children[0]?.text, "left"); + assert.equal(afterRow.children[0]?.text, "left"); + assert.equal(afterRow.children[1]?.text, "RIGHT!"); + + __inkCompatTranslationTestHooks.setCacheEnabled(true); +}); + +test("insert/remove/reorder children match non-cached translation baseline", () => { + const a = textNode("A"); + const b = textNode("B"); + const c = textNode("C"); + const row = boxNode({ flexDirection: "row" }, [a, b, c]); + const container = containerWith(row); + + __inkCompatTranslationTestHooks.setCacheEnabled(true); + __inkCompatTranslationTestHooks.clearCache(); + translateTree(container); + + const inserted = textNode("X"); + appendChild(row, inserted); + const cachedAfterInsert = translateTree(container); + __inkCompatTranslationTestHooks.setCacheEnabled(false); + __inkCompatTranslationTestHooks.clearCache(); + const baselineAfterInsert = translateTree(container); + assert.deepEqual(cachedAfterInsert, baselineAfterInsert); + + __inkCompatTranslationTestHooks.setCacheEnabled(true); + __inkCompatTranslationTestHooks.clearCache(); + translateTree(container); + insertBefore(row, c, a); + const cachedAfterReorder = translateTree(container); + __inkCompatTranslationTestHooks.setCacheEnabled(false); + __inkCompatTranslationTestHooks.clearCache(); + const baselineAfterReorder = translateTree(container); + assert.deepEqual(cachedAfterReorder, baselineAfterReorder); + + __inkCompatTranslationTestHooks.setCacheEnabled(true); + __inkCompatTranslationTestHooks.clearCache(); + removeChild(row, b); + const cachedAfterRemove = translateTree(container); + __inkCompatTranslationTestHooks.setCacheEnabled(false); + __inkCompatTranslationTestHooks.clearCache(); + const baselineAfterRemove = translateTree(container); + assert.deepEqual(cachedAfterRemove, baselineAfterRemove); + + __inkCompatTranslationTestHooks.setCacheEnabled(true); +}); + +test("dynamic translation metadata reads static/ansi markers from root flags", () => { + const staticBranch = boxNode({ __inkStatic: true }, [textNode("Static branch")]); + const dynamicAnsi = createHostNode("ink-text", {}); + appendChild(dynamicAnsi, textLeaf("A\u001b[31mB\u001b[0m")); + const root = boxNode({}, [dynamicAnsi, staticBranch]); + const container = containerWith(root); + + const translated = translateDynamicTreeWithMetadata(container); + + assert.equal(translated.meta.hasStaticNodes, true); + assert.equal(translated.meta.hasAnsiSgr, true); +}); diff --git a/packages/ink-compat/src/reconciler/hostConfig.ts b/packages/ink-compat/src/reconciler/hostConfig.ts index f7ffa347..0b0b9206 100644 --- a/packages/ink-compat/src/reconciler/hostConfig.ts +++ b/packages/ink-compat/src/reconciler/hostConfig.ts @@ -8,6 +8,8 @@ import { createHostNode, insertBefore, removeChild, + setNodeProps, + setNodeTextContent, } from "./types.js"; function mapNodeType(type: string): InkNodeType { @@ -42,7 +44,7 @@ export const hostConfig = { createTextInstance(text: string): InkHostNode { const node = createHostNode("ink-text", {}); - node.textContent = text; + setNodeTextContent(node, text); return node; }, @@ -89,17 +91,17 @@ export const hostConfig = { // Support both legacy (instance, type, oldProps, newProps[, handle]) and // React 19 mutation signatures (instance, updatePayload, type, oldProps, newProps, handle). if (typeof updatePayloadOrType === "string") { - instance.props = sanitizeProps(oldPropsOrNewProps); + setNodeProps(instance, sanitizeProps(oldPropsOrNewProps)); return; } if (!updatePayloadOrType) return; if (typeof typeOrOldProps !== "string") return; - instance.props = sanitizeProps(maybeNewProps); + setNodeProps(instance, sanitizeProps(maybeNewProps)); }, commitTextUpdate(instance: InkHostNode, _oldText: string, newText: string): void { - instance.textContent = newText; + setNodeTextContent(instance, newText); }, getPublicInstance(instance: InkHostNode): InkHostNode { @@ -158,10 +160,11 @@ export const hostConfig = { }, clearContainer(container: InkHostContainer): boolean { - for (const child of container.children) { - child.parent = null; + while (container.children.length > 0) { + const child = container.children[0]; + if (!child) break; + removeChild(container, child); } - container.children = []; return false; }, diff --git a/packages/ink-compat/src/reconciler/types.ts b/packages/ink-compat/src/reconciler/types.ts index 1b4f6040..886db637 100644 --- a/packages/ink-compat/src/reconciler/types.ts +++ b/packages/ink-compat/src/reconciler/types.ts @@ -5,6 +5,15 @@ export type InkNodeType = "ink-box" | "ink-text" | "ink-root" | "ink-virtual"; +const ANSI_SGR_DETECT_REGEX = /\u001b\[[0-9:;]*m/; + +let globalInkRevision = 0; + +function nextInkRevision(): number { + globalInkRevision += 1; + return globalInkRevision; +} + export interface InkHostNode { type: InkNodeType; props: Record; @@ -14,6 +23,20 @@ export interface InkHostNode { textContent: string | null; /** Compatibility surface for libraries that expect Ink DOM elements to expose yogaNode. */ yogaNode?: unknown; + /** Monotonically increasing revision for translation cache invalidation. */ + __inkRevision: number; + /** Root container this node is currently attached to, if any. */ + __inkContainer: InkHostContainer | null; + /** Local marker contributions (self only). */ + __inkSelfHasStatic: boolean; + __inkSelfHasAnsiSgr: boolean; + /** Aggregated marker contributions (self + subtree). */ + __inkSubtreeStaticCount: number; + __inkSubtreeAnsiSgrCount: number; + __inkSubtreeHasStatic: boolean; + __inkSubtreeHasAnsiSgr: boolean; + /** Layout generation validity marker for runtime layout caches. */ + __inkLayoutGen?: number; } export interface InkHostContainer { @@ -21,39 +44,248 @@ export interface InkHostContainer { children: InkHostNode[]; /** Callback invoked after every React commit phase */ onCommit: (() => void) | null; + /** Aggregated marker contributions across all root children. */ + __inkSubtreeStaticCount: number; + __inkSubtreeAnsiSgrCount: number; + __inkSubtreeHasStatic: boolean; + __inkSubtreeHasAnsiSgr: boolean; + /** Current layout generation assigned by runtime render. */ + __inkLayoutGeneration: number; } -export function createHostNode(type: InkNodeType, props: Record): InkHostNode { - return { type, props, children: [], parent: null, textContent: null }; +function updateNodeMarkerBooleans(node: InkHostNode): void { + node.__inkSubtreeHasStatic = node.__inkSubtreeStaticCount > 0; + node.__inkSubtreeHasAnsiSgr = node.__inkSubtreeAnsiSgrCount > 0; } -export function createHostContainer(): InkHostContainer { - return { type: "ink-root", children: [], onCommit: null }; +function updateContainerMarkerBooleans(container: InkHostContainer): void { + container.__inkSubtreeHasStatic = container.__inkSubtreeStaticCount > 0; + container.__inkSubtreeHasAnsiSgr = container.__inkSubtreeAnsiSgrCount > 0; } -function isContainer(parent: InkHostNode | InkHostContainer): parent is InkHostContainer { - return parent.type === "ink-root" && "onCommit" in parent; +function detectNodeSelfStatic(type: InkNodeType, props: Record): boolean { + return type === "ink-box" && props["__inkStatic"] === true; } -function detachChildIfPresent(parent: InkHostNode | InkHostContainer, child: InkHostNode): number { - if (child.parent != null && child.parent !== parent) { - const previousIndex = child.parent.children.indexOf(child); - if (previousIndex >= 0) { - child.parent.children.splice(previousIndex, 1); +function detectNodeSelfAnsi(textContent: string | null): boolean { + if (typeof textContent !== "string" || textContent.length === 0) return false; + return ANSI_SGR_DETECT_REGEX.test(textContent); +} + +function recomputeSubtreeMarkers(node: InkHostNode): { staticCount: number; ansiCount: number } { + const stack: Array<{ node: InkHostNode; visited: boolean }> = [{ node, visited: false }]; + + while (stack.length > 0) { + const current = stack.pop(); + if (!current) continue; + + if (!current.visited) { + stack.push({ node: current.node, visited: true }); + for (let i = current.node.children.length - 1; i >= 0; i -= 1) { + const child = current.node.children[i]; + if (child) stack.push({ node: child, visited: false }); + } + continue; + } + + const selfStatic = detectNodeSelfStatic(current.node.type, current.node.props); + const selfAnsi = detectNodeSelfAnsi(current.node.textContent); + + let staticCount = selfStatic ? 1 : 0; + let ansiCount = selfAnsi ? 1 : 0; + + for (const child of current.node.children) { + staticCount += child.__inkSubtreeStaticCount; + ansiCount += child.__inkSubtreeAnsiSgrCount; } + + current.node.__inkSelfHasStatic = selfStatic; + current.node.__inkSelfHasAnsiSgr = selfAnsi; + current.node.__inkSubtreeStaticCount = staticCount; + current.node.__inkSubtreeAnsiSgrCount = ansiCount; + updateNodeMarkerBooleans(current.node); } - const existingIndex = parent.children.indexOf(child); - if (existingIndex >= 0) { - parent.children.splice(existingIndex, 1); + return { staticCount: node.__inkSubtreeStaticCount, ansiCount: node.__inkSubtreeAnsiSgrCount }; +} + +function setContainerRecursive(node: InkHostNode, container: InkHostContainer | null): void { + const stack: InkHostNode[] = [node]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) continue; + current.__inkContainer = container; + for (let i = current.children.length - 1; i >= 0; i -= 1) { + const child = current.children[i]; + if (child) stack.push(child); + } } - return existingIndex; } -export function appendChild(parent: InkHostNode | InkHostContainer, child: InkHostNode): void { - detachChildIfPresent(parent, child); +function applyDeltaToNodeTree( + parent: InkHostNode, + staticDelta: number, + ansiDelta: number, + shouldBumpRevision: boolean, +): void { + let current: InkHostNode | null = parent; + while (current) { + if (staticDelta !== 0) { + current.__inkSubtreeStaticCount += staticDelta; + } + if (ansiDelta !== 0) { + current.__inkSubtreeAnsiSgrCount += ansiDelta; + } + if (staticDelta !== 0 || ansiDelta !== 0) { + updateNodeMarkerBooleans(current); + } + if (shouldBumpRevision) { + current.__inkRevision = nextInkRevision(); + } + current = current.parent; + } +} + +function applyDeltaToContainer( + container: InkHostContainer, + staticDelta: number, + ansiDelta: number, +): void { + if (staticDelta !== 0) { + container.__inkSubtreeStaticCount += staticDelta; + } + if (ansiDelta !== 0) { + container.__inkSubtreeAnsiSgrCount += ansiDelta; + } + if (staticDelta !== 0 || ansiDelta !== 0) { + updateContainerMarkerBooleans(container); + } +} + +function applyDeltaForAttachedNode( + node: InkHostNode, + staticDelta: number, + ansiDelta: number, + shouldBumpRevision: boolean, +): void { + if (node.parent) { + applyDeltaToNodeTree(node.parent, staticDelta, ansiDelta, shouldBumpRevision); + } + if (node.__inkContainer) { + applyDeltaToContainer(node.__inkContainer, staticDelta, ansiDelta); + } + if (shouldBumpRevision) { + node.__inkRevision = nextInkRevision(); + } +} + +function attachToParent( + parent: InkHostNode | InkHostContainer, + child: InkHostNode, + index: number | null, +): void { + recomputeSubtreeMarkers(child); + + const container = isContainer(parent) ? parent : parent.__inkContainer; child.parent = isContainer(parent) ? null : parent; - parent.children.push(child); + setContainerRecursive(child, container ?? null); + + if (index == null || index < 0 || index > parent.children.length) { + parent.children.push(child); + } else { + parent.children.splice(index, 0, child); + } + + if (isContainer(parent)) { + applyDeltaToContainer(parent, child.__inkSubtreeStaticCount, child.__inkSubtreeAnsiSgrCount); + return; + } + + applyDeltaToNodeTree(parent, child.__inkSubtreeStaticCount, child.__inkSubtreeAnsiSgrCount, true); + if (container) { + applyDeltaToContainer(container, child.__inkSubtreeStaticCount, child.__inkSubtreeAnsiSgrCount); + } +} + +function detachFromCurrentParent(child: InkHostNode): void { + if (child.parent) { + const oldParent = child.parent; + const oldContainer = child.__inkContainer; + const oldIndex = oldParent.children.indexOf(child); + if (oldIndex >= 0) { + oldParent.children.splice(oldIndex, 1); + applyDeltaToNodeTree( + oldParent, + -child.__inkSubtreeStaticCount, + -child.__inkSubtreeAnsiSgrCount, + true, + ); + if (oldContainer) { + applyDeltaToContainer( + oldContainer, + -child.__inkSubtreeStaticCount, + -child.__inkSubtreeAnsiSgrCount, + ); + } + } + child.parent = null; + setContainerRecursive(child, null); + return; + } + + const oldContainer = child.__inkContainer; + if (!oldContainer) return; + const oldIndex = oldContainer.children.indexOf(child); + if (oldIndex >= 0) { + oldContainer.children.splice(oldIndex, 1); + applyDeltaToContainer( + oldContainer, + -child.__inkSubtreeStaticCount, + -child.__inkSubtreeAnsiSgrCount, + ); + } + setContainerRecursive(child, null); +} + +export function createHostNode(type: InkNodeType, props: Record): InkHostNode { + const selfStatic = detectNodeSelfStatic(type, props); + return { + type, + props, + children: [], + parent: null, + textContent: null, + __inkRevision: nextInkRevision(), + __inkContainer: null, + __inkSelfHasStatic: selfStatic, + __inkSelfHasAnsiSgr: false, + __inkSubtreeStaticCount: selfStatic ? 1 : 0, + __inkSubtreeAnsiSgrCount: 0, + __inkSubtreeHasStatic: selfStatic, + __inkSubtreeHasAnsiSgr: false, + }; +} + +export function createHostContainer(): InkHostContainer { + return { + type: "ink-root", + children: [], + onCommit: null, + __inkSubtreeStaticCount: 0, + __inkSubtreeAnsiSgrCount: 0, + __inkSubtreeHasStatic: false, + __inkSubtreeHasAnsiSgr: false, + __inkLayoutGeneration: 0, + }; +} + +function isContainer(parent: InkHostNode | InkHostContainer): parent is InkHostContainer { + return parent.type === "ink-root" && "onCommit" in parent; +} + +export function appendChild(parent: InkHostNode | InkHostContainer, child: InkHostNode): void { + detachFromCurrentParent(child); + attachToParent(parent, child, null); } export function removeChild(parent: InkHostNode | InkHostContainer, child: InkHostNode): void { @@ -62,6 +294,26 @@ export function removeChild(parent: InkHostNode | InkHostContainer, child: InkHo parent.children.splice(idx, 1); child.parent = null; + setContainerRecursive(child, null); + + if (isContainer(parent)) { + applyDeltaToContainer(parent, -child.__inkSubtreeStaticCount, -child.__inkSubtreeAnsiSgrCount); + return; + } + + applyDeltaToNodeTree( + parent, + -child.__inkSubtreeStaticCount, + -child.__inkSubtreeAnsiSgrCount, + true, + ); + if (parent.__inkContainer) { + applyDeltaToContainer( + parent.__inkContainer, + -child.__inkSubtreeStaticCount, + -child.__inkSubtreeAnsiSgrCount, + ); + } } export function insertBefore( @@ -69,13 +321,39 @@ export function insertBefore( child: InkHostNode, before: InkHostNode, ): void { - detachChildIfPresent(parent, child); - child.parent = isContainer(parent) ? null : parent; - const idx = parent.children.indexOf(before); if (idx === -1) { throw new Error("ZRUI_INSERT_BEFORE_TARGET_MISSING"); } - parent.children.splice(idx, 0, child); + detachFromCurrentParent(child); + attachToParent(parent, child, idx); +} + +export function setNodeProps(node: InkHostNode, props: Record): void { + const previousSelfStatic = node.__inkSelfHasStatic; + node.props = props; + + const nextSelfStatic = detectNodeSelfStatic(node.type, props); + const staticDelta = previousSelfStatic === nextSelfStatic ? 0 : nextSelfStatic ? 1 : -1; + if (staticDelta !== 0) { + node.__inkSelfHasStatic = nextSelfStatic; + node.__inkSubtreeStaticCount += staticDelta; + updateNodeMarkerBooleans(node); + } + applyDeltaForAttachedNode(node, staticDelta, 0, true); +} + +export function setNodeTextContent(node: InkHostNode, textContent: string | null): void { + const previousSelfAnsi = node.__inkSelfHasAnsiSgr; + node.textContent = textContent; + + const nextSelfAnsi = detectNodeSelfAnsi(textContent); + const ansiDelta = previousSelfAnsi === nextSelfAnsi ? 0 : nextSelfAnsi ? 1 : -1; + if (ansiDelta !== 0) { + node.__inkSelfHasAnsiSgr = nextSelfAnsi; + node.__inkSubtreeAnsiSgrCount += ansiDelta; + updateNodeMarkerBooleans(node); + } + applyDeltaForAttachedNode(node, 0, ansiDelta, true); } diff --git a/packages/ink-compat/src/runtime/ResizeObserver.ts b/packages/ink-compat/src/runtime/ResizeObserver.ts index c9b53626..a8a123a0 100644 --- a/packages/ink-compat/src/runtime/ResizeObserver.ts +++ b/packages/ink-compat/src/runtime/ResizeObserver.ts @@ -1,13 +1,5 @@ import type { InkHostNode } from "../reconciler/types.js"; - -interface InkLayout { - x: number; - y: number; - w: number; - h: number; -} - -type NodeWithLayout = InkHostNode & { __inkLayout?: InkLayout }; +import { readCurrentLayout } from "./layoutState.js"; const activeObservers = new Set(); export interface ResizeObserverEntry { @@ -41,7 +33,7 @@ export class InkResizeObserver { observe(element: InkHostNode): void { if (this.disconnected) return; - const layout = (element as NodeWithLayout).__inkLayout; + const layout = readCurrentLayout(element); const w = layout?.w ?? 0; const h = layout?.h ?? 0; this.observed.set(element, { w, h }); @@ -62,7 +54,7 @@ export class InkResizeObserver { check(): void { if (this.disconnected) return; for (const [element, prev] of this.observed) { - const layout = (element as NodeWithLayout).__inkLayout; + const layout = readCurrentLayout(element); const w = layout?.w ?? 0; const h = layout?.h ?? 0; if (w !== prev.w || h !== prev.h) { diff --git a/packages/ink-compat/src/runtime/bridge.ts b/packages/ink-compat/src/runtime/bridge.ts index 050e7b1d..aa1aba28 100644 --- a/packages/ink-compat/src/runtime/bridge.ts +++ b/packages/ink-compat/src/runtime/bridge.ts @@ -4,7 +4,9 @@ import type { VNode } from "@rezi-ui/core"; import { kittyModifiers } from "../kitty-keyboard.js"; import { type InkHostContainer, createHostContainer } from "../reconciler/types.js"; import { + type TranslationMetadata, translateDynamicTree, + translateDynamicTreeWithMetadata, translateStaticTree, translateTree, } from "../translation/propsToVNode.js"; @@ -24,6 +26,8 @@ export interface InkBridge { context: InkContextValue; translateToVNode(): VNode; translateDynamicToVNode(): VNode; + /** Translate dynamic tree and collect metadata in a single pass. */ + translateDynamicWithMetadata(): { vnode: VNode; meta: TranslationMetadata }; translateStaticToVNode(): VNode; hasStaticNodes(): boolean; exit(result?: unknown): void; @@ -438,22 +442,7 @@ export function createBridge(options: BridgeOptions): InkBridge { }; const hasStaticNodes = (): boolean => { - const stack = [...rootNode.children]; - while (stack.length > 0) { - const node = stack.pop(); - if (!node) continue; - - if (node.type === "ink-box" && node.props["__inkStatic"] === true) { - return true; - } - - for (let index = node.children.length - 1; index >= 0; index -= 1) { - const child = node.children[index]; - if (child) stack.push(child); - } - } - - return false; + return rootNode.__inkSubtreeHasStatic; }; return { @@ -461,6 +450,7 @@ export function createBridge(options: BridgeOptions): InkBridge { context, translateToVNode: () => translateTree(rootNode), translateDynamicToVNode: () => translateDynamicTree(rootNode), + translateDynamicWithMetadata: () => translateDynamicTreeWithMetadata(rootNode), translateStaticToVNode: () => translateStaticTree(rootNode), hasStaticNodes, exit, diff --git a/packages/ink-compat/src/runtime/domHelpers.ts b/packages/ink-compat/src/runtime/domHelpers.ts index afff967f..3f21b7ae 100644 --- a/packages/ink-compat/src/runtime/domHelpers.ts +++ b/packages/ink-compat/src/runtime/domHelpers.ts @@ -1,13 +1,5 @@ import type { InkHostNode } from "../reconciler/types.js"; - -interface InkLayout { - x: number; - y: number; - w: number; - h: number; -} - -type NodeWithLayout = InkHostNode & { __inkLayout?: InkLayout }; +import { readCurrentLayout } from "./layoutState.js"; /** * Returns the inner (visible/viewport) height of an element. @@ -19,7 +11,7 @@ type NodeWithLayout = InkHostNode & { __inkLayout?: InkLayout }; * Gemini CLI rounds the result and uses it for scroll calculations. */ export function getInnerHeight(element: InkHostNode): number { - const layout = (element as NodeWithLayout).__inkLayout; + const layout = readCurrentLayout(element); return layout?.h ?? 0; } @@ -34,17 +26,17 @@ export function getInnerHeight(element: InkHostNode): number { export function getScrollHeight(element: InkHostNode): number { let total = 0; for (const child of element.children) { - const childLayout = (child as NodeWithLayout).__inkLayout; + const childLayout = readCurrentLayout(child); if (childLayout) { total = Math.max(total, (childLayout.y ?? 0) + childLayout.h); } } // If no children have layout, fall back to the element's own height if (total === 0) { - const layout = (element as NodeWithLayout).__inkLayout; + const layout = readCurrentLayout(element); return layout?.h ?? 0; } // Scroll height is relative to the element's top, not absolute - const elementY = (element as NodeWithLayout).__inkLayout?.y ?? 0; + const elementY = readCurrentLayout(element)?.y ?? 0; return total - elementY; } diff --git a/packages/ink-compat/src/runtime/getBoundingBox.ts b/packages/ink-compat/src/runtime/getBoundingBox.ts index f84c1b43..d3dfe6a6 100644 --- a/packages/ink-compat/src/runtime/getBoundingBox.ts +++ b/packages/ink-compat/src/runtime/getBoundingBox.ts @@ -1,4 +1,5 @@ import type { InkHostNode } from "../reconciler/types.js"; +import { readCurrentLayout } from "./layoutState.js"; export interface BoundingBox { x: number; @@ -17,9 +18,7 @@ export interface BoundingBox { * cached layout rect that the testing/render pipeline writes onto nodes. */ export function getBoundingBox(element: InkHostNode): BoundingBox { - const layout = ( - element as InkHostNode & { __inkLayout?: { x: number; y: number; w: number; h: number } } - ).__inkLayout; + const layout = readCurrentLayout(element); if (!layout) { return { x: 0, y: 0, width: 0, height: 0 }; } diff --git a/packages/ink-compat/src/runtime/layoutState.ts b/packages/ink-compat/src/runtime/layoutState.ts new file mode 100644 index 00000000..bc16cc61 --- /dev/null +++ b/packages/ink-compat/src/runtime/layoutState.ts @@ -0,0 +1,47 @@ +import type { InkHostContainer, InkHostNode } from "../reconciler/types.js"; + +export interface InkLayoutRect { + x: number; + y: number; + w: number; + h: number; +} + +type LayoutHostNode = InkHostNode & { + __inkLayout?: InkLayoutRect; + __inkLayoutGen?: number; +}; + +export function advanceLayoutGeneration(container: InkHostContainer): number { + container.__inkLayoutGeneration += 1; + return container.__inkLayoutGeneration; +} + +export function writeCurrentLayout( + node: InkHostNode, + layout: InkLayoutRect, + generation: number, +): void { + const host = node as LayoutHostNode; + host.__inkLayout = layout; + host.__inkLayoutGen = generation; +} + +export function readCurrentLayout(node: InkHostNode): InkLayoutRect | undefined { + const host = node as LayoutHostNode; + const layout = host.__inkLayout; + if (!layout) return undefined; + + const container = node.__inkContainer; + const generation = host.__inkLayoutGen; + if (!container) { + // Detached runtime nodes retain stale layout objects; consider generation-tagged + // entries invalid once detached. Untagged layouts remain readable for tests + // that manually assign __inkLayout without a render session. + if (generation != null) return undefined; + return layout; + } + if (generation == null) return layout; + + return generation === container.__inkLayoutGeneration ? layout : undefined; +} diff --git a/packages/ink-compat/src/runtime/measureElement.ts b/packages/ink-compat/src/runtime/measureElement.ts index 855c66a0..96f9e2a1 100644 --- a/packages/ink-compat/src/runtime/measureElement.ts +++ b/packages/ink-compat/src/runtime/measureElement.ts @@ -1,7 +1,8 @@ import type { InkHostNode } from "../reconciler/types.js"; +import { readCurrentLayout } from "./layoutState.js"; export function measureElement(ref: InkHostNode): { width: number; height: number } { - const layout = (ref as InkHostNode & { __inkLayout?: { w: number; h: number } }).__inkLayout; + const layout = readCurrentLayout(ref); if (!layout) return { width: 0, height: 0 }; return { diff --git a/packages/ink-compat/src/runtime/render.ts b/packages/ink-compat/src/runtime/render.ts index d77a5af0..c5a08df1 100644 --- a/packages/ink-compat/src/runtime/render.ts +++ b/packages/ink-compat/src/runtime/render.ts @@ -1,7 +1,14 @@ import { appendFileSync } from "node:fs"; import type { Readable, Writable } from "node:stream"; import { format as formatConsoleMessage } from "node:util"; -import { type Rgb24, type TextStyle, type VNode, measureTextCells } from "@rezi-ui/core"; +import { + type Rgb24, + type TextStyle, + type VNode, + createTestRenderer, + measureTextCells, + rgb, +} from "@rezi-ui/core"; import React from "react"; import { type KittyFlagName, resolveKittyFlags } from "../kitty-keyboard.js"; @@ -10,7 +17,7 @@ import { enableTranslationTrace, flushTranslationTrace } from "../translation/tr import { checkAllResizeObservers } from "./ResizeObserver.js"; import { createBridge } from "./bridge.js"; import { InkContext } from "./context.js"; -import { type InkRendererTraceEvent, createInkRenderer } from "./createInkRenderer.js"; +import { advanceLayoutGeneration, readCurrentLayout, writeCurrentLayout } from "./layoutState.js"; import { commitSync, createReactRoot } from "./reactHelpers.js"; export interface KittyKeyboardOptions { @@ -116,16 +123,44 @@ interface ResizeSignalRecord { viewport: ViewportSize; } +interface ReziRendererTraceEvent { + renderId: number; + viewport: ViewportSize; + focusedId: string | null; + tick: number; + timings: { + commitMs: number; + layoutMs: number; + drawMs: number; + textMs: number; + totalMs: number; + }; + nodeCount: number; + opCount: number; + clipDepthMax: number; + textChars: number; + textLines: number; + nonBlankLines: number; + widestLine: number; + minRectY: number; + maxRectBottom: number; + zeroHeightRects: number; + detailIncluded: boolean; + nodes?: readonly unknown[]; + ops?: readonly unknown[]; + text?: string; +} + interface RenderWritePayload { output: string; staticOutput: string; } const MAX_QUEUED_OUTPUTS = 4; -const CORE_DEFAULT_FG: Rgb24 = 0xe8eef5; -const CORE_DEFAULT_BG: Rgb24 = 0x070a0c; +const CORE_DEFAULT_FG: Rgb24 = rgb(232, 238, 245); +const CORE_DEFAULT_BG: Rgb24 = rgb(7, 10, 12); const FORCED_TRUECOLOR_SUPPORT: ColorSupport = Object.freeze({ level: 3, noColor: false }); -const ANSI_SGR_PATTERN = /\u001b\[[0-9:;]*m|\u009b[0-9:;]*m/; +const FILL_CELLS_SMALL_SPAN_THRESHOLD = 160; function readViewportSize(stdout: Writable, fallbackStdout: Writable): ViewportSize { const readPositiveInt = (value: unknown): number | undefined => { @@ -897,10 +932,12 @@ function snapshotCellGridRows( for (let col = 0; col < captureTo; col++) { const cell = row[col]!; const entry: Record = { c: cell.char }; - if (cell.style?.bg) + if (cell.style?.bg != null) { entry["bg"] = `${rgbR(cell.style.bg)},${rgbG(cell.style.bg)},${rgbB(cell.style.bg)}`; - if (cell.style?.fg) + } + if (cell.style?.fg != null) { entry["fg"] = `${rgbR(cell.style.fg)},${rgbG(cell.style.fg)},${rgbB(cell.style.fg)}`; + } if (cell.style?.bold) entry["bold"] = true; if (cell.style?.dim) entry["dim"] = true; if (cell.style?.inverse) entry["inv"] = true; @@ -991,29 +1028,53 @@ function summarizeHostTree(rootNode: InkHostContainer): HostTreeSummary { }; } -function hostTreeContainsAnsiSgr(rootNode: InkHostContainer): boolean { - const stack: InkHostNode[] = [...rootNode.children]; +function scanHostTreeForStaticAndAnsi(rootNode: InkHostContainer): { + hasStaticNodes: boolean; + hasAnsiSgr: boolean; +} { + return { + hasStaticNodes: rootNode.__inkSubtreeHasStatic, + hasAnsiSgr: rootNode.__inkSubtreeHasAnsiSgr, + }; +} + +function rootChildRevisionSignature(rootNode: InkHostContainer): string { + if (rootNode.children.length === 0) return ""; + const revisions: string[] = []; + for (const child of rootNode.children) { + revisions.push(String(child.__inkRevision)); + } + return revisions.join(","); +} + +function staticRootRevisionSignature(rootNode: InkHostContainer): string { + if (!rootNode.__inkSubtreeHasStatic) return ""; + + const revisions: string[] = []; + const stack: InkHostNode[] = []; + for (let index = rootNode.children.length - 1; index >= 0; index -= 1) { + const child = rootNode.children[index]; + if (child) stack.push(child); + } + while (stack.length > 0) { const node = stack.pop(); - if (!node) continue; - const text = node.textContent; - if (typeof text === "string" && ANSI_SGR_PATTERN.test(text)) { - return true; + if (!node || !node.__inkSubtreeHasStatic) continue; + if (node.__inkSelfHasStatic) { + revisions.push(String(node.__inkRevision)); + continue; } for (let index = node.children.length - 1; index >= 0; index -= 1) { const child = node.children[index]; if (child) stack.push(child); } } - return false; -} -function clampByte(value: number): number { - return Math.max(0, Math.min(255, Math.round(value))); + return revisions.join(","); } -function packRgb24(r: number, g: number, b: number): Rgb24 { - return ((clampByte(r) & 0xff) << 16) | ((clampByte(g) & 0xff) << 8) | (clampByte(b) & 0xff); +function isRgb24(value: unknown): value is Rgb24 { + return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 0xffffff; } function rgbR(value: Rgb24): number { @@ -1028,16 +1089,8 @@ function rgbB(value: Rgb24): number { return value & 0xff; } -function clampRgb24(value: Rgb24): Rgb24 { - return packRgb24(rgbR(value), rgbG(value), rgbB(value)); -} - -function isRgb24(value: unknown): value is Rgb24 { - return typeof value === "number" && Number.isFinite(value); -} - -function isSameRgb(a: Rgb24, b: Rgb24): boolean { - return a === b; +function clampByte(value: number): number { + return Math.max(0, Math.min(255, Math.round(value))); } const ANSI16_PALETTE: readonly [number, number, number][] = [ @@ -1128,43 +1181,58 @@ function rgbChannelToCubeLevel(channel: number): number { } function toAnsi256Code(color: Rgb24): number { - const rLevel = rgbChannelToCubeLevel(rgbR(color)); - const gLevel = rgbChannelToCubeLevel(rgbG(color)); - const bLevel = rgbChannelToCubeLevel(rgbB(color)); + const r = rgbR(color); + const g = rgbG(color); + const b = rgbB(color); + const rLevel = rgbChannelToCubeLevel(r); + const gLevel = rgbChannelToCubeLevel(g); + const bLevel = rgbChannelToCubeLevel(b); const cubeCode = 16 + 36 * rLevel + 6 * gLevel + bLevel; - const cubeColor: readonly [number, number, number] = [ + const cubeColor = rgb( rLevel === 0 ? 0 : 55 + 40 * rLevel, gLevel === 0 ? 0 : 55 + 40 * gLevel, bLevel === 0 ? 0 : 55 + 40 * bLevel, - ]; + ); - const avg = Math.round((rgbR(color) + rgbG(color) + rgbB(color)) / 3); + const avg = Math.round((r + g + b) / 3); const grayLevel = Math.max(0, Math.min(23, Math.round((avg - 8) / 10))); const grayCode = 232 + grayLevel; const grayValue = 8 + 10 * grayLevel; - const grayColor: readonly [number, number, number] = [grayValue, grayValue, grayValue]; + const grayColor = rgb(grayValue, grayValue, grayValue); - const cubeDistance = colorDistanceSq(color, cubeColor); - const grayDistance = colorDistanceSq(color, grayColor); + const cubeDistance = colorDistanceSq(color, [rgbR(cubeColor), rgbG(cubeColor), rgbB(cubeColor)]); + const grayDistance = colorDistanceSq(color, [rgbR(grayColor), rgbG(grayColor), rgbB(grayColor)]); return grayDistance < cubeDistance ? grayCode : cubeCode; } +/** + * Cache normalized styles by identity — Rezi's renderer reuses TextStyle + * objects across draw ops, so identity-based caching is highly effective. + */ +const normalizeStyleCache = new WeakMap(); + function normalizeStyle(style: TextStyle | undefined): CellStyle | undefined { if (!style) return undefined; + const cached = normalizeStyleCache.get(style); + if (cached !== undefined) return cached; + // WeakMap returns undefined for both missing entries and stored undefined + // values, so use a separate check for the "computed but undefined" case. + if (normalizeStyleCache.has(style)) return undefined; + const normalized: CellStyle = {}; - if (isRgb24(style.fg) && style.fg !== 0) { - const fg = clampRgb24(style.fg); + if (isRgb24(style.fg)) { + const fg = rgb(clampByte(rgbR(style.fg)), clampByte(rgbG(style.fg)), clampByte(rgbB(style.fg))); // Rezi carries DEFAULT_BASE_STYLE through every text draw op. Ink treats // terminal defaults as implicit, so suppress those default color channels. - if (!isSameRgb(fg, CORE_DEFAULT_FG)) { + if (fg !== CORE_DEFAULT_FG) { normalized.fg = fg; } } - if (isRgb24(style.bg) && style.bg !== 0) { - const bg = clampRgb24(style.bg); - if (!isSameRgb(bg, CORE_DEFAULT_BG)) { + if (isRgb24(style.bg)) { + const bg = rgb(clampByte(rgbR(style.bg)), clampByte(rgbG(style.bg)), clampByte(rgbB(style.bg))); + if (bg !== CORE_DEFAULT_BG) { normalized.bg = bg; } } @@ -1175,39 +1243,149 @@ function normalizeStyle(style: TextStyle | undefined): CellStyle | undefined { if (style.strikethrough === true) normalized.strikethrough = true; if (style.inverse === true) normalized.inverse = true; - return Object.keys(normalized).length > 0 ? normalized : undefined; + const hasKeys = + normalized.fg !== undefined || + normalized.bg !== undefined || + normalized.bold !== undefined || + normalized.dim !== undefined || + normalized.italic !== undefined || + normalized.underline !== undefined || + normalized.strikethrough !== undefined || + normalized.inverse !== undefined; + const result = hasKeys ? normalized : undefined; + normalizeStyleCache.set(style, result); + return result; +} + +function rgbEqual(a: Rgb24 | undefined, b: Rgb24 | undefined): boolean { + return a === b; } function stylesEqual(a: CellStyle | undefined, b: CellStyle | undefined): boolean { if (a === b) return true; if (!a || !b) return false; + return ( + a.bold === b.bold && + a.dim === b.dim && + a.italic === b.italic && + a.underline === b.underline && + a.strikethrough === b.strikethrough && + a.inverse === b.inverse && + rgbEqual(a.fg, b.fg) && + rgbEqual(a.bg, b.bg) + ); +} - const keysA = Object.keys(a).sort(); - const keysB = Object.keys(b).sort(); - if (keysA.length !== keysB.length) return false; +function styleVisibleOnSpace(style: CellStyle | undefined): boolean { + if (!style) return false; + return style.bg !== undefined || style.inverse === true || style.underline === true; +} - for (let i = 0; i < keysA.length; i += 1) { - const key = keysA[i]!; - if (key !== keysB[i]) return false; - if ( - JSON.stringify((a as Record)[key]) !== - JSON.stringify((b as Record)[key]) - ) { - return false; - } +// Cells are treated as immutable; we always replace array elements instead of mutating +// `char`/`style` in place. This lets us safely reuse a few shared cell objects. +const BLANK_CELL: StyledCell = { char: " ", style: undefined }; +const WIDE_EMPTY_CELL: StyledCell = { char: "", style: undefined }; +const SPACE_CELL_CACHE = new WeakMap(); +const ASCII_CELL_CACHE_UNSTYLED: Array = new Array(128); +ASCII_CELL_CACHE_UNSTYLED[0x20] = BLANK_CELL; +const ASCII_CELL_CACHE_STYLED = new WeakMap>(); +const ASCII_CHAR_STRINGS: readonly string[] = (() => { + const out: string[] = new Array(128); + for (let code = 0; code < out.length; code += 1) { + out[code] = String.fromCharCode(code); + } + return out; +})(); + +function getSpaceCell(style: CellStyle | undefined): StyledCell { + if (!style) return BLANK_CELL; + const cached = SPACE_CELL_CACHE.get(style); + if (cached) return cached; + const cell: StyledCell = { char: " ", style }; + SPACE_CELL_CACHE.set(style, cell); + return cell; +} + +function getAsciiCell(code: number, style: CellStyle | undefined): StyledCell { + const char = ASCII_CHAR_STRINGS[code] ?? String.fromCharCode(code); + if (!style) { + const cached = ASCII_CELL_CACHE_UNSTYLED[code]; + if (cached) return cached; + const cell: StyledCell = { char, style: undefined }; + ASCII_CELL_CACHE_UNSTYLED[code] = cell; + return cell; + } + + let styleCache = ASCII_CELL_CACHE_STYLED.get(style); + if (!styleCache) { + styleCache = new Array(128); + styleCache[0x20] = getSpaceCell(style); + ASCII_CELL_CACHE_STYLED.set(style, styleCache); } + const cached = styleCache[code]; + if (cached) return cached; + const cell: StyledCell = { char, style }; + styleCache[code] = cell; + return cell; +} + +function isSimpleAsciiText(text: string): boolean { + for (let index = 0; index < text.length; index += 1) { + const code = text.charCodeAt(index); + // Deliberately exclude control chars and DEL; they may have special terminal semantics. + if (code < 0x20 || code > 0x7e) return false; + } return true; } -function styleVisibleOnSpace(style: CellStyle | undefined): boolean { - if (!style) return false; - return style.bg !== undefined || style.inverse === true || style.underline === true; +function isCombiningMark(code: number): boolean { + // Common combining mark blocks (BMP). We treat any presence as "complex" and + // fall back to grapheme segmentation for correctness. + return ( + (code >= 0x0300 && code <= 0x036f) || + (code >= 0x1ab0 && code <= 0x1aff) || + (code >= 0x1dc0 && code <= 0x1dff) || + (code >= 0x20d0 && code <= 0x20ff) || + (code >= 0xfe20 && code <= 0xfe2f) + ); } +function isSimpleBmpText(text: string): boolean { + for (let index = 0; index < text.length; index += 1) { + const code = text.charCodeAt(index); + // Exclude control chars, DEL, and surrogate halves (astral emoji, flags). + if (code < 0x20 || code === 0x7f) return false; + if (code >= 0xd800 && code <= 0xdfff) return false; + // Exclude known complex grapheme / zero-width code points. + if (code === 0x200b || code === 0x200c || code === 0x200d) return false; // ZWSP/ZWNJ/ZWJ + if (code === 0x200e || code === 0x200f) return false; // bidi marks + if (code === 0xfeff) return false; // zero-width no-break space (BOM) + if (code >= 0xfe00 && code <= 0xfe0f) return false; // variation selectors + if (isCombiningMark(code)) return false; + } + return true; +} + +/** + * Identity-based SGR cache. Most frames use only 3-5 distinct CellStyle + * objects, so caching by identity avoids rebuilding ANSI strings per-cell. + */ +const sgrCache = new Map(); +let sgrCacheColorLevel = -1; + function styleToSgr(style: CellStyle | undefined, colorSupport: ColorSupport): string { if (!style) return "\u001b[0m"; + // Invalidate cache when color support changes (rare) + if (colorSupport.level !== sgrCacheColorLevel) { + sgrCache.clear(); + sgrCacheColorLevel = colorSupport.level; + } + + const cached = sgrCache.get(style); + if (cached !== undefined) return cached; + const codes: string[] = []; if (style.bold) codes.push("1"); if (style.dim) codes.push("2"); @@ -1216,18 +1394,22 @@ function styleToSgr(style: CellStyle | undefined, colorSupport: ColorSupport): s if (style.inverse) codes.push("7"); if (style.strikethrough) codes.push("9"); if (colorSupport.level > 0) { - if (style.fg) { + if (style.fg != null) { if (colorSupport.level >= 3) { - codes.push(`38;2;${rgbR(style.fg)};${rgbG(style.fg)};${rgbB(style.fg)}`); + codes.push( + `38;2;${clampByte(rgbR(style.fg))};${clampByte(rgbG(style.fg))};${clampByte(rgbB(style.fg))}`, + ); } else if (colorSupport.level === 2) { codes.push(`38;5;${toAnsi256Code(style.fg)}`); } else { codes.push(String(toAnsi16Code(style.fg, false))); } } - if (style.bg) { + if (style.bg != null) { if (colorSupport.level >= 3) { - codes.push(`48;2;${rgbR(style.bg)};${rgbG(style.bg)};${rgbB(style.bg)}`); + codes.push( + `48;2;${clampByte(rgbR(style.bg))};${clampByte(rgbG(style.bg))};${clampByte(rgbB(style.bg))}`, + ); } else if (colorSupport.level === 2) { codes.push(`48;5;${toAnsi256Code(style.bg)}`); } else { @@ -1236,10 +1418,22 @@ function styleToSgr(style: CellStyle | undefined, colorSupport: ColorSupport): s } } - if (codes.length === 0) return "\u001b[0m"; - // Always reset (0) before applying new attributes to prevent attribute - // bleed from previous cells (e.g. bold, bg carrying over). - return `\u001b[0;${codes.join(";")}m`; + let result: string; + if (codes.length === 0) { + result = "\u001b[0m"; + } else { + // Always reset (0) before applying new attributes to prevent attribute + // bleed from previous cells (e.g. bold, bg carrying over). + result = `\u001b[0;${codes.join(";")}m`; + } + + sgrCache.set(style, result); + // Prevent unbounded growth — evict oldest when too large + if (sgrCache.size > 256) { + const firstKey = sgrCache.keys().next().value; + if (firstKey) sgrCache.delete(firstKey); + } + return result; } function inClipStack(x: number, y: number, clipStack: readonly ClipRect[]): boolean { @@ -1249,24 +1443,68 @@ function inClipStack(x: number, y: number, clipStack: readonly ClipRect[]): bool return true; } +/** + * Pre-compute the effective clip rect (intersection of all rects in stack). + * Returns null for empty clip stack. + * Empty intersections are represented as a zero-sized rect. + * Reduces per-cell clip checking from O(clipStack.length) to O(1). + */ +function computeEffectiveClip(clipStack: readonly ClipRect[]): ClipRect | null { + if (clipStack.length === 0) return null; + let x1 = clipStack[0]!.x; + let y1 = clipStack[0]!.y; + let x2 = x1 + clipStack[0]!.w; + let y2 = y1 + clipStack[0]!.h; + for (let i = 1; i < clipStack.length; i++) { + const c = clipStack[i]!; + x1 = Math.max(x1, c.x); + y1 = Math.max(y1, c.y); + x2 = Math.min(x2, c.x + c.w); + y2 = Math.min(y2, c.y + c.h); + } + if (x1 >= x2 || y1 >= y2) return { x: x1, y: y1, w: 0, h: 0 }; + return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 }; +} + +function inEffectiveClip(x: number, y: number, clip: ClipRect | null): boolean { + if (clip === null) return true; + if (clip.w <= 0 || clip.h <= 0) return false; + return x >= clip.x && x < clip.x + clip.w && y >= clip.y && y < clip.y + clip.h; +} + function fillCells( grid: StyledCell[][], viewport: ViewportSize, - clipStack: readonly ClipRect[], + clip: ClipRect | null, x: number, y: number, w: number, h: number, style: CellStyle | undefined, ): void { - for (let yy = y; yy < y + h; yy += 1) { - if (yy < 0 || yy >= viewport.rows) continue; + // Compute effective bounds (intersection of fill rect, viewport, and clip) + let startX = Math.max(0, x); + let startY = Math.max(0, y); + let endX = Math.min(viewport.cols, x + w); + let endY = Math.min(viewport.rows, y + h); + if (clip !== null) { + startX = Math.max(startX, clip.x); + startY = Math.max(startY, clip.y); + endX = Math.min(endX, clip.x + clip.w); + endY = Math.min(endY, clip.y + clip.h); + } + const fillCell = getSpaceCell(style); + for (let yy = startY; yy < endY; yy += 1) { const row = grid[yy]; if (!row) continue; - for (let xx = x; xx < x + w; xx += 1) { - if (xx < 0 || xx >= viewport.cols || !inClipStack(xx, yy, clipStack)) continue; - row[xx] = { char: " ", style }; + const span = endX - startX; + if (span <= FILL_CELLS_SMALL_SPAN_THRESHOLD) { + for (let xx = startX; xx < endX; xx += 1) { + row[xx] = fillCell; + } + continue; } + row.fill(fillCell, startX, endX); } } @@ -1275,6 +1513,8 @@ function fillCells( * Preserves base properties (especially bg from fillRect) when the * overlay doesn't explicitly set them. */ +const MERGED_STYLE_CACHE = new WeakMap>(); + function mergeCellStyles( base: CellStyle | undefined, overlay: CellStyle | undefined, @@ -1282,19 +1522,52 @@ function mergeCellStyles( if (!overlay && !base) return undefined; if (!overlay) return base; if (!base) return overlay; + if (base === overlay) return base; + + let overlayCache = MERGED_STYLE_CACHE.get(base); + if (!overlayCache) { + overlayCache = new WeakMap(); + MERGED_STYLE_CACHE.set(base, overlayCache); + } + const cached = overlayCache.get(overlay); + if (cached) return cached; - const merged: CellStyle = {}; const bg = overlay.bg ?? base.bg; const fg = overlay.fg ?? base.fg; + const bold = overlay.bold ?? base.bold; + const dim = overlay.dim ?? base.dim; + const italic = overlay.italic ?? base.italic; + const underline = overlay.underline ?? base.underline; + const strikethrough = overlay.strikethrough ?? base.strikethrough; + const inverse = overlay.inverse ?? base.inverse; + + // If the overlay doesn't change anything, reuse the base style object. + if ( + bg === base.bg && + fg === base.fg && + bold === base.bold && + dim === base.dim && + italic === base.italic && + underline === base.underline && + strikethrough === base.strikethrough && + inverse === base.inverse + ) { + overlayCache.set(overlay, base); + return base; + } + + const merged: CellStyle = {}; if (bg) merged.bg = bg; if (fg) merged.fg = fg; - if (overlay.bold ?? base.bold) merged.bold = true; - if (overlay.dim ?? base.dim) merged.dim = true; - if (overlay.italic ?? base.italic) merged.italic = true; - if (overlay.underline ?? base.underline) merged.underline = true; - if (overlay.strikethrough ?? base.strikethrough) merged.strikethrough = true; - if (overlay.inverse ?? base.inverse) merged.inverse = true; - return Object.keys(merged).length > 0 ? merged : undefined; + if (bold) merged.bold = true; + if (dim) merged.dim = true; + if (italic) merged.italic = true; + if (underline) merged.underline = true; + if (strikethrough) merged.strikethrough = true; + if (inverse) merged.inverse = true; + + overlayCache.set(overlay, merged); + return merged; } type GraphemeSegmenter = { @@ -1348,59 +1621,158 @@ function forEachGraphemeCluster(text: string, visit: (cluster: string) => void): function drawTextToCells( grid: StyledCell[][], viewport: ViewportSize, - clipStack: readonly ClipRect[], + clip: ClipRect | null, x0: number, y: number, text: string, style: CellStyle | undefined, ): void { if (y < 0 || y >= viewport.rows) return; + if (clip !== null && (y < clip.y || y >= clip.y + clip.h)) return; + const row = grid[y]; + if (!row) return; + + const clipMinX = clip === null ? 0 : Math.max(0, clip.x); + const clipMaxX = clip === null ? viewport.cols : Math.min(viewport.cols, clip.x + clip.w); + if (clipMaxX <= clipMinX) return; + + if (isSimpleAsciiText(text)) { + const baseMin = Math.max(0, clipMinX - x0); + const baseMax = Math.min(text.length, clipMaxX - x0); + if (baseMin >= baseMax) return; + + let cursorX = x0 + baseMin; + for (let index = baseMin; index < baseMax; index += 1) { + const code = text.charCodeAt(index); + const existingCell = row[cursorX]; + const existingStyle = existingCell?.style; + const nextStyle = existingStyle + ? style + ? mergeCellStyles(existingStyle, style) + : existingStyle + : style; + const nextCell = + code === 0x20 + ? getSpaceCell(nextStyle) + : // Printable ASCII fast path: cached cell objects avoid per-glyph allocations. + getAsciiCell(code, nextStyle); + if (existingCell !== nextCell) { + row[cursorX] = nextCell; + } + cursorX += 1; + } + return; + } - let cursorX = x0; - forEachGraphemeCluster(text, (glyph) => { - const width = measureTextCells(glyph); - if (width <= 0) return; + // Fast path for mixed ASCII + BMP symbols (box drawing, bullets, etc.) that + // don't require full grapheme segmentation (no surrogate pairs, no ZWJ, + // no combining marks/variation selectors). + if (isSimpleBmpText(text)) { + let cursorX = x0; + for (let index = 0; index < text.length; index += 1) { + const code = text.charCodeAt(index); + const isAscii = code >= 0x20 && code <= 0x7e; + const glyph = isAscii ? "" : (text[index] ?? ""); + const width = isAscii ? 1 : measureTextCells(glyph); + if (width <= 0) { + // Zero-width / non-rendering glyph; skip without advancing. + continue; + } - if (cursorX >= 0 && cursorX < viewport.cols && inClipStack(cursorX, y, clipStack)) { - const row = grid[y]; - if (row) { - const existing = row[cursorX]; - row[cursorX] = { char: glyph, style: mergeCellStyles(existing?.style, style) }; + if (cursorX >= 0 && cursorX < viewport.cols && inEffectiveClip(cursorX, y, clip)) { + const existingCell = row[cursorX]; + const existingStyle = existingCell?.style; + const nextStyle = existingStyle + ? style + ? mergeCellStyles(existingStyle, style) + : existingStyle + : style; + const nextCell = + code === 0x20 + ? getSpaceCell(nextStyle) + : isAscii + ? getAsciiCell(code, nextStyle) + : { char: glyph, style: nextStyle }; + if (existingCell !== nextCell) { + row[cursorX] = nextCell; + } } - } - for (let offset = 1; offset < width; offset += 1) { - const fillX = cursorX + offset; - if (fillX < 0 || fillX >= viewport.cols || !inClipStack(fillX, y, clipStack)) continue; - const row = grid[y]; - if (row) { - row[fillX] = { char: "", style: undefined }; + for (let offset = 1; offset < width; offset += 1) { + const fillX = cursorX + offset; + if (fillX >= 0 && fillX < viewport.cols && inEffectiveClip(fillX, y, clip)) { + row[fillX] = WIDE_EMPTY_CELL; + } + } + + cursorX += width; + if (cursorX >= clipMaxX) return; + } + } else { + let cursorX = x0; + forEachGraphemeCluster(text, (glyph) => { + const width = measureTextCells(glyph); + if (width <= 0) return; + + if (cursorX >= 0 && cursorX < viewport.cols && inEffectiveClip(cursorX, y, clip)) { + const existingCell = row[cursorX]; + const existingStyle = existingCell?.style; + const nextStyle = existingStyle + ? style + ? mergeCellStyles(existingStyle, style) + : existingStyle + : style; + + let nextCell: StyledCell; + if (glyph === " ") { + nextCell = getSpaceCell(nextStyle); + } else if (glyph.length === 1) { + const code = glyph.charCodeAt(0); + nextCell = + code >= 0x20 && code <= 0x7e + ? getAsciiCell(code, nextStyle) + : { char: glyph, style: nextStyle }; + } else { + nextCell = { char: glyph, style: nextStyle }; + } + + if (existingCell !== nextCell) { + row[cursorX] = nextCell; + } } - } - cursorX += width; - }); + for (let offset = 1; offset < width; offset += 1) { + const fillX = cursorX + offset; + if (fillX >= 0 && fillX < viewport.cols && inEffectiveClip(fillX, y, clip)) { + row[fillX] = WIDE_EMPTY_CELL; + } + } + + cursorX += width; + }); + } + + return; } function renderOpsToAnsi( ops: readonly RenderOp[], viewport: ViewportSize, colorSupport: ColorSupport, -): { ansi: string; grid: StyledCell[][] } { - const grid: StyledCell[][] = []; +): { ansi: string; grid: StyledCell[][]; shape: OutputShapeSummary } { + const grid: StyledCell[][] = new Array(viewport.rows); for (let rowIndex = 0; rowIndex < viewport.rows; rowIndex += 1) { - const row: StyledCell[] = []; - for (let colIndex = 0; colIndex < viewport.cols; colIndex += 1) { - row.push({ char: " ", style: undefined }); - } - grid.push(row); + const row = new Array(viewport.cols); + row.fill(BLANK_CELL); + grid[rowIndex] = row; } const clipStack: ClipRect[] = []; + let effectiveClip: ClipRect | null = null; for (const op of ops) { if (op.kind === "clear") { - fillCells(grid, viewport, clipStack, 0, 0, viewport.cols, viewport.rows, undefined); + fillCells(grid, viewport, effectiveClip, 0, 0, viewport.cols, viewport.rows, undefined); continue; } if (op.kind === "clearTo") { @@ -1413,7 +1785,7 @@ function renderOpsToAnsi( fillCells( grid, viewport, - clipStack, + effectiveClip, 0, 0, Math.max(0, Math.trunc(op.cols)), @@ -1426,7 +1798,7 @@ function renderOpsToAnsi( fillCells( grid, viewport, - clipStack, + effectiveClip, Math.trunc(op.x), Math.trunc(op.y), Math.max(0, Math.trunc(op.w)), @@ -1439,7 +1811,7 @@ function renderOpsToAnsi( drawTextToCells( grid, viewport, - clipStack, + effectiveClip, Math.trunc(op.x), Math.trunc(op.y), op.text, @@ -1454,28 +1826,44 @@ function renderOpsToAnsi( w: Math.max(0, Math.trunc(op.w)), h: Math.max(0, Math.trunc(op.h)), }); + effectiveClip = computeEffectiveClip(clipStack); continue; } if (op.kind === "popClip") { clipStack.pop(); + effectiveClip = clipStack.length === 0 ? null : computeEffectiveClip(clipStack); } } const lines: string[] = []; + let nonBlankLines = 0; + let firstNonBlankLine = -1; + let lastNonBlankLine = -1; + let widestLine = 0; - for (const row of grid) { + for (let rowIndex = 0; rowIndex < grid.length; rowIndex += 1) { + const row = grid[rowIndex]!; let lastUsefulCol = -1; - for (let index = 0; index < row.length; index += 1) { - const cell = row[index]!; - if ((cell.char !== "" && cell.char !== " ") || styleVisibleOnSpace(cell.style)) - lastUsefulCol = index; + for (let colIndex = row.length - 1; colIndex >= 0; colIndex -= 1) { + const cell = row[colIndex]; + if (!cell) continue; + if ((cell.char !== "" && cell.char !== " ") || styleVisibleOnSpace(cell.style)) { + lastUsefulCol = colIndex; + break; + } } + widestLine = Math.max(widestLine, lastUsefulCol + 1); + if (lastUsefulCol < 0) { lines.push(""); continue; } + nonBlankLines += 1; + if (firstNonBlankLine === -1) firstNonBlankLine = rowIndex; + lastNonBlankLine = rowIndex; + let line = ""; let activeStyle: CellStyle | undefined; @@ -1493,7 +1881,11 @@ function renderOpsToAnsi( } while (lines.length > 1 && lines[lines.length - 1] === "") lines.pop(); - return { ansi: lines.join("\n"), grid }; + return { + ansi: lines.join("\n"), + grid, + shape: { lines: grid.length, nonBlankLines, firstNonBlankLine, lastNonBlankLine, widestLine }, + }; } function asFiniteNumber(value: unknown): number | undefined { @@ -1507,6 +1899,7 @@ function resolvePercent(value: number, base: number): number { type HostNodeWithLayout = InkHostNode & { __inkLayout?: { x: number; y: number; w: number; h: number }; + __inkLayoutGen?: number; }; type FlexMainAxis = "row" | "column"; @@ -1514,6 +1907,39 @@ type FlexMainAxis = "row" | "column"; interface PercentResolveContext { parentSize: ViewportSize; parentMainAxis: FlexMainAxis; + deps?: PercentResolveDeps; +} + +interface PercentParentDep { + cols: number; + rows: number; + usesCols: boolean; + usesRows: boolean; +} + +interface PercentResolveDeps { + // Host parents whose current layout (w/h) was used as the base for resolving percent props. + parents: Map; + // True when a node had a host parent but that parent's layout was unavailable, meaning + // a second render can change percent resolution once layouts are assigned. + missingParentLayout: boolean; +} + +function recordPercentParentDep( + deps: PercentResolveDeps, + parent: HostNodeWithLayout, + size: ViewportSize, + usesCols: boolean, + usesRows: boolean, +): void { + if (!usesCols && !usesRows) return; + const existing = deps.parents.get(parent); + if (existing) { + existing.usesCols = existing.usesCols || usesCols; + existing.usesRows = existing.usesRows || usesRows; + return; + } + deps.parents.set(parent, { cols: size.cols, rows: size.rows, usesCols, usesRows }); } function readHostNode(value: unknown): HostNodeWithLayout | undefined { @@ -1523,20 +1949,6 @@ function readHostNode(value: unknown): HostNodeWithLayout | undefined { return candidate; } -function readHostParentSize( - hostNode: HostNodeWithLayout | undefined, - fallback: ViewportSize, -): ViewportSize { - const parentLayout = hostNode?.parent - ? (hostNode.parent as HostNodeWithLayout).__inkLayout - : undefined; - if (!parentLayout) return fallback; - return { - cols: Math.max(0, Math.trunc(parentLayout.w)), - rows: Math.max(0, Math.trunc(parentLayout.h)), - }; -} - function readHostMainAxis(hostNode: HostNodeWithLayout | null): FlexMainAxis | undefined { if (!hostNode || hostNode.type !== "ink-box") return undefined; const direction = hostNode.props["flexDirection"]; @@ -1550,10 +1962,6 @@ function readNodeMainAxis(kind: unknown): FlexMainAxis { } function hasPercentMarkers(vnode: VNode): boolean { - // width, height, and flexBasis percent values are now passed as native - // percent strings (e.g. "50%") directly to the VNode props and resolved by - // the layout engine in a single pass. Only minWidth/minHeight still use - // __inkPercent* markers (rare in practice). if (typeof vnode !== "object" || vnode === null) return false; const candidate = vnode as { props?: unknown; children?: unknown }; const props = @@ -1563,8 +1971,11 @@ function hasPercentMarkers(vnode: VNode): boolean { if ( props && - (typeof props["__inkPercentMinWidth"] === "number" || - typeof props["__inkPercentMinHeight"] === "number") + (typeof props["__inkPercentWidth"] === "number" || + typeof props["__inkPercentHeight"] === "number" || + typeof props["__inkPercentMinWidth"] === "number" || + typeof props["__inkPercentMinHeight"] === "number" || + typeof props["__inkPercentFlexBasis"] === "number") ) { return true; } @@ -1577,9 +1988,6 @@ function hasPercentMarkers(vnode: VNode): boolean { } function resolvePercentMarkers(vnode: VNode, context: PercentResolveContext): VNode { - // NOTE: width, height, and flexBasis percent values are now passed as native - // percent strings directly to VNode props. This function only handles the - // remaining __inkPercentMinWidth / __inkPercentMinHeight markers (rare). if (typeof vnode !== "object" || vnode === null) { return vnode; } @@ -1596,11 +2004,58 @@ function resolvePercentMarkers(vnode: VNode, context: PercentResolveContext): VN : {}; const nextProps: Record = { ...originalProps }; const hostNode = readHostNode(originalProps["__inkHostNode"]); - const parentSize = readHostParentSize(hostNode, context.parentSize); + const hostParent = + hostNode?.parent && typeof hostNode.parent === "object" && hostNode.parent !== null + ? (hostNode.parent as HostNodeWithLayout) + : undefined; + const parentLayout = hostParent ? readCurrentLayout(hostParent) : undefined; + const parentSize = parentLayout + ? { + cols: Math.max(0, Math.trunc(parentLayout.w)), + rows: Math.max(0, Math.trunc(parentLayout.h)), + } + : context.parentSize; + const parentMainAxis = readHostMainAxis(hostParent ?? null) ?? context.parentMainAxis; + const percentWidth = asFiniteNumber(originalProps["__inkPercentWidth"]); + const percentHeight = asFiniteNumber(originalProps["__inkPercentHeight"]); const percentMinWidth = asFiniteNumber(originalProps["__inkPercentMinWidth"]); const percentMinHeight = asFiniteNumber(originalProps["__inkPercentMinHeight"]); + const percentFlexBasis = asFiniteNumber(originalProps["__inkPercentFlexBasis"]); + const deps = context.deps; + if ( + deps && + (percentWidth != null || + percentHeight != null || + percentMinWidth != null || + percentMinHeight != null || + percentFlexBasis != null) + ) { + // If this node has a host parent but that parent's layout isn't available yet, we cannot + // know whether the first-pass percent resolution is correct; force a second pass. + if (hostParent && !parentLayout) { + deps.missingParentLayout = true; + } + if (hostParent && parentLayout) { + const usesCols = + percentWidth != null || + percentMinWidth != null || + (percentFlexBasis != null && parentMainAxis === "row"); + const usesRows = + percentHeight != null || + percentMinHeight != null || + (percentFlexBasis != null && parentMainAxis === "column"); + recordPercentParentDep(deps, hostParent, parentSize, usesCols, usesRows); + } + } + + if (percentWidth != null) { + nextProps["width"] = resolvePercent(percentWidth, parentSize.cols); + } + if (percentHeight != null) { + nextProps["height"] = resolvePercent(percentHeight, parentSize.rows); + } if (percentMinWidth != null) { nextProps["minWidth"] = resolvePercent(percentMinWidth, parentSize.cols); } @@ -1608,8 +2063,16 @@ function resolvePercentMarkers(vnode: VNode, context: PercentResolveContext): VN nextProps["minHeight"] = resolvePercent(percentMinHeight, parentSize.rows); } + if (percentFlexBasis != null) { + const basisBase = parentMainAxis === "column" ? parentSize.rows : parentSize.cols; + nextProps["flexBasis"] = resolvePercent(percentFlexBasis, basisBase); + } + + delete nextProps["__inkPercentWidth"]; + delete nextProps["__inkPercentHeight"]; delete nextProps["__inkPercentMinWidth"]; delete nextProps["__inkPercentMinHeight"]; + delete nextProps["__inkPercentFlexBasis"]; const localWidth = asFiniteNumber(nextProps["width"]); const localHeight = asFiniteNumber(nextProps["height"]); @@ -1621,6 +2084,7 @@ function resolvePercentMarkers(vnode: VNode, context: PercentResolveContext): VN const nextContext: PercentResolveContext = { parentSize: nextParentSize, parentMainAxis: readNodeMainAxis(candidate.kind), + ...(context.deps ? { deps: context.deps } : {}), }; const originalChildren = Array.isArray(candidate.children) ? (candidate.children as VNode[]) : []; @@ -1633,25 +2097,14 @@ function resolvePercentMarkers(vnode: VNode, context: PercentResolveContext): VN } as unknown as VNode; } -function clearHostLayouts(container: InkHostContainer): void { - const stack: InkHostNode[] = [...container.children]; - while (stack.length > 0) { - const node = stack.pop(); - if (!node) continue; - delete (node as HostNodeWithLayout).__inkLayout; - for (let index = node.children.length - 1; index >= 0; index -= 1) { - const child = node.children[index]; - if (child) stack.push(child); - } - } -} - function assignHostLayouts( + container: InkHostContainer, nodes: readonly { rect?: { x?: number; y?: number; w?: number; h?: number }; props?: Record; }[], ): void { + const generation = advanceLayoutGeneration(container); for (const node of nodes) { if (!node) continue; const host = node.props?.["__inkHostNode"]; @@ -1674,12 +2127,16 @@ function assignHostLayouts( ) { continue; } - hostNode.__inkLayout = { - x: Math.trunc(x), - y: Math.trunc(y), - w: Math.max(0, Math.trunc(w)), - h: Math.max(0, Math.trunc(h)), - }; + writeCurrentLayout( + hostNode, + { + x: Math.trunc(x), + y: Math.trunc(y), + w: Math.max(0, Math.trunc(w)), + h: Math.max(0, Math.trunc(h)), + }, + generation, + ); } } @@ -1726,7 +2183,6 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions const detailNodeLimit = traceDetailFull ? 2000 : 300; const detailOpLimit = traceDetailFull ? 4000 : 500; const detailResizeLimit = traceDetailFull ? 300 : 80; - const frameProfileFile = process.env["INK_COMPAT_FRAME_PROFILE_FILE"]; const writeErr = (stderr as { write: (s: string) => void }).write.bind(stderr); const traceStartAt = Date.now(); @@ -1743,6 +2199,63 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions } }; + const phaseProfileFile = process.env["INK_COMPAT_PHASE_PROFILE_FILE"]; + const phaseProfile = + typeof phaseProfileFile === "string" && phaseProfileFile.length > 0 + ? { + frames: 0, + translationMs: 0, + percentResolveMs: 0, + coreRenderMs: 0, + assignLayoutsMs: 0, + rectScanMs: 0, + ansiMs: 0, + otherMs: 0, + percentFrames: 0, + coreRenderPasses: 0, + maxFrameMs: 0, + } + : null; + let phaseProfileFlushed = false; + const flushPhaseProfile = (): void => { + if (!phaseProfile || phaseProfileFlushed) return; + phaseProfileFlushed = true; + const frames = Math.max(1, phaseProfile.frames); + const payload = { + at: new Date().toISOString(), + frames: phaseProfile.frames, + totalsMs: { + translationMs: Math.round(phaseProfile.translationMs * 100) / 100, + percentResolveMs: Math.round(phaseProfile.percentResolveMs * 100) / 100, + coreRenderMs: Math.round(phaseProfile.coreRenderMs * 100) / 100, + assignLayoutsMs: Math.round(phaseProfile.assignLayoutsMs * 100) / 100, + rectScanMs: Math.round(phaseProfile.rectScanMs * 100) / 100, + ansiMs: Math.round(phaseProfile.ansiMs * 100) / 100, + otherMs: Math.round(phaseProfile.otherMs * 100) / 100, + }, + avgMs: { + translationMs: Math.round((phaseProfile.translationMs / frames) * 1000) / 1000, + percentResolveMs: Math.round((phaseProfile.percentResolveMs / frames) * 1000) / 1000, + coreRenderMs: Math.round((phaseProfile.coreRenderMs / frames) * 1000) / 1000, + assignLayoutsMs: Math.round((phaseProfile.assignLayoutsMs / frames) * 1000) / 1000, + rectScanMs: Math.round((phaseProfile.rectScanMs / frames) * 1000) / 1000, + ansiMs: Math.round((phaseProfile.ansiMs / frames) * 1000) / 1000, + otherMs: Math.round((phaseProfile.otherMs / frames) * 1000) / 1000, + }, + counters: { + percentFrames: phaseProfile.percentFrames, + coreRenderPasses: phaseProfile.coreRenderPasses, + maxFrameMs: Math.round(phaseProfile.maxFrameMs * 1000) / 1000, + }, + }; + + const filePath = phaseProfileFile; + if (typeof filePath !== "string" || filePath.length === 0) return; + try { + appendFileSync(filePath, `${JSON.stringify(payload)}\n`); + } catch {} + }; + const bridge = createBridge({ stdout, stdin, @@ -1759,14 +2272,14 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions }); let viewport = readViewportSize(stdout, fallbackStdout); - const renderer = createInkRenderer({ + const renderer = createTestRenderer({ viewport, ...(traceEnabled ? { traceDetail: traceDetailFull, - trace: (event: InkRendererTraceEvent) => { + trace: (event: ReziRendererTraceEvent) => { trace( - `rezi#${event.renderId} viewport=${event.viewport.cols}x${event.viewport.rows} focused=${event.focusedId ?? "none"} tick=${event.tick} totalMs=${event.timings.totalMs} commitMs=${event.timings.commitMs} layoutMs=${event.timings.layoutMs} drawMs=${event.timings.drawMs} textMs=${event.timings.textMs} nodes=${event.nodeCount} ops=${event.opCount} clipMax=${event.clipDepthMax} textChars=${event.textChars} textLines=${event.textLines} nonBlank=${event.nonBlankLines} widest=${event.widestLine} minY=${event.minRectY} maxBottom=${event.maxRectBottom} zeroH=${event.zeroHeightRects} detailIncluded=${event.detailIncluded} layoutSkipped=${event.layoutSkipped}`, + `rezi#${event.renderId} viewport=${event.viewport.cols}x${event.viewport.rows} focused=${event.focusedId ?? "none"} tick=${event.tick} totalMs=${event.timings.totalMs} commitMs=${event.timings.commitMs} layoutMs=${event.timings.layoutMs} drawMs=${event.timings.drawMs} textMs=${event.timings.textMs} nodes=${event.nodeCount} ops=${event.opCount} clipMax=${event.clipDepthMax} textChars=${event.textChars} textLines=${event.textLines} nonBlank=${event.nonBlankLines} widest=${event.widestLine} minY=${event.minRectY} maxBottom=${event.maxRectBottom} zeroH=${event.zeroHeightRects} detailIncluded=${event.detailIncluded}`, ); if (!traceDetail) return; @@ -1791,10 +2304,6 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions } : {}), }); - // Separate renderer for content so static renders don't pollute - // the dynamic renderer's prevRoot / layout caches (the root cause of 0% - // instance reuse — every frame was mounting all nodes as new). - const staticRenderer = createInkRenderer({ viewport }); let lastOutput = ""; let lastStableOutput = ""; @@ -1824,6 +2333,8 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions let compatWriteDepth = 0; let restoreStdoutWrite: (() => void) | undefined; let lastCursorSignature = "hidden"; + let lastCommitSignature = ""; + let lastStaticCaptureSignature = ""; const _s = debug ? writeErr : (_msg: string): void => {}; @@ -2161,18 +2672,24 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions }; const capturePendingStaticOutput = (): void => { - if (!bridge.hasStaticNodes()) return; + const scan = scanHostTreeForStaticAndAnsi(bridge.rootNode); + if (!scan.hasStaticNodes) { + lastStaticCaptureSignature = ""; + return; + } + + const nextStaticSignature = staticRootRevisionSignature(bridge.rootNode); + if (nextStaticSignature === lastStaticCaptureSignature) return; const translatedStatic = bridge.translateStaticToVNode(); - const translatedStaticWithPercent = resolvePercentMarkers(translatedStatic, { - parentSize: viewport, - parentMainAxis: "column", - }); - const staticResult = staticRenderer.render(translatedStaticWithPercent, { - viewport, - forceLayout: true, - }); - const staticHasAnsiSgr = hostTreeContainsAnsiSgr(bridge.rootNode); + const translatedStaticWithPercent = hasPercentMarkers(translatedStatic) + ? resolvePercentMarkers(translatedStatic, { + parentSize: viewport, + parentMainAxis: "column", + }) + : translatedStatic; + const staticResult = renderer.render(translatedStaticWithPercent, { viewport }); + const staticHasAnsiSgr = scan.hasAnsiSgr; const staticColorSupport = staticHasAnsiSgr ? FORCED_TRUECOLOR_SUPPORT : colorSupport; const { ansi: staticAnsi } = renderOpsToAnsi( staticResult.ops as readonly RenderOp[], @@ -2185,13 +2702,21 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions `staticCapture viewport=${viewport.cols}x${viewport.rows} hasAnsiSgr=${staticHasAnsiSgr} baseColorLevel=${colorSupport.level} effectiveColorLevel=${staticColorSupport.level} rawLines=${staticAnsi.split("\n").length} trimmedLines=${staticTrimmed.split("\n").length}`, ); + lastStaticCaptureSignature = nextStaticSignature; if (staticTrimmed.length === 0) return; pendingStaticOutput += `${staticTrimmed}\n`; }; const renderFrame = (force = false): void => { - const frameStartedAt = Date.now(); + const frameStartedAt = performance.now(); frameCount++; + let translationMs = 0; + let percentResolveMs = 0; + let coreRenderMs = 0; + let coreRenderPassesThisFrame = 0; + let assignLayoutsMs = 0; + let rectScanMs = 0; + let ansiMs = 0; try { const frameNow = Date.now(); const nextViewport = readViewportSize(stdout, fallbackStdout); @@ -2201,9 +2726,11 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions viewport = nextViewport; } - const translateStartedAt = performance.now(); - const translatedDynamic = bridge.translateDynamicToVNode(); - const hasDynamicPercentMarkers = hasPercentMarkers(translatedDynamic); + const translationStartedAt = phaseProfile ? performance.now() : 0; + const { vnode: translatedDynamic, meta: translationMeta } = + bridge.translateDynamicWithMetadata(); + if (phaseProfile) translationMs = performance.now() - translationStartedAt; + const hasDynamicPercentMarkers = translationMeta.hasPercentMarkers; // In static-channel mode, static output is rendered above the dynamic // frame, so dynamic layout works inside the remaining rows. @@ -2219,71 +2746,96 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions ? { cols: viewport.cols, rows: Math.max(1, viewport.rows - staticRowsUsed) } : viewport; - const percentStartedAt = performance.now(); - let translatedDynamicWithPercent = resolvePercentMarkers(translatedDynamic, { - parentSize: layoutViewport, - parentMainAxis: "column", - }); + let percentDeps: PercentResolveDeps | null = null; + const percentResolveStartedAt = + phaseProfile && hasDynamicPercentMarkers ? performance.now() : 0; + let translatedDynamicWithPercent = translatedDynamic; + if (hasDynamicPercentMarkers) { + percentDeps = { parents: new Map(), missingParentLayout: false }; + translatedDynamicWithPercent = resolvePercentMarkers(translatedDynamic, { + parentSize: layoutViewport, + parentMainAxis: "column", + deps: percentDeps, + }); + } const translationTraceEntries = traceEnabled ? flushTranslationTrace() : []; - const translationMs = performance.now() - translateStartedAt; - const percentResolveMs = performance.now() - percentStartedAt; let vnode: VNode; let rootHeightCoerced: boolean; - let coreRenderPassesThisFrame = 1; - let coreRenderMs = 0; - let assignLayoutsMs = 0; - const coerced = coerceRootViewportHeight(translatedDynamicWithPercent, layoutViewport); vnode = coerced.vnode; rootHeightCoerced = coerced.coerced; + if (phaseProfile && hasDynamicPercentMarkers) { + percentResolveMs += performance.now() - percentResolveStartedAt; + } - let t0 = performance.now(); - let result = renderer.render(vnode, { - viewport: layoutViewport, - forceLayout: viewportChanged, - }); - coreRenderMs += performance.now() - t0; + coreRenderPassesThisFrame = 1; + const renderStartedAt = phaseProfile ? performance.now() : 0; + let result = renderer.render(vnode, { viewport: layoutViewport }); + if (phaseProfile) coreRenderMs += performance.now() - renderStartedAt; - t0 = performance.now(); - clearHostLayouts(bridge.rootNode); + const assignLayoutsStartedAt = phaseProfile ? performance.now() : 0; assignHostLayouts( + bridge.rootNode, result.nodes as readonly { rect?: { x?: number; y?: number; w?: number; h?: number }; props?: Record; }[], ); - assignLayoutsMs += performance.now() - t0; - + if (phaseProfile) assignLayoutsMs += performance.now() - assignLayoutsStartedAt; if (hasDynamicPercentMarkers) { - coreRenderPassesThisFrame = 2; - translatedDynamicWithPercent = resolvePercentMarkers(translatedDynamic, { - parentSize: layoutViewport, - parentMainAxis: "column", - }); - const secondPass = coerceRootViewportHeight(translatedDynamicWithPercent, layoutViewport); - vnode = secondPass.vnode; - rootHeightCoerced = rootHeightCoerced || secondPass.coerced; - - t0 = performance.now(); - result = renderer.render(vnode, { viewport: layoutViewport, forceLayout: true }); - coreRenderMs += performance.now() - t0; - - t0 = performance.now(); - clearHostLayouts(bridge.rootNode); - assignHostLayouts( - result.nodes as readonly { - rect?: { x?: number; y?: number; w?: number; h?: number }; - props?: Record; - }[], - ); - assignLayoutsMs += performance.now() - t0; + // Percent sizing is resolved against parent layout. On the first pass we only have + // the previous generation's layouts, so we run a second render only when a percent + // base actually changes (or when parent layouts were missing entirely). + let needsSecondPass = percentDeps?.missingParentLayout === true; + if (!needsSecondPass && percentDeps && percentDeps.parents.size > 0) { + for (const [parent, dep] of percentDeps.parents) { + const layout = readCurrentLayout(parent); + if (!layout) { + needsSecondPass = true; + break; + } + const cols = Math.max(0, Math.trunc(layout.w)); + const rows = Math.max(0, Math.trunc(layout.h)); + if ((dep.usesCols && cols !== dep.cols) || (dep.usesRows && rows !== dep.rows)) { + needsSecondPass = true; + break; + } + } + } + + if (needsSecondPass) { + coreRenderPassesThisFrame = 2; + const secondPercentStartedAt = phaseProfile ? performance.now() : 0; + translatedDynamicWithPercent = resolvePercentMarkers(translatedDynamic, { + parentSize: layoutViewport, + parentMainAxis: "column", + }); + const secondPass = coerceRootViewportHeight(translatedDynamicWithPercent, layoutViewport); + vnode = secondPass.vnode; + rootHeightCoerced = rootHeightCoerced || secondPass.coerced; + if (phaseProfile) percentResolveMs += performance.now() - secondPercentStartedAt; + + const secondRenderStartedAt = phaseProfile ? performance.now() : 0; + result = renderer.render(vnode, { viewport: layoutViewport }); + if (phaseProfile) coreRenderMs += performance.now() - secondRenderStartedAt; + + const secondAssignStartedAt = phaseProfile ? performance.now() : 0; + assignHostLayouts( + bridge.rootNode, + result.nodes as readonly { + rect?: { x?: number; y?: number; w?: number; h?: number }; + props?: Record; + }[], + ); + if (phaseProfile) assignLayoutsMs += performance.now() - secondAssignStartedAt; + } } checkAllResizeObservers(); + const rectScanStartedAt = phaseProfile ? performance.now() : 0; // Compute maxRectBottom from layout result — needed to size the ANSI // grid correctly in non-alternate-buffer mode. - const rectScanStartedAt = performance.now(); let minRectY = Number.POSITIVE_INFINITY; let maxRectBottom = 0; let zeroHeightRects = 0; @@ -2297,7 +2849,7 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions maxRectBottom = Math.max(maxRectBottom, y + h); if (h === 0) zeroHeightRects += 1; } - const rectScanMs = performance.now() - rectScanStartedAt; + if (phaseProfile) rectScanMs = performance.now() - rectScanStartedAt; // Keep non-alt output content-sized by using computed layout height. // When root coercion applies (overflow hidden/scroll), maxRectBottom @@ -2306,15 +2858,15 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions ? layoutViewport : { cols: layoutViewport.cols, rows: Math.max(1, maxRectBottom) }; - const ansiStartedAt = performance.now(); - const frameHasAnsiSgr = hostTreeContainsAnsiSgr(bridge.rootNode); + const frameHasAnsiSgr = translationMeta.hasAnsiSgr; const frameColorSupport = frameHasAnsiSgr ? FORCED_TRUECOLOR_SUPPORT : colorSupport; - const { ansi: rawAnsiOutput, grid: cellGrid } = renderOpsToAnsi( - result.ops as readonly RenderOp[], - gridViewport, - frameColorSupport, - ); - const ansiMs = performance.now() - ansiStartedAt; + const ansiStartedAt = phaseProfile ? performance.now() : 0; + const { + ansi: rawAnsiOutput, + grid: cellGrid, + shape: outputShape, + } = renderOpsToAnsi(result.ops as readonly RenderOp[], gridViewport, frameColorSupport); + if (phaseProfile) ansiMs = performance.now() - ansiStartedAt; // In alternate-buffer mode the output fills the full layoutViewport. // In non-alternate-buffer mode the grid is content-sized so the @@ -2323,7 +2875,6 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions const staticOutput = pendingStaticOutput; pendingStaticOutput = ""; - const outputShape = summarizeOutputShape(output); const emptyOutputFrame = outputShape.nonBlankLines === 0; const rootChildCount = bridge.rootNode.children.length; const msSinceResizeSignal = @@ -2380,7 +2931,7 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions ); trace( - `frame#${frameCount} force=${force} viewport=${viewport.cols}x${viewport.rows} layoutViewport=${layoutViewport.cols}x${layoutViewport.rows} gridViewport=${gridViewport.cols}x${gridViewport.rows} staticRowsUsed=${staticRowsUsed} staticRowsFull=${fullStaticRows} staticRowsPending=${pendingStaticRows} viewportChanged=${viewportChanged} renderTimeMs=${Date.now() - frameStartedAt} outputLen=${output.length} nonBlank=${outputShape.nonBlankLines}/${outputShape.lines} first=${outputShape.firstNonBlankLine} last=${outputShape.lastNonBlankLine} widest=${outputShape.widestLine} ops=${result.ops.length} nodes=${result.nodes.length} minY=${Number.isFinite(minRectY) ? minRectY : -1} maxBottom=${maxRectBottom} zeroH=${zeroHeightRects} hostNodes=${host.nodeCount} hostBoxes=${host.boxCount} hostScrollNodes=${host.scrollNodeCount} hostMaxScrollTop=${host.maxScrollTop} hostMaxScrollLeft=${host.maxScrollLeft} hostRootScrollTop=${host.rootScrollTop} hostRootOverflow=${host.rootOverflow || "none"} hostRootWidth=${host.rootWidthProp || "unset"} hostRootHeight=${host.rootHeightProp || "unset"} hostRootFlexGrow=${host.rootFlexGrowProp || "unset"} hostRootFlexShrink=${host.rootFlexShrinkProp || "unset"} rootChildren=${rootChildCount} msSinceResizeSignal=${Number.isFinite(msSinceResizeSignal) ? msSinceResizeSignal : -1} msSinceResizeFlush=${Number.isFinite(msSinceResizeFlush) ? msSinceResizeFlush : -1} transientEmptyAfterResize=${transientEmptyAfterResize} vnode=${vnodeKind} vnodeOverflow=${translatedOverflow || "none"} vnodeScrollY=${translatedScrollY} vnodeScrollX=${translatedScrollX} rootHeightCoerced=${rootHeightCoerced} writeBlocked=${writeBlocked} collapsed=${collapsed} opViewportOverflowCount=${opViewportOverflows.length}`, + `frame#${frameCount} force=${force} viewport=${viewport.cols}x${viewport.rows} layoutViewport=${layoutViewport.cols}x${layoutViewport.rows} gridViewport=${gridViewport.cols}x${gridViewport.rows} staticRowsUsed=${staticRowsUsed} staticRowsFull=${fullStaticRows} staticRowsPending=${pendingStaticRows} viewportChanged=${viewportChanged} renderTimeMs=${performance.now() - frameStartedAt} outputLen=${output.length} nonBlank=${outputShape.nonBlankLines}/${outputShape.lines} first=${outputShape.firstNonBlankLine} last=${outputShape.lastNonBlankLine} widest=${outputShape.widestLine} ops=${result.ops.length} nodes=${result.nodes.length} minY=${Number.isFinite(minRectY) ? minRectY : -1} maxBottom=${maxRectBottom} zeroH=${zeroHeightRects} hostNodes=${host.nodeCount} hostBoxes=${host.boxCount} hostScrollNodes=${host.scrollNodeCount} hostMaxScrollTop=${host.maxScrollTop} hostMaxScrollLeft=${host.maxScrollLeft} hostRootScrollTop=${host.rootScrollTop} hostRootOverflow=${host.rootOverflow || "none"} hostRootWidth=${host.rootWidthProp || "unset"} hostRootHeight=${host.rootHeightProp || "unset"} hostRootFlexGrow=${host.rootFlexGrowProp || "unset"} hostRootFlexShrink=${host.rootFlexShrinkProp || "unset"} rootChildren=${rootChildCount} msSinceResizeSignal=${Number.isFinite(msSinceResizeSignal) ? msSinceResizeSignal : -1} msSinceResizeFlush=${Number.isFinite(msSinceResizeFlush) ? msSinceResizeFlush : -1} transientEmptyAfterResize=${transientEmptyAfterResize} vnode=${vnodeKind} vnodeOverflow=${translatedOverflow || "none"} vnodeScrollY=${translatedScrollY} vnodeScrollX=${translatedScrollX} rootHeightCoerced=${rootHeightCoerced} writeBlocked=${writeBlocked} collapsed=${collapsed} opViewportOverflowCount=${opViewportOverflows.length}`, ); trace( `frame#${frameCount} colorSupport baseLevel=${colorSupport.level} baseNoColor=${colorSupport.noColor} hasAnsiSgr=${frameHasAnsiSgr} effectiveLevel=${frameColorSupport.level} effectiveNoColor=${frameColorSupport.noColor}`, @@ -2506,43 +3057,7 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions } } - const renderTime = Date.now() - frameStartedAt; - options.onRender?.({ - renderTime, - output, - ...(staticOutput.length > 0 ? { staticOutput } : {}), - }); - - if (frameProfileFile) { - const _r = (v: number): number => Math.round(v * 1000) / 1000; - const layoutProfile = (result.timings as { _layoutProfile?: unknown } | undefined) - ?._layoutProfile; - try { - appendFileSync( - frameProfileFile, - `${JSON.stringify({ - frame: frameCount, - ts: Date.now(), - totalMs: _r(renderTime), - translationMs: _r(translationMs), - percentResolveMs: _r(percentResolveMs), - coreRenderMs: _r(coreRenderMs), - coreCommitMs: _r(result.timings?.commitMs ?? 0), - coreLayoutMs: _r(result.timings?.layoutMs ?? 0), - coreDrawMs: _r(result.timings?.drawMs ?? 0), - layoutSkipped: result.timings?.layoutSkipped ?? false, - assignLayoutsMs: _r(assignLayoutsMs), - rectScanMs: _r(rectScanMs), - ansiMs: _r(ansiMs), - passes: coreRenderPassesThisFrame, - ops: result.ops.length, - nodes: result.nodes.length, - ...(layoutProfile === undefined ? {} : { _lp: layoutProfile }), - })}\n`, - ); - } catch {} - } - + const renderTime = performance.now() - frameStartedAt; const cursorPosition = bridge.context.getCursorPosition(); const cursorSignature = cursorPosition ? `${Math.trunc(cursorPosition.x)},${Math.trunc(cursorPosition.y)}` @@ -2575,7 +3090,29 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions return; } + if (phaseProfile) { + const accounted = + translationMs + percentResolveMs + coreRenderMs + assignLayoutsMs + rectScanMs + ansiMs; + const otherMs = Math.max(0, renderTime - accounted); + phaseProfile.frames += 1; + phaseProfile.translationMs += translationMs; + phaseProfile.percentResolveMs += percentResolveMs; + phaseProfile.coreRenderMs += coreRenderMs; + phaseProfile.assignLayoutsMs += assignLayoutsMs; + phaseProfile.rectScanMs += rectScanMs; + phaseProfile.ansiMs += ansiMs; + phaseProfile.otherMs += otherMs; + if (hasDynamicPercentMarkers) phaseProfile.percentFrames += 1; + phaseProfile.coreRenderPasses += coreRenderPassesThisFrame || 1; + phaseProfile.maxFrameMs = Math.max(phaseProfile.maxFrameMs, renderTime); + } + writeOutput({ output, staticOutput }); + options.onRender?.({ + renderTime, + output, + ...(staticOutput.length > 0 ? { staticOutput } : {}), + }); lastCursorSignature = cursorSignature; } catch (err) { _s( @@ -2624,6 +3161,11 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions }; bridge.rootNode.onCommit = () => { + const nextCommitSignature = rootChildRevisionSignature(bridge.rootNode); + if (nextCommitSignature === lastCommitSignature) { + return; + } + lastCommitSignature = nextCommitSignature; capturePendingStaticOutput(); scheduleRender(false); }; @@ -2826,6 +3368,7 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions function cleanup(unmountTree: boolean): void { if (cleanedUp) return; cleanedUp = true; + flushPhaseProfile(); if (translationTraceEnabled) { enableTranslationTrace(false); } diff --git a/packages/ink-compat/src/translation/propsToVNode.ts b/packages/ink-compat/src/translation/propsToVNode.ts index 29fd3406..ed77e21d 100644 --- a/packages/ink-compat/src/translation/propsToVNode.ts +++ b/packages/ink-compat/src/translation/propsToVNode.ts @@ -131,13 +131,13 @@ interface LayoutProps extends Record { mb?: number; ml?: number; gap?: number; - width?: number | `${number}%`; - height?: number | `${number}%`; + width?: number; + height?: number; minWidth?: number; minHeight?: number; maxWidth?: number; maxHeight?: number; - flexBasis?: number | `${number}%`; + flexBasis?: number; flex?: number; flexShrink?: number; items?: string; @@ -169,18 +169,92 @@ export interface TranslateTreeOptions { mode?: TranslationMode; } +/** Metadata collected during a single translation pass, eliminating separate tree walks. */ +export interface TranslationMetadata { + hasStaticNodes: boolean; + hasPercentMarkers: boolean; + hasAnsiSgr: boolean; +} + interface TranslateContext { parentDirection: LayoutDirection; parentMainDefinite: boolean; isRoot: boolean; mode: TranslationMode; inStaticSubtree: boolean; + /** Mutable metadata accumulator — shared across the entire translation pass. */ + meta: TranslationMetadata; } let warnedWrapReverse = false; const ANSI_SGR_REGEX = /\u001b\[([0-9:;]*)m/g; +// Separate non-global regex for `.test()` so we don't mutate `ANSI_SGR_REGEX.lastIndex`. +const ANSI_SGR_DETECT_REGEX = /\u001b\[[0-9:;]*m/; const PERCENT_VALUE_REGEX = /^(-?\d+(?:\.\d+)?)%$/; +const ESC = "\u001b"; + +interface CachedTranslation { + revision: number; + contextSignature: string; + vnode: VNode | null; + meta: TranslationMetadata; +} + +interface TranslationPerfStats { + translatedNodes: number; + cacheHits: number; + cacheMisses: number; + parseAnsiFastPathHits: number; + parseAnsiFallbackPathHits: number; +} + +let translationCache = new WeakMap>(); +const translationPerfStats: TranslationPerfStats = { + translatedNodes: 0, + cacheHits: 0, + cacheMisses: 0, + parseAnsiFastPathHits: 0, + parseAnsiFallbackPathHits: 0, +}; +let translationCacheEnabled = process.env["INK_COMPAT_DISABLE_TRANSLATION_CACHE"] !== "1"; + +function clearTranslationCache(): void { + translationCache = new WeakMap>(); +} + +function resetTranslationPerfStats(): void { + translationPerfStats.translatedNodes = 0; + translationPerfStats.cacheHits = 0; + translationPerfStats.cacheMisses = 0; + translationPerfStats.parseAnsiFastPathHits = 0; + translationPerfStats.parseAnsiFallbackPathHits = 0; +} + +function contextSignature(context: TranslateContext): string { + const mode = context.mode; + const direction = context.parentDirection; + const parentMainDefinite = context.parentMainDefinite ? "1" : "0"; + const isRoot = context.isRoot ? "1" : "0"; + const inStaticSubtree = context.inStaticSubtree ? "1" : "0"; + return `${mode}|${direction}|${parentMainDefinite}|${isRoot}|${inStaticSubtree}`; +} + +function mergeMeta(target: TranslationMetadata, source: TranslationMetadata): void { + if (source.hasStaticNodes) target.hasStaticNodes = true; + if (source.hasPercentMarkers) target.hasPercentMarkers = true; + if (source.hasAnsiSgr) target.hasAnsiSgr = true; +} + +function hasDisallowedControlChars(text: string): boolean { + for (let index = 0; index < text.length; index += 1) { + const code = text.charCodeAt(index); + if (code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d) { + return true; + } + } + return false; +} function toNonNegativeInt(value: unknown): number | undefined { if (typeof value !== "number" || !Number.isFinite(value)) return undefined; @@ -240,6 +314,10 @@ const ANSI_16_PALETTE: readonly Rgb24[] = [ rgb(255, 255, 255), ]; +function createMeta(): TranslationMetadata { + return { hasStaticNodes: false, hasPercentMarkers: false, hasAnsiSgr: false }; +} + /** * Translate the entire InkHostNode tree into a Rezi VNode tree. */ @@ -248,12 +326,14 @@ export function translateTree( options: TranslateTreeOptions = {}, ): VNode { const mode = options.mode ?? "all"; + const meta = createMeta(); const rootContext: TranslateContext = { parentDirection: "column", parentMainDefinite: true, isRoot: true, mode, inStaticSubtree: false, + meta, }; const children = container.children .map((child) => translateNode(child, rootContext)) @@ -271,16 +351,83 @@ export function translateStaticTree(container: InkHostContainer): VNode { return translateTree(container, { mode: "static" }); } -function translateNode( - node: InkHostNode, - context: TranslateContext = { +/** + * Translate and collect metadata in a single pass — eliminates separate + * hasStaticNodes(), hasPercentMarkers(), and hostTreeContainsAnsiSgr() walks. + */ +export function translateDynamicTreeWithMetadata(container: InkHostContainer): { + vnode: VNode; + meta: TranslationMetadata; +} { + const meta = createMeta(); + meta.hasStaticNodes = container.__inkSubtreeHasStatic; + meta.hasAnsiSgr = container.__inkSubtreeHasAnsiSgr; + const rootContext: TranslateContext = { parentDirection: "column", parentMainDefinite: true, - isRoot: false, - mode: "all", + isRoot: true, + mode: "dynamic", inStaticSubtree: false, - }, -): VNode | null { + meta, + }; + + const children = container.children + .map((child) => translateNode(child, rootContext)) + .filter(Boolean) as VNode[]; + + let vnode: VNode; + if (children.length === 0) vnode = ui.text(""); + else if (children.length === 1) vnode = children[0]!; + else vnode = ui.column({ gap: 0 }, children); + + return { vnode, meta }; +} + +function translateNode(node: InkHostNode, context: TranslateContext): VNode | null { + const parentMeta = context.meta; + const localMeta = createMeta(); + const localContext: TranslateContext = { + ...context, + meta: localMeta, + }; + + if (!translationCacheEnabled) { + translationPerfStats.cacheMisses += 1; + translationPerfStats.translatedNodes += 1; + const translated = translateNodeUncached(node, localContext); + mergeMeta(parentMeta, localMeta); + return translated; + } + + const signature = contextSignature(context); + const cached = translationCache.get(node)?.get(signature); + if (cached && cached.revision === node.__inkRevision) { + translationPerfStats.cacheHits += 1; + mergeMeta(parentMeta, cached.meta); + return cached.vnode; + } + + translationPerfStats.cacheMisses += 1; + translationPerfStats.translatedNodes += 1; + const translated = translateNodeUncached(node, localContext); + mergeMeta(parentMeta, localMeta); + + let perNodeCache = translationCache.get(node); + if (!perNodeCache) { + perNodeCache = new Map(); + translationCache.set(node, perNodeCache); + } + perNodeCache.set(signature, { + revision: node.__inkRevision, + contextSignature: signature, + vnode: translated, + meta: localMeta, + }); + + return translated; +} + +function translateNodeUncached(node: InkHostNode, context: TranslateContext): VNode | null { const props = (node.props ?? {}) as Record; const isStaticNode = node.type === "ink-box" && props["__inkStatic"] === true; @@ -420,6 +567,7 @@ function translateBox(node: InkHostNode, context: TranslateContext): VNode | nul isRoot: false, mode: context.mode, inStaticSubtree, + meta: context.meta, }; if (p.display === "none") return null; @@ -492,14 +640,6 @@ function translateBox(node: InkHostNode, context: TranslateContext): VNode | nul layoutProps.gap = p.rowGap; } - // Rezi core's layout engine natively resolves percent strings (e.g. "50%") - // for width, height, and flexBasis via resolveConstraint(). Pass them through - // directly instead of creating __inkPercent* markers that trigger a costly - // two-pass layout in renderFrame(). - // minWidth/minHeight only accept numbers in Rezi core, so those still use - // the marker approach (but gemini-cli doesn't use percent values for those). - const NATIVE_PERCENT_PROPS = new Set(["width", "height", "flexBasis"]); - const applyNumericOrPercentDimension = ( prop: "width" | "height" | "minWidth" | "minHeight" | "flexBasis", value: unknown, @@ -511,14 +651,9 @@ function translateBox(node: InkHostNode, context: TranslateContext): VNode | nul const percent = parsePercentValue(value); if (percent != null) { - if (NATIVE_PERCENT_PROPS.has(prop)) { - // Pass percent string directly — layout engine resolves it natively - (layoutProps as Record)[prop] = `${percent}%`; - } else { - // minWidth/minHeight: layout engine only accepts numbers, use marker - const markerKey = `__inkPercent${prop.charAt(0).toUpperCase()}${prop.slice(1)}`; - layoutProps[markerKey] = percent; - } + const markerKey = `__inkPercent${prop.charAt(0).toUpperCase()}${prop.slice(1)}`; + layoutProps[markerKey] = percent; + context.meta.hasPercentMarkers = true; return; } @@ -921,18 +1056,32 @@ function flattenTextChildren( if (child.type === "ink-text") { const cp = child.props as TextNodeProps; - const childStyle: TextStyleMap = { ...parentStyle }; - - const fg = parseColor(cp.color as string | undefined); - if (fg !== undefined) childStyle.fg = fg; - const bg = parseColor(cp.backgroundColor as string | undefined); - if (bg !== undefined) childStyle.bg = bg; - if (cp.bold) childStyle.bold = true; - if (cp.italic) childStyle.italic = true; - if (cp.underline) childStyle.underline = true; - if (cp.strikethrough) childStyle.strikethrough = true; - if (cp.dimColor) childStyle.dim = true; - if (cp.inverse) childStyle.inverse = true; + const hasOverrides = + cp.color != null || + cp.backgroundColor != null || + cp.bold || + cp.italic || + cp.underline || + cp.strikethrough || + cp.dimColor || + cp.inverse; + + let childStyle: TextStyleMap; + if (hasOverrides) { + childStyle = { ...parentStyle }; + const fg = parseColor(cp.color as string | undefined); + if (fg !== undefined) childStyle.fg = fg; + const bg = parseColor(cp.backgroundColor as string | undefined); + if (bg !== undefined) childStyle.bg = bg; + if (cp.bold) childStyle.bold = true; + if (cp.italic) childStyle.italic = true; + if (cp.underline) childStyle.underline = true; + if (cp.strikethrough) childStyle.strikethrough = true; + if (cp.dimColor) childStyle.dim = true; + if (cp.inverse) childStyle.inverse = true; + } else { + childStyle = parentStyle; + } const nested = flattenTextChildren(child, childStyle); spans.push(...nested.spans); @@ -945,7 +1094,7 @@ function flattenTextChildren( const count = virtualProps.count; const repeatCount = count == null ? 1 : Math.max(0, Math.trunc(count)); const newlines = "\n".repeat(repeatCount); - spans.push({ text: newlines, style: { ...parentStyle } }); + spans.push({ text: newlines, style: parentStyle }); fullText += newlines; } } @@ -958,18 +1107,22 @@ function flattenTextChildren( return { spans, isSingleSpan: allSameStyle, fullText }; } -function stylesEqual(a: TextStyleMap, b: TextStyleMap): boolean { - const keysA = Object.keys(a).sort(); - const keysB = Object.keys(b).sort(); - if (keysA.length !== keysB.length) return false; - - for (let i = 0; i < keysA.length; i += 1) { - const key = keysA[i]!; - if (key !== keysB[i]) return false; - if (JSON.stringify(a[key]) !== JSON.stringify(b[key])) return false; - } +function textRgbEqual(a: Rgb24 | undefined, b: Rgb24 | undefined): boolean { + return a === b; +} - return true; +function stylesEqual(a: TextStyleMap, b: TextStyleMap): boolean { + if (a === b) return true; + return ( + a.bold === b.bold && + a.dim === b.dim && + a.italic === b.italic && + a.underline === b.underline && + a.strikethrough === b.strikethrough && + a.inverse === b.inverse && + textRgbEqual(a.fg, b.fg) && + textRgbEqual(a.bg, b.bg) + ); } function parseAnsiText( @@ -980,6 +1133,15 @@ function parseAnsiText( return { spans: [], fullText: "" }; } + if (text.indexOf(ESC) === -1 && !hasDisallowedControlChars(text)) { + translationPerfStats.parseAnsiFastPathHits += 1; + return { + spans: [{ text, style: { ...baseStyle } }], + fullText: text, + }; + } + + translationPerfStats.parseAnsiFallbackPathHits += 1; const sanitized = sanitizeAnsiInput(text); if (sanitized.length === 0) { return { spans: [], fullText: "" }; @@ -991,6 +1153,7 @@ function parseAnsiText( let hadAnsiMatch = false; const activeStyle: TextStyleMap = { ...baseStyle }; + ANSI_SGR_REGEX.lastIndex = 0; for (const match of sanitized.matchAll(ANSI_SGR_REGEX)) { const index = match.index; if (index == null) continue; @@ -1023,47 +1186,102 @@ function parseAnsiText( } function sanitizeAnsiInput(input: string): string { - let output = ""; + // Fast-path: scan without allocating output unless we need to drop something. + const ESC = 0x1b; + let output: string[] | null = null; + let runStart = 0; let index = 0; while (index < input.length) { - const codePoint = input.codePointAt(index); - if (codePoint == null) break; - const char = String.fromCodePoint(codePoint); - const width = char.length; - - if (char !== "\u001b") { - if (codePoint === 0x09 || codePoint === 0x0a || codePoint === 0x0d || codePoint >= 0x20) { - output += char; + const code = input.charCodeAt(index); + + if (code === ESC) { + const next = input[index + 1]; + if (next === "[") { + const csiEnd = findCsiEndIndex(input, index + 2); + if (csiEnd === -1) { + if (!output) { + output = []; + if (index > 0) output.push(input.slice(0, index)); + } else if (runStart < index) { + output.push(input.slice(runStart, index)); + } + index = input.length; + runStart = index; + break; + } + + const keep = input[csiEnd] === "m"; + if (output) { + if (runStart < index) output.push(input.slice(runStart, index)); + if (keep) output.push(input.slice(index, csiEnd + 1)); + } else if (!keep) { + output = []; + if (index > 0) output.push(input.slice(0, index)); + } + + index = csiEnd + 1; + runStart = index; + continue; + } + + if (next === "]") { + const oscEnd = findOscEndIndex(input, index + 2); + if (oscEnd === -1) { + if (!output) { + output = []; + if (index > 0) output.push(input.slice(0, index)); + } else if (runStart < index) { + output.push(input.slice(runStart, index)); + } + index = input.length; + runStart = index; + break; + } + + if (!output) { + output = []; + if (index > 0) output.push(input.slice(0, index)); + } else if (runStart < index) { + output.push(input.slice(runStart, index)); + } + + index = oscEnd; + runStart = index; + continue; } - index += width; - continue; - } - const next = input[index + 1]; - if (next === "[") { - const csiEnd = findCsiEndIndex(input, index + 2); - if (csiEnd === -1) break; - if (input[csiEnd] === "m") { - output += input.slice(index, csiEnd + 1); + // Drop unsupported escape sequence starter. + if (!output) { + output = []; + if (index > 0) output.push(input.slice(0, index)); + } else if (runStart < index) { + output.push(input.slice(runStart, index)); } - index = csiEnd + 1; + index += next == null ? 1 : 2; + runStart = index; continue; } - if (next === "]") { - const oscEnd = findOscEndIndex(input, index + 2); - if (oscEnd === -1) break; - output += input.slice(index, oscEnd); - index = oscEnd; + // Drop control chars other than tab/newline/carriage-return. + if (code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d) { + if (!output) { + output = []; + if (index > 0) output.push(input.slice(0, index)); + } else if (runStart < index) { + output.push(input.slice(runStart, index)); + } + index += 1; + runStart = index; continue; } - // Drop unsupported escape sequence starter. - index += next == null ? 1 : 2; + index += 1; } - return output; + if (!output) return input; + if (runStart < input.length) output.push(input.slice(runStart)); + return output.join(""); } function findCsiEndIndex(input: string, start: number): number { @@ -1092,14 +1310,13 @@ function findOscEndIndex(input: string, start: number): number { function appendStyledText(spans: TextSpan[], text: string, style: TextStyleMap): void { if (text.length === 0) return; - const styleCopy = { ...style }; const prev = spans[spans.length - 1]; - if (prev && stylesEqual(prev.style, styleCopy)) { + if (prev && stylesEqual(prev.style, style)) { prev.text += text; return; } - spans.push({ text, style: styleCopy }); + spans.push({ text, style: { ...style } }); } function parseSgrCodes(raw: string): number[] { @@ -1341,3 +1558,37 @@ function translateChildren(node: InkHostNode, context: TranslateContext): VNode if (children.length === 1) return children[0]!; return ui.column({ gap: 0 }, children); } + +export const __inkCompatTranslationTestHooks = { + clearCache(): void { + clearTranslationCache(); + }, + resetStats(): void { + resetTranslationPerfStats(); + }, + getStats(): { + translatedNodes: number; + cacheHits: number; + cacheMisses: number; + parseAnsiFastPathHits: number; + parseAnsiFallbackPathHits: number; + } { + return { ...translationPerfStats }; + }, + setCacheEnabled(enabled: boolean): void { + translationCacheEnabled = enabled; + }, + parseAnsiText( + text: string, + baseStyle: Record = {}, + ): { spans: Array<{ text: string; style: Record }>; fullText: string } { + const parsed = parseAnsiText(text, baseStyle as TextStyleMap); + return { + fullText: parsed.fullText, + spans: parsed.spans.map((span) => ({ + text: span.text, + style: { ...span.style }, + })), + }; + }, +}; diff --git a/scripts/bench-full-compare.mjs b/scripts/bench-full-compare.mjs index b66ff4c0..42e467ed 100644 --- a/scripts/bench-full-compare.mjs +++ b/scripts/bench-full-compare.mjs @@ -244,8 +244,8 @@ function compareRuns(baselineRun, currentRun) { .filter((entry) => entry.framework === "rezi-native") .filter((entry) => entry.metrics["timing.mean"] !== null) .sort((a, b) => { - const deltaA = a.metrics["timing.mean"]?.delta ?? -Infinity; - const deltaB = b.metrics["timing.mean"]?.delta ?? -Infinity; + const deltaA = a.metrics["timing.mean"]?.delta ?? Number.NEGATIVE_INFINITY; + const deltaB = b.metrics["timing.mean"]?.delta ?? Number.NEGATIVE_INFINITY; return deltaB - deltaA; }); @@ -312,7 +312,7 @@ function buildMarkdown(report, opts) { const ops = entry.metrics.opsPerSec; const bytes = entry.metrics.bytesProduced; lines.push( - [ + `${[ `| ${entry.scenario}`, fmtParams(entry.params), fmtNum(mean?.baseline), @@ -322,7 +322,7 @@ function buildMarkdown(report, opts) { fmtNum(p95?.delta), fmtNum(ops?.delta), fmtNum(bytes?.delta), - ].join(" | ") + " |", + ].join(" | ")} |`, ); } lines.push("");