From 61c391d1a6dac7317d2d61476f067b06d7cffcc1 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:13:19 +0400 Subject: [PATCH 01/29] Fix starship rendering regressions and add PTY debug runbook --- AGENTS.md | 19 ++ CLAUDE.md | 9 + docs/dev/live-pty-debugging.md | 176 ++++++++++ docs/dev/testing.md | 12 + mkdocs.yml | 1 + .../renderToDrawlist/widgets/collections.ts | 27 ++ .../src/widgets/__tests__/pagination.test.ts | 5 +- packages/core/src/widgets/pagination.ts | 12 +- .../templates/starship/src/screens/bridge.ts | 2 +- .../templates/starship/src/screens/cargo.ts | 2 +- .../templates/starship/src/screens/comms.ts | 2 +- .../templates/starship/src/screens/crew.ts | 307 +++++++++++------- .../starship/src/screens/engineering.ts | 38 ++- .../starship/src/screens/settings.ts | 4 +- packages/node/src/frameAudit.ts | 8 + packages/node/src/worker/engineWorker.ts | 136 +++++++- 16 files changed, 627 insertions(+), 133 deletions(-) create mode 100644 docs/dev/live-pty-debugging.md diff --git a/AGENTS.md b/AGENTS.md index 1e221149..9b2fceec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -170,6 +170,25 @@ node scripts/run-tests.mjs --filter "widget" 2. If changing runtime, layout, or renderer code, also run integration tests. 3. Run the full suite before committing. +### Mandatory Live PTY Validation for UI Regressions + +For rendering/layout/theme regressions, do not stop at unit snapshots. Run the +app in a real PTY and collect frame audit evidence yourself before asking a +human to reproduce. + +Canonical runbook: + +- [`docs/dev/live-pty-debugging.md`](docs/dev/live-pty-debugging.md) + +Minimum required checks for UI regression work: + +1. Run target app/template in PTY with deterministic viewport. +2. Exercise relevant routes/keys (for starship: `1..6`, `t`, `q`). +3. Capture `REZI_FRAME_AUDIT` logs and analyze with + `node scripts/frame-audit-report.mjs ... --latest-pid`. +4. Capture app-level debug snapshots (`REZI_STARSHIP_DEBUG=1`) when applicable. +5. Include concrete evidence in your report (hash changes, route summary, key stages). + ## Verification Protocol (Two-Agent Verification) When verifying documentation or code changes, split into two passes: diff --git a/CLAUDE.md b/CLAUDE.md index 5c8e22a5..433355bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -505,6 +505,15 @@ result.toText(); // Render to plain text for snapshots Test runner: `node:test`. Run all tests with `node scripts/run-tests.mjs`. +For rendering regressions, add a live PTY verification pass and frame-audit +evidence (not just snapshot/unit tests). Use: + +- [`docs/dev/live-pty-debugging.md`](docs/dev/live-pty-debugging.md) + +This runbook covers deterministic viewport setup, worker-mode PTY execution, +route/theme key driving, and cross-layer log analysis (`REZI_FRAME_AUDIT`, +`REZI_STARSHIP_DEBUG`, `frame-audit-report.mjs`). + ## Skills (Repeatable Recipes) Project-level skills for both Claude Code and Codex: diff --git a/docs/dev/live-pty-debugging.md b/docs/dev/live-pty-debugging.md new file mode 100644 index 00000000..bdb18d91 --- /dev/null +++ b/docs/dev/live-pty-debugging.md @@ -0,0 +1,176 @@ +# Live PTY UI Testing and Frame Audit Runbook + +This runbook documents how to validate Rezi UI behavior autonomously in a real +terminal (PTY), capture end-to-end frame telemetry, and pinpoint regressions +across core/node/native layers. + +Use this before asking a human for screenshots. + +## Why this exists + +Headless/unit tests catch many issues, but rendering regressions often involve: + +- terminal dimensions and capability negotiation +- worker transport boundaries (core -> node worker -> native) +- partial redraw/damage behavior across many frames + +The PTY + frame-audit workflow gives deterministic evidence for all of those. + +## Prerequisites + +From repo root: + +```bash +cd /home/k3nig/Rezi +npx tsc -b packages/core packages/node packages/create-rezi +``` + +## Canonical interactive run (Starship template) + +This enables: + +- app-level debug snapshots (`REZI_STARSHIP_DEBUG`) +- cross-layer frame audit (`REZI_FRAME_AUDIT`) +- worker execution path (`REZI_STARSHIP_EXECUTION_MODE=worker`) + +```bash +cd /home/k3nig/Rezi +: > /tmp/rezi-frame-audit.ndjson +: > /tmp/starship.log + +env -u NO_COLOR \ + REZI_STARSHIP_EXECUTION_MODE=worker \ + REZI_STARSHIP_DEBUG=1 \ + REZI_STARSHIP_DEBUG_LOG=/tmp/starship.log \ + REZI_FRAME_AUDIT=1 \ + REZI_FRAME_AUDIT_LOG=/tmp/rezi-frame-audit.ndjson \ + npx tsx packages/create-rezi/templates/starship/src/main.ts +``` + +Key controls in template: + +- `1..6`: route switch (bridge/engineering/crew/comms/cargo/settings) +- `t`: cycle theme +- `q`: quit + +## Deterministic viewport (important) + +Many regressions are viewport-threshold dependent. Always test with a known +size before comparing runs. + +For an interactive shell/PTY: + +```bash +stty rows 68 cols 300 +``` + +Then launch the app in that same PTY. + +## Autonomous PTY execution (agent workflow) + +When your agent runtime supports PTY stdin/stdout control: + +1. Start app in PTY mode (with env above). +2. Send key sequences (`2`, `3`, `t`, `q`) through stdin. +3. Wait between keys to allow frames to settle. +4. Quit and analyze logs. + +Do not rely only on static test snapshots for visual regressions. + +## Frame audit analysis + +Use the built-in analyzer: + +```bash +node scripts/frame-audit-report.mjs /tmp/rezi-frame-audit.ndjson --latest-pid +``` + +What to look for: + +- `backend_submitted`, `worker_payload`, `worker_accepted`, `worker_completed` + should stay aligned in worker mode. +- `hash_mismatch_backend_vs_worker` should be `0`. +- `top_opcodes` should reflect expected widget workload. +- `route_summary` should show submissions for every exercised route. +- `native_summary_records`/`native_header_records` confirm native debug pull + from worker path. + +If a log contains multiple app runs, always use `--latest-pid` (or `--pid=`) +to avoid mixed-session confusion. + +## Useful grep patterns + +```bash +rg "runtime.command|runtime.fatal|shell.layout|engineering.layout|engineering.render|crew.render" /tmp/starship.log +rg "\"stage\":\"table.layout\"|\"stage\":\"drawlist.built\"|\"stage\":\"frame.submitted\"|\"stage\":\"frame.completed\"" /tmp/rezi-frame-audit.ndjson +``` + +## Optional deep capture (drawlist bytes) + +Capture raw drawlist payload snapshots for diffing: + +```bash +env \ + REZI_FRAME_AUDIT=1 \ + REZI_FRAME_AUDIT_DUMP_DIR=/tmp/rezi-drawlist-dumps \ + REZI_FRAME_AUDIT_DUMP_MAX=20 \ + REZI_FRAME_AUDIT_DUMP_ROUTE=crew \ + npx tsx packages/create-rezi/templates/starship/src/main.ts +``` + +This writes paired `.bin` + `.json` files with hashes and metadata. + +## Native trace through frame-audit + +Native debug records are enabled by frame audit in worker mode. Controls: + +- `REZI_FRAME_AUDIT_NATIVE=1|0` (default on when frame audit is enabled) +- `REZI_FRAME_AUDIT_NATIVE_RING=` (ring size override) + +Look for stages such as: + +- `native.debug.header` +- `native.drawlist.summary` +- `native.frame.*` +- `native.perf.*` + +## Triage playbook for common regressions + +### 1) “Theme only updates animated region” + +Check: + +1. `runtime.command` contains `cycle-theme`. +2. `drawlist.built` hashes change after theme switch. +3. `frame.submitted`/`frame.completed` continue for that route. + +If hashes do not change, bug is likely in view/theme resolution. +If hashes change but screen does not, investigate native diff/damage path. + +### 2) “Table looks empty or only one row visible” + +Check `table.layout` record: + +- `bodyH` +- `visibleRows` +- `startIndex` / `endIndex` +- table rect height + +If `bodyH` is too small, inspect parent layout/flex and sibling widgets +(pagination or controls often steal height). + +### 3) “Worker mode renders differently from inline” + +Run both modes with identical viewport and compare audit summaries: + +- worker: `REZI_STARSHIP_EXECUTION_MODE=worker` +- inline: `REZI_STARSHIP_EXECUTION_MODE=inline` + +If only worker diverges, focus on backend transport and worker audit stages. + +## Guardrails + +- Keep all instrumentation opt-in via env vars. +- Never print continuous debug spam to stdout during normal app usage. +- Write logs to files (`/tmp/...`) and inspect post-run. +- Prefer deterministic viewport + scripted route/theme steps when verifying fixes. diff --git a/docs/dev/testing.md b/docs/dev/testing.md index 3eab0163..f3fdd9b1 100644 --- a/docs/dev/testing.md +++ b/docs/dev/testing.md @@ -66,6 +66,18 @@ npm run test:e2e npm run test:e2e:reduced ``` +## Live PTY Rendering Validation (for UI regressions) + +For terminal rendering/theme/layout regressions, run a live PTY session with +frame-audit instrumentation in addition to normal tests. + +Use the dedicated runbook: + +- [Live PTY UI Testing and Frame Audit Runbook](live-pty-debugging.md) + +That guide includes deterministic viewport setup, worker-mode run commands, +scripted key driving, and cross-layer telemetry analysis. + ## Test Categories ### Unit Tests diff --git a/mkdocs.yml b/mkdocs.yml index c66f6e8e..bcc9b91b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -250,6 +250,7 @@ nav: - Repo Layout: dev/repo-layout.md - Build: dev/build.md - Testing: dev/testing.md + - Live PTY Debugging: dev/live-pty-debugging.md - Code Standards: dev/code-standards.md - Ink Compat Debugging: dev/ink-compat-debugging.md - Perf Regressions: dev/perf-regressions.md diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts b/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts index 33ec6158..f92cd8fd 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts @@ -28,6 +28,7 @@ import { getTotalHeight, resolveVirtualListItemHeightSpec, } from "../../../widgets/virtualList.js"; +import { emitFrameAudit, FRAME_AUDIT_ENABLED } from "../../../perf/frameAudit.js"; import { asTextStyle } from "../../styles.js"; import { renderBoxBorder } from "../boxBorder.js"; import { isVisibleRect } from "../indices.js"; @@ -605,6 +606,32 @@ export function renderCollectionWidget( ? Math.min(rowCount, startIndex + visibleRows + overscan) : rowCount; + if (FRAME_AUDIT_ENABLED) { + emitFrameAudit( + "tableWidget", + "table.layout", + Object.freeze({ + tableId: props.id, + x: rect.x, + y: rect.y, + w: rect.w, + h: rect.h, + innerW, + innerH, + bodyY, + bodyH, + rowCount, + headerHeight, + rowHeight: safeRowHeight, + virtualized, + startIndex, + endIndex, + visibleRows, + overscan, + }), + ); + } + if (tableStore) { tableStore.set(props.id, { viewportHeight: bodyH, startIndex, endIndex }); } diff --git a/packages/core/src/widgets/__tests__/pagination.test.ts b/packages/core/src/widgets/__tests__/pagination.test.ts index d1ce4396..1b8ab376 100644 --- a/packages/core/src/widgets/__tests__/pagination.test.ts +++ b/packages/core/src/widgets/__tests__/pagination.test.ts @@ -128,7 +128,10 @@ describe("pagination ids and vnode", () => { const zoneNode = children[0]; assert.equal(zoneNode?.kind, "focusZone"); if (zoneNode?.kind !== "focusZone") return; - const ids = zoneNode.children + const controlsRow = zoneNode.children[0]; + assert.equal(controlsRow?.kind, "row"); + if (controlsRow?.kind !== "row") return; + const ids = controlsRow.children .filter((child) => child.kind === "button") .map((child) => (child.kind === "button" ? child.props.id : "")); assert.equal(ids.includes(getPaginationControlId("pages", "first")), true); diff --git a/packages/core/src/widgets/pagination.ts b/packages/core/src/widgets/pagination.ts index ea202124..6bfa73d4 100644 --- a/packages/core/src/widgets/pagination.ts +++ b/packages/core/src/widgets/pagination.ts @@ -243,6 +243,16 @@ export function buildPaginationChildren(props: PaginationProps): readonly VNode[ }); } + const controlsRow: VNode = { + kind: "row", + props: { + gap: 0, + wrap: true, + items: "center", + }, + children: Object.freeze(controls), + }; + const zone: VNode = { kind: "focusZone", props: { @@ -252,7 +262,7 @@ export function buildPaginationChildren(props: PaginationProps): readonly VNode[ columns: 1, wrapAround: false, }, - children: Object.freeze(controls), + children: Object.freeze([controlsRow]), }; return Object.freeze([zone]); diff --git a/packages/create-rezi/templates/starship/src/screens/bridge.ts b/packages/create-rezi/templates/starship/src/screens/bridge.ts index a2666219..bb4546b1 100644 --- a/packages/create-rezi/templates/starship/src/screens/bridge.ts +++ b/packages/create-rezi/templates/starship/src/screens/bridge.ts @@ -554,7 +554,7 @@ export function renderBridgeScreen( title: "Bridge Overview", context, deps, - body: ui.column({ gap: SPACE.sm, width: "100%" }, [ + body: ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ BridgeCommandDeck({ key: "bridge-command-deck", state, dispatch: deps.dispatch }), ]), }); diff --git a/packages/create-rezi/templates/starship/src/screens/cargo.ts b/packages/create-rezi/templates/starship/src/screens/cargo.ts index f5735e23..7cef074d 100644 --- a/packages/create-rezi/templates/starship/src/screens/cargo.ts +++ b/packages/create-rezi/templates/starship/src/screens/cargo.ts @@ -30,7 +30,7 @@ export function renderCargoScreen( title: "Cargo Hold", context, deps, - body: ui.column({ gap: SPACE.sm, width: "100%" }, [ + body: ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ CargoDeck({ key: "cargo-deck", state: context.state, diff --git a/packages/create-rezi/templates/starship/src/screens/comms.ts b/packages/create-rezi/templates/starship/src/screens/comms.ts index 1a115088..8830a08c 100644 --- a/packages/create-rezi/templates/starship/src/screens/comms.ts +++ b/packages/create-rezi/templates/starship/src/screens/comms.ts @@ -462,7 +462,7 @@ export function renderCommsScreen( title: "Communications", context, deps, - body: ui.column({ gap: SPACE.sm, width: "100%" }, [ + body: ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ CommsDeck({ key: "comms-deck", state: context.state, diff --git a/packages/create-rezi/templates/starship/src/screens/crew.ts b/packages/create-rezi/templates/starship/src/screens/crew.ts index 44f71ff1..0013fd90 100644 --- a/packages/create-rezi/templates/starship/src/screens/crew.ts +++ b/packages/create-rezi/templates/starship/src/screens/crew.ts @@ -7,6 +7,7 @@ import { type RouteRenderContext, type VNode, } from "@rezi-ui/core"; +import { debugSnapshot } from "../helpers/debug.js"; import { resolveLayout } from "../helpers/layout.js"; import { crewCounts, departmentLabel, rankBadge, statusBadge } from "../helpers/formatters.js"; import { selectedCrew, visibleCrew } from "../helpers/state.js"; @@ -193,7 +194,7 @@ const CrewDeck = defineWidget((props, ctx): VNode => { ...tableSkin(tokens), }); - const detailPanel = ui.column({ gap: SPACE.sm }, [ + const detailPanel = ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ maybe(selected, (member) => surfacePanel( tokens, @@ -300,8 +301,18 @@ const CrewDeck = defineWidget((props, ctx): VNode => { ), ]); - const manifestBlock = ui.column({ gap: SPACE.sm }, [ - surfacePanel(tokens, "Crew Manifest", [table]), + const manifestBlock = ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ + ui.box( + { + border: "none", + p: 0, + width: "100%", + flex: 1, + minHeight: 10, + overflow: "hidden", + }, + [surfacePanel(tokens, "Crew Manifest", [table])], + ), ui.pagination({ id: ctx.id("crew-pagination"), page, @@ -313,126 +324,185 @@ const CrewDeck = defineWidget((props, ctx): VNode => { const deckLayout = layout.wide ? showDetailPane - ? ui.masterDetail({ - id: ctx.id("crew-master-detail"), - masterWidth: layout.crewMasterWidth, - master: manifestBlock, - detail: detailPanel, - }) - : manifestBlock - : showDetailPane - ? ui.column({ gap: SPACE.sm }, [manifestBlock, detailPanel]) - : manifestBlock; - - const operationsPanel = surfacePanel( - tokens, - "Crew Operations", - [ - sectionHeader(tokens, "Manifest Controls", "Consistent staffing and assignment flow"), - ui.row({ gap: SPACE.md, wrap: !layout.wide, items: "start" }, [ - ui.box( + ? ui.row( { - border: "none", - p: 0, + id: ctx.id("crew-master-detail"), gap: SPACE.sm, - ...(layout.wide ? { flex: 2 } : {}), + width: "100%", + height: "100%", + items: "stretch", + wrap: false, }, [ - ui.row({ gap: SPACE.sm, wrap: true }, [ - ui.badge(`Total ${counts.total}`, { variant: "info" }), - ui.badge(`Active ${counts.active}`, { variant: "success" }), - ui.badge(`Away ${counts.away}`, { variant: "warning" }), - ui.badge(`Injured ${counts.injured}`, { variant: "error" }), - ]), - ui.form([ - ui.field({ - label: "Search Crew", - hint: "Filter by name, rank, or department", - children: ui.input({ - id: ctx.id("crew-search"), - value: props.state.crewSearchQuery, - placeholder: "Type to filter", - onInput: (value) => props.dispatch({ type: "set-crew-search", query: value }), - }), - }), - ]), - ui.actions([ - ui.button({ - id: ctx.id("crew-new-assignment"), - label: "New Assignment", - intent: "primary", - onPress: () => props.dispatch({ type: "toggle-crew-editor" }), - }), - ui.button({ - id: ctx.id("crew-edit-selected"), - label: "Edit Selected", - intent: "secondary", - onPress: () => props.dispatch({ type: "toggle-crew-editor" }), - }), - ]), + ui.box( + { + border: "none", + p: 0, + width: layout.crewMasterWidth, + height: "100%", + overflow: "hidden", + }, + [manifestBlock], + ), + ui.box( + { + border: "none", + p: 0, + flex: 1, + height: "100%", + overflow: "hidden", + }, + [detailPanel], + ), ], - ), - ...(layout.wide - ? [ - ui.box( - { - border: "none", - p: 0, - flex: 1, - }, - [ - surfacePanel( - tokens, - "Crew Snapshot", + ) + : manifestBlock + : showDetailPane + ? ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ + ui.box( + { border: "none", p: 0, width: "100%", flex: 1, minHeight: 10, overflow: "hidden" }, + [manifestBlock], + ), + ui.box( + { border: "none", p: 0, width: "100%", flex: 1, minHeight: 10, overflow: "hidden" }, + [detailPanel], + ), + ]) + : manifestBlock; + + const operationsPanelMaxHeight = Math.max(12, Math.min(22, Math.floor(layout.height * 0.34))); + debugSnapshot("crew.render", { + viewportCols: props.state.viewportCols, + viewportRows: props.state.viewportRows, + visibleCount: visible.length, + sortedCount: sorted.length, + page, + totalPages, + pageDataCount: pageData.length, + showDetailPane, + operationsPanelMaxHeight, + editingCrew: props.state.editingCrew, + }); + const operationsPanel = ui.box( + { + border: "none", + p: 0, + width: "100%", + height: operationsPanelMaxHeight, + overflow: "scroll", + }, + [ + surfacePanel( + tokens, + "Crew Operations", + [ + sectionHeader(tokens, "Manifest Controls", "Consistent staffing and assignment flow"), + ui.row({ gap: SPACE.md, wrap: !layout.wide, items: "start" }, [ + ui.box( + { + border: "none", + p: 0, + gap: SPACE.sm, + ...(layout.wide ? { flex: 2 } : {}), + }, + [ + ui.row({ gap: SPACE.sm, wrap: true }, [ + ui.badge(`Total ${counts.total}`, { variant: "info" }), + ui.badge(`Active ${counts.active}`, { variant: "success" }), + ui.badge(`Away ${counts.away}`, { variant: "warning" }), + ui.badge(`Injured ${counts.injured}`, { variant: "error" }), + ]), + ui.form([ + ui.field({ + label: "Search Crew", + hint: "Filter by name, rank, or department", + children: ui.input({ + id: ctx.id("crew-search"), + value: props.state.crewSearchQuery, + placeholder: "Type to filter", + onInput: (value) => props.dispatch({ type: "set-crew-search", query: value }), + }), + }), + ]), + ui.actions([ + ui.button({ + id: ctx.id("crew-new-assignment"), + label: "New Assignment", + intent: "primary", + onPress: () => props.dispatch({ type: "toggle-crew-editor" }), + }), + ui.button({ + id: ctx.id("crew-edit-selected"), + label: "Edit Selected", + intent: "secondary", + onPress: () => props.dispatch({ type: "toggle-crew-editor" }), + }), + ]), + ], + ), + ...(layout.wide + ? [ + ui.box( + { + border: "none", + p: 0, + flex: 1, + }, [ - selected - ? ui.column({ gap: SPACE.xs }, [ - ui.text(selected.name, { variant: "label" }), - ui.row({ gap: SPACE.xs, wrap: true }, [ - ui.badge(rankBadge(selected.rank).text, { - variant: rankBadge(selected.rank).variant, + surfacePanel( + tokens, + "Crew Snapshot", + [ + selected + ? ui.column({ gap: SPACE.xs }, [ + ui.text(selected.name, { variant: "label" }), + ui.row({ gap: SPACE.xs, wrap: true }, [ + ui.badge(rankBadge(selected.rank).text, { + variant: rankBadge(selected.rank).variant, + }), + ui.badge(statusBadge(selected.status).text, { + variant: statusBadge(selected.status).variant, + }), + ui.tag(departmentLabel(selected.department), { variant: "info" }), + ]), + ]) + : ui.text("No crew selected", { + variant: "caption", + style: { fg: tokens.text.muted, dim: true }, }), - ui.badge(statusBadge(selected.status).text, { - variant: statusBadge(selected.status).variant, + ui.divider(), + ui.row({ gap: SPACE.xs, wrap: true }, [ + ui.badge(`Visible ${sorted.length}`, { variant: "info" }), + ui.badge(`Page ${page}/${totalPages}`, { variant: "default" }), + ]), + staffingError + ? ui.callout("Critical staffing below minimum.", { + title: "Guardrail", + variant: "warning", + }) + : ui.callout("Critical staffing thresholds healthy.", { + title: "Guardrail", + variant: "success", }), - ui.tag(departmentLabel(selected.department), { variant: "info" }), - ]), - ]) - : ui.text("No crew selected", { - variant: "caption", - style: { fg: tokens.text.muted, dim: true }, - }), - ui.divider(), - ui.row({ gap: SPACE.xs, wrap: true }, [ - ui.badge(`Visible ${sorted.length}`, { variant: "info" }), - ui.badge(`Page ${page}/${totalPages}`, { variant: "default" }), - ]), - staffingError - ? ui.callout("Critical staffing below minimum.", { - title: "Guardrail", - variant: "warning", - }) - : ui.callout("Critical staffing thresholds healthy.", { - title: "Guardrail", - variant: "success", - }), + ], + { + tone: "inset", + p: SPACE.sm, + gap: SPACE.sm, + }, + ), ], - { - tone: "inset", - p: SPACE.sm, - gap: SPACE.sm, - }, ), - ], - ), - ] - : []), - ]), + ] + : []), + ]), + ], + { tone: "base" }, + ), ], - { tone: "base" }, ); - return ui.column({ gap: SPACE.md, width: "100%" }, [ + return ui.column({ gap: SPACE.md, width: "100%", height: "100%" }, [ operationsPanel, show( asyncCrew.loading, @@ -443,7 +513,20 @@ const CrewDeck = defineWidget((props, ctx): VNode => { ui.skeleton(44, { variant: "text" }), ], { tone: "inset" }), ), - show(!asyncCrew.loading, deckLayout), + show( + !asyncCrew.loading, + ui.box( + { + border: "none", + p: 0, + width: "100%", + flex: 1, + minHeight: 12, + overflow: "hidden", + }, + [deckLayout], + ), + ), ]); }); @@ -452,7 +535,7 @@ export function renderCrewScreen(context: RouteRenderContext, dep title: "Crew Manifest", context, deps, - body: ui.column({ gap: SPACE.sm, width: "100%" }, [ + body: ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ CrewDeck({ key: "crew-deck", state: context.state, diff --git a/packages/create-rezi/templates/starship/src/screens/engineering.ts b/packages/create-rezi/templates/starship/src/screens/engineering.ts index 6247de81..016b2158 100644 --- a/packages/create-rezi/templates/starship/src/screens/engineering.ts +++ b/packages/create-rezi/templates/starship/src/screens/engineering.ts @@ -371,16 +371,27 @@ const EngineeringDeck = defineWidget((props, ctx): VNode = }), ]); - const leftPane = ui.column({ gap: SPACE.sm, width: "100%" }, [ - reactorPanel, - ...(showSecondaryPanels ? [treePanel] : []), - ]); - const rightPane = ui.column({ gap: SPACE.sm, width: "100%" }, [ - powerPanel, - ...(showSecondaryPanels ? [thermalPanel, diagnosticsPanel] : []), - ]); + const leftPane = showSecondaryPanels + ? ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ + ui.box({ border: "none", p: 0, width: "100%", flex: 3, minHeight: 12 }, [reactorPanel]), + ui.box({ border: "none", p: 0, width: "100%", flex: 2, minHeight: 10, overflow: "hidden" }, [ + treePanel, + ]), + ]) + : ui.column({ gap: SPACE.sm, width: "100%" }, [reactorPanel]); + const rightPane = showSecondaryPanels + ? ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ + ui.box({ border: "none", p: 0, width: "100%", flex: 3, minHeight: 12, overflow: "hidden" }, [ + powerPanel, + ]), + ui.box({ border: "none", p: 0, width: "100%", flex: 2, minHeight: 10 }, [thermalPanel]), + ui.box({ border: "none", p: 0, width: "100%", flex: 2, minHeight: 10, overflow: "hidden" }, [ + diagnosticsPanel, + ]), + ]) + : ui.column({ gap: SPACE.sm, width: "100%" }, [powerPanel]); - const responsiveDeckHeight = Math.max( + const responsiveDeckMinHeight = Math.max( 16, contentRows - (showControlsSummary ? 12 : 10) - (showSecondaryPanels ? 0 : 2), ); @@ -395,7 +406,8 @@ const EngineeringDeck = defineWidget((props, ctx): VNode = border: "none", p: 0, width: "100%", - height: responsiveDeckHeight, + flex: 1, + minHeight: responsiveDeckMinHeight, overflow: "scroll", }, [responsiveDeckBody], @@ -491,7 +503,7 @@ const EngineeringDeck = defineWidget((props, ctx): VNode = includeResponsiveDeck: renderMode === "full", responsiveDeckMode: useWideRow ? "row" : "column", forceStackViaEnv, - responsiveDeckHeight, + responsiveDeckMinHeight, }); if (veryCompactHeight) { @@ -502,7 +514,7 @@ const EngineeringDeck = defineWidget((props, ctx): VNode = return ui.column({ gap: SPACE.sm, width: "100%" }, [controlsPanel, reactorPanel]); } - return ui.column({ gap: SPACE.sm, width: "100%" }, [ + return ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ controlsRegion, responsiveDeck, ]); @@ -516,7 +528,7 @@ export function renderEngineeringScreen( title: "Engineering Deck", context, deps, - body: ui.column({ gap: SPACE.sm, width: "100%" }, [ + body: ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ EngineeringDeck({ key: "engineering-deck", state: context.state, diff --git a/packages/create-rezi/templates/starship/src/screens/settings.ts b/packages/create-rezi/templates/starship/src/screens/settings.ts index 9b9aac93..e18b31da 100644 --- a/packages/create-rezi/templates/starship/src/screens/settings.ts +++ b/packages/create-rezi/templates/starship/src/screens/settings.ts @@ -209,7 +209,7 @@ function settingsRightRail(state: StarshipState, deps: RouteDeps): VNode { subtitle: activeTheme.label, actions: [ui.badge("Preview", { variant: "info" })], }), - body: ui.column({ gap: SPACE.xs }, [ + body: ui.column({ gap: SPACE.xs, width: "100%", height: "100%" }, [ ui.breadcrumb({ items: [{ label: "Bridge" }, { label: "Settings" }, { label: "Theme Preview" }], }), @@ -277,7 +277,7 @@ export function renderSettingsScreen( title: "Ship Settings", context, deps, - body: ui.column({ gap: SPACE.sm, width: "100%" }, [ + body: ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ SettingsDeck({ key: "settings-deck", state: context.state, diff --git a/packages/node/src/frameAudit.ts b/packages/node/src/frameAudit.ts index 0b98f472..63953f08 100644 --- a/packages/node/src/frameAudit.ts +++ b/packages/node/src/frameAudit.ts @@ -145,10 +145,18 @@ export const FRAME_AUDIT_NATIVE_RING_BYTES = envPositiveInt( ); // Match zr_debug category/code values. +export const ZR_DEBUG_CAT_FRAME = 1; export const ZR_DEBUG_CAT_DRAWLIST = 3; +export const ZR_DEBUG_CAT_PERF = 6; +export const ZR_DEBUG_CODE_FRAME_BEGIN = 0x0100; +export const ZR_DEBUG_CODE_FRAME_SUBMIT = 0x0101; +export const ZR_DEBUG_CODE_FRAME_PRESENT = 0x0102; +export const ZR_DEBUG_CODE_FRAME_RESIZE = 0x0103; export const ZR_DEBUG_CODE_DRAWLIST_VALIDATE = 0x0300; export const ZR_DEBUG_CODE_DRAWLIST_EXECUTE = 0x0301; export const ZR_DEBUG_CODE_DRAWLIST_CMD = 0x0302; +export const ZR_DEBUG_CODE_PERF_TIMING = 0x0600; +export const ZR_DEBUG_CODE_PERF_DIFF_PATH = 0x0601; function readContextRoute(): string | null { const g = globalThis as { diff --git a/packages/node/src/worker/engineWorker.ts b/packages/node/src/worker/engineWorker.ts index b07aef8c..4d62620d 100644 --- a/packages/node/src/worker/engineWorker.ts +++ b/packages/node/src/worker/engineWorker.ts @@ -11,10 +11,18 @@ import { parentPort, workerData } from "node:worker_threads"; import { FRAME_AUDIT_NATIVE_ENABLED, FRAME_AUDIT_NATIVE_RING_BYTES, + ZR_DEBUG_CAT_FRAME, ZR_DEBUG_CAT_DRAWLIST, + ZR_DEBUG_CAT_PERF, + ZR_DEBUG_CODE_FRAME_BEGIN, + ZR_DEBUG_CODE_FRAME_SUBMIT, + ZR_DEBUG_CODE_FRAME_PRESENT, + ZR_DEBUG_CODE_FRAME_RESIZE, ZR_DEBUG_CODE_DRAWLIST_CMD, ZR_DEBUG_CODE_DRAWLIST_EXECUTE, ZR_DEBUG_CODE_DRAWLIST_VALIDATE, + ZR_DEBUG_CODE_PERF_TIMING, + ZR_DEBUG_CODE_PERF_DIFF_PATH, createFrameAuditLogger, drawlistFingerprint, } from "../frameAudit.js"; @@ -318,6 +326,26 @@ const DEBUG_QUERY_MIN_HEADERS_CAP = DEBUG_HEADER_BYTES; const DEBUG_QUERY_MAX_HEADERS_CAP = 1 << 20; // 1 MiB const NO_RECYCLED_DRAWLISTS: readonly ArrayBuffer[] = Object.freeze([]); const DEBUG_DRAWLIST_RECORD_BYTES = 48; +const DEBUG_FRAME_RECORD_BYTES = 56; +const DEBUG_PERF_RECORD_BYTES = 24; +const DEBUG_DIFF_PATH_RECORD_BYTES = 56; +const NATIVE_FRAME_AUDIT_CATEGORY_MASK = + (1 << ZR_DEBUG_CAT_DRAWLIST) | (1 << ZR_DEBUG_CAT_FRAME) | (1 << ZR_DEBUG_CAT_PERF); + +function nativeFrameCodeName(code: number): string { + if (code === ZR_DEBUG_CODE_FRAME_BEGIN) return "frame.begin"; + if (code === ZR_DEBUG_CODE_FRAME_SUBMIT) return "frame.submit"; + if (code === ZR_DEBUG_CODE_FRAME_PRESENT) return "frame.present"; + if (code === ZR_DEBUG_CODE_FRAME_RESIZE) return "frame.resize"; + return "frame.unknown"; +} + +function nativePerfPhaseName(phase: number): string { + if (phase === 0) return "poll"; + if (phase === 1) return "submit"; + if (phase === 2) return "present"; + return "unknown"; +} type FrameAuditMeta = { frameSeq: number; @@ -438,7 +466,7 @@ function drainNativeFrameAudit(reason: string): void { engineId, { minRecordId: nativeFrameAuditNextRecordId, - categoryMask: 1 << ZR_DEBUG_CAT_DRAWLIST, + categoryMask: NATIVE_FRAME_AUDIT_CATEGORY_MASK, minSeverity: 0, maxRecords: Math.floor(headersCap / DEBUG_HEADER_BYTES), }, @@ -552,6 +580,112 @@ function drainNativeFrameAudit(reason: string): void { fillRects: dvPayload.getUint32(36, true), }); } + continue; + } + + if ( + (code === ZR_DEBUG_CODE_FRAME_BEGIN || + code === ZR_DEBUG_CODE_FRAME_SUBMIT || + code === ZR_DEBUG_CODE_FRAME_PRESENT || + code === ZR_DEBUG_CODE_FRAME_RESIZE) && + payloadSize >= DEBUG_FRAME_RECORD_BYTES + ) { + const payload = new Uint8Array(DEBUG_FRAME_RECORD_BYTES); + let wrote = 0; + try { + wrote = native.engineDebugGetPayload(engineId, recordId, payload); + } catch (err) { + frameAudit.emit("native.debug.payload_error", { + reason, + recordId: recordId.toString(), + detail: safeDetail(err), + }); + continue; + } + if (wrote >= DEBUG_FRAME_RECORD_BYTES) { + const dvPayload = new DataView(payload.buffer, payload.byteOffset, DEBUG_FRAME_RECORD_BYTES); + frameAudit.emit("native.frame.summary", { + reason, + recordId: recordId.toString(), + frameId: u64FromView(dvPayload, 0).toString(), + code, + codeName: nativeFrameCodeName(code), + cols: dvPayload.getUint32(8, true), + rows: dvPayload.getUint32(12, true), + drawlistBytes: dvPayload.getUint32(16, true), + drawlistCmds: dvPayload.getUint32(20, true), + diffBytesEmitted: dvPayload.getUint32(24, true), + dirtyLines: dvPayload.getUint32(28, true), + dirtyCells: dvPayload.getUint32(32, true), + damageRects: dvPayload.getUint32(36, true), + usDrawlist: dvPayload.getUint32(40, true), + usDiff: dvPayload.getUint32(44, true), + usWrite: dvPayload.getUint32(48, true), + }); + } + continue; + } + + if (code === ZR_DEBUG_CODE_PERF_TIMING && payloadSize >= DEBUG_PERF_RECORD_BYTES) { + const payload = new Uint8Array(DEBUG_PERF_RECORD_BYTES); + let wrote = 0; + try { + wrote = native.engineDebugGetPayload(engineId, recordId, payload); + } catch (err) { + frameAudit.emit("native.debug.payload_error", { + reason, + recordId: recordId.toString(), + detail: safeDetail(err), + }); + continue; + } + if (wrote >= DEBUG_PERF_RECORD_BYTES) { + const dvPayload = new DataView(payload.buffer, payload.byteOffset, DEBUG_PERF_RECORD_BYTES); + const phase = dvPayload.getUint32(8, true); + frameAudit.emit("native.perf.timing", { + reason, + recordId: recordId.toString(), + frameId: u64FromView(dvPayload, 0).toString(), + phase, + phaseName: nativePerfPhaseName(phase), + usElapsed: dvPayload.getUint32(12, true), + bytesProcessed: dvPayload.getUint32(16, true), + }); + } + continue; + } + + if (code === ZR_DEBUG_CODE_PERF_DIFF_PATH && payloadSize >= DEBUG_DIFF_PATH_RECORD_BYTES) { + const payload = new Uint8Array(DEBUG_DIFF_PATH_RECORD_BYTES); + let wrote = 0; + try { + wrote = native.engineDebugGetPayload(engineId, recordId, payload); + } catch (err) { + frameAudit.emit("native.debug.payload_error", { + reason, + recordId: recordId.toString(), + detail: safeDetail(err), + }); + continue; + } + if (wrote >= DEBUG_DIFF_PATH_RECORD_BYTES) { + const dvPayload = new DataView(payload.buffer, payload.byteOffset, DEBUG_DIFF_PATH_RECORD_BYTES); + frameAudit.emit("native.perf.diffPath", { + reason, + recordId: recordId.toString(), + frameId: u64FromView(dvPayload, 0).toString(), + sweepFramesTotal: u64FromView(dvPayload, 8).toString(), + damageFramesTotal: u64FromView(dvPayload, 16).toString(), + scrollAttemptsTotal: u64FromView(dvPayload, 24).toString(), + scrollHitsTotal: u64FromView(dvPayload, 32).toString(), + collisionGuardHitsTotal: u64FromView(dvPayload, 40).toString(), + pathSweepUsed: dvPayload.getUint8(48), + pathDamageUsed: dvPayload.getUint8(49), + scrollOptAttempted: dvPayload.getUint8(50), + scrollOptHit: dvPayload.getUint8(51), + collisionGuardHitsLast: dvPayload.getUint32(52, true), + }); + } } } if (!advanced) return; From e0292100ebd192c1060ffb02b21d60b16a62b97f Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:48:19 +0400 Subject: [PATCH 02/29] Fix starship template theme to use packed rgb colors --- .../templates/starship/src/theme.ts | 199 +++++++++--------- 1 file changed, 94 insertions(+), 105 deletions(-) diff --git a/packages/create-rezi/templates/starship/src/theme.ts b/packages/create-rezi/templates/starship/src/theme.ts index 91d1ba95..df0e53c7 100644 --- a/packages/create-rezi/templates/starship/src/theme.ts +++ b/packages/create-rezi/templates/starship/src/theme.ts @@ -1,12 +1,12 @@ import { type BadgeVariant, - type Rgb, type Rgb24, type TextStyle, type ThemeDefinition, draculaTheme, extendTheme, nordTheme, + rgb, } from "@rezi-ui/core"; import type { AlertLevel, ThemeName } from "./types.js"; @@ -42,52 +42,52 @@ const DAY_SHIFT_THEME = extendTheme(nordTheme, { focusIndicator: { bold: true, underline: false, - focusRingColor: { r: 118, g: 208, b: 255 }, + focusRingColor: rgb(118, 208, 255), }, colors: { bg: { - base: { r: 29, g: 42, b: 58 }, - elevated: { r: 40, g: 57, b: 76 }, - overlay: { r: 52, g: 72, b: 94 }, - subtle: { r: 34, g: 49, b: 67 }, + base: rgb(29, 42, 58), + elevated: rgb(40, 57, 76), + overlay: rgb(52, 72, 94), + subtle: rgb(34, 49, 67), }, fg: { - primary: { r: 236, g: 243, b: 255 }, - secondary: { r: 190, g: 217, b: 242 }, - muted: { r: 108, g: 138, b: 168 }, - inverse: { r: 20, g: 30, b: 42 }, + primary: rgb(236, 243, 255), + secondary: rgb(190, 217, 242), + muted: rgb(108, 138, 168), + inverse: rgb(20, 30, 42), }, border: { - subtle: { r: 74, g: 98, b: 126 }, - default: { r: 104, g: 136, b: 168 }, - strong: { r: 137, g: 171, b: 206 }, + subtle: rgb(74, 98, 126), + default: rgb(104, 136, 168), + strong: rgb(137, 171, 206), }, accent: { - primary: { r: 106, g: 195, b: 255 }, - secondary: { r: 129, g: 217, b: 255 }, - tertiary: { r: 180, g: 231, b: 164 }, + primary: rgb(106, 195, 255), + secondary: rgb(129, 217, 255), + tertiary: rgb(180, 231, 164), }, - info: { r: 118, g: 208, b: 255 }, - success: { r: 166, g: 228, b: 149 }, - warning: { r: 255, g: 211, b: 131 }, - error: { r: 239, g: 118, b: 132 }, + info: rgb(118, 208, 255), + success: rgb(166, 228, 149), + warning: rgb(255, 211, 131), + error: rgb(239, 118, 132), selected: { - bg: { r: 68, g: 105, b: 139 }, - fg: { r: 236, g: 243, b: 255 }, + bg: rgb(68, 105, 139), + fg: rgb(236, 243, 255), }, disabled: { - fg: { r: 95, g: 121, b: 149 }, - bg: { r: 39, g: 55, b: 72 }, + fg: rgb(95, 121, 149), + bg: rgb(39, 55, 72), }, diagnostic: { - error: { r: 239, g: 118, b: 132 }, - warning: { r: 255, g: 211, b: 131 }, - info: { r: 118, g: 208, b: 255 }, - hint: { r: 149, g: 187, b: 228 }, + error: rgb(239, 118, 132), + warning: rgb(255, 211, 131), + info: rgb(118, 208, 255), + hint: rgb(149, 187, 228), }, focus: { - ring: { r: 118, g: 208, b: 255 }, - bg: { r: 63, g: 96, b: 126 }, + ring: rgb(118, 208, 255), + bg: rgb(63, 96, 126), }, }, }); @@ -105,52 +105,52 @@ const NIGHT_SHIFT_THEME = extendTheme(draculaTheme, { focusIndicator: { bold: true, underline: false, - focusRingColor: { r: 176, g: 133, b: 255 }, + focusRingColor: rgb(176, 133, 255), }, colors: { bg: { - base: { r: 19, g: 22, b: 33 }, - elevated: { r: 28, g: 31, b: 46 }, - overlay: { r: 37, g: 41, b: 60 }, - subtle: { r: 24, g: 27, b: 40 }, + base: rgb(19, 22, 33), + elevated: rgb(28, 31, 46), + overlay: rgb(37, 41, 60), + subtle: rgb(24, 27, 40), }, fg: { - primary: { r: 244, g: 246, b: 252 }, - secondary: { r: 202, g: 185, b: 252 }, - muted: { r: 131, g: 146, b: 186 }, - inverse: { r: 19, g: 22, b: 33 }, + primary: rgb(244, 246, 252), + secondary: rgb(202, 185, 252), + muted: rgb(131, 146, 186), + inverse: rgb(19, 22, 33), }, accent: { - primary: { r: 176, g: 133, b: 255 }, - secondary: { r: 129, g: 235, b: 255 }, - tertiary: { r: 119, g: 255, b: 196 }, + primary: rgb(176, 133, 255), + secondary: rgb(129, 235, 255), + tertiary: rgb(119, 255, 196), }, - info: { r: 129, g: 235, b: 255 }, - success: { r: 110, g: 249, b: 174 }, - warning: { r: 255, g: 207, b: 124 }, - error: { r: 255, g: 118, b: 132 }, + info: rgb(129, 235, 255), + success: rgb(110, 249, 174), + warning: rgb(255, 207, 124), + error: rgb(255, 118, 132), selected: { - bg: { r: 68, g: 76, b: 112 }, - fg: { r: 244, g: 246, b: 252 }, + bg: rgb(68, 76, 112), + fg: rgb(244, 246, 252), }, disabled: { - fg: { r: 99, g: 111, b: 146 }, - bg: { r: 28, g: 31, b: 46 }, + fg: rgb(99, 111, 146), + bg: rgb(28, 31, 46), }, diagnostic: { - error: { r: 255, g: 118, b: 132 }, - warning: { r: 255, g: 207, b: 124 }, - info: { r: 129, g: 235, b: 255 }, - hint: { r: 206, g: 158, b: 255 }, + error: rgb(255, 118, 132), + warning: rgb(255, 207, 124), + info: rgb(129, 235, 255), + hint: rgb(206, 158, 255), }, focus: { - ring: { r: 176, g: 133, b: 255 }, - bg: { r: 64, g: 57, b: 96 }, + ring: rgb(176, 133, 255), + bg: rgb(64, 57, 96), }, border: { - subtle: { r: 43, g: 49, b: 71 }, - default: { r: 72, g: 81, b: 116 }, - strong: { r: 104, g: 115, b: 156 }, + subtle: rgb(43, 49, 71), + default: rgb(72, 81, 116), + strong: rgb(104, 115, 156), }, }, }); @@ -168,52 +168,52 @@ const RED_ALERT_THEME = extendTheme(draculaTheme, { focusIndicator: { bold: true, underline: false, - focusRingColor: { r: 255, g: 112, b: 112 }, + focusRingColor: rgb(255, 112, 112), }, colors: { bg: { - base: { r: 24, g: 12, b: 19 }, - elevated: { r: 34, g: 15, b: 24 }, - overlay: { r: 46, g: 21, b: 32 }, - subtle: { r: 29, g: 13, b: 22 }, + base: rgb(24, 12, 19), + elevated: rgb(34, 15, 24), + overlay: rgb(46, 21, 32), + subtle: rgb(29, 13, 22), }, fg: { - primary: { r: 255, g: 238, b: 242 }, - secondary: { r: 244, g: 190, b: 205 }, - muted: { r: 170, g: 122, b: 139 }, - inverse: { r: 24, g: 12, b: 19 }, + primary: rgb(255, 238, 242), + secondary: rgb(244, 190, 205), + muted: rgb(170, 122, 139), + inverse: rgb(24, 12, 19), }, accent: { - primary: { r: 255, g: 114, b: 144 }, - secondary: { r: 255, g: 182, b: 120 }, - tertiary: { r: 255, g: 220, b: 146 }, + primary: rgb(255, 114, 144), + secondary: rgb(255, 182, 120), + tertiary: rgb(255, 220, 146), }, - success: { r: 134, g: 247, b: 176 }, - warning: { r: 255, g: 181, b: 112 }, - error: { r: 255, g: 93, b: 117 }, - info: { r: 255, g: 141, b: 153 }, + success: rgb(134, 247, 176), + warning: rgb(255, 181, 112), + error: rgb(255, 93, 117), + info: rgb(255, 141, 153), selected: { - bg: { r: 82, g: 34, b: 52 }, - fg: { r: 255, g: 238, b: 242 }, + bg: rgb(82, 34, 52), + fg: rgb(255, 238, 242), }, disabled: { - fg: { r: 142, g: 96, b: 112 }, - bg: { r: 34, g: 15, b: 24 }, + fg: rgb(142, 96, 112), + bg: rgb(34, 15, 24), }, diagnostic: { - error: { r: 255, g: 93, b: 117 }, - warning: { r: 255, g: 181, b: 112 }, - info: { r: 255, g: 141, b: 153 }, - hint: { r: 255, g: 203, b: 133 }, + error: rgb(255, 93, 117), + warning: rgb(255, 181, 112), + info: rgb(255, 141, 153), + hint: rgb(255, 203, 133), }, focus: { - ring: { r: 255, g: 112, b: 112 }, - bg: { r: 76, g: 35, b: 50 }, + ring: rgb(255, 112, 112), + bg: rgb(76, 35, 50), }, border: { - subtle: { r: 86, g: 45, b: 61 }, - default: { r: 124, g: 65, b: 86 }, - strong: { r: 172, g: 86, b: 112 }, + subtle: rgb(86, 45, 61), + default: rgb(124, 65, 86), + strong: rgb(172, 86, 112), }, }, }); @@ -244,14 +244,10 @@ function clampChannel(value: number): number { return Math.max(0, Math.min(255, Math.round(value))); } -type ColorInput = Rgb | Rgb24; +type ColorInput = Rgb24; -function packRgb(value: Rgb): Rgb24 { - return ( - ((clampChannel(value.r) & 0xff) << 16) | - ((clampChannel(value.g) & 0xff) << 8) | - (clampChannel(value.b) & 0xff) - ); +function packRgb(value: Rgb24): Rgb24 { + return (Math.round(value) >>> 0) & 0x00ff_ffff; } function rgbChannel(value: Rgb24, shift: 0 | 8 | 16): number { @@ -259,17 +255,10 @@ function rgbChannel(value: Rgb24, shift: 0 | 8 | 16): number { } function unpackRgb(value: ColorInput): Readonly<{ r: number; g: number; b: number }> { - if (typeof value === "number") { - return Object.freeze({ - r: rgbChannel(value, 16), - g: rgbChannel(value, 8), - b: rgbChannel(value, 0), - }); - } return Object.freeze({ - r: clampChannel(value.r), - g: clampChannel(value.g), - b: clampChannel(value.b), + r: rgbChannel(value, 16), + g: rgbChannel(value, 8), + b: rgbChannel(value, 0), }); } From 56868eb96bd8ada1d4a9b38acc9480150020a02b Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:38:51 +0400 Subject: [PATCH 03/29] Address PR feedback and fix lint/native diff telemetry audit --- docs/dev/live-pty-debugging.md | 4 +-- .../renderToDrawlist/widgets/collections.ts | 2 +- packages/node/src/worker/engineWorker.ts | 35 +++++++++++++------ 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/docs/dev/live-pty-debugging.md b/docs/dev/live-pty-debugging.md index bdb18d91..05ed2a37 100644 --- a/docs/dev/live-pty-debugging.md +++ b/docs/dev/live-pty-debugging.md @@ -21,7 +21,7 @@ The PTY + frame-audit workflow gives deterministic evidence for all of those. From repo root: ```bash -cd /home/k3nig/Rezi +cd npx tsc -b packages/core packages/node packages/create-rezi ``` @@ -34,7 +34,7 @@ This enables: - worker execution path (`REZI_STARSHIP_EXECUTION_MODE=worker`) ```bash -cd /home/k3nig/Rezi +cd : > /tmp/rezi-frame-audit.ndjson : > /tmp/starship.log diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts b/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts index f92cd8fd..c5787984 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts @@ -6,6 +6,7 @@ import { truncateWithEllipsis, } from "../../../layout/textMeasure.js"; import type { Rect } from "../../../layout/types.js"; +import { FRAME_AUDIT_ENABLED, emitFrameAudit } from "../../../perf/frameAudit.js"; import type { RuntimeInstance } from "../../../runtime/commit.js"; import type { FocusState } from "../../../runtime/focus.js"; import type { @@ -28,7 +29,6 @@ import { getTotalHeight, resolveVirtualListItemHeightSpec, } from "../../../widgets/virtualList.js"; -import { emitFrameAudit, FRAME_AUDIT_ENABLED } from "../../../perf/frameAudit.js"; import { asTextStyle } from "../../styles.js"; import { renderBoxBorder } from "../boxBorder.js"; import { isVisibleRect } from "../indices.js"; diff --git a/packages/node/src/worker/engineWorker.ts b/packages/node/src/worker/engineWorker.ts index 4d62620d..cec86dd1 100644 --- a/packages/node/src/worker/engineWorker.ts +++ b/packages/node/src/worker/engineWorker.ts @@ -11,18 +11,18 @@ import { parentPort, workerData } from "node:worker_threads"; import { FRAME_AUDIT_NATIVE_ENABLED, FRAME_AUDIT_NATIVE_RING_BYTES, - ZR_DEBUG_CAT_FRAME, ZR_DEBUG_CAT_DRAWLIST, + ZR_DEBUG_CAT_FRAME, ZR_DEBUG_CAT_PERF, - ZR_DEBUG_CODE_FRAME_BEGIN, - ZR_DEBUG_CODE_FRAME_SUBMIT, - ZR_DEBUG_CODE_FRAME_PRESENT, - ZR_DEBUG_CODE_FRAME_RESIZE, ZR_DEBUG_CODE_DRAWLIST_CMD, ZR_DEBUG_CODE_DRAWLIST_EXECUTE, ZR_DEBUG_CODE_DRAWLIST_VALIDATE, - ZR_DEBUG_CODE_PERF_TIMING, + ZR_DEBUG_CODE_FRAME_BEGIN, + ZR_DEBUG_CODE_FRAME_PRESENT, + ZR_DEBUG_CODE_FRAME_RESIZE, + ZR_DEBUG_CODE_FRAME_SUBMIT, ZR_DEBUG_CODE_PERF_DIFF_PATH, + ZR_DEBUG_CODE_PERF_TIMING, createFrameAuditLogger, drawlistFingerprint, } from "../frameAudit.js"; @@ -328,7 +328,8 @@ const NO_RECYCLED_DRAWLISTS: readonly ArrayBuffer[] = Object.freeze([]); const DEBUG_DRAWLIST_RECORD_BYTES = 48; const DEBUG_FRAME_RECORD_BYTES = 56; const DEBUG_PERF_RECORD_BYTES = 24; -const DEBUG_DIFF_PATH_RECORD_BYTES = 56; +// Must cover sizeof(zr_diff_telemetry_record_t) (includes native trailing pad). +const DEBUG_DIFF_PATH_RECORD_BYTES = 64; const NATIVE_FRAME_AUDIT_CATEGORY_MASK = (1 << ZR_DEBUG_CAT_DRAWLIST) | (1 << ZR_DEBUG_CAT_FRAME) | (1 << ZR_DEBUG_CAT_PERF); @@ -436,7 +437,7 @@ function maybeEnableNativeFrameAudit(): void { enabled: true, ringCapacity: FRAME_AUDIT_NATIVE_RING_BYTES, minSeverity: 0, - categoryMask: 0xffff_ffff, + categoryMask: NATIVE_FRAME_AUDIT_CATEGORY_MASK, captureRawEvents: false, captureDrawlistBytes: true, }); @@ -603,7 +604,11 @@ function drainNativeFrameAudit(reason: string): void { continue; } if (wrote >= DEBUG_FRAME_RECORD_BYTES) { - const dvPayload = new DataView(payload.buffer, payload.byteOffset, DEBUG_FRAME_RECORD_BYTES); + const dvPayload = new DataView( + payload.buffer, + payload.byteOffset, + DEBUG_FRAME_RECORD_BYTES, + ); frameAudit.emit("native.frame.summary", { reason, recordId: recordId.toString(), @@ -640,7 +645,11 @@ function drainNativeFrameAudit(reason: string): void { continue; } if (wrote >= DEBUG_PERF_RECORD_BYTES) { - const dvPayload = new DataView(payload.buffer, payload.byteOffset, DEBUG_PERF_RECORD_BYTES); + const dvPayload = new DataView( + payload.buffer, + payload.byteOffset, + DEBUG_PERF_RECORD_BYTES, + ); const phase = dvPayload.getUint32(8, true); frameAudit.emit("native.perf.timing", { reason, @@ -669,7 +678,11 @@ function drainNativeFrameAudit(reason: string): void { continue; } if (wrote >= DEBUG_DIFF_PATH_RECORD_BYTES) { - const dvPayload = new DataView(payload.buffer, payload.byteOffset, DEBUG_DIFF_PATH_RECORD_BYTES); + const dvPayload = new DataView( + payload.buffer, + payload.byteOffset, + DEBUG_DIFF_PATH_RECORD_BYTES, + ); frameAudit.emit("native.perf.diffPath", { reason, recordId: recordId.toString(), From 6cf158f4da0a7731fccb24217b242f52b13265c2 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:43:29 +0400 Subject: [PATCH 04/29] Fix native vendor integrity check for uninitialized submodules --- .../check-native-vendor-integrity.test.mjs | 26 +++++++++++++++++++ scripts/check-native-vendor-integrity.mjs | 7 +++++ 2 files changed, 33 insertions(+) diff --git a/scripts/__tests__/check-native-vendor-integrity.test.mjs b/scripts/__tests__/check-native-vendor-integrity.test.mjs index 14ee256c..5abe8ea2 100644 --- a/scripts/__tests__/check-native-vendor-integrity.test.mjs +++ b/scripts/__tests__/check-native-vendor-integrity.test.mjs @@ -3,6 +3,7 @@ */ import { strict as assert } from "node:assert"; +import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -21,6 +22,10 @@ function makeFixtureRoot() { return mkdtempSync(join(tmpdir(), "rezi-native-vendor-")); } +function runGit(cwd, args) { + return execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); +} + function writeBaseFixture(root, commit = COMMIT_A) { writeUtf8( join(root, "packages/native/build.rs"), @@ -133,4 +138,25 @@ describe("check-native-vendor-integrity", () => { rmSync(root, { recursive: true, force: true }); } }); + + test("ignores uninitialized gitlink directory in superproject checkout", () => { + const root = makeFixtureRoot(); + try { + writeBaseFixture(root, COMMIT_A); + runGit(root, ["init"]); + runGit(root, ["config", "user.name", "Test User"]); + runGit(root, ["config", "user.email", "test@example.com"]); + runGit(root, ["add", "."]); + runGit(root, ["commit", "-m", "fixture"]); + + const result = checkNativeVendorIntegrity(root, { + resolveGitlinkCommit: () => COMMIT_A, + }); + assert.equal(result.success, true); + assert.equal(result.pinnedCommit, COMMIT_A); + assert.equal(result.gitlinkCommit, COMMIT_A); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); diff --git a/scripts/check-native-vendor-integrity.mjs b/scripts/check-native-vendor-integrity.mjs index df2c85ed..dd7ecb7d 100644 --- a/scripts/check-native-vendor-integrity.mjs +++ b/scripts/check-native-vendor-integrity.mjs @@ -67,6 +67,13 @@ function readSubmoduleHead(rootDir) { return null; } + // A gitlink path may exist as an ordinary directory when submodules are not + // initialized. In that case `git -C ` resolves to the parent repo and + // returns the superproject HEAD, which is not a submodule HEAD. + // Only treat vendor/zireael as checked out when it has its own git metadata. + const submoduleGitMeta = join(submoduleDir, ".git"); + if (!existsSync(submoduleGitMeta)) return null; + try { const head = runGit(submoduleDir, ["rev-parse", "HEAD"]).toLowerCase(); return isHex40(head) ? head : null; From 9cebb4f2c948fb5eb675945a967193a3cde88e42 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:56:13 +0400 Subject: [PATCH 05/29] Fix CI color-style tests and native payload short-read audit --- .../__tests__/translation/colorMap.test.ts | 19 +- .../translation/propsToVNode.test.ts | 27 +- packages/node/src/worker/engineWorker.ts | 301 +++++++++--------- 3 files changed, 167 insertions(+), 180 deletions(-) diff --git a/packages/ink-compat/src/__tests__/translation/colorMap.test.ts b/packages/ink-compat/src/__tests__/translation/colorMap.test.ts index dccacde3..5bbe85dd 100644 --- a/packages/ink-compat/src/__tests__/translation/colorMap.test.ts +++ b/packages/ink-compat/src/__tests__/translation/colorMap.test.ts @@ -1,27 +1,28 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { rgb } from "@rezi-ui/core"; import { parseColor } from "../../translation/colorMap.js"; test("parseColor maps named colors", () => { - assert.deepEqual(parseColor("green"), { r: 0, g: 205, b: 0 }); - assert.deepEqual(parseColor("whiteBright"), { r: 255, g: 255, b: 255 }); - assert.deepEqual(parseColor("GRAY"), { r: 127, g: 127, b: 127 }); + assert.equal(parseColor("green"), rgb(0, 205, 0)); + assert.equal(parseColor("whiteBright"), rgb(255, 255, 255)); + assert.equal(parseColor("GRAY"), rgb(127, 127, 127)); }); test("parseColor parses hex colors", () => { - assert.deepEqual(parseColor("#00ff7f"), { r: 0, g: 255, b: 127 }); - assert.deepEqual(parseColor("#abc"), { r: 170, g: 187, b: 204 }); + assert.equal(parseColor("#00ff7f"), rgb(0, 255, 127)); + assert.equal(parseColor("#abc"), rgb(170, 187, 204)); }); test("parseColor parses rgb() colors", () => { - assert.deepEqual(parseColor("rgb(12, 34, 56)"), { r: 12, g: 34, b: 56 }); - assert.deepEqual(parseColor("rgb( 0 , 255 , 127 )"), { r: 0, g: 255, b: 127 }); + assert.equal(parseColor("rgb(12, 34, 56)"), rgb(12, 34, 56)); + assert.equal(parseColor("rgb( 0 , 255 , 127 )"), rgb(0, 255, 127)); }); test("parseColor parses ansi256() colors", () => { - assert.deepEqual(parseColor("ansi256(196)"), { r: 255, g: 0, b: 0 }); - assert.deepEqual(parseColor("ansi256(244)"), { r: 128, g: 128, b: 128 }); + assert.equal(parseColor("ansi256(196)"), rgb(255, 0, 0)); + assert.equal(parseColor("ansi256(244)"), rgb(128, 128, 128)); assert.equal(parseColor("ansi256(999)"), undefined); }); diff --git a/packages/ink-compat/src/__tests__/translation/propsToVNode.test.ts b/packages/ink-compat/src/__tests__/translation/propsToVNode.test.ts index 711ead54..72888f2e 100644 --- a/packages/ink-compat/src/__tests__/translation/propsToVNode.test.ts +++ b/packages/ink-compat/src/__tests__/translation/propsToVNode.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { rgb } from "@rezi-ui/core"; import { type InkHostContainer, @@ -83,10 +84,10 @@ test("bordered box maps per-edge border styles", () => { const vnode = translateTree(containerWith(node)) as any; assert.equal(vnode.kind, "box"); - assert.deepEqual(vnode.props.borderStyleSides.top, { fg: { r: 205, g: 0, b: 0 }, dim: true }); - assert.deepEqual(vnode.props.borderStyleSides.right, { fg: { r: 0, g: 205, b: 0 } }); - assert.deepEqual(vnode.props.borderStyleSides.bottom, { fg: { r: 0, g: 0, b: 238 } }); - assert.deepEqual(vnode.props.borderStyleSides.left, { fg: { r: 205, g: 205, b: 0 }, dim: true }); + assert.deepEqual(vnode.props.borderStyleSides.top, { fg: rgb(205, 0, 0), dim: true }); + assert.deepEqual(vnode.props.borderStyleSides.right, { fg: rgb(0, 205, 0) }); + assert.deepEqual(vnode.props.borderStyleSides.bottom, { fg: rgb(0, 0, 238) }); + assert.deepEqual(vnode.props.borderStyleSides.left, { fg: rgb(205, 205, 0), dim: true }); }); test("bordered row box nests ui.row inside ui.box", () => { @@ -116,7 +117,7 @@ test("background-only box explicitly disables default borders", () => { assert.equal(vnode.kind, "box"); assert.equal(vnode.props.border, "none"); - assert.deepEqual(vnode.props.style, { bg: { r: 28, g: 28, b: 28 } }); + assert.deepEqual(vnode.props.style, { bg: rgb(28, 28, 28) }); }); test("background-only row box keeps row layout without implicit border", () => { @@ -138,7 +139,7 @@ test("styled text maps to text style", () => { assert.equal(vnode.kind, "text"); assert.equal(vnode.text, "Hello"); - assert.deepEqual(vnode.props.style, { fg: { r: 0, g: 205, b: 0 }, bold: true }); + assert.deepEqual(vnode.props.style, { fg: rgb(0, 205, 0), bold: true }); }); test("nested text produces richText spans", () => { @@ -187,7 +188,7 @@ test("ANSI SGR sequences map to richText styles", () => { assert.equal(vnode.kind, "richText"); assert.equal(vnode.props.spans.length, 3); assert.equal(vnode.props.spans[0]?.text, "Red"); - assert.deepEqual(vnode.props.spans[0]?.style?.fg, { r: 205, g: 0, b: 0 }); + assert.deepEqual(vnode.props.spans[0]?.style?.fg, rgb(205, 0, 0)); assert.equal(vnode.props.spans[1]?.text, " plain "); assert.equal("inverse" in (vnode.props.spans[1]?.style ?? {}), false); assert.equal(vnode.props.spans[2]?.text, "Inv"); @@ -203,11 +204,11 @@ test("ANSI reset restores parent style", () => { assert.equal(vnode.kind, "richText"); assert.equal(vnode.props.spans.length, 3); assert.equal(vnode.props.spans[0]?.text, "A"); - assert.deepEqual(vnode.props.spans[0]?.style?.fg, { r: 0, g: 205, b: 0 }); + assert.deepEqual(vnode.props.spans[0]?.style?.fg, rgb(0, 205, 0)); assert.equal(vnode.props.spans[1]?.text, "B"); - assert.deepEqual(vnode.props.spans[1]?.style?.fg, { r: 205, g: 0, b: 0 }); + assert.deepEqual(vnode.props.spans[1]?.style?.fg, rgb(205, 0, 0)); assert.equal(vnode.props.spans[2]?.text, "C"); - assert.deepEqual(vnode.props.spans[2]?.style?.fg, { r: 0, g: 205, b: 0 }); + assert.deepEqual(vnode.props.spans[2]?.style?.fg, rgb(0, 205, 0)); }); test("ANSI truecolor maps to RGB style", () => { @@ -219,7 +220,7 @@ test("ANSI truecolor maps to RGB style", () => { assert.equal(vnode.kind, "richText"); assert.equal(vnode.props.spans.length, 1); assert.equal(vnode.props.spans[0]?.text, "C"); - assert.deepEqual(vnode.props.spans[0]?.style?.fg, { r: 120, g: 80, b: 200 }); + assert.deepEqual(vnode.props.spans[0]?.style?.fg, rgb(120, 80, 200)); }); test("ANSI truecolor colon form maps to RGB style", () => { @@ -231,7 +232,7 @@ test("ANSI truecolor colon form maps to RGB style", () => { assert.equal(vnode.kind, "richText"); assert.equal(vnode.props.spans.length, 1); assert.equal(vnode.props.spans[0]?.text, "X"); - assert.deepEqual(vnode.props.spans[0]?.style?.fg, { r: 255, g: 120, b: 40 }); + assert.deepEqual(vnode.props.spans[0]?.style?.fg, rgb(255, 120, 40)); }); test("spacer virtual node maps to ui.spacer", () => { @@ -496,7 +497,7 @@ test("scroll overflow maps scroll props and scrollbar style", () => { assert.equal(vnode.props.overflow, "scroll"); assert.equal(vnode.props.scrollX, 2); assert.equal(vnode.props.scrollY, 5); - assert.deepEqual(vnode.props.scrollbarStyle, { fg: { r: 18, g: 52, b: 86 } }); + assert.deepEqual(vnode.props.scrollbarStyle, { fg: rgb(18, 52, 86) }); }); test("hidden overflow stays hidden without scroll axis", () => { diff --git a/packages/node/src/worker/engineWorker.ts b/packages/node/src/worker/engineWorker.ts index cec86dd1..5d58b40f 100644 --- a/packages/node/src/worker/engineWorker.ts +++ b/packages/node/src/worker/engineWorker.ts @@ -460,6 +460,54 @@ function drainNativeFrameAudit(reason: string): void { if (engineId === null) return; const headersCap = DEBUG_HEADER_BYTES * 64; const headersBuf = new Uint8Array(headersCap); + const tryReadDebugPayload = ( + recordId: bigint, + code: number, + expectedBytes: number, + opts: Readonly<{ allowPartialOnShortRead?: boolean }> = {}, + ): Uint8Array | null => { + if (engineId === null) return null; + const payload = new Uint8Array(expectedBytes); + let wrote = 0; + try { + wrote = native.engineDebugGetPayload(engineId, recordId, payload); + } catch (err) { + frameAudit.emit("native.debug.payload_error", { + reason, + recordId: recordId.toString(), + code, + expectedBytes, + detail: safeDetail(err), + }); + return null; + } + if (wrote <= 0) { + frameAudit.emit("native.debug.payload_error", { + reason, + recordId: recordId.toString(), + code, + expectedBytes, + wrote, + detail: "payload read returned non-positive length", + }); + return null; + } + if (wrote < expectedBytes) { + frameAudit.emit("native.debug.payload_error", { + reason, + recordId: recordId.toString(), + code, + expectedBytes, + wrote, + detail: "short payload read", + }); + if (opts.allowPartialOnShortRead !== true) { + return null; + } + } + return payload.subarray(0, Math.min(wrote, payload.byteLength)); + }; + for (let iter = 0; iter < 8; iter++) { let result: DebugQueryResultNative; try { @@ -516,32 +564,21 @@ function drainNativeFrameAudit(reason: string): void { if (code === ZR_DEBUG_CODE_DRAWLIST_CMD && payloadSize > 0) { const cap = Math.min(Math.max(payloadSize, 1), 4096); - const payload = new Uint8Array(cap); - let wrote = 0; - try { - wrote = native.engineDebugGetPayload(engineId, recordId, payload); - } catch (err) { - frameAudit.emit("native.debug.payload_error", { - reason, - recordId: recordId.toString(), - detail: safeDetail(err), - }); - continue; - } - if (wrote > 0) { - const view = payload.subarray(0, Math.min(wrote, payload.byteLength)); - const fp = drawlistFingerprint(view); - frameAudit.emit("native.drawlist.payload", { - reason, - recordId: recordId.toString(), - frameId: frameId.toString(), - payloadSize: view.byteLength, - hash32: fp.hash32, - prefixHash32: fp.prefixHash32, - head16: fp.head16, - tail16: fp.tail16, - }); - } + const view = tryReadDebugPayload(recordId, code, cap, { + allowPartialOnShortRead: true, + }); + if (view === null) continue; + const fp = drawlistFingerprint(view); + frameAudit.emit("native.drawlist.payload", { + reason, + recordId: recordId.toString(), + frameId: frameId.toString(), + payloadSize: view.byteLength, + hash32: fp.hash32, + prefixHash32: fp.prefixHash32, + head16: fp.head16, + tail16: fp.tail16, + }); continue; } @@ -549,38 +586,26 @@ function drainNativeFrameAudit(reason: string): void { (code === ZR_DEBUG_CODE_DRAWLIST_VALIDATE || code === ZR_DEBUG_CODE_DRAWLIST_EXECUTE) && payloadSize >= DEBUG_DRAWLIST_RECORD_BYTES ) { - const payload = new Uint8Array(DEBUG_DRAWLIST_RECORD_BYTES); - let wrote = 0; - try { - wrote = native.engineDebugGetPayload(engineId, recordId, payload); - } catch (err) { - frameAudit.emit("native.debug.payload_error", { - reason, - recordId: recordId.toString(), - detail: safeDetail(err), - }); - continue; - } - if (wrote >= DEBUG_DRAWLIST_RECORD_BYTES) { - const dvPayload = new DataView( - payload.buffer, - payload.byteOffset, - DEBUG_DRAWLIST_RECORD_BYTES, - ); - frameAudit.emit("native.drawlist.summary", { - reason, - recordId: recordId.toString(), - frameId: u64FromView(dvPayload, 0).toString(), - totalBytes: dvPayload.getUint32(8, true), - cmdCount: dvPayload.getUint32(12, true), - version: dvPayload.getUint32(16, true), - validationResult: dvPayload.getInt32(20, true), - executionResult: dvPayload.getInt32(24, true), - clipStackMaxDepth: dvPayload.getUint32(28, true), - textRuns: dvPayload.getUint32(32, true), - fillRects: dvPayload.getUint32(36, true), - }); - } + const payload = tryReadDebugPayload(recordId, code, DEBUG_DRAWLIST_RECORD_BYTES); + if (payload === null) continue; + const dvPayload = new DataView( + payload.buffer, + payload.byteOffset, + DEBUG_DRAWLIST_RECORD_BYTES, + ); + frameAudit.emit("native.drawlist.summary", { + reason, + recordId: recordId.toString(), + frameId: u64FromView(dvPayload, 0).toString(), + totalBytes: dvPayload.getUint32(8, true), + cmdCount: dvPayload.getUint32(12, true), + version: dvPayload.getUint32(16, true), + validationResult: dvPayload.getInt32(20, true), + executionResult: dvPayload.getInt32(24, true), + clipStackMaxDepth: dvPayload.getUint32(28, true), + textRuns: dvPayload.getUint32(32, true), + fillRects: dvPayload.getUint32(36, true), + }); continue; } @@ -591,114 +616,74 @@ function drainNativeFrameAudit(reason: string): void { code === ZR_DEBUG_CODE_FRAME_RESIZE) && payloadSize >= DEBUG_FRAME_RECORD_BYTES ) { - const payload = new Uint8Array(DEBUG_FRAME_RECORD_BYTES); - let wrote = 0; - try { - wrote = native.engineDebugGetPayload(engineId, recordId, payload); - } catch (err) { - frameAudit.emit("native.debug.payload_error", { - reason, - recordId: recordId.toString(), - detail: safeDetail(err), - }); - continue; - } - if (wrote >= DEBUG_FRAME_RECORD_BYTES) { - const dvPayload = new DataView( - payload.buffer, - payload.byteOffset, - DEBUG_FRAME_RECORD_BYTES, - ); - frameAudit.emit("native.frame.summary", { - reason, - recordId: recordId.toString(), - frameId: u64FromView(dvPayload, 0).toString(), - code, - codeName: nativeFrameCodeName(code), - cols: dvPayload.getUint32(8, true), - rows: dvPayload.getUint32(12, true), - drawlistBytes: dvPayload.getUint32(16, true), - drawlistCmds: dvPayload.getUint32(20, true), - diffBytesEmitted: dvPayload.getUint32(24, true), - dirtyLines: dvPayload.getUint32(28, true), - dirtyCells: dvPayload.getUint32(32, true), - damageRects: dvPayload.getUint32(36, true), - usDrawlist: dvPayload.getUint32(40, true), - usDiff: dvPayload.getUint32(44, true), - usWrite: dvPayload.getUint32(48, true), - }); - } + const payload = tryReadDebugPayload(recordId, code, DEBUG_FRAME_RECORD_BYTES); + if (payload === null) continue; + const dvPayload = new DataView( + payload.buffer, + payload.byteOffset, + DEBUG_FRAME_RECORD_BYTES, + ); + frameAudit.emit("native.frame.summary", { + reason, + recordId: recordId.toString(), + frameId: u64FromView(dvPayload, 0).toString(), + code, + codeName: nativeFrameCodeName(code), + cols: dvPayload.getUint32(8, true), + rows: dvPayload.getUint32(12, true), + drawlistBytes: dvPayload.getUint32(16, true), + drawlistCmds: dvPayload.getUint32(20, true), + diffBytesEmitted: dvPayload.getUint32(24, true), + dirtyLines: dvPayload.getUint32(28, true), + dirtyCells: dvPayload.getUint32(32, true), + damageRects: dvPayload.getUint32(36, true), + usDrawlist: dvPayload.getUint32(40, true), + usDiff: dvPayload.getUint32(44, true), + usWrite: dvPayload.getUint32(48, true), + }); continue; } if (code === ZR_DEBUG_CODE_PERF_TIMING && payloadSize >= DEBUG_PERF_RECORD_BYTES) { - const payload = new Uint8Array(DEBUG_PERF_RECORD_BYTES); - let wrote = 0; - try { - wrote = native.engineDebugGetPayload(engineId, recordId, payload); - } catch (err) { - frameAudit.emit("native.debug.payload_error", { - reason, - recordId: recordId.toString(), - detail: safeDetail(err), - }); - continue; - } - if (wrote >= DEBUG_PERF_RECORD_BYTES) { - const dvPayload = new DataView( - payload.buffer, - payload.byteOffset, - DEBUG_PERF_RECORD_BYTES, - ); - const phase = dvPayload.getUint32(8, true); - frameAudit.emit("native.perf.timing", { - reason, - recordId: recordId.toString(), - frameId: u64FromView(dvPayload, 0).toString(), - phase, - phaseName: nativePerfPhaseName(phase), - usElapsed: dvPayload.getUint32(12, true), - bytesProcessed: dvPayload.getUint32(16, true), - }); - } + const payload = tryReadDebugPayload(recordId, code, DEBUG_PERF_RECORD_BYTES); + if (payload === null) continue; + const dvPayload = new DataView(payload.buffer, payload.byteOffset, DEBUG_PERF_RECORD_BYTES); + const phase = dvPayload.getUint32(8, true); + frameAudit.emit("native.perf.timing", { + reason, + recordId: recordId.toString(), + frameId: u64FromView(dvPayload, 0).toString(), + phase, + phaseName: nativePerfPhaseName(phase), + usElapsed: dvPayload.getUint32(12, true), + bytesProcessed: dvPayload.getUint32(16, true), + }); continue; } if (code === ZR_DEBUG_CODE_PERF_DIFF_PATH && payloadSize >= DEBUG_DIFF_PATH_RECORD_BYTES) { - const payload = new Uint8Array(DEBUG_DIFF_PATH_RECORD_BYTES); - let wrote = 0; - try { - wrote = native.engineDebugGetPayload(engineId, recordId, payload); - } catch (err) { - frameAudit.emit("native.debug.payload_error", { - reason, - recordId: recordId.toString(), - detail: safeDetail(err), - }); - continue; - } - if (wrote >= DEBUG_DIFF_PATH_RECORD_BYTES) { - const dvPayload = new DataView( - payload.buffer, - payload.byteOffset, - DEBUG_DIFF_PATH_RECORD_BYTES, - ); - frameAudit.emit("native.perf.diffPath", { - reason, - recordId: recordId.toString(), - frameId: u64FromView(dvPayload, 0).toString(), - sweepFramesTotal: u64FromView(dvPayload, 8).toString(), - damageFramesTotal: u64FromView(dvPayload, 16).toString(), - scrollAttemptsTotal: u64FromView(dvPayload, 24).toString(), - scrollHitsTotal: u64FromView(dvPayload, 32).toString(), - collisionGuardHitsTotal: u64FromView(dvPayload, 40).toString(), - pathSweepUsed: dvPayload.getUint8(48), - pathDamageUsed: dvPayload.getUint8(49), - scrollOptAttempted: dvPayload.getUint8(50), - scrollOptHit: dvPayload.getUint8(51), - collisionGuardHitsLast: dvPayload.getUint32(52, true), - }); - } + const payload = tryReadDebugPayload(recordId, code, DEBUG_DIFF_PATH_RECORD_BYTES); + if (payload === null) continue; + const dvPayload = new DataView( + payload.buffer, + payload.byteOffset, + DEBUG_DIFF_PATH_RECORD_BYTES, + ); + frameAudit.emit("native.perf.diffPath", { + reason, + recordId: recordId.toString(), + frameId: u64FromView(dvPayload, 0).toString(), + sweepFramesTotal: u64FromView(dvPayload, 8).toString(), + damageFramesTotal: u64FromView(dvPayload, 16).toString(), + scrollAttemptsTotal: u64FromView(dvPayload, 24).toString(), + scrollHitsTotal: u64FromView(dvPayload, 32).toString(), + collisionGuardHitsTotal: u64FromView(dvPayload, 40).toString(), + pathSweepUsed: dvPayload.getUint8(48), + pathDamageUsed: dvPayload.getUint8(49), + scrollOptAttempted: dvPayload.getUint8(50), + scrollOptHit: dvPayload.getUint8(51), + collisionGuardHitsLast: dvPayload.getUint32(52, true), + }); } } if (!advanced) return; From 9f73007c874811d9cbc487744541bd313d40a982 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:20:39 +0400 Subject: [PATCH 06/29] fix(ink-compat): optimize translation and layout hot paths --- .../src/__tests__/integration/basic.test.ts | 156 +++ .../__tests__/perf/bottleneck-profile.test.ts | 1103 ++++++++++++++++ .../src/__tests__/reconciler/types.test.ts | 38 + .../src/__tests__/runtime/newApis.test.ts | 57 +- .../translation/propsToVNode.test.ts | 188 ++- .../ink-compat/src/reconciler/hostConfig.ts | 17 +- packages/ink-compat/src/reconciler/types.ts | 322 ++++- .../ink-compat/src/runtime/ResizeObserver.ts | 14 +- packages/ink-compat/src/runtime/bridge.ts | 22 +- packages/ink-compat/src/runtime/domHelpers.ts | 18 +- .../ink-compat/src/runtime/getBoundingBox.ts | 5 +- .../ink-compat/src/runtime/layoutState.ts | 47 + .../ink-compat/src/runtime/measureElement.ts | 3 +- packages/ink-compat/src/runtime/render.ts | 1130 ++++++++++++----- .../src/translation/propsToVNode.ts | 435 +++++-- 15 files changed, 3074 insertions(+), 481 deletions(-) create mode 100644 packages/ink-compat/src/__tests__/perf/bottleneck-profile.test.ts create mode 100644 packages/ink-compat/src/runtime/layoutState.ts diff --git a/packages/ink-compat/src/__tests__/integration/basic.test.ts b/packages/ink-compat/src/__tests__/integration/basic.test.ts index 32b471d8..26d96025 100644 --- a/packages/ink-compat/src/__tests__/integration/basic.test.ts +++ b/packages/ink-compat/src/__tests__/integration/basic.test.ts @@ -577,6 +577,96 @@ 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 +1038,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 +1262,38 @@ 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", () => { + 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 { + 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..ff149162 --- /dev/null +++ b/packages/ink-compat/src/__tests__/perf/bottleneck-profile.test.ts @@ -0,0 +1,1103 @@ +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; + } + 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 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..fd9d64a5 100644 --- a/packages/ink-compat/src/__tests__/translation/propsToVNode.test.ts +++ b/packages/ink-compat/src/__tests__/translation/propsToVNode.test.ts @@ -1,6 +1,5 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { rgb } from "@rezi-ui/core"; import { type InkHostContainer, @@ -8,9 +7,14 @@ import { appendChild, createHostContainer, createHostNode, + insertBefore, + removeChild, + setNodeTextContent, } from "../../reconciler/types.js"; import { + __inkCompatTranslationTestHooks, translateDynamicTree, + translateDynamicTreeWithMetadata, translateStaticTree, translateTree, } from "../../translation/propsToVNode.js"; @@ -84,10 +88,10 @@ test("bordered box maps per-edge border styles", () => { const vnode = translateTree(containerWith(node)) as any; assert.equal(vnode.kind, "box"); - assert.deepEqual(vnode.props.borderStyleSides.top, { fg: rgb(205, 0, 0), dim: true }); - assert.deepEqual(vnode.props.borderStyleSides.right, { fg: rgb(0, 205, 0) }); - assert.deepEqual(vnode.props.borderStyleSides.bottom, { fg: rgb(0, 0, 238) }); - assert.deepEqual(vnode.props.borderStyleSides.left, { fg: rgb(205, 205, 0), dim: true }); + assert.deepEqual(vnode.props.borderStyleSides.top, { fg: { r: 205, g: 0, b: 0 }, dim: true }); + assert.deepEqual(vnode.props.borderStyleSides.right, { fg: { r: 0, g: 205, b: 0 } }); + assert.deepEqual(vnode.props.borderStyleSides.bottom, { fg: { r: 0, g: 0, b: 238 } }); + assert.deepEqual(vnode.props.borderStyleSides.left, { fg: { r: 205, g: 205, b: 0 }, dim: true }); }); test("bordered row box nests ui.row inside ui.box", () => { @@ -117,7 +121,7 @@ test("background-only box explicitly disables default borders", () => { assert.equal(vnode.kind, "box"); assert.equal(vnode.props.border, "none"); - assert.deepEqual(vnode.props.style, { bg: rgb(28, 28, 28) }); + assert.deepEqual(vnode.props.style, { bg: { r: 28, g: 28, b: 28 } }); }); test("background-only row box keeps row layout without implicit border", () => { @@ -139,7 +143,7 @@ test("styled text maps to text style", () => { assert.equal(vnode.kind, "text"); assert.equal(vnode.text, "Hello"); - assert.deepEqual(vnode.props.style, { fg: rgb(0, 205, 0), bold: true }); + assert.deepEqual(vnode.props.style, { fg: { r: 0, g: 205, b: 0 }, bold: true }); }); test("nested text produces richText spans", () => { @@ -188,7 +192,7 @@ test("ANSI SGR sequences map to richText styles", () => { assert.equal(vnode.kind, "richText"); assert.equal(vnode.props.spans.length, 3); assert.equal(vnode.props.spans[0]?.text, "Red"); - assert.deepEqual(vnode.props.spans[0]?.style?.fg, rgb(205, 0, 0)); + assert.deepEqual(vnode.props.spans[0]?.style?.fg, { r: 205, g: 0, b: 0 }); assert.equal(vnode.props.spans[1]?.text, " plain "); assert.equal("inverse" in (vnode.props.spans[1]?.style ?? {}), false); assert.equal(vnode.props.spans[2]?.text, "Inv"); @@ -204,11 +208,11 @@ test("ANSI reset restores parent style", () => { assert.equal(vnode.kind, "richText"); assert.equal(vnode.props.spans.length, 3); assert.equal(vnode.props.spans[0]?.text, "A"); - assert.deepEqual(vnode.props.spans[0]?.style?.fg, rgb(0, 205, 0)); + assert.deepEqual(vnode.props.spans[0]?.style?.fg, { r: 0, g: 205, b: 0 }); assert.equal(vnode.props.spans[1]?.text, "B"); - assert.deepEqual(vnode.props.spans[1]?.style?.fg, rgb(205, 0, 0)); + assert.deepEqual(vnode.props.spans[1]?.style?.fg, { r: 205, g: 0, b: 0 }); assert.equal(vnode.props.spans[2]?.text, "C"); - assert.deepEqual(vnode.props.spans[2]?.style?.fg, rgb(0, 205, 0)); + assert.deepEqual(vnode.props.spans[2]?.style?.fg, { r: 0, g: 205, b: 0 }); }); test("ANSI truecolor maps to RGB style", () => { @@ -220,7 +224,7 @@ test("ANSI truecolor maps to RGB style", () => { assert.equal(vnode.kind, "richText"); assert.equal(vnode.props.spans.length, 1); assert.equal(vnode.props.spans[0]?.text, "C"); - assert.deepEqual(vnode.props.spans[0]?.style?.fg, rgb(120, 80, 200)); + assert.deepEqual(vnode.props.spans[0]?.style?.fg, { r: 120, g: 80, b: 200 }); }); test("ANSI truecolor colon form maps to RGB style", () => { @@ -232,7 +236,40 @@ test("ANSI truecolor colon form maps to RGB style", () => { assert.equal(vnode.kind, "richText"); assert.equal(vnode.props.spans.length, 1); assert.equal(vnode.props.spans[0]?.text, "X"); - assert.deepEqual(vnode.props.spans[0]?.style?.fg, rgb(255, 120, 40)); + assert.deepEqual(vnode.props.spans[0]?.style?.fg, { r: 255, g: 120, b: 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, { r: 205, g: 0, b: 0 }); + assert.equal(vnode.props.spans[2]?.text, "Z"); }); test("spacer virtual node maps to ui.spacer", () => { @@ -289,7 +326,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 +339,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", () => { @@ -497,7 +532,7 @@ test("scroll overflow maps scroll props and scrollbar style", () => { assert.equal(vnode.props.overflow, "scroll"); assert.equal(vnode.props.scrollX, 2); assert.equal(vnode.props.scrollY, 5); - assert.deepEqual(vnode.props.scrollbarStyle, { fg: rgb(18, 52, 86) }); + assert.deepEqual(vnode.props.scrollbarStyle, { fg: { r: 18, g: 52, b: 86 } }); }); test("hidden overflow stays hidden without scroll axis", () => { @@ -568,3 +603,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..aee7f48d 100644 --- a/packages/ink-compat/src/runtime/render.ts +++ b/packages/ink-compat/src/runtime/render.ts @@ -1,7 +1,13 @@ 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 Rgb, + type TextStyle, + type VNode, + createTestRenderer, + measureTextCells, +} from "@rezi-ui/core"; import React from "react"; import { type KittyFlagName, resolveKittyFlags } from "../kitty-keyboard.js"; @@ -10,7 +16,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 { @@ -59,8 +65,8 @@ interface ClipRect { } interface CellStyle { - fg?: Rgb24; - bg?: Rgb24; + fg?: Rgb; + bg?: Rgb; bold?: boolean; dim?: boolean; italic?: boolean; @@ -116,16 +122,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: Readonly = Object.freeze({ r: 232, g: 238, b: 245 }); +const CORE_DEFAULT_BG: Readonly = Object.freeze({ r: 7, g: 10, b: 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 +931,8 @@ function snapshotCellGridRows( for (let col = 0; col < captureTo; col++) { const cell = row[col]!; const entry: Record = { c: cell.char }; - if (cell.style?.bg) - entry["bg"] = `${rgbR(cell.style.bg)},${rgbG(cell.style.bg)},${rgbB(cell.style.bg)}`; - if (cell.style?.fg) - entry["fg"] = `${rgbR(cell.style.fg)},${rgbG(cell.style.fg)},${rgbB(cell.style.fg)}`; + if (cell.style?.bg) entry["bg"] = `${cell.style.bg.r},${cell.style.bg.g},${cell.style.bg.b}`; + if (cell.style?.fg) entry["fg"] = `${cell.style.fg.r},${cell.style.fg.g},${cell.style.fg.b}`; if (cell.style?.bold) entry["bold"] = true; if (cell.style?.dim) entry["dim"] = true; if (cell.style?.inverse) entry["inv"] = true; @@ -991,53 +1023,72 @@ 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))); -} - -function packRgb24(r: number, g: number, b: number): Rgb24 { - return ((clampByte(r) & 0xff) << 16) | ((clampByte(g) & 0xff) << 8) | (clampByte(b) & 0xff); -} - -function rgbR(value: Rgb24): number { - return (value >>> 16) & 0xff; -} -function rgbG(value: Rgb24): number { - return (value >>> 8) & 0xff; + return revisions.join(","); } -function rgbB(value: Rgb24): number { - return value & 0xff; -} - -function clampRgb24(value: Rgb24): Rgb24 { - return packRgb24(rgbR(value), rgbG(value), rgbB(value)); +function isRgb(value: unknown): value is Rgb { + if (typeof value !== "object" || value === null) return false; + const r = (value as { r?: unknown }).r; + const g = (value as { g?: unknown }).g; + const b = (value as { b?: unknown }).b; + return ( + typeof r === "number" && + Number.isFinite(r) && + typeof g === "number" && + Number.isFinite(g) && + typeof b === "number" && + Number.isFinite(b) + ); } -function isRgb24(value: unknown): value is Rgb24 { - return typeof value === "number" && Number.isFinite(value); +function clampByte(value: number): number { + return Math.max(0, Math.min(255, Math.round(value))); } -function isSameRgb(a: Rgb24, b: Rgb24): boolean { - return a === b; +function isSameRgb(a: Rgb, b: Readonly): boolean { + return a.r === b.r && a.g === b.g && a.b === b.b; } const ANSI16_PALETTE: readonly [number, number, number][] = [ @@ -1096,14 +1147,14 @@ function detectColorSupport(stdout: Writable): ColorSupport { return { level: 3, noColor: false }; } -function colorDistanceSq(a: Rgb24, b: readonly [number, number, number]): number { - const dr = rgbR(a) - b[0]; - const dg = rgbG(a) - b[1]; - const db = rgbB(a) - b[2]; +function colorDistanceSq(a: Rgb, b: readonly [number, number, number]): number { + const dr = a.r - b[0]; + const dg = a.g - b[1]; + const db = a.b - b[2]; return dr * dr + dg * dg + db * db; } -function toAnsi16Code(color: Rgb24, background: boolean): number { +function toAnsi16Code(color: Rgb, background: boolean): number { let bestIndex = 0; let bestDistance = Number.POSITIVE_INFINITY; for (let index = 0; index < ANSI16_PALETTE.length; index += 1) { @@ -1127,43 +1178,55 @@ function rgbChannelToCubeLevel(channel: number): number { return Math.min(5, Math.floor((channel - 35) / 40)); } -function toAnsi256Code(color: Rgb24): number { - const rLevel = rgbChannelToCubeLevel(rgbR(color)); - const gLevel = rgbChannelToCubeLevel(rgbG(color)); - const bLevel = rgbChannelToCubeLevel(rgbB(color)); +function toAnsi256Code(color: Rgb): number { + const rLevel = rgbChannelToCubeLevel(color.r); + const gLevel = rgbChannelToCubeLevel(color.g); + const bLevel = rgbChannelToCubeLevel(color.b); const cubeCode = 16 + 36 * rLevel + 6 * gLevel + bLevel; - const cubeColor: readonly [number, number, number] = [ - rLevel === 0 ? 0 : 55 + 40 * rLevel, - gLevel === 0 ? 0 : 55 + 40 * gLevel, - bLevel === 0 ? 0 : 55 + 40 * bLevel, - ]; + const cubeColor: Rgb = { + r: rLevel === 0 ? 0 : 55 + 40 * rLevel, + g: gLevel === 0 ? 0 : 55 + 40 * gLevel, + b: bLevel === 0 ? 0 : 55 + 40 * bLevel, + }; - const avg = Math.round((rgbR(color) + rgbG(color) + rgbB(color)) / 3); + const avg = Math.round((color.r + color.g + color.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 = { r: grayValue, g: grayValue, b: grayValue }; - const cubeDistance = colorDistanceSq(color, cubeColor); - const grayDistance = colorDistanceSq(color, grayColor); + const cubeDistance = colorDistanceSq(color, [cubeColor.r, cubeColor.g, cubeColor.b]); + const grayDistance = colorDistanceSq(color, [grayColor.r, grayColor.g, grayColor.b]); 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 (isRgb(style.fg)) { + const fg = { r: clampByte(style.fg.r), g: clampByte(style.fg.g), b: clampByte(style.fg.b) }; // 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)) { normalized.fg = fg; } } - if (isRgb24(style.bg) && style.bg !== 0) { - const bg = clampRgb24(style.bg); + if (isRgb(style.bg)) { + const bg = { r: clampByte(style.bg.r), g: clampByte(style.bg.g), b: clampByte(style.bg.b) }; if (!isSameRgb(bg, CORE_DEFAULT_BG)) { normalized.bg = bg; } @@ -1175,39 +1238,151 @@ 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: Rgb | undefined, b: Rgb | 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(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"); @@ -1218,7 +1393,9 @@ function styleToSgr(style: CellStyle | undefined, colorSupport: ColorSupport): s if (colorSupport.level > 0) { if (style.fg) { if (colorSupport.level >= 3) { - codes.push(`38;2;${rgbR(style.fg)};${rgbG(style.fg)};${rgbB(style.fg)}`); + codes.push( + `38;2;${clampByte(style.fg.r)};${clampByte(style.fg.g)};${clampByte(style.fg.b)}`, + ); } else if (colorSupport.level === 2) { codes.push(`38;5;${toAnsi256Code(style.fg)}`); } else { @@ -1227,7 +1404,9 @@ function styleToSgr(style: CellStyle | undefined, colorSupport: ColorSupport): s } if (style.bg) { if (colorSupport.level >= 3) { - codes.push(`48;2;${rgbR(style.bg)};${rgbG(style.bg)};${rgbB(style.bg)}`); + codes.push( + `48;2;${clampByte(style.bg.r)};${clampByte(style.bg.g)};${clampByte(style.bg.b)}`, + ); } else if (colorSupport.level === 2) { codes.push(`48;5;${toAnsi256Code(style.bg)}`); } else { @@ -1236,10 +1415,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 +1440,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 +1510,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 +1519,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 +1618,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; - }); + 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; + } + } + + 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 +1782,7 @@ function renderOpsToAnsi( fillCells( grid, viewport, - clipStack, + effectiveClip, 0, 0, Math.max(0, Math.trunc(op.cols)), @@ -1426,7 +1795,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 +1808,7 @@ function renderOpsToAnsi( drawTextToCells( grid, viewport, - clipStack, + effectiveClip, Math.trunc(op.x), Math.trunc(op.y), op.text, @@ -1454,28 +1823,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 +1878,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 +1896,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 +1904,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 +1946,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 +1959,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 +1968,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 +1985,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 +2001,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 +2060,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 +2081,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 +2094,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 +2124,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 +2180,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 +2196,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 +2269,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 +2301,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 +2330,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 +2669,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 +2699,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 +2723,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 +2743,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 +2846,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 +2855,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 +2872,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 +2928,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 +3054,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 +3087,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 +3158,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 +3365,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..4aa08666 100644 --- a/packages/ink-compat/src/translation/propsToVNode.ts +++ b/packages/ink-compat/src/translation/propsToVNode.ts @@ -1,4 +1,4 @@ -import { type Rgb24, type VNode, rgb, ui } from "@rezi-ui/core"; +import { type Rgb, type VNode, rgb, ui } from "@rezi-ui/core"; import type { InkHostContainer, InkHostNode } from "../reconciler/types.js"; import { mapBorderStyle } from "./borderMap.js"; @@ -12,8 +12,8 @@ interface TextSpan { } interface TextStyleMap { - fg?: Rgb24; - bg?: Rgb24; + fg?: Rgb; + bg?: Rgb; bold?: boolean; italic?: boolean; underline?: boolean; @@ -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; @@ -221,7 +295,7 @@ function readAccessibilityLabel(props: Record): string | undefi return undefined; } -const ANSI_16_PALETTE: readonly Rgb24[] = [ +const ANSI_16_PALETTE: readonly Rgb[] = [ rgb(0, 0, 0), rgb(205, 0, 0), rgb(0, 205, 0), @@ -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; } @@ -652,7 +787,7 @@ function translateBox(node: InkHostNode, context: TranslateContext): VNode | nul if (scrollY != null) layoutProps.scrollY = scrollY; const scrollbarThumbColor = parseColor(p.scrollbarThumbColor as string | undefined); - if (scrollbarThumbColor !== undefined) { + if (scrollbarThumbColor) { layoutProps.scrollbarStyle = { fg: scrollbarThumbColor }; } } else if (hasHiddenOverflow) { @@ -693,11 +828,11 @@ function translateBox(node: InkHostNode, context: TranslateContext): VNode | nul const style: Record = {}; const bg = parseColor(p.backgroundColor as string | undefined); - if (bg !== undefined) style["bg"] = bg; + if (bg) style["bg"] = bg; if (Object.keys(style).length > 0) layoutProps.style = style; const explicitBorderColor = parseColor(p.borderColor as string | undefined); - const edgeBorderColors: Record<"top" | "right" | "bottom" | "left", Rgb24 | undefined> = { + const edgeBorderColors: Record<"top" | "right" | "bottom" | "left", Rgb | undefined> = { top: parseColor(p.borderTopColor as string | undefined), right: parseColor(p.borderRightColor as string | undefined), bottom: parseColor(p.borderBottomColor as string | undefined), @@ -712,7 +847,7 @@ function translateBox(node: InkHostNode, context: TranslateContext): VNode | nul }; const borderColor = explicitBorderColor; - if (borderColor !== undefined) { + if (borderColor) { layoutProps.borderStyle = { ...(typeof layoutProps.borderStyle === "object" && layoutProps.borderStyle !== null ? layoutProps.borderStyle @@ -737,7 +872,7 @@ function translateBox(node: InkHostNode, context: TranslateContext): VNode | nul if (!hasColorOverride && !hasDimOverride) continue; const sideStyle: Record = {}; const resolvedColor = edgeBorderColors[side] ?? explicitBorderColor; - if (resolvedColor !== undefined) sideStyle["fg"] = resolvedColor; + if (resolvedColor) sideStyle["fg"] = resolvedColor; if (globalBorderDim || hasDimOverride) sideStyle["dim"] = true; if (Object.keys(sideStyle).length > 0) { borderStyleSides[side] = sideStyle; @@ -820,9 +955,9 @@ function translateText(node: InkHostNode): VNode { const style: TextStyleMap = {}; const fg = parseColor(p.color as string | undefined); - if (fg !== undefined) style.fg = fg; + if (fg) style.fg = fg; const bg = parseColor(p.backgroundColor as string | undefined); - if (bg !== undefined) style.bg = bg; + if (bg) style.bg = bg; if (p.bold) style.bold = true; if (p.italic) style.italic = true; if (p.underline) style.underline = true; @@ -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) childStyle.fg = fg; + const bg = parseColor(cp.backgroundColor as string | undefined); + if (bg) 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,24 @@ 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: Rgb | undefined, b: Rgb | 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; +} - 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 +1135,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 +1155,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 +1188,100 @@ 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) { + if (runStart < index) output.push(input.slice(runStart, index)); + output.push(input.slice(index, oscEnd)); + } + + 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[] { @@ -1312,7 +1529,7 @@ function resetSgrColor( delete activeStyle[channel]; } -function decodeAnsi256Color(index: number): Rgb24 { +function decodeAnsi256Color(index: number): Rgb { if (index < 16) return ANSI_16_PALETTE[index]!; if (index <= 231) { @@ -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 }, + })), + }; + }, +}; From b44f973226aa2294d366530b929a11a3d460e64c Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:42:58 +0400 Subject: [PATCH 07/29] fix(ink-compat): handle packed rgb and stabilize review regressions --- .../src/__tests__/integration/basic.test.ts | 15 ++- .../__tests__/perf/bottleneck-profile.test.ts | 23 ++-- .../translation/propsToVNode.test.ts | 29 ++--- packages/ink-compat/src/runtime/render.ts | 115 +++++++++--------- .../src/translation/propsToVNode.ts | 42 +++---- 5 files changed, 121 insertions(+), 103 deletions(-) diff --git a/packages/ink-compat/src/__tests__/integration/basic.test.ts b/packages/ink-compat/src/__tests__/integration/basic.test.ts index 26d96025..4d7eafc2 100644 --- a/packages/ink-compat/src/__tests__/integration/basic.test.ts +++ b/packages/ink-compat/src/__tests__/integration/basic.test.ts @@ -613,7 +613,11 @@ test("runtime render re-resolves percent sizing when parent layout changes (no f ); } - const instance = runtimeRender(React.createElement(App, { parentWidth: 20 }), { stdin, stdout, stderr }); + 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"); @@ -1262,7 +1266,7 @@ 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", () => { +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(); @@ -1286,6 +1290,13 @@ test("nested non-overlapping clips do not leak text", () => { ); 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 { diff --git a/packages/ink-compat/src/__tests__/perf/bottleneck-profile.test.ts b/packages/ink-compat/src/__tests__/perf/bottleneck-profile.test.ts index ff149162..e4da372d 100644 --- a/packages/ink-compat/src/__tests__/perf/bottleneck-profile.test.ts +++ b/packages/ink-compat/src/__tests__/perf/bottleneck-profile.test.ts @@ -119,17 +119,20 @@ function textStylesEqual_FIXED(a: TextStyleMap, b: TextStyleMap): boolean { 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 ( - 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) - ); + return true; } // ─── Bottleneck 3: Grid allocation ─── diff --git a/packages/ink-compat/src/__tests__/translation/propsToVNode.test.ts b/packages/ink-compat/src/__tests__/translation/propsToVNode.test.ts index fd9d64a5..0cacfab3 100644 --- a/packages/ink-compat/src/__tests__/translation/propsToVNode.test.ts +++ b/packages/ink-compat/src/__tests__/translation/propsToVNode.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { rgb } from "@rezi-ui/core"; import { type InkHostContainer, @@ -88,10 +89,10 @@ test("bordered box maps per-edge border styles", () => { const vnode = translateTree(containerWith(node)) as any; assert.equal(vnode.kind, "box"); - assert.deepEqual(vnode.props.borderStyleSides.top, { fg: { r: 205, g: 0, b: 0 }, dim: true }); - assert.deepEqual(vnode.props.borderStyleSides.right, { fg: { r: 0, g: 205, b: 0 } }); - assert.deepEqual(vnode.props.borderStyleSides.bottom, { fg: { r: 0, g: 0, b: 238 } }); - assert.deepEqual(vnode.props.borderStyleSides.left, { fg: { r: 205, g: 205, b: 0 }, dim: true }); + assert.deepEqual(vnode.props.borderStyleSides.top, { fg: rgb(205, 0, 0), dim: true }); + assert.deepEqual(vnode.props.borderStyleSides.right, { fg: rgb(0, 205, 0) }); + assert.deepEqual(vnode.props.borderStyleSides.bottom, { fg: rgb(0, 0, 238) }); + assert.deepEqual(vnode.props.borderStyleSides.left, { fg: rgb(205, 205, 0), dim: true }); }); test("bordered row box nests ui.row inside ui.box", () => { @@ -121,7 +122,7 @@ test("background-only box explicitly disables default borders", () => { assert.equal(vnode.kind, "box"); assert.equal(vnode.props.border, "none"); - assert.deepEqual(vnode.props.style, { bg: { r: 28, g: 28, b: 28 } }); + assert.deepEqual(vnode.props.style, { bg: rgb(28, 28, 28) }); }); test("background-only row box keeps row layout without implicit border", () => { @@ -143,7 +144,7 @@ test("styled text maps to text style", () => { assert.equal(vnode.kind, "text"); assert.equal(vnode.text, "Hello"); - assert.deepEqual(vnode.props.style, { fg: { r: 0, g: 205, b: 0 }, bold: true }); + assert.deepEqual(vnode.props.style, { fg: rgb(0, 205, 0), bold: true }); }); test("nested text produces richText spans", () => { @@ -192,7 +193,7 @@ test("ANSI SGR sequences map to richText styles", () => { assert.equal(vnode.kind, "richText"); assert.equal(vnode.props.spans.length, 3); assert.equal(vnode.props.spans[0]?.text, "Red"); - assert.deepEqual(vnode.props.spans[0]?.style?.fg, { r: 205, g: 0, b: 0 }); + assert.deepEqual(vnode.props.spans[0]?.style?.fg, rgb(205, 0, 0)); assert.equal(vnode.props.spans[1]?.text, " plain "); assert.equal("inverse" in (vnode.props.spans[1]?.style ?? {}), false); assert.equal(vnode.props.spans[2]?.text, "Inv"); @@ -208,11 +209,11 @@ test("ANSI reset restores parent style", () => { assert.equal(vnode.kind, "richText"); assert.equal(vnode.props.spans.length, 3); assert.equal(vnode.props.spans[0]?.text, "A"); - assert.deepEqual(vnode.props.spans[0]?.style?.fg, { r: 0, g: 205, b: 0 }); + assert.deepEqual(vnode.props.spans[0]?.style?.fg, rgb(0, 205, 0)); assert.equal(vnode.props.spans[1]?.text, "B"); - assert.deepEqual(vnode.props.spans[1]?.style?.fg, { r: 205, g: 0, b: 0 }); + assert.deepEqual(vnode.props.spans[1]?.style?.fg, rgb(205, 0, 0)); assert.equal(vnode.props.spans[2]?.text, "C"); - assert.deepEqual(vnode.props.spans[2]?.style?.fg, { r: 0, g: 205, b: 0 }); + assert.deepEqual(vnode.props.spans[2]?.style?.fg, rgb(0, 205, 0)); }); test("ANSI truecolor maps to RGB style", () => { @@ -224,7 +225,7 @@ test("ANSI truecolor maps to RGB style", () => { assert.equal(vnode.kind, "richText"); assert.equal(vnode.props.spans.length, 1); assert.equal(vnode.props.spans[0]?.text, "C"); - assert.deepEqual(vnode.props.spans[0]?.style?.fg, { r: 120, g: 80, b: 200 }); + assert.deepEqual(vnode.props.spans[0]?.style?.fg, rgb(120, 80, 200)); }); test("ANSI truecolor colon form maps to RGB style", () => { @@ -236,7 +237,7 @@ test("ANSI truecolor colon form maps to RGB style", () => { assert.equal(vnode.kind, "richText"); assert.equal(vnode.props.spans.length, 1); assert.equal(vnode.props.spans[0]?.text, "X"); - assert.deepEqual(vnode.props.spans[0]?.style?.fg, { r: 255, g: 120, b: 40 }); + assert.deepEqual(vnode.props.spans[0]?.style?.fg, rgb(255, 120, 40)); }); test("plain text without ANSI/control keeps single text vnode shape", () => { @@ -268,7 +269,7 @@ test("text containing ESC still sanitizes + parses ANSI SGR", () => { 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, { r: 205, g: 0, b: 0 }); + assert.deepEqual(vnode.props.spans[1]?.style?.fg, rgb(205, 0, 0)); assert.equal(vnode.props.spans[2]?.text, "Z"); }); @@ -532,7 +533,7 @@ test("scroll overflow maps scroll props and scrollbar style", () => { assert.equal(vnode.props.overflow, "scroll"); assert.equal(vnode.props.scrollX, 2); assert.equal(vnode.props.scrollY, 5); - assert.deepEqual(vnode.props.scrollbarStyle, { fg: { r: 18, g: 52, b: 86 } }); + assert.deepEqual(vnode.props.scrollbarStyle, { fg: rgb(18, 52, 86) }); }); test("hidden overflow stays hidden without scroll axis", () => { diff --git a/packages/ink-compat/src/runtime/render.ts b/packages/ink-compat/src/runtime/render.ts index aee7f48d..c5a08df1 100644 --- a/packages/ink-compat/src/runtime/render.ts +++ b/packages/ink-compat/src/runtime/render.ts @@ -2,11 +2,12 @@ import { appendFileSync } from "node:fs"; import type { Readable, Writable } from "node:stream"; import { format as formatConsoleMessage } from "node:util"; import { - type Rgb, + type Rgb24, type TextStyle, type VNode, createTestRenderer, measureTextCells, + rgb, } from "@rezi-ui/core"; import React from "react"; @@ -65,8 +66,8 @@ interface ClipRect { } interface CellStyle { - fg?: Rgb; - bg?: Rgb; + fg?: Rgb24; + bg?: Rgb24; bold?: boolean; dim?: boolean; italic?: boolean; @@ -156,8 +157,8 @@ interface RenderWritePayload { } const MAX_QUEUED_OUTPUTS = 4; -const CORE_DEFAULT_FG: Readonly = Object.freeze({ r: 232, g: 238, b: 245 }); -const CORE_DEFAULT_BG: Readonly = Object.freeze({ r: 7, g: 10, b: 12 }); +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 FILL_CELLS_SMALL_SPAN_THRESHOLD = 160; @@ -931,8 +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) entry["bg"] = `${cell.style.bg.r},${cell.style.bg.g},${cell.style.bg.b}`; - if (cell.style?.fg) entry["fg"] = `${cell.style.fg.r},${cell.style.fg.g},${cell.style.fg.b}`; + if (cell.style?.bg != null) { + entry["bg"] = `${rgbR(cell.style.bg)},${rgbG(cell.style.bg)},${rgbB(cell.style.bg)}`; + } + 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; @@ -1068,27 +1073,24 @@ function staticRootRevisionSignature(rootNode: InkHostContainer): string { return revisions.join(","); } -function isRgb(value: unknown): value is Rgb { - if (typeof value !== "object" || value === null) return false; - const r = (value as { r?: unknown }).r; - const g = (value as { g?: unknown }).g; - const b = (value as { b?: unknown }).b; - return ( - typeof r === "number" && - Number.isFinite(r) && - typeof g === "number" && - Number.isFinite(g) && - typeof b === "number" && - Number.isFinite(b) - ); +function isRgb24(value: unknown): value is Rgb24 { + return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 0xffffff; } -function clampByte(value: number): number { - return Math.max(0, Math.min(255, Math.round(value))); +function rgbR(value: Rgb24): number { + return (value >>> 16) & 0xff; +} + +function rgbG(value: Rgb24): number { + return (value >>> 8) & 0xff; } -function isSameRgb(a: Rgb, b: Readonly): boolean { - return a.r === b.r && a.g === b.g && a.b === b.b; +function rgbB(value: Rgb24): number { + return value & 0xff; +} + +function clampByte(value: number): number { + return Math.max(0, Math.min(255, Math.round(value))); } const ANSI16_PALETTE: readonly [number, number, number][] = [ @@ -1147,14 +1149,14 @@ function detectColorSupport(stdout: Writable): ColorSupport { return { level: 3, noColor: false }; } -function colorDistanceSq(a: Rgb, b: readonly [number, number, number]): number { - const dr = a.r - b[0]; - const dg = a.g - b[1]; - const db = a.b - b[2]; +function colorDistanceSq(a: Rgb24, b: readonly [number, number, number]): number { + const dr = rgbR(a) - b[0]; + const dg = rgbG(a) - b[1]; + const db = rgbB(a) - b[2]; return dr * dr + dg * dg + db * db; } -function toAnsi16Code(color: Rgb, background: boolean): number { +function toAnsi16Code(color: Rgb24, background: boolean): number { let bestIndex = 0; let bestDistance = Number.POSITIVE_INFINITY; for (let index = 0; index < ANSI16_PALETTE.length; index += 1) { @@ -1178,26 +1180,29 @@ function rgbChannelToCubeLevel(channel: number): number { return Math.min(5, Math.floor((channel - 35) / 40)); } -function toAnsi256Code(color: Rgb): number { - const rLevel = rgbChannelToCubeLevel(color.r); - const gLevel = rgbChannelToCubeLevel(color.g); - const bLevel = rgbChannelToCubeLevel(color.b); +function toAnsi256Code(color: Rgb24): number { + 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: Rgb = { - r: rLevel === 0 ? 0 : 55 + 40 * rLevel, - g: gLevel === 0 ? 0 : 55 + 40 * gLevel, - b: bLevel === 0 ? 0 : 55 + 40 * bLevel, - }; + 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((color.r + color.g + color.b) / 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: Rgb = { r: grayValue, g: grayValue, b: grayValue }; + const grayColor = rgb(grayValue, grayValue, grayValue); - const cubeDistance = colorDistanceSq(color, [cubeColor.r, cubeColor.g, cubeColor.b]); - const grayDistance = colorDistanceSq(color, [grayColor.r, grayColor.g, grayColor.b]); + 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; } @@ -1217,17 +1222,17 @@ function normalizeStyle(style: TextStyle | undefined): CellStyle | undefined { if (normalizeStyleCache.has(style)) return undefined; const normalized: CellStyle = {}; - if (isRgb(style.fg)) { - const fg = { r: clampByte(style.fg.r), g: clampByte(style.fg.g), b: clampByte(style.fg.b) }; + 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 (isRgb(style.bg)) { - const bg = { r: clampByte(style.bg.r), g: clampByte(style.bg.g), b: clampByte(style.bg.b) }; - 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; } } @@ -1252,10 +1257,8 @@ function normalizeStyle(style: TextStyle | undefined): CellStyle | undefined { return result; } -function rgbEqual(a: Rgb | undefined, b: Rgb | 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 rgbEqual(a: Rgb24 | undefined, b: Rgb24 | undefined): boolean { + return a === b; } function stylesEqual(a: CellStyle | undefined, b: CellStyle | undefined): boolean { @@ -1391,10 +1394,10 @@ 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;${clampByte(style.fg.r)};${clampByte(style.fg.g)};${clampByte(style.fg.b)}`, + `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)}`); @@ -1402,10 +1405,10 @@ function styleToSgr(style: CellStyle | undefined, colorSupport: ColorSupport): s codes.push(String(toAnsi16Code(style.fg, false))); } } - if (style.bg) { + if (style.bg != null) { if (colorSupport.level >= 3) { codes.push( - `48;2;${clampByte(style.bg.r)};${clampByte(style.bg.g)};${clampByte(style.bg.b)}`, + `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)}`); diff --git a/packages/ink-compat/src/translation/propsToVNode.ts b/packages/ink-compat/src/translation/propsToVNode.ts index 4aa08666..ed77e21d 100644 --- a/packages/ink-compat/src/translation/propsToVNode.ts +++ b/packages/ink-compat/src/translation/propsToVNode.ts @@ -1,4 +1,4 @@ -import { type Rgb, type VNode, rgb, ui } from "@rezi-ui/core"; +import { type Rgb24, type VNode, rgb, ui } from "@rezi-ui/core"; import type { InkHostContainer, InkHostNode } from "../reconciler/types.js"; import { mapBorderStyle } from "./borderMap.js"; @@ -12,8 +12,8 @@ interface TextSpan { } interface TextStyleMap { - fg?: Rgb; - bg?: Rgb; + fg?: Rgb24; + bg?: Rgb24; bold?: boolean; italic?: boolean; underline?: boolean; @@ -295,7 +295,7 @@ function readAccessibilityLabel(props: Record): string | undefi return undefined; } -const ANSI_16_PALETTE: readonly Rgb[] = [ +const ANSI_16_PALETTE: readonly Rgb24[] = [ rgb(0, 0, 0), rgb(205, 0, 0), rgb(0, 205, 0), @@ -787,7 +787,7 @@ function translateBox(node: InkHostNode, context: TranslateContext): VNode | nul if (scrollY != null) layoutProps.scrollY = scrollY; const scrollbarThumbColor = parseColor(p.scrollbarThumbColor as string | undefined); - if (scrollbarThumbColor) { + if (scrollbarThumbColor !== undefined) { layoutProps.scrollbarStyle = { fg: scrollbarThumbColor }; } } else if (hasHiddenOverflow) { @@ -828,11 +828,11 @@ function translateBox(node: InkHostNode, context: TranslateContext): VNode | nul const style: Record = {}; const bg = parseColor(p.backgroundColor as string | undefined); - if (bg) style["bg"] = bg; + if (bg !== undefined) style["bg"] = bg; if (Object.keys(style).length > 0) layoutProps.style = style; const explicitBorderColor = parseColor(p.borderColor as string | undefined); - const edgeBorderColors: Record<"top" | "right" | "bottom" | "left", Rgb | undefined> = { + const edgeBorderColors: Record<"top" | "right" | "bottom" | "left", Rgb24 | undefined> = { top: parseColor(p.borderTopColor as string | undefined), right: parseColor(p.borderRightColor as string | undefined), bottom: parseColor(p.borderBottomColor as string | undefined), @@ -847,7 +847,7 @@ function translateBox(node: InkHostNode, context: TranslateContext): VNode | nul }; const borderColor = explicitBorderColor; - if (borderColor) { + if (borderColor !== undefined) { layoutProps.borderStyle = { ...(typeof layoutProps.borderStyle === "object" && layoutProps.borderStyle !== null ? layoutProps.borderStyle @@ -872,7 +872,7 @@ function translateBox(node: InkHostNode, context: TranslateContext): VNode | nul if (!hasColorOverride && !hasDimOverride) continue; const sideStyle: Record = {}; const resolvedColor = edgeBorderColors[side] ?? explicitBorderColor; - if (resolvedColor) sideStyle["fg"] = resolvedColor; + if (resolvedColor !== undefined) sideStyle["fg"] = resolvedColor; if (globalBorderDim || hasDimOverride) sideStyle["dim"] = true; if (Object.keys(sideStyle).length > 0) { borderStyleSides[side] = sideStyle; @@ -955,9 +955,9 @@ function translateText(node: InkHostNode): VNode { const style: TextStyleMap = {}; const fg = parseColor(p.color as string | undefined); - if (fg) style.fg = fg; + if (fg !== undefined) style.fg = fg; const bg = parseColor(p.backgroundColor as string | undefined); - if (bg) style.bg = bg; + if (bg !== undefined) style.bg = bg; if (p.bold) style.bold = true; if (p.italic) style.italic = true; if (p.underline) style.underline = true; @@ -1070,9 +1070,9 @@ function flattenTextChildren( if (hasOverrides) { childStyle = { ...parentStyle }; const fg = parseColor(cp.color as string | undefined); - if (fg) childStyle.fg = fg; + if (fg !== undefined) childStyle.fg = fg; const bg = parseColor(cp.backgroundColor as string | undefined); - if (bg) childStyle.bg = bg; + if (bg !== undefined) childStyle.bg = bg; if (cp.bold) childStyle.bold = true; if (cp.italic) childStyle.italic = true; if (cp.underline) childStyle.underline = true; @@ -1107,10 +1107,8 @@ function flattenTextChildren( return { spans, isSingleSpan: allSameStyle, fullText }; } -function textRgbEqual(a: Rgb | undefined, b: Rgb | 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 textRgbEqual(a: Rgb24 | undefined, b: Rgb24 | undefined): boolean { + return a === b; } function stylesEqual(a: TextStyleMap, b: TextStyleMap): boolean { @@ -1241,9 +1239,11 @@ function sanitizeAnsiInput(input: string): string { break; } - if (output) { - if (runStart < index) output.push(input.slice(runStart, index)); - output.push(input.slice(index, oscEnd)); + if (!output) { + output = []; + if (index > 0) output.push(input.slice(0, index)); + } else if (runStart < index) { + output.push(input.slice(runStart, index)); } index = oscEnd; @@ -1529,7 +1529,7 @@ function resetSgrColor( delete activeStyle[channel]; } -function decodeAnsi256Color(index: number): Rgb { +function decodeAnsi256Color(index: number): Rgb24 { if (index < 16) return ANSI_16_PALETTE[index]!; if (index <= 231) { From fe4533e54386628850c9f24688091cb3103c8392 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:50:33 +0400 Subject: [PATCH 08/29] chore(lint): resolve biome violations in ci files --- packages/bench/src/io.ts | 5 ++--- packages/bench/src/reziProfile.ts | 1 - .../bench/src/scenarios/terminalVirtualList.ts | 8 +++++++- .../src/app/widgetRenderer/submitFramePipeline.ts | 5 ++--- .../src/renderer/__tests__/renderPackets.test.ts | 6 +----- .../renderer/renderToDrawlist/renderPackets.ts | 15 ++++++++------- scripts/bench-full-compare.mjs | 8 ++++---- 7 files changed, 24 insertions(+), 24 deletions(-) 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..7e4e33be 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,12 @@ 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 style = props.style; + const maxWidth = props.maxWidth; + const wrap = props.wrap; + const variant = props.variant; + const dim = props.dim; + const textOverflow = props.textOverflow; // Common case for plain text nodes with no explicit props. if ( 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(""); From 8257f3d666a0b4c51f449c0038247aa828f1d6e6 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:53:24 +0400 Subject: [PATCH 09/29] fix(core): satisfy strict index-signature access in hashTextProps --- .../renderToDrawlist/renderPackets.ts | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/core/src/renderer/renderToDrawlist/renderPackets.ts b/packages/core/src/renderer/renderToDrawlist/renderPackets.ts index 7e4e33be..ec9eb32f 100644 --- a/packages/core/src/renderer/renderToDrawlist/renderPackets.ts +++ b/packages/core/src/renderer/renderToDrawlist/renderPackets.ts @@ -251,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 ( From 2e4c8a4bd13e764806d85b386ff0a79adb4e5f71 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 19:06:46 +0400 Subject: [PATCH 10/29] docs(changelog): add missing merged PR entries through #227 --- CHANGELOG.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86bc9baf..2c9e2eb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,57 @@ The format is based on Keep a Changelog and the project follows Semantic Version ## [Unreleased] +## [0.1.0-alpha.48] - 2026-02-27 + +### Features + +- **ink-compat**: Improved Ink compatibility fidelity, diagnostics, and documentation coverage. +- **drawlist/backend**: Added builder `buildInto(dst)` and backend zero-copy `beginFrame` SAB path. +- **renderer/perf**: Shipped packed-style pipeline, frame text arena, retained sub-display-lists, BLIT_RECT plumbing, and logs scrolling optimizations. +- **runtime/perf**: Added layout stability signatures and content-keyed render packets with additional hot-path optimizations. + +### Bug Fixes + +- **release/publish**: Fixed npm publish flow for `@rezi-ui/ink-compat` and shim packages. +- **native**: Fixed MSVC `ZR_ARRAYLEN` compatibility and bumped vendored Zireael revisions. +- **node/backend**: Prevented reclaiming READY SAB slots during `beginFrame`. +- **starship/template**: Fixed rendering regressions and added PTY debugging runbook coverage. +- **ink-compat**: Fixed translation/layout hot paths and regression fallout from the optimization pass. + +### Developer Experience + +- **docs/dev**: Added code-standards enforcement references. +- **ci**: Optimized PR pipeline concurrency and fast-gate behavior. +- **renderer/refactor**: Replaced WeakMap theme propagation with stack-based propagation. +- **release**: Added release prep updates leading into alpha.40+ publishing flow. + +### Merged Pull Requests + +- [#201](https://github.com/RtlZeroMemory/Rezi/pull/201) docs(dev): add Rezi code standards and enforcement references +- [#202](https://github.com/RtlZeroMemory/Rezi/pull/202) chore(release): bump Zireael vendor and prepare alpha.40 +- [#203](https://github.com/RtlZeroMemory/Rezi/pull/203) feat(ink-compat): improve fidelity, diagnostics, and docs +- [#204](https://github.com/RtlZeroMemory/Rezi/pull/204) fix(release): publish ink-compat and shim packages +- [#205](https://github.com/RtlZeroMemory/Rezi/pull/205) fix(native): make ZR_ARRAYLEN MSVC-compatible +- [#206](https://github.com/RtlZeroMemory/Rezi/pull/206) fix(release): publish ink-compat by path +- [#207](https://github.com/RtlZeroMemory/Rezi/pull/207) docs: add comprehensive Ink-compat guide and README feature callout +- [#208](https://github.com/RtlZeroMemory/Rezi/pull/208) chore(release): publish scoped ink shim packages +- [#210](https://github.com/RtlZeroMemory/Rezi/pull/210) fix(native): bump Zireael vendor to v1.3.9 +- [#211](https://github.com/RtlZeroMemory/Rezi/pull/211) refactor(renderer): replace WeakMap theme propagation with stack +- [#212](https://github.com/RtlZeroMemory/Rezi/pull/212) feat(core): add drawlist builder buildInto(dst) for v2/v3 +- [#213](https://github.com/RtlZeroMemory/Rezi/pull/213) feat: add backend beginFrame zero-copy SAB frame path +- [#214](https://github.com/RtlZeroMemory/Rezi/pull/214) fix(node): do not reclaim READY SAB slots in beginFrame +- [#215](https://github.com/RtlZeroMemory/Rezi/pull/215) ci: optimize PR pipeline — concurrency, fast gate, reduced matrix +- [#216](https://github.com/RtlZeroMemory/Rezi/pull/216) drawlist: make v1 the only protocol and persistent builder +- [#217](https://github.com/RtlZeroMemory/Rezi/pull/217) EPIC 6: packed style pipeline + Zireael vendor bump +- [#218](https://github.com/RtlZeroMemory/Rezi/pull/218) EPIC 8: frame text arena + slice-referenced text ops +- [#219](https://github.com/RtlZeroMemory/Rezi/pull/219) chore(native): bump vendored Zireael to v1.3.11 +- [#220](https://github.com/RtlZeroMemory/Rezi/pull/220) EPIC 7: retained sub-display-lists via per-instance render packets +- [#221](https://github.com/RtlZeroMemory/Rezi/pull/221) EPIC 9B: plumb BLIT_RECT and optimize logs scroll rendering +- [#223](https://github.com/RtlZeroMemory/Rezi/pull/223) Fix post-refactor regressions and bump native vendor to Zireael #103 +- [#225](https://github.com/RtlZeroMemory/Rezi/pull/225) Fix starship rendering regressions with clean diff and PTY debug runbook +- [#226](https://github.com/RtlZeroMemory/Rezi/pull/226) perf: layout stability signatures, content-keyed packets, hot-path fixes +- [#227](https://github.com/RtlZeroMemory/Rezi/pull/227) fix(ink-compat): optimize translation and layout hot paths + ## [0.1.0-alpha.40] - 2026-02-25 ### Bug Fixes From 13d93b57955c9b2f42d2da096a78313ad00363ee Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 19:26:51 +0400 Subject: [PATCH 11/29] feat(bench): add ink-compat benchmark harness --- .gitignore | 5 + package-lock.json | 409 ++++++++--- package.json | 13 +- packages/bench-app/package.json | 24 + packages/bench-app/src/entry.tsx | 685 ++++++++++++++++++ packages/bench-app/src/types/ink-module.d.ts | 4 + packages/bench-app/tsconfig.json | 12 + packages/bench-harness/package.json | 23 + packages/bench-harness/src/index.ts | 9 + packages/bench-harness/src/procSampler.ts | 62 ++ packages/bench-harness/src/ptyRun.ts | 135 ++++ packages/bench-harness/src/screen.ts | 56 ++ packages/bench-harness/src/screenDiff.ts | 28 + packages/bench-harness/tsconfig.json | 11 + packages/bench-runner/package.json | 16 + packages/bench-runner/src/cli.ts | 338 +++++++++ packages/bench-runner/src/verify.ts | 203 ++++++ packages/bench-runner/tsconfig.json | 11 + .../ink-compat-bench/prepare-ink-compat.mjs | 18 + scripts/ink-compat-bench/prepare-real-ink.mjs | 18 + 20 files changed, 1982 insertions(+), 98 deletions(-) create mode 100644 packages/bench-app/package.json create mode 100644 packages/bench-app/src/entry.tsx create mode 100644 packages/bench-app/src/types/ink-module.d.ts create mode 100644 packages/bench-app/tsconfig.json create mode 100644 packages/bench-harness/package.json create mode 100644 packages/bench-harness/src/index.ts create mode 100644 packages/bench-harness/src/procSampler.ts create mode 100644 packages/bench-harness/src/ptyRun.ts create mode 100644 packages/bench-harness/src/screen.ts create mode 100644 packages/bench-harness/src/screenDiff.ts create mode 100644 packages/bench-harness/tsconfig.json create mode 100644 packages/bench-runner/package.json create mode 100644 packages/bench-runner/src/cli.ts create mode 100644 packages/bench-runner/src/verify.ts create mode 100644 packages/bench-runner/tsconfig.json create mode 100644 scripts/ink-compat-bench/prepare-ink-compat.mjs create mode 100644 scripts/ink-compat-bench/prepare-real-ink.mjs diff --git a/.gitignore b/.gitignore index c8b4e1f8..6e0d7a92 100644 --- a/.gitignore +++ b/.gitignore @@ -182,3 +182,8 @@ CODEX_*.md .last_perf_pack_quick *.cpuprofile packages/bench/bubbletea-bench/.bin/ + +# Ink compat benchmark artifacts (runner output) +results/ink-bench_*/ +results/verify_*/ +results/verify_*.json diff --git a/package-lock.json b/package-lock.json index 4d9f81a9..0a49b8bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,9 @@ "packages/native", "packages/jsx", "packages/ink-compat", + "packages/bench-app", + "packages/bench-harness", + "packages/bench-runner", "packages/bench", "examples/*" ], @@ -59,6 +62,19 @@ "@rezi-ui/node": "0.1.0-alpha.0" } }, + "node_modules/@alcalzone/ansi-tokenize": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.5.tgz", + "integrity": "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@biomejs/biome": { "version": "1.9.4", "dev": true, @@ -1115,6 +1131,53 @@ "node": ">=18" } }, + "node_modules/@jrichman/ink": { + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz", + "integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==", + "license": "MIT", + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.2.1", + "ansi-escapes": "^7.0.0", + "ansi-styles": "^6.2.1", + "auto-bind": "^5.0.1", + "chalk": "^5.6.0", + "cli-boxes": "^3.0.0", + "cli-cursor": "^4.0.0", + "cli-truncate": "^4.0.0", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.39.10", + "indent-string": "^5.0.0", + "is-in-ci": "^2.0.0", + "mnemonist": "^0.40.3", + "patch-console": "^2.0.0", + "react-reconciler": "^0.32.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^7.1.0", + "stack-utils": "^2.0.6", + "string-width": "^8.1.0", + "type-fest": "^4.27.0", + "wrap-ansi": "^9.0.0", + "ws": "^8.18.0", + "yoga-layout": "~3.2.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@types/react": ">=19.0.0", + "react": ">=19.0.0", + "react-devtools-core": "^6.1.2" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, "node_modules/@napi-rs/cli": { "version": "2.18.4", "dev": true, @@ -1142,6 +1205,18 @@ "resolved": "packages/ink-compat", "link": true }, + "node_modules/@rezi-ui/ink-compat-bench-app": { + "resolved": "packages/bench-app", + "link": true + }, + "node_modules/@rezi-ui/ink-compat-bench-harness": { + "resolved": "packages/bench-harness", + "link": true + }, + "node_modules/@rezi-ui/ink-compat-bench-runner": { + "resolved": "packages/bench-runner", + "link": true + }, "node_modules/@rezi-ui/jsx": { "resolved": "packages/jsx", "link": true @@ -1269,7 +1344,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz", "integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==", - "dev": true, "license": "MIT", "workspaces": [ "addons/*" @@ -1287,6 +1361,21 @@ "node": ">=6.5" } }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -1536,6 +1625,67 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/code-excerpt": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", @@ -1586,6 +1736,12 @@ "node": ">=0.3.1" } }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -1829,6 +1985,21 @@ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", "license": "MIT" }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-in-ci": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz", @@ -1972,6 +2143,15 @@ "node": ">=6" } }, + "node_modules/mnemonist": { + "version": "0.40.3", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz", + "integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.4" + } + }, "node_modules/ndarray": { "version": "1.0.19", "resolved": "https://registry.npmjs.org/ndarray/-/ndarray-1.0.19.tgz", @@ -2005,7 +2185,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "dev": true, "license": "MIT" }, "node_modules/node-bitmap": { @@ -2020,13 +2199,18 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", - "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { "node-addon-api": "^7.1.0" } }, + "node_modules/obliterator": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "license": "MIT" + }, "node_modules/omggif": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz", @@ -2322,6 +2506,22 @@ "node": ">=20.12.2" } }, + "node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -2363,6 +2563,22 @@ "node": ">=14.15.0" } }, + "node_modules/string-width": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", + "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", @@ -2497,6 +2713,18 @@ "fsevents": "~2.3.3" } }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typedoc": { "version": "0.28.16", "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.16.tgz", @@ -2589,6 +2817,40 @@ } } }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ws": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", @@ -2691,17 +2953,58 @@ "node": ">=18" } }, - "packages/bench/node_modules/@alcalzone/ansi-tokenize": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.5.tgz", - "integrity": "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==", + "packages/bench-app": { + "name": "@rezi-ui/ink-compat-bench-app", + "version": "0.0.0", + "license": "Apache-2.0", + "dependencies": { + "@jrichman/ink": "^6.4.10", + "@rezi-ui/ink-compat": "0.1.0-alpha.34", + "react": "^19.0.0", + "react-reconciler": "^0.31.0" + }, + "devDependencies": { + "@types/node": "^22.13.1", + "@types/react": "^19.0.0", + "typescript": "^5.6.3" + } + }, + "packages/bench-app/node_modules/react-reconciler": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.31.0.tgz", + "integrity": "sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ==", "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" + "scheduler": "^0.25.0" }, "engines": { - "node": ">=18" + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "packages/bench-app/node_modules/scheduler": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", + "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", + "license": "MIT" + }, + "packages/bench-harness": { + "name": "@rezi-ui/ink-compat-bench-harness", + "version": "0.0.0", + "license": "Apache-2.0", + "dependencies": { + "@xterm/headless": "^6.0.0", + "node-pty": "^1.0.0" + } + }, + "packages/bench-runner": { + "name": "@rezi-ui/ink-compat-bench-runner", + "version": "0.0.0", + "license": "Apache-2.0", + "dependencies": { + "@rezi-ui/ink-compat-bench-harness": "0.0.0" } }, "packages/bench/node_modules/@opentui/core": { @@ -2931,21 +3234,6 @@ "win32" ] }, - "packages/bench/node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "packages/bench/node_modules/bun-webgpu": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/bun-webgpu/-/bun-webgpu-0.1.5.tgz", @@ -2994,12 +3282,6 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "packages/bench/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "license": "MIT" - }, "packages/bench/node_modules/ink": { "version": "6.8.0", "resolved": "https://registry.npmjs.org/ink/-/ink-6.8.0.tgz", @@ -3070,21 +3352,6 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, - "packages/bench/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "packages/bench/node_modules/react-devtools-core": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-7.0.1.tgz", @@ -3134,22 +3401,6 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "packages/bench/node_modules/string-width": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", - "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "packages/bench/node_modules/type-fest": { "version": "5.4.4", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.4.tgz", @@ -3180,40 +3431,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "packages/bench/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "packages/bench/node_modules/wrap-ansi/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "packages/core": { "name": "@rezi-ui/core", "version": "0.1.0-alpha.34", diff --git a/package.json b/package.json index 3ad53aa2..d09be1dd 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,9 @@ "packages/native", "packages/jsx", "packages/ink-compat", + "packages/bench-app", + "packages/bench-harness", + "packages/bench-runner", "packages/bench", "examples/*" ], @@ -43,8 +46,14 @@ "hsr:record:router": "node scripts/record-hsr-gif.mjs --mode router", "hsr:record:widget:auto": "node scripts/record-hsr-gif.mjs --mode widget --scripted", "hsr:record:router:auto": "node scripts/record-hsr-gif.mjs --mode router --scripted", - "bench": "node --expose-gc packages/bench/dist/run.js", - "bench:report": "node --expose-gc packages/bench/dist/run.js --markdown", + "prebench": "npm -w @rezi-ui/ink-compat-bench-harness run build && npm -w @rezi-ui/ink-compat-bench-runner run build && npm -w @rezi-ui/ink-compat-bench-app run build && npm -w @rezi-ui/ink-compat run build", + "preverify": "npm run prebench", + "prepare:real-ink": "node scripts/ink-compat-bench/prepare-real-ink.mjs", + "prepare:ink-compat": "node scripts/ink-compat-bench/prepare-ink-compat.mjs", + "verify": "node packages/bench-runner/dist/verify.js", + "bench": "node --expose-gc packages/bench-runner/dist/cli.js", + "bench:rezi": "node --expose-gc packages/bench/dist/run.js", + "bench:rezi:report": "node --expose-gc packages/bench/dist/run.js --markdown", "bench:ci": "node scripts/run-bench-ci.mjs", "bench:ci:compare": "node scripts/bench-ci-compare.mjs", "bench:full:compare": "node scripts/bench-full-compare.mjs", diff --git a/packages/bench-app/package.json b/packages/bench-app/package.json new file mode 100644 index 00000000..e401cfff --- /dev/null +++ b/packages/bench-app/package.json @@ -0,0 +1,24 @@ +{ + "name": "@rezi-ui/ink-compat-bench-app", + "version": "0.0.0", + "private": true, + "type": "module", + "license": "Apache-2.0", + "main": "./dist/entry.js", + "types": "./dist/entry.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json --pretty false", + "typecheck": "tsc -p tsconfig.json --pretty false --noEmit" + }, + "dependencies": { + "@jrichman/ink": "^6.4.10", + "@rezi-ui/ink-compat": "0.1.0-alpha.34", + "react": "^19.0.0", + "react-reconciler": "^0.31.0" + }, + "devDependencies": { + "@types/node": "^22.13.1", + "@types/react": "^19.0.0", + "typescript": "^5.6.3" + } +} diff --git a/packages/bench-app/src/entry.tsx b/packages/bench-app/src/entry.tsx new file mode 100644 index 00000000..b08a5322 --- /dev/null +++ b/packages/bench-app/src/entry.tsx @@ -0,0 +1,685 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import net from "node:net"; +import { createRequire } from "node:module"; +import { performance } from "node:perf_hooks"; + +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Box, Text, render, useApp, useInput } from "ink"; + +type RendererName = "real-ink" | "ink-compat"; +type ScenarioName = + | "streaming-chat" + | "large-list-scroll" + | "dashboard-grid" + | "style-churn" + | "resize-storm"; + +type ControlMsg = + | Readonly<{ type: "init"; seed: number }> + | Readonly<{ type: "tick"; n?: number }> + | Readonly<{ type: "token"; text: string }> + | Readonly<{ type: "done" }>; + +type StdoutDelta = Readonly<{ writeMs: number; bytes: number; writes: number }>; + +declare global { + // eslint-disable-next-line no-var + var __INK_COMPAT_BENCH_ON_FRAME: undefined | ((m: unknown) => void); +} + +type InkCompatFrameBreakdown = Readonly<{ + translationMs: number; + percentResolveMs: number; + coreRenderMs: number; + assignLayoutsMs: number; + rectScanMs: number; + ansiMs: number; + nodes: number; + ops: number; + coreRenderPasses: number; +}>; + +type FrameMetric = Readonly<{ + frame: number; + tsMs: number; + renderTotalMs: number; + scheduleWaitMs: number | null; + stdoutWriteMs: number; + stdoutBytes: number; + stdoutWrites: number; + updatesRequestedDelta: number; + translationMs: number | null; + percentResolveMs: number | null; + coreRenderMs: number | null; + assignLayoutsMs: number | null; + rectScanMs: number | null; + ansiMs: number | null; + nodes: number | null; + ops: number | null; + coreRenderPasses: number | null; +}>; + +function resolveInkImpl(): { resolvedFrom: string; name: string; version: string } { + const req = createRequire(import.meta.url); + const inkEntryPath = req.resolve("ink"); + + let pkgPath: string | null = null; + let dir = path.dirname(inkEntryPath); + for (let i = 0; i < 25; i += 1) { + const candidate = path.join(dir, "package.json"); + if (existsSync(candidate)) { + pkgPath = candidate; + break; + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + + const pkg = + pkgPath === null + ? null + : (JSON.parse(readFileSync(pkgPath, "utf8")) as { name?: unknown; version?: unknown }); + return { + resolvedFrom: pkgPath ?? inkEntryPath, + name: typeof pkg?.name === "string" ? pkg.name : "unknown", + version: typeof pkg?.version === "string" ? pkg.version : "unknown", + }; +} + +function createStdoutWriteProbe(): { + install: () => void; + readAndReset: () => StdoutDelta; +} { + let writeMs = 0; + let bytes = 0; + let writes = 0; + + const original = process.stdout.write.bind(process.stdout); + + const install = (): void => { + (process.stdout as unknown as { write: typeof process.stdout.write }).write = (( + chunk: unknown, + encoding?: unknown, + cb?: unknown, + ): boolean => { + const start = performance.now(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const ret = (original as any)(chunk, encoding, cb) as boolean; + const end = performance.now(); + writeMs += end - start; + writes += 1; + if (typeof chunk === "string") bytes += Buffer.byteLength(chunk, "utf8"); + else if (chunk instanceof Uint8Array) bytes += chunk.byteLength; + return ret; + }) as typeof process.stdout.write; + }; + + const readAndReset = (): StdoutDelta => { + const d = { writeMs, bytes, writes }; + writeMs = 0; + bytes = 0; + writes = 0; + return d; + }; + + return { install, readAndReset }; +} + +function useControlSocket( + socketPath: string | undefined, + onMsg: (msg: ControlMsg) => void, +): void { + useEffect(() => { + if (!socketPath) return; + let buf = ""; + const client = net.createConnection(socketPath); + client.setEncoding("utf8"); + client.on("data", (chunk) => { + buf += chunk; + while (true) { + const idx = buf.indexOf("\n"); + if (idx === -1) break; + let line = buf.slice(0, idx); + buf = buf.slice(idx + 1); + if (line.endsWith("\r")) line = line.slice(0, -1); + if (!line) continue; + try { + const parsed = JSON.parse(line) as unknown; + if (parsed && typeof parsed === "object" && "type" in (parsed as any)) { + onMsg(parsed as ControlMsg); + } + } catch { + // ignore + } + } + }); + return () => { + client.destroy(); + }; + }, [socketPath, onMsg]); +} + +type ScenarioState = Readonly<{ + seed: number; + updatesRequested: number; + firstUpdateRequestedAtMs: number | null; +}>; + +type ScenarioController = Readonly<{ onMsg: (msg: ControlMsg) => void }>; + +function markUpdateRequested(stateRef: React.MutableRefObject): void { + const cur = stateRef.current; + stateRef.current = { + ...cur, + updatesRequested: cur.updatesRequested + 1, + firstUpdateRequestedAtMs: cur.firstUpdateRequestedAtMs ?? performance.now(), + }; +} + +type InlineSegment = Readonly<{ + text: string; + kind: "plain" | "bold" | "code"; +}>; + +function parseInlineMarkup(text: string): readonly InlineSegment[] { + const segments: InlineSegment[] = []; + let i = 0; + let kind: InlineSegment["kind"] = "plain"; + let buf = ""; + + const flush = (): void => { + if (!buf) return; + segments.push({ text: buf, kind }); + buf = ""; + }; + + while (i < text.length) { + const ch = text[i] ?? ""; + const next = text[i + 1] ?? ""; + if (ch === "*" && next === "*") { + flush(); + kind = kind === "bold" ? "plain" : "bold"; + i += 2; + continue; + } + if (ch === "`") { + flush(); + kind = kind === "code" ? "plain" : "code"; + i += 1; + continue; + } + buf += ch; + i += 1; + } + + flush(); + return segments; +} + +function StreamingChatScenario(props: { + stateRef: React.MutableRefObject; + setController: (c: ScenarioController) => void; +}): React.ReactElement { + const [lines, setLines] = useState([""]); + const [tokenCount, setTokenCount] = useState(0); + const [scrollLock, setScrollLock] = useState(true); + const tokenRef = useRef(0); + + useInput((input) => { + if (input === "s") setScrollLock((v) => !v); + }); + + useEffect(() => { + props.setController({ + onMsg: (msg) => { + if (msg.type !== "token") return; + tokenRef.current += 1; + setTokenCount(tokenRef.current); + setLines((prev) => { + const next = prev.length > 800 ? prev.slice(prev.length - 800) : prev.slice(); + if (next.length === 0) next.push(""); + const lastIndex = next.length - 1; + const last = next[lastIndex] ?? ""; + next[lastIndex] = last.length === 0 ? msg.text : `${last} ${msg.text}`; + + // Insert deterministic "code blocks" periodically. + const t = tokenRef.current; + if (t % 53 === 0) { + next.push("```js"); + next.push("const x = 1 + 2; // codeblock"); + next.push("console.log(x)"); + next.push("```"); + next.push(""); + } else if (t % 12 === 0) { + next.push(""); + } + return next; + }); + markUpdateRequested(props.stateRef); + }, + }); + }, [props]); + + const visible = scrollLock ? lines.slice(-12) : lines.slice(0, 12); + return ( + + BENCH_READY streaming-chat tokens={tokenCount} scrollLock={String(scrollLock)} + + {visible.map((line, i) => { + if (line.startsWith("```")) { + return ( + + {line} + + ); + } + const segments = parseInlineMarkup(line); + return ( + + {segments.map((seg, j) => { + if (seg.kind === "plain") return seg.text; + if (seg.kind === "bold") { + return ( + + {seg.text} + + ); + } + return ( + + {seg.text} + + ); + })} + + ); + })} + + Keys: s=toggle-scroll-lock + + ); +} + +function LargeListScrollScenario(props: { + stateRef: React.MutableRefObject; + setController: (c: ScenarioController) => void; +}): React.ReactElement { + const rowCount = 10_000; + const viewportRows = 16; + const [scroll, setScroll] = useState(0); + const [tick, setTick] = useState(0); + + useInput((_input, key) => { + if (key.downArrow) { + setScroll((s) => Math.min(rowCount - viewportRows, s + 1)); + markUpdateRequested(props.stateRef); + } + if (key.upArrow) { + setScroll((s) => Math.max(0, s - 1)); + markUpdateRequested(props.stateRef); + } + }); + + useEffect(() => { + props.setController({ + onMsg: (msg) => { + if (msg.type !== "tick") return; + setTick((t) => t + (msg.n ?? 1)); + markUpdateRequested(props.stateRef); + }, + }); + }, [props]); + + const rows: React.ReactElement[] = []; + for (let i = 0; i < viewportRows; i++) { + const idx = scroll + i; + const hot = (idx + tick) % 97 === 0; + rows.push( + + {String(idx).padStart(5, "0")} row={idx} tick={tick} {hot ? "[hot]" : ""} + , + ); + } + + return ( + + BENCH_READY large-list-scroll scroll={scroll} tick={tick} + {rows} + Keys: ↑/↓ (scripted) to scroll + + ); +} + +function DashboardGridScenario(props: { + stateRef: React.MutableRefObject; + setController: (c: ScenarioController) => void; +}): React.ReactElement { + const [tick, setTick] = useState(0); + + useEffect(() => { + props.setController({ + onMsg: (msg) => { + if (msg.type !== "tick") return; + setTick((t) => t + (msg.n ?? 1)); + markUpdateRequested(props.stateRef); + }, + }); + }, [props]); + + const bar = (n: number): string => { + const pct = n % 100; + const filled = Math.round((pct / 100) * 20); + return `${"█".repeat(filled)}${"░".repeat(20 - filled)} ${String(pct).padStart(3, " ")}%`; + }; + + return ( + + BENCH_READY dashboard-grid tick={tick} + + + CPU + {bar(tick * 3)} + threads: 8 + + + MEM + {bar(tick * 5)} + rss: sampled + + + NET + {bar(tick * 7)} + offline + + + + + Queue + {bar(tick * 11)} + stable: true + + + Workers + {bar(tick * 13)} + ok: 16 + + + + ); +} + +function StyleChurnScenario(props: { + stateRef: React.MutableRefObject; + setController: (c: ScenarioController) => void; +}): React.ReactElement { + const [tick, setTick] = useState(0); + const palette = ["red", "green", "yellow", "blue", "magenta", "cyan", "white"] as const; + + useEffect(() => { + props.setController({ + onMsg: (msg) => { + if (msg.type !== "tick") return; + setTick((t) => t + (msg.n ?? 1)); + markUpdateRequested(props.stateRef); + }, + }); + }, [props]); + + const lines: React.ReactElement[] = []; + for (let i = 0; i < 18; i++) { + const fg = palette[(tick + i) % palette.length] ?? "white"; + const bg = palette[(tick * 3 + i) % palette.length] ?? "black"; + const bold = (tick + i) % 3 === 0; + const italic = (tick + i) % 5 === 0; + const underline = (tick + i) % 7 === 0; + lines.push( + + line {String(i).padStart(2, "0")} fg={fg} bg={bg} bold={String(bold)} italic= + {String(italic)} underline={String(underline)} tick={tick} + , + ); + } + + return ( + + BENCH_READY style-churn tick={tick} + {lines} + + ); +} + +function ResizeStormScenario(props: { + stateRef: React.MutableRefObject; + setController: (c: ScenarioController) => void; +}): React.ReactElement { + const [tick, setTick] = useState(0); + const [resizesSeen, setResizesSeen] = useState(0); + + useEffect(() => { + props.setController({ + onMsg: (msg) => { + if (msg.type !== "tick") return; + setTick((t) => t + (msg.n ?? 1)); + setResizesSeen((c) => c + 1); + markUpdateRequested(props.stateRef); + }, + }); + }, [props]); + + return ( + + BENCH_READY resize-storm tick={tick} resizes={resizesSeen} + + Viewport is driven by PTY resizes (runner). + tick={tick} + + + ); +} + +type StdoutWriteProbe = ReturnType; + +function BenchApp(props: { + scenario: ScenarioName; + renderer: RendererName; + outDir: string; + controlSocketPath: string | undefined; + stdoutProbe: StdoutWriteProbe; +}): React.ReactElement { + const { exit } = useApp(); + const stdoutProbe = props.stdoutProbe; + const framesRef = useRef([]); + const startAt = useMemo(() => performance.now(), []); + const lastUpdatesRequestedRef = useRef(0); + + const stateRef = useRef({ + seed: 1, + updatesRequested: 0, + firstUpdateRequestedAtMs: null, + }); + + const compatFrameRef = useRef(null); + useEffect(() => { + if (process.env["BENCH_INK_COMPAT_PHASES"] === "1") { + globalThis.__INK_COMPAT_BENCH_ON_FRAME = (m) => { + compatFrameRef.current = m as InkCompatFrameBreakdown; + }; + } + return () => { + globalThis.__INK_COMPAT_BENCH_ON_FRAME = undefined; + }; + }, []); + + const pendingMsgsRef = useRef([]); + const controllerRef = useRef({ + onMsg: (msg) => { + pendingMsgsRef.current.push(msg); + }, + }); + const setController = (c: ScenarioController): void => { + controllerRef.current = c; + const pending = pendingMsgsRef.current; + if (pending.length > 0) { + pendingMsgsRef.current = []; + for (const msg of pending) c.onMsg(msg); + } + }; + + const [doneSeq, setDoneSeq] = useState(0); + + const onMsg = useMemo( + () => + (msg: ControlMsg): void => { + if (msg.type === "init") { + stateRef.current = { ...stateRef.current, seed: msg.seed }; + return; + } + if (msg.type === "done") { + setDoneSeq((s) => s + 1); + return; + } + controllerRef.current.onMsg(msg); + }, + [], + ); + + useControlSocket(props.controlSocketPath, onMsg); + + useEffect(() => { + if (doneSeq <= 0) return; + const ms = + Number.parseInt(process.env["BENCH_EXIT_AFTER_DONE_MS"] ?? "300", 10) || + 300; + const t = setTimeout(() => exit(), Math.max(0, ms)); + return () => clearTimeout(t); + }, [doneSeq, exit]); + + useEffect(() => { + const ms = Number.parseInt(process.env["BENCH_TIMEOUT_MS"] ?? "15000", 10) || 15000; + const t = setTimeout(() => exit(new Error(`bench timeout ${ms}ms`)), ms); + return () => clearTimeout(t); + }, [exit]); + + (globalThis as unknown as { __BENCH_ON_RENDER?: (renderTimeMs: number) => void }).__BENCH_ON_RENDER = + (renderTimeMs: number): void => { + const now = performance.now(); + const tsMs = now - startAt; + + const state = stateRef.current; + const updatesDelta = state.updatesRequested - lastUpdatesRequestedRef.current; + lastUpdatesRequestedRef.current = state.updatesRequested; + + const stdout = stdoutProbe.readAndReset(); + + let scheduleWaitMs: number | null = null; + if (state.firstUpdateRequestedAtMs != null) { + const frameStartApprox = now - renderTimeMs; + scheduleWaitMs = Math.max(0, frameStartApprox - state.firstUpdateRequestedAtMs); + stateRef.current = { ...stateRef.current, firstUpdateRequestedAtMs: null }; + } + + const compat = compatFrameRef.current; + compatFrameRef.current = null; + + framesRef.current.push({ + frame: framesRef.current.length + 1, + tsMs, + renderTotalMs: renderTimeMs, + scheduleWaitMs, + stdoutWriteMs: stdout.writeMs, + stdoutBytes: stdout.bytes, + stdoutWrites: stdout.writes, + updatesRequestedDelta: updatesDelta, + translationMs: compat?.translationMs ?? null, + percentResolveMs: compat?.percentResolveMs ?? null, + coreRenderMs: compat?.coreRenderMs ?? null, + assignLayoutsMs: compat?.assignLayoutsMs ?? null, + rectScanMs: compat?.rectScanMs ?? null, + ansiMs: compat?.ansiMs ?? null, + nodes: compat?.nodes ?? null, + ops: compat?.ops ?? null, + coreRenderPasses: compat?.coreRenderPasses ?? null, + }); + }; + + useEffect(() => { + return () => { + delete (globalThis as any).__BENCH_ON_RENDER; + }; + }, []); + + useEffect(() => { + return () => { + mkdirSync(props.outDir, { recursive: true }); + writeFileSync( + path.join(props.outDir, "frames.jsonl"), + `${framesRef.current.map((x) => JSON.stringify(x)).join("\n")}\n`, + ); + }; + }, [props.outDir]); + + if (props.scenario === "streaming-chat") { + return ; + } + if (props.scenario === "large-list-scroll") { + return ; + } + if (props.scenario === "dashboard-grid") { + return ; + } + if (props.scenario === "style-churn") { + return ; + } + return ; +} + +function main(): void { + const scenario = (process.env["BENCH_SCENARIO"] as ScenarioName | undefined) ?? "streaming-chat"; + const renderer = (process.env["BENCH_RENDERER"] as RendererName | undefined) ?? "real-ink"; + const outDir = process.env["BENCH_OUT_DIR"] ?? "results/tmp"; + const cols = Number.parseInt(process.env["BENCH_COLS"] ?? "80", 10) || 80; + const rows = Number.parseInt(process.env["BENCH_ROWS"] ?? "24", 10) || 24; + const controlSocketPath = process.env["BENCH_CONTROL_SOCKET"]; + + const inkImpl = resolveInkImpl(); + mkdirSync(outDir, { recursive: true }); + writeFileSync( + path.join(outDir, "run-meta.json"), + JSON.stringify({ scenario, renderer, cols, rows, inkImpl, node: process.version }, null, 2), + ); + + const stdoutProbe = createStdoutWriteProbe(); + stdoutProbe.install(); + + render( + , + { + alternateBuffer: false, + incrementalRendering: true, + maxFps: Number.parseInt(process.env["BENCH_MAX_FPS"] ?? "60", 10) || 60, + patchConsole: false, + debug: false, + onRender: ({ renderTime }) => { + const hook = (globalThis as unknown as { __BENCH_ON_RENDER?: (rt: number) => void }) + .__BENCH_ON_RENDER; + hook?.(renderTime); + }, + }, + ); +} + +main(); diff --git a/packages/bench-app/src/types/ink-module.d.ts b/packages/bench-app/src/types/ink-module.d.ts new file mode 100644 index 00000000..56f04562 --- /dev/null +++ b/packages/bench-app/src/types/ink-module.d.ts @@ -0,0 +1,4 @@ +declare module "ink" { + export * from "@jrichman/ink"; +} + diff --git a/packages/bench-app/tsconfig.json b/packages/bench-app/tsconfig.json new file mode 100644 index 00000000..36e53478 --- /dev/null +++ b/packages/bench-app/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": "./src", + "outDir": "./dist", + "lib": ["ES2022"], + "jsx": "react-jsx" + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"] +} + diff --git a/packages/bench-harness/package.json b/packages/bench-harness/package.json new file mode 100644 index 00000000..6a0d1260 --- /dev/null +++ b/packages/bench-harness/package.json @@ -0,0 +1,23 @@ +{ + "name": "@rezi-ui/ink-compat-bench-harness", + "version": "0.0.0", + "private": true, + "type": "module", + "license": "Apache-2.0", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json --pretty false", + "typecheck": "tsc -p tsconfig.json --pretty false --noEmit" + }, + "dependencies": { + "@xterm/headless": "^6.0.0", + "node-pty": "^1.0.0" + } +} diff --git a/packages/bench-harness/src/index.ts b/packages/bench-harness/src/index.ts new file mode 100644 index 00000000..eb429f4b --- /dev/null +++ b/packages/bench-harness/src/index.ts @@ -0,0 +1,9 @@ +export type { PtyRunOptions, PtyRunResult } from "./ptyRun.js"; +export { runInPty } from "./ptyRun.js"; +export type { ScreenSnapshot } from "./screen.js"; +export { createScreen } from "./screen.js"; +export type { ScreenDiff } from "./screenDiff.js"; +export { diffScreens } from "./screenDiff.js"; +export type { ProcSample, ProcSamplerOptions } from "./procSampler.js"; +export { sampleProcUntilExit } from "./procSampler.js"; + diff --git a/packages/bench-harness/src/procSampler.ts b/packages/bench-harness/src/procSampler.ts new file mode 100644 index 00000000..81a991fc --- /dev/null +++ b/packages/bench-harness/src/procSampler.ts @@ -0,0 +1,62 @@ +import { readFileSync } from "node:fs"; +import { setTimeout as delay } from "node:timers/promises"; + +export type ProcSample = Readonly<{ + ts: number; + rssBytes: number | null; + cpuUserTicks: number | null; + cpuSystemTicks: number | null; +}>; + +export type ProcSamplerOptions = Readonly<{ + pid: number; + intervalMs: number; +}>; + +function readProcStatusRssBytes(pid: number): number | null { + try { + const status = readFileSync(`/proc/${pid}/status`, "utf8"); + const match = status.match(/^VmRSS:\\s+(\\d+)\\s+kB\\s*$/m); + if (!match) return null; + const kb = Number.parseInt(match[1] ?? "", 10); + return Number.isFinite(kb) ? kb * 1024 : null; + } catch { + return null; + } +} + +function readProcStatTicks(pid: number): { user: number; system: number } | null { + try { + const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); + const end = stat.lastIndexOf(")"); + if (end < 0) return null; + const after = stat.slice(end + 2); + const parts = after.split(" "); + const utime = Number.parseInt(parts[11] ?? "", 10); + const stime = Number.parseInt(parts[12] ?? "", 10); + if (!Number.isFinite(utime) || !Number.isFinite(stime)) return null; + return { user: utime, system: stime }; + } catch { + return null; + } +} + +export async function sampleProcUntilExit( + opts: ProcSamplerOptions, + isExited: () => boolean, +): Promise { + const samples: ProcSample[] = []; + while (!isExited()) { + const now = performance.now(); + const rssBytes = readProcStatusRssBytes(opts.pid); + const ticks = readProcStatTicks(opts.pid); + samples.push({ + ts: now, + rssBytes, + cpuUserTicks: ticks?.user ?? null, + cpuSystemTicks: ticks?.system ?? null, + }); + await delay(opts.intervalMs); + } + return samples; +} diff --git a/packages/bench-harness/src/ptyRun.ts b/packages/bench-harness/src/ptyRun.ts new file mode 100644 index 00000000..9713d0a9 --- /dev/null +++ b/packages/bench-harness/src/ptyRun.ts @@ -0,0 +1,135 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +import pty from "node-pty"; + +import { sampleProcUntilExit, type ProcSample } from "./procSampler.js"; +import { createScreen } from "./screen.js"; + +export type PtyRunOptions = Readonly<{ + cwd: string; + command: string; + args: readonly string[]; + env: Readonly>; + cols: number; + rows: number; + outDir: string; + rawOutputFile: string; + screenFile: string; + stableWindowMs: number; + meaningfulPaintText: string; + inputScript?: readonly ( + | Readonly<{ kind: "write"; atMs: number; data: string }> + | Readonly<{ kind: "resize"; atMs: number; cols: number; rows: number }> + )[]; + procSampleIntervalMs: number; +}>; + +export type PtyRunResult = Readonly<{ + wallStartMs: number; + wallEndMs: number; + durationMs: number; + rawBytes: number; + stableAtMs: number | null; + meaningfulPaintAtMs: number | null; + finalScreenHash: string; + procSamples: readonly ProcSample[]; +}>; + +export async function runInPty(opts: PtyRunOptions): Promise { + mkdirSync(opts.outDir, { recursive: true }); + const screen = createScreen({ cols: opts.cols, rows: opts.rows }); + + const wallStartMs = performance.now(); + let wallEndMs = wallStartMs; + let exited = false; + + const term = pty.spawn(opts.command, [...opts.args], { + name: "xterm-256color", + cols: opts.cols, + rows: opts.rows, + cwd: opts.cwd, + env: { + ...Object.fromEntries(Object.entries(opts.env).filter(([, v]) => v !== undefined)), + TERM: "xterm-256color", + COLUMNS: String(opts.cols), + LINES: String(opts.rows), + FORCE_COLOR: "1", + }, + }); + + const raw: Buffer[] = []; + let rawBytes = 0; + + let lastHash = ""; + let lastChangeAt = wallStartMs; + let stableAtMs: number | null = null; + let meaningfulPaintAtMs: number | null = null; + + const applyChunk = async (chunk: string): Promise => { + await screen.write(chunk); + const snap = screen.snapshot(); + if (snap.hash !== lastHash) { + lastHash = snap.hash; + lastChangeAt = performance.now(); + } + if ( + meaningfulPaintAtMs == null && + snap.lines.some((l) => l.includes(opts.meaningfulPaintText)) + ) { + meaningfulPaintAtMs = performance.now() - wallStartMs; + } + }; + + term.onData((data) => { + const buf = Buffer.from(data, "utf8"); + raw.push(buf); + rawBytes += buf.length; + void applyChunk(data); + }); + + const procSamplesPromise = sampleProcUntilExit( + { pid: term.pid, intervalMs: opts.procSampleIntervalMs }, + () => exited, + ); + + const script = opts.inputScript ?? []; + for (const step of script) { + setTimeout(() => { + try { + if (step.kind === "write") term.write(step.data); + else term.resize(step.cols, step.rows); + } catch {} + }, Math.max(0, step.atMs)); + } + + await new Promise((resolve) => { + term.onExit(() => { + exited = true; + wallEndMs = performance.now(); + resolve(); + }); + }); + + const procSamples = await procSamplesPromise; + await screen.flush(); + const snap = screen.snapshot(); + + if (wallEndMs - lastChangeAt >= opts.stableWindowMs) { + stableAtMs = lastChangeAt - wallStartMs + opts.stableWindowMs; + } + + writeFileSync(path.join(opts.outDir, opts.rawOutputFile), Buffer.concat(raw)); + writeFileSync(path.join(opts.outDir, opts.screenFile), `${snap.lines.join("\n")}\n`); + + return { + wallStartMs, + wallEndMs, + durationMs: wallEndMs - wallStartMs, + rawBytes, + stableAtMs, + meaningfulPaintAtMs, + finalScreenHash: snap.hash, + procSamples, + }; +} diff --git a/packages/bench-harness/src/screen.ts b/packages/bench-harness/src/screen.ts new file mode 100644 index 00000000..72c1a4e6 --- /dev/null +++ b/packages/bench-harness/src/screen.ts @@ -0,0 +1,56 @@ +import xtermHeadless from "@xterm/headless"; +import { createHash } from "node:crypto"; + +export type ScreenSnapshot = Readonly<{ + cols: number; + rows: number; + lines: readonly string[]; + hash: string; +}>; + +export function createScreen(opts: Readonly<{ cols: number; rows: number }>): { + write: (data: string) => Promise; + flush: () => Promise; + snapshot: () => ScreenSnapshot; +} { + const Terminal = (xtermHeadless as unknown as { Terminal?: unknown }).Terminal; + if (typeof Terminal !== "function") { + throw new Error("Unexpected @xterm/headless shape: missing Terminal export"); + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + const term = new (Terminal as any)({ + cols: opts.cols, + rows: opts.rows, + allowProposedApi: true, + convertEol: false, + scrollback: 0, + }); + + let pending = Promise.resolve(); + const write = async (data: string): Promise => { + pending = pending.then( + () => + new Promise((resolve) => { + term.write(data, resolve); + }), + ); + await pending; + }; + + const flush = async (): Promise => { + await pending; + }; + + const snapshot = (): ScreenSnapshot => { + const lines: string[] = []; + for (let r = 0; r < opts.rows; r++) { + const line = term.buffer.active.getLine(r); + const text = line?.translateToString(false) ?? ""; + lines.push(text.padEnd(opts.cols, " ").slice(0, opts.cols)); + } + const hash = createHash("sha256").update(lines.join("\n")).digest("hex"); + return { cols: opts.cols, rows: opts.rows, lines, hash }; + }; + + return { write, flush, snapshot }; +} diff --git a/packages/bench-harness/src/screenDiff.ts b/packages/bench-harness/src/screenDiff.ts new file mode 100644 index 00000000..905e00b9 --- /dev/null +++ b/packages/bench-harness/src/screenDiff.ts @@ -0,0 +1,28 @@ +import type { ScreenSnapshot } from "./screen.js"; + +export type ScreenDiff = Readonly<{ + equal: boolean; + firstDiffRow: number | null; + aLine: string | null; + bLine: string | null; +}>; + +export function diffScreens(a: ScreenSnapshot, b: ScreenSnapshot): ScreenDiff { + if (a.cols !== b.cols || a.rows !== b.rows) { + return { + equal: false, + firstDiffRow: null, + aLine: `size=${a.cols}x${a.rows}`, + bLine: `size=${b.cols}x${b.rows}`, + }; + } + for (let i = 0; i < a.lines.length; i++) { + const aLine = a.lines[i]; + const bLine = b.lines[i]; + if (aLine !== bLine) { + return { equal: false, firstDiffRow: i + 1, aLine: aLine ?? null, bLine: bLine ?? null }; + } + } + return { equal: true, firstDiffRow: null, aLine: null, bLine: null }; +} + diff --git a/packages/bench-harness/tsconfig.json b/packages/bench-harness/tsconfig.json new file mode 100644 index 00000000..5d737447 --- /dev/null +++ b/packages/bench-harness/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": "./src", + "outDir": "./dist", + "lib": ["ES2022"] + }, + "include": ["src/**/*.ts"] +} + diff --git a/packages/bench-runner/package.json b/packages/bench-runner/package.json new file mode 100644 index 00000000..9e9a4c99 --- /dev/null +++ b/packages/bench-runner/package.json @@ -0,0 +1,16 @@ +{ + "name": "@rezi-ui/ink-compat-bench-runner", + "version": "0.0.0", + "private": true, + "type": "module", + "license": "Apache-2.0", + "main": "./dist/cli.js", + "types": "./dist/cli.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json --pretty false", + "typecheck": "tsc -p tsconfig.json --pretty false --noEmit" + }, + "dependencies": { + "@rezi-ui/ink-compat-bench-harness": "0.0.0" + } +} diff --git a/packages/bench-runner/src/cli.ts b/packages/bench-runner/src/cli.ts new file mode 100644 index 00000000..b68f0e5b --- /dev/null +++ b/packages/bench-runner/src/cli.ts @@ -0,0 +1,338 @@ +import { mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import net from "node:net"; +import os from "node:os"; +import { setTimeout as delay } from "node:timers/promises"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { performance } from "node:perf_hooks"; + +import { runInPty } from "@rezi-ui/ink-compat-bench-harness"; + +type RendererName = "real-ink" | "ink-compat"; + +function readArg(name: string): string | undefined { + const idx = process.argv.indexOf(`--${name}`); + if (idx === -1) return undefined; + return process.argv[idx + 1]; +} + +function hasFlag(name: string): boolean { + return process.argv.includes(`--${name}`); +} + +function requireArg(name: string): string { + const value = readArg(name); + if (!value) throw new Error(`Missing --${name}`); + return value; +} + +function parseIntArg(name: string, fallback: number): number { + const raw = readArg(name); + if (!raw) return fallback; + const v = Number.parseInt(raw, 10); + return Number.isFinite(v) ? v : fallback; +} + +async function getClockTicksPerSecond(): Promise { + try { + const pExec = promisify(execFile); + const { stdout } = await pExec("getconf", ["CLK_TCK"]); + const v = Number.parseInt(String(stdout).trim(), 10); + return Number.isFinite(v) && v > 0 ? v : 100; + } catch { + return 100; + } +} + +function computeCpuSecondsFromProcSamples( + samples: readonly { cpuUserTicks: number | null; cpuSystemTicks: number | null }[], + clkTck: number, +): number | null { + const first = samples.find((s) => s.cpuUserTicks != null && s.cpuSystemTicks != null); + const last = [...samples] + .reverse() + .find((s) => s.cpuUserTicks != null && s.cpuSystemTicks != null); + if (!first || !last) return null; + const dt = + (last.cpuUserTicks! + last.cpuSystemTicks!) - (first.cpuUserTicks! + first.cpuSystemTicks!); + return dt / clkTck; +} + +async function openControlServer(socketPath: string): Promise<{ + sendLine: (obj: unknown) => void; + waitForClient: (timeoutMs: number) => Promise; + close: () => Promise; +}> { + rmSync(socketPath, { force: true }); + let sock: net.Socket | null = null; + const server = net.createServer((s) => { + sock = s; + sock.setNoDelay(true); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, () => resolve()); + }); + + const waitForClient = async (timeoutMs: number): Promise => { + if (sock) return true; + return await Promise.race([ + new Promise((resolve) => server.once("connection", () => resolve(true))), + delay(timeoutMs).then(() => false), + ]); + }; + + const sendLine = (obj: unknown): void => { + sock?.write(`${JSON.stringify(obj)}\n`); + }; + + const close = async (): Promise => { + try { + sock?.destroy(); + } catch {} + await new Promise((resolve) => server.close(() => resolve())); + rmSync(socketPath, { force: true }); + }; + + return { sendLine, waitForClient, close }; +} + +function seededToken(i: number): string { + const words = [ + "lorem", + "ipsum", + "dolor", + "sit", + "amet,", + "consectetur", + "adipiscing", + "elit.", + "sed", + "do", + "eiusmod", + "tempor", + "incididunt", + "ut", + "labore", + "et", + "dolore", + "magna", + "aliqua.", + ]; + const w = words[i % words.length] ?? "x"; + const mod = i % 29; + if (mod === 0) return `**${w}**`; + if (mod === 7) return `\`code:${w}\``; + if (mod === 13) return `(${w})`; + return w; +} + +async function driveScenario( + scenario: string, + seed: number, + control: Awaited>, + childFinished: () => boolean, +): Promise { + const ok = await control.waitForClient(4000); + if (!ok) return; + if (childFinished()) return; + + control.sendLine({ type: "init", seed }); + + if (scenario === "streaming-chat") { + const total = 360; + const ratePerSecond = 120; + const intervalMs = Math.round(1000 / ratePerSecond); + for (let i = 0; i < total; i++) { + if (childFinished()) break; + control.sendLine({ type: "token", text: `t=${i} ${seededToken(i)} ${seededToken(i + 1)}` }); + await delay(intervalMs); + } + control.sendLine({ type: "done" }); + return; + } + + const tickMs = + scenario === "large-list-scroll" ? 33 : scenario === "resize-storm" ? 25 : 16; + const tickCount = + scenario === "large-list-scroll" + ? 120 + : scenario === "dashboard-grid" + ? 140 + : scenario === "style-churn" + ? 180 + : scenario === "resize-storm" + ? 40 + : 60; + + for (let i = 0; i < tickCount; i++) { + if (childFinished()) break; + control.sendLine({ type: "tick" }); + await delay(tickMs); + } + control.sendLine({ type: "done" }); +} + +function safeReadJsonl(pathname: string): readonly Record[] { + try { + const text = readFileSync(pathname, "utf8"); + return text + .split("\n") + .filter(Boolean) + .map((l) => JSON.parse(l) as Record); + } catch { + return []; + } +} + +async function main(): Promise { + const scenario = requireArg("scenario"); + const renderer = requireArg("renderer") as RendererName; + const runs = parseIntArg("runs", 1); + const outRoot = path.resolve(readArg("out") ?? "results"); + const cols = parseIntArg("cols", 80); + const rows = parseIntArg("rows", 24); + const stableWindowMs = parseIntArg("stable-ms", 250); + const cpuProf = hasFlag("cpu-prof"); + + const repoRoot = process.cwd(); + const appEntry = path.join(repoRoot, "packages/bench-app/dist/entry.js"); + const clkTck = await getClockTicksPerSecond(); + + mkdirSync(outRoot, { recursive: true }); + const startedAt = new Date().toISOString().replace(/[:.]/g, "-"); + const batchDir = path.join(outRoot, `ink-bench_${scenario}_${renderer}_${startedAt}`); + mkdirSync(batchDir, { recursive: true }); + + const summaries: unknown[] = []; + + for (let i = 0; i < runs; i++) { + linkInkForRenderer(repoRoot, renderer); + const runDir = path.join(batchDir, `run_${String(i + 1).padStart(2, "0")}`); + mkdirSync(runDir, { recursive: true }); + + const controlSocket = path.join( + os.tmpdir(), + `inkbench_${process.pid}_${Math.trunc(performance.now())}_${i}.sock`, + ); + const controlServer = await openControlServer(controlSocket); + const seed = 1337 + i; + + const args = [ + "--no-warnings", + ...(cpuProf + ? [ + "--cpu-prof", + "--cpu-prof-dir", + path.join(runDir, "cpu-prof"), + "--cpu-prof-name", + `${scenario}_${renderer}_run${i + 1}.cpuprofile`, + ] + : []), + appEntry, + ]; + + const env: Record = { + ...process.env, + BENCH_SCENARIO: scenario, + BENCH_RENDERER: renderer, + BENCH_OUT_DIR: runDir, + BENCH_COLS: String(cols), + BENCH_ROWS: String(rows), + BENCH_CONTROL_SOCKET: controlSocket, + BENCH_TIMEOUT_MS: process.env["BENCH_TIMEOUT_MS"] ?? "15000", + BENCH_EXIT_AFTER_DONE_MS: + process.env["BENCH_EXIT_AFTER_DONE_MS"] ?? String(Math.max(0, stableWindowMs + 50)), + BENCH_INK_COMPAT_PHASES: process.env["BENCH_INK_COMPAT_PHASES"] ?? "1", + BENCH_MAX_FPS: process.env["BENCH_MAX_FPS"] ?? "60", + }; + + const inputScript = + scenario === "large-list-scroll" + ? Array.from({ length: 40 }, (_, j) => ({ + kind: "write" as const, + atMs: 250 + j * 35, + data: "\\u001b[B", + })) + : scenario === "resize-storm" + ? [ + { kind: "resize" as const, atMs: 200, cols: 100, rows: 30 }, + { kind: "resize" as const, atMs: 350, cols: 80, rows: 24 }, + { kind: "resize" as const, atMs: 500, cols: 120, rows: 28 }, + { kind: "resize" as const, atMs: 650, cols: 80, rows: 24 }, + { kind: "resize" as const, atMs: 800, cols: 90, rows: 26 }, + { kind: "resize" as const, atMs: 950, cols: 80, rows: 24 }, + ] + : undefined; + + let childExited = false; + const runPromise = runInPty({ + cwd: repoRoot, + command: process.execPath, + args, + env, + cols, + rows, + outDir: runDir, + rawOutputFile: "pty-output.bin", + screenFile: "screen-final.txt", + stableWindowMs, + meaningfulPaintText: "BENCH_READY", + ...(inputScript ? { inputScript } : {}), + procSampleIntervalMs: 50, + }).finally(() => { + childExited = true; + }); + + const drivePromise = driveScenario(scenario, seed, controlServer, () => childExited).finally( + () => controlServer.close(), + ); + + const result = await Promise.all([runPromise, drivePromise]).then(([r]) => r); + + const frames = safeReadJsonl(path.join(runDir, "frames.jsonl")); + const renderTotalMs = frames.reduce((a, f) => a + (Number(f["renderTotalMs"]) || 0), 0); + const stdoutBytes = frames.reduce((a, f) => a + (Number(f["stdoutBytes"]) || 0), 0); + const stdoutWrites = frames.reduce((a, f) => a + (Number(f["stdoutWrites"]) || 0), 0); + const cpuSeconds = computeCpuSecondsFromProcSamples(result.procSamples, clkTck); + + const summary = { + scenario, + renderer, + run: i + 1, + meanWallS: (result.stableAtMs ?? result.durationMs) / 1000, + totalCpuTimeS: cpuSeconds, + meanRenderTotalMs: renderTotalMs, + timeToFirstMeaningfulPaintMs: result.meaningfulPaintAtMs, + timeToStableMs: result.stableAtMs, + writes: stdoutWrites, + bytes: stdoutBytes, + renderMsPerKB: stdoutBytes > 0 ? renderTotalMs / (stdoutBytes / 1024) : null, + framesEmitted: frames.length, + ...result, + }; + + summaries.push(summary); + writeFileSync(path.join(runDir, "run-summary.json"), JSON.stringify(summary, null, 2)); + } + + writeFileSync(path.join(batchDir, "batch-summary.json"), JSON.stringify(summaries, null, 2)); +} + +main().catch((err: unknown) => { + const msg = err instanceof Error ? err.stack ?? err.message : String(err); + console.error(msg); + process.exitCode = 1; +}); +function linkInkForRenderer(repoRoot: string, renderer: RendererName): void { + const benchNodeModules = path.join(repoRoot, "packages/bench-app/node_modules"); + mkdirSync(benchNodeModules, { recursive: true }); + const linkPath = path.join(benchNodeModules, "ink"); + rmSync(linkPath, { force: true }); + const target = + renderer === "real-ink" + ? path.join(repoRoot, "node_modules/@jrichman/ink") + : path.join(repoRoot, "packages/ink-compat"); + symlinkSync(target, linkPath, "junction"); +} diff --git a/packages/bench-runner/src/verify.ts b/packages/bench-runner/src/verify.ts new file mode 100644 index 00000000..b32dcc21 --- /dev/null +++ b/packages/bench-runner/src/verify.ts @@ -0,0 +1,203 @@ +import { mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import net from "node:net"; +import os from "node:os"; +import { setTimeout as delay } from "node:timers/promises"; +import { performance } from "node:perf_hooks"; + +import { diffScreens, runInPty } from "@rezi-ui/ink-compat-bench-harness"; + +type RendererName = "real-ink" | "ink-compat"; + +function parseRendererName(value: string): RendererName { + if (value === "real-ink" || value === "ink-compat") return value; + throw new Error(`Invalid renderer: ${value}`); +} + +function linkInkForRenderer(repoRoot: string, renderer: RendererName): void { + const benchNodeModules = path.join(repoRoot, "packages/bench-app/node_modules"); + mkdirSync(benchNodeModules, { recursive: true }); + const linkPath = path.join(benchNodeModules, "ink"); + rmSync(linkPath, { force: true }); + const target = + renderer === "real-ink" + ? path.join(repoRoot, "node_modules/@jrichman/ink") + : path.join(repoRoot, "packages/ink-compat"); + symlinkSync(target, linkPath, "junction"); +} + +function readArg(name: string): string | undefined { + const idx = process.argv.indexOf(`--${name}`); + if (idx === -1) return undefined; + return process.argv[idx + 1]; +} + +function requireArg(name: string): string { + const v = readArg(name); + if (!v) throw new Error(`Missing --${name}`); + return v; +} + +async function openControlServer(socketPath: string): Promise<{ + sendLine: (obj: unknown) => void; + waitForClient: (timeoutMs: number) => Promise; + close: () => Promise; +}> { + rmSync(socketPath, { force: true }); + let sock: net.Socket | null = null; + const server = net.createServer((s) => { + sock = s; + sock.setNoDelay(true); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, () => resolve()); + }); + + const waitForClient = async (timeoutMs: number): Promise => { + if (sock) return true; + return await Promise.race([ + new Promise((resolve) => server.once("connection", () => resolve(true))), + delay(timeoutMs).then(() => false), + ]); + }; + + const sendLine = (obj: unknown): void => { + sock?.write(`${JSON.stringify(obj)}\n`); + }; + + const close = async (): Promise => { + try { + sock?.destroy(); + } catch {} + await new Promise((resolve) => server.close(() => resolve())); + rmSync(socketPath, { force: true }); + }; + + return { sendLine, waitForClient, close }; +} + +async function driveScenario( + scenario: string, + seed: number, + control: Awaited>, +): Promise { + const ok = await control.waitForClient(4000); + if (!ok) return; + control.sendLine({ type: "init", seed }); + if (scenario === "streaming-chat") { + for (let i = 0; i < 120; i++) { + control.sendLine({ type: "token", text: `t=${i} verify` }); + await delay(8); + } + } else { + for (let i = 0; i < 60; i++) { + control.sendLine({ type: "tick" }); + await delay(16); + } + } + control.sendLine({ type: "done" }); +} + +async function runOnce( + repoRoot: string, + scenario: string, + renderer: RendererName, + outDir: string, +): Promise { + linkInkForRenderer(repoRoot, renderer); + const appEntry = path.join(repoRoot, "packages/bench-app/dist/entry.js"); + const cols = Number.parseInt(process.env["BENCH_COLS"] ?? "80", 10) || 80; + const rows = Number.parseInt(process.env["BENCH_ROWS"] ?? "24", 10) || 24; + const controlSocket = path.join( + os.tmpdir(), + `inkbench_verify_${process.pid}_${Math.trunc(performance.now())}_${renderer}.sock`, + ); + const controlServer = await openControlServer(controlSocket); + + const runPromise = runInPty({ + cwd: repoRoot, + command: process.execPath, + args: ["--no-warnings", appEntry], + env: { + ...process.env, + BENCH_SCENARIO: scenario, + BENCH_RENDERER: renderer, + BENCH_OUT_DIR: outDir, + BENCH_COLS: String(cols), + BENCH_ROWS: String(rows), + BENCH_CONTROL_SOCKET: controlSocket, + BENCH_TIMEOUT_MS: process.env["BENCH_TIMEOUT_MS"] ?? "15000", + BENCH_EXIT_AFTER_DONE_MS: process.env["BENCH_EXIT_AFTER_DONE_MS"] ?? "300", + BENCH_INK_COMPAT_PHASES: process.env["BENCH_INK_COMPAT_PHASES"] ?? "1", + BENCH_MAX_FPS: process.env["BENCH_MAX_FPS"] ?? "60", + }, + cols, + rows, + outDir, + rawOutputFile: "pty-output.bin", + screenFile: "screen-final.txt", + stableWindowMs: 250, + meaningfulPaintText: "BENCH_READY", + procSampleIntervalMs: 50, + }); + + const drivePromise = driveScenario(scenario, 7331, controlServer).finally(() => + controlServer.close(), + ); + + await Promise.all([runPromise, drivePromise]); + return readFileSync(path.join(outDir, "screen-final.txt"), "utf8"); +} + +async function main(): Promise { + const scenario = requireArg("scenario"); + const rawCompare = requireArg("compare").split(","); + if (rawCompare.length !== 2 || !rawCompare[0] || !rawCompare[1]) { + throw new Error( + `--compare must be "real-ink,ink-compat" (got ${JSON.stringify(rawCompare)})`, + ); + } + const compare = [parseRendererName(rawCompare[0]), parseRendererName(rawCompare[1])] as const; + + const repoRoot = process.cwd(); + const outRoot = path.resolve(readArg("out") ?? "results"); + mkdirSync(outRoot, { recursive: true }); + const startedAt = new Date().toISOString().replace(/[:.]/g, "-"); + + const runA = path.join(outRoot, `verify_${scenario}_${compare[0]}_${startedAt}`); + const runB = path.join(outRoot, `verify_${scenario}_${compare[1]}_${startedAt}`); + mkdirSync(runA, { recursive: true }); + mkdirSync(runB, { recursive: true }); + + const aScreen = await runOnce(repoRoot, scenario, compare[0], runA); + const bScreen = await runOnce(repoRoot, scenario, compare[1], runB); + + const cols = Number.parseInt(process.env["BENCH_COLS"] ?? "80", 10) || 80; + const rows = Number.parseInt(process.env["BENCH_ROWS"] ?? "24", 10) || 24; + + const toSnap = (screen: string) => { + const lines = screen.split("\n"); + return { + cols, + rows, + lines: lines.map((l) => l.padEnd(cols, " ").slice(0, cols)).slice(0, rows), + hash: "", + }; + }; + + const diff = diffScreens(toSnap(aScreen), toSnap(bScreen)); + const out = { scenario, compare, equalFinalScreen: diff.equal, diff }; + const outFile = path.join(outRoot, `verify_${scenario}_${startedAt}.json`); + writeFileSync(outFile, JSON.stringify(out, null, 2)); + + if (!diff.equal) { + console.error(`Final screen mismatch at row ${diff.firstDiffRow ?? "?"}`); + process.exitCode = 2; + } +} + +main().catch((err: unknown) => { + console.error(err instanceof Error ? err.stack ?? err.message : String(err)); + process.exitCode = 1; +}); diff --git a/packages/bench-runner/tsconfig.json b/packages/bench-runner/tsconfig.json new file mode 100644 index 00000000..5d737447 --- /dev/null +++ b/packages/bench-runner/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": "./src", + "outDir": "./dist", + "lib": ["ES2022"] + }, + "include": ["src/**/*.ts"] +} + diff --git a/scripts/ink-compat-bench/prepare-ink-compat.mjs b/scripts/ink-compat-bench/prepare-ink-compat.mjs new file mode 100644 index 00000000..71b4e2bd --- /dev/null +++ b/scripts/ink-compat-bench/prepare-ink-compat.mjs @@ -0,0 +1,18 @@ +import { mkdirSync, rmSync, symlinkSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const repoRoot = path.resolve(__dirname, "../.."); +const benchAppNodeModules = path.join(repoRoot, "packages/bench-app/node_modules"); +mkdirSync(benchAppNodeModules, { recursive: true }); + +const inkLinkPath = path.join(benchAppNodeModules, "ink"); +const compatPath = path.join(repoRoot, "packages/ink-compat"); + +rmSync(inkLinkPath, { force: true }); +symlinkSync(compatPath, inkLinkPath, "junction"); +console.log(`[ink-compat-bench] linked packages/bench-app/node_modules/ink -> ${compatPath}`); + diff --git a/scripts/ink-compat-bench/prepare-real-ink.mjs b/scripts/ink-compat-bench/prepare-real-ink.mjs new file mode 100644 index 00000000..ff30523c --- /dev/null +++ b/scripts/ink-compat-bench/prepare-real-ink.mjs @@ -0,0 +1,18 @@ +import { mkdirSync, rmSync, symlinkSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const repoRoot = path.resolve(__dirname, "../.."); +const benchAppNodeModules = path.join(repoRoot, "packages/bench-app/node_modules"); +mkdirSync(benchAppNodeModules, { recursive: true }); + +const inkLinkPath = path.join(benchAppNodeModules, "ink"); +const realInkPath = path.join(repoRoot, "node_modules/@jrichman/ink"); + +rmSync(inkLinkPath, { force: true }); +symlinkSync(realInkPath, inkLinkPath, "junction"); +console.log(`[ink-compat-bench] linked packages/bench-app/node_modules/ink -> ${realInkPath}`); + From 045b95d6673feadeae4713956cd649a6f9bb94ca Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 19:27:11 +0400 Subject: [PATCH 12/29] feat(ink-compat): add bench phase hook --- packages/ink-compat/src/runtime/render.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/ink-compat/src/runtime/render.ts b/packages/ink-compat/src/runtime/render.ts index c5a08df1..282fa737 100644 --- a/packages/ink-compat/src/runtime/render.ts +++ b/packages/ink-compat/src/runtime/render.ts @@ -20,6 +20,8 @@ import { InkContext } from "./context.js"; import { advanceLayoutGeneration, readCurrentLayout, writeCurrentLayout } from "./layoutState.js"; import { commitSync, createReactRoot } from "./reactHelpers.js"; +const BENCH_PHASES_ENABLED = process.env["BENCH_INK_COMPAT_PHASES"] === "1"; + export interface KittyKeyboardOptions { mode?: "auto" | "enabled" | "disabled"; flags?: readonly KittyFlagName[]; @@ -3107,6 +3109,27 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions phaseProfile.maxFrameMs = Math.max(phaseProfile.maxFrameMs, renderTime); } + if (BENCH_PHASES_ENABLED) { + const hook = ( + globalThis as unknown as { + __INK_COMPAT_BENCH_ON_FRAME?: ((m: unknown) => void) | undefined; + } + ).__INK_COMPAT_BENCH_ON_FRAME; + if (hook) { + hook({ + translationMs, + percentResolveMs, + coreRenderMs, + assignLayoutsMs, + rectScanMs, + ansiMs, + nodes: result.nodes.length, + ops: result.ops.length, + coreRenderPasses: coreRenderPassesThisFrame || 1, + }); + } + } + writeOutput({ output, staticOutput }); options.onRender?.({ renderTime, From a0fcb87309c224dd24669ac6227e0edb453c7d43 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 19:27:15 +0400 Subject: [PATCH 13/29] fix(ink-compat): match Ink soft-wrap whitespace --- packages/ink-compat/src/translation/propsToVNode.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/ink-compat/src/translation/propsToVNode.ts b/packages/ink-compat/src/translation/propsToVNode.ts index ed77e21d..1961da13 100644 --- a/packages/ink-compat/src/translation/propsToVNode.ts +++ b/packages/ink-compat/src/translation/propsToVNode.ts @@ -27,6 +27,11 @@ type LayoutDirection = "row" | "column"; type TranslationMode = "all" | "dynamic" | "static"; type BorderStyleValue = string | Record; +const INK_SOFT_WRAP_TRANSFORM = (line: string, index: number): string => { + if (index <= 0) return line; + return line.startsWith(" ") ? line.slice(1) : line; +}; + interface VirtualNodeProps extends Record { __inkType?: "spacer" | "newline" | "transform"; count?: number; @@ -977,6 +982,9 @@ function translateText(node: InkHostNode): VNode { const inkWrap = (p.wrap as string | undefined) ?? "wrap"; if (inkWrap === "wrap") { textProps["wrap"] = true; + // Ink drops the whitespace token used as the soft-wrap break point, so wrapped + // continuation lines don't start with an extra leading space. + textProps["__inkTransform"] = INK_SOFT_WRAP_TRANSFORM; } else if (inkWrap === "truncate" || inkWrap === "truncate-end") { textProps["textOverflow"] = "ellipsis"; } else if (inkWrap === "truncate-middle") { From 4beed4ff57362deb17ea2232ba59927b520f8d7b Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 19:51:28 +0400 Subject: [PATCH 14/29] bench: improve determinism and resize handling --- packages/bench-app/src/entry.tsx | 90 ++++++----------------- packages/bench-harness/src/ptyRun.ts | 5 +- packages/bench-harness/src/screen.ts | 25 +++++-- packages/ink-compat/src/runtime/render.ts | 54 +++++++------- 4 files changed, 75 insertions(+), 99 deletions(-) diff --git a/packages/bench-app/src/entry.tsx b/packages/bench-app/src/entry.tsx index b08a5322..dcafc1b2 100644 --- a/packages/bench-app/src/entry.tsx +++ b/packages/bench-app/src/entry.tsx @@ -178,46 +178,6 @@ function markUpdateRequested(stateRef: React.MutableRefObject): v }; } -type InlineSegment = Readonly<{ - text: string; - kind: "plain" | "bold" | "code"; -}>; - -function parseInlineMarkup(text: string): readonly InlineSegment[] { - const segments: InlineSegment[] = []; - let i = 0; - let kind: InlineSegment["kind"] = "plain"; - let buf = ""; - - const flush = (): void => { - if (!buf) return; - segments.push({ text: buf, kind }); - buf = ""; - }; - - while (i < text.length) { - const ch = text[i] ?? ""; - const next = text[i + 1] ?? ""; - if (ch === "*" && next === "*") { - flush(); - kind = kind === "bold" ? "plain" : "bold"; - i += 2; - continue; - } - if (ch === "`") { - flush(); - kind = kind === "code" ? "plain" : "code"; - i += 1; - continue; - } - buf += ch; - i += 1; - } - - flush(); - return segments; -} - function StreamingChatScenario(props: { stateRef: React.MutableRefObject; setController: (c: ScenarioController) => void; @@ -252,7 +212,7 @@ function StreamingChatScenario(props: { next.push("console.log(x)"); next.push("```"); next.push(""); - } else if (t % 12 === 0) { + } else if ((next[lastIndex] ?? "").length > 120) { next.push(""); } return next; @@ -262,7 +222,7 @@ function StreamingChatScenario(props: { }); }, [props]); - const visible = scrollLock ? lines.slice(-12) : lines.slice(0, 12); + const visible = scrollLock ? lines.slice(-8) : lines.slice(0, 8); return ( BENCH_READY streaming-chat tokens={tokenCount} scrollLock={String(scrollLock)} @@ -275,24 +235,17 @@ function StreamingChatScenario(props: { ); } - const segments = parseInlineMarkup(line); + const hasBoldMarker = line.includes("**"); + const hasCodeMarker = line.includes("`"); + const clean = line.replaceAll("**", "").replaceAll("`", ""); + const props = hasCodeMarker + ? { color: "yellow" as const, backgroundColor: "black" as const } + : hasBoldMarker + ? { bold: true } + : {}; return ( - - {segments.map((seg, j) => { - if (seg.kind === "plain") return seg.text; - if (seg.kind === "bold") { - return ( - - {seg.text} - - ); - } - return ( - - {seg.text} - - ); - })} + + {clean} ); })} @@ -357,6 +310,10 @@ function DashboardGridScenario(props: { setController: (c: ScenarioController) => void; }): React.ReactElement { const [tick, setTick] = useState(0); + const cols = Number.parseInt(process.env["BENCH_COLS"] ?? "80", 10) || 80; + const gap = 2; + const topW = Math.max(18, Math.floor((cols - gap * 2) / 3)); + const bottomW = Math.max(18, Math.floor((cols - gap) / 2)); useEffect(() => { props.setController({ @@ -370,37 +327,38 @@ function DashboardGridScenario(props: { const bar = (n: number): string => { const pct = n % 100; - const filled = Math.round((pct / 100) * 20); - return `${"█".repeat(filled)}${"░".repeat(20 - filled)} ${String(pct).padStart(3, " ")}%`; + const barW = 16; + const filled = Math.round((pct / 100) * barW); + return `${"█".repeat(filled)}${"░".repeat(barW - filled)} ${String(pct).padStart(3, " ")}%`; }; return ( BENCH_READY dashboard-grid tick={tick} - + CPU {bar(tick * 3)} threads: 8 - + MEM {bar(tick * 5)} rss: sampled - + NET {bar(tick * 7)} offline - + Queue {bar(tick * 11)} stable: true - + Workers {bar(tick * 13)} ok: 16 diff --git a/packages/bench-harness/src/ptyRun.ts b/packages/bench-harness/src/ptyRun.ts index 9713d0a9..34a26fb4 100644 --- a/packages/bench-harness/src/ptyRun.ts +++ b/packages/bench-harness/src/ptyRun.ts @@ -98,7 +98,10 @@ export async function runInPty(opts: PtyRunOptions): Promise { setTimeout(() => { try { if (step.kind === "write") term.write(step.data); - else term.resize(step.cols, step.rows); + else { + term.resize(step.cols, step.rows); + void screen.resize(step.cols, step.rows); + } } catch {} }, Math.max(0, step.atMs)); } diff --git a/packages/bench-harness/src/screen.ts b/packages/bench-harness/src/screen.ts index 72c1a4e6..798f718c 100644 --- a/packages/bench-harness/src/screen.ts +++ b/packages/bench-harness/src/screen.ts @@ -11,16 +11,20 @@ export type ScreenSnapshot = Readonly<{ export function createScreen(opts: Readonly<{ cols: number; rows: number }>): { write: (data: string) => Promise; flush: () => Promise; + resize: (cols: number, rows: number) => Promise; snapshot: () => ScreenSnapshot; } { const Terminal = (xtermHeadless as unknown as { Terminal?: unknown }).Terminal; if (typeof Terminal !== "function") { throw new Error("Unexpected @xterm/headless shape: missing Terminal export"); } + let cols = opts.cols; + let rows = opts.rows; + // eslint-disable-next-line @typescript-eslint/no-unsafe-call const term = new (Terminal as any)({ - cols: opts.cols, - rows: opts.rows, + cols, + rows, allowProposedApi: true, convertEol: false, scrollback: 0, @@ -41,16 +45,25 @@ export function createScreen(opts: Readonly<{ cols: number; rows: number }>): { await pending; }; + const resize = async (nextCols: number, nextRows: number): Promise => { + cols = nextCols; + rows = nextRows; + pending = pending.then(() => { + term.resize(nextCols, nextRows); + }); + await pending; + }; + const snapshot = (): ScreenSnapshot => { const lines: string[] = []; - for (let r = 0; r < opts.rows; r++) { + for (let r = 0; r < rows; r++) { const line = term.buffer.active.getLine(r); const text = line?.translateToString(false) ?? ""; - lines.push(text.padEnd(opts.cols, " ").slice(0, opts.cols)); + lines.push(text.padEnd(cols, " ").slice(0, cols)); } const hash = createHash("sha256").update(lines.join("\n")).digest("hex"); - return { cols: opts.cols, rows: opts.rows, lines, hash }; + return { cols, rows, lines, hash }; }; - return { write, flush, snapshot }; + return { write, flush, resize, snapshot }; } diff --git a/packages/ink-compat/src/runtime/render.ts b/packages/ink-compat/src/runtime/render.ts index edb1af5a..661f0afe 100644 --- a/packages/ink-compat/src/runtime/render.ts +++ b/packages/ink-compat/src/runtime/render.ts @@ -2186,7 +2186,7 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions const detailOpLimit = traceDetailFull ? 4000 : 500; const detailResizeLimit = traceDetailFull ? 300 : 80; const writeErr = (stderr as { write: (s: string) => void }).write.bind(stderr); - const traceStartAt = Date.now(); + const traceStartAt = performance.now(); const trace = (message: string): void => { if (!traceEnabled) return; @@ -2720,7 +2720,7 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions let rectScanMs = 0; let ansiMs = 0; try { - const frameNow = Date.now(); + const frameNow = performance.now(); const nextViewport = readViewportSize(stdout, fallbackStdout); const viewportChanged = nextViewport.cols !== viewport.cols || nextViewport.rows !== viewport.rows; @@ -2728,10 +2728,12 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions viewport = nextViewport; } - const translationStartedAt = phaseProfile ? performance.now() : 0; + const timePhases = phaseProfile != null || BENCH_PHASES_ENABLED; + + const translationStartedAt = timePhases ? performance.now() : 0; const { vnode: translatedDynamic, meta: translationMeta } = bridge.translateDynamicWithMetadata(); - if (phaseProfile) translationMs = performance.now() - translationStartedAt; + if (timePhases) translationMs = performance.now() - translationStartedAt; const hasDynamicPercentMarkers = translationMeta.hasPercentMarkers; // In static-channel mode, static output is rendered above the dynamic @@ -2750,7 +2752,7 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions let percentDeps: PercentResolveDeps | null = null; const percentResolveStartedAt = - phaseProfile && hasDynamicPercentMarkers ? performance.now() : 0; + timePhases && hasDynamicPercentMarkers ? performance.now() : 0; let translatedDynamicWithPercent = translatedDynamic; if (hasDynamicPercentMarkers) { percentDeps = { parents: new Map(), missingParentLayout: false }; @@ -2767,16 +2769,16 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions const coerced = coerceRootViewportHeight(translatedDynamicWithPercent, layoutViewport); vnode = coerced.vnode; rootHeightCoerced = coerced.coerced; - if (phaseProfile && hasDynamicPercentMarkers) { + if (timePhases && hasDynamicPercentMarkers) { percentResolveMs += performance.now() - percentResolveStartedAt; } coreRenderPassesThisFrame = 1; - const renderStartedAt = phaseProfile ? performance.now() : 0; + const renderStartedAt = timePhases ? performance.now() : 0; let result = renderer.render(vnode, { viewport: layoutViewport }); - if (phaseProfile) coreRenderMs += performance.now() - renderStartedAt; + if (timePhases) coreRenderMs += performance.now() - renderStartedAt; - const assignLayoutsStartedAt = phaseProfile ? performance.now() : 0; + const assignLayoutsStartedAt = timePhases ? performance.now() : 0; assignHostLayouts( bridge.rootNode, result.nodes as readonly { @@ -2784,7 +2786,7 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions props?: Record; }[], ); - if (phaseProfile) assignLayoutsMs += performance.now() - assignLayoutsStartedAt; + if (timePhases) assignLayoutsMs += performance.now() - assignLayoutsStartedAt; if (hasDynamicPercentMarkers) { // 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 @@ -2808,7 +2810,7 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions if (needsSecondPass) { coreRenderPassesThisFrame = 2; - const secondPercentStartedAt = phaseProfile ? performance.now() : 0; + const secondPercentStartedAt = timePhases ? performance.now() : 0; translatedDynamicWithPercent = resolvePercentMarkers(translatedDynamic, { parentSize: layoutViewport, parentMainAxis: "column", @@ -2816,13 +2818,13 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions const secondPass = coerceRootViewportHeight(translatedDynamicWithPercent, layoutViewport); vnode = secondPass.vnode; rootHeightCoerced = rootHeightCoerced || secondPass.coerced; - if (phaseProfile) percentResolveMs += performance.now() - secondPercentStartedAt; + if (timePhases) percentResolveMs += performance.now() - secondPercentStartedAt; - const secondRenderStartedAt = phaseProfile ? performance.now() : 0; + const secondRenderStartedAt = timePhases ? performance.now() : 0; result = renderer.render(vnode, { viewport: layoutViewport }); - if (phaseProfile) coreRenderMs += performance.now() - secondRenderStartedAt; + if (timePhases) coreRenderMs += performance.now() - secondRenderStartedAt; - const secondAssignStartedAt = phaseProfile ? performance.now() : 0; + const secondAssignStartedAt = timePhases ? performance.now() : 0; assignHostLayouts( bridge.rootNode, result.nodes as readonly { @@ -2830,12 +2832,12 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions props?: Record; }[], ); - if (phaseProfile) assignLayoutsMs += performance.now() - secondAssignStartedAt; + if (timePhases) assignLayoutsMs += performance.now() - secondAssignStartedAt; } } checkAllResizeObservers(); - const rectScanStartedAt = phaseProfile ? performance.now() : 0; + const rectScanStartedAt = timePhases ? performance.now() : 0; // Compute maxRectBottom from layout result — needed to size the ANSI // grid correctly in non-alternate-buffer mode. let minRectY = Number.POSITIVE_INFINITY; @@ -2851,7 +2853,7 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions maxRectBottom = Math.max(maxRectBottom, y + h); if (h === 0) zeroHeightRects += 1; } - if (phaseProfile) rectScanMs = performance.now() - rectScanStartedAt; + if (timePhases) rectScanMs = performance.now() - rectScanStartedAt; // Keep non-alt output content-sized by using computed layout height. // When root coercion applies (overflow hidden/scroll), maxRectBottom @@ -2862,13 +2864,13 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions const frameHasAnsiSgr = translationMeta.hasAnsiSgr; const frameColorSupport = frameHasAnsiSgr ? FORCED_TRUECOLOR_SUPPORT : colorSupport; - const ansiStartedAt = phaseProfile ? performance.now() : 0; + const ansiStartedAt = timePhases ? performance.now() : 0; const { ansi: rawAnsiOutput, grid: cellGrid, shape: outputShape, } = renderOpsToAnsi(result.ops as readonly RenderOp[], gridViewport, frameColorSupport); - if (phaseProfile) ansiMs = performance.now() - ansiStartedAt; + if (timePhases) 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 @@ -3155,7 +3157,7 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions const force = pendingRenderForce; pendingRender = false; pendingRenderForce = false; - lastRenderAt = Date.now(); + lastRenderAt = performance.now(); renderFrame(force); if (pendingRender) { scheduleRender(pendingRenderForce); @@ -3170,13 +3172,13 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions const nextForce = pendingRenderForce; pendingRender = false; pendingRenderForce = false; - lastRenderAt = Date.now(); + lastRenderAt = performance.now(); renderFrame(nextForce); return; } if (throttledRenderTimer !== undefined) return; - const elapsed = Date.now() - lastRenderAt; + const elapsed = performance.now() - lastRenderAt; const waitMs = Math.max(0, renderIntervalMs - elapsed); throttledRenderTimer = setTimeout(flushScheduledRender, waitMs); throttledRenderTimer.unref?.(); @@ -3245,7 +3247,7 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions const scheduleResize = (source: string): void => { const latest = readViewportSize(stdout, fallbackStdout); - const now = Date.now(); + const now = performance.now(); lastResizeSignalAt = now; resizeTimeline.push({ at: now, @@ -3261,7 +3263,7 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions if (!changed) return; viewport = latest; - const flushAt = Date.now(); + const flushAt = performance.now(); lastResizeFlushAt = flushAt; resizeTimeline.push({ at: flushAt, @@ -3384,7 +3386,7 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions pendingRender = false; pendingRenderForce = false; renderFrame(true); - lastRenderAt = Date.now(); + lastRenderAt = performance.now(); let cleanedUp = false; function cleanup(unmountTree: boolean): void { From d235c1383469f87f619eb7905d171fc2e3e6be33 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 21:42:44 +0400 Subject: [PATCH 15/29] feat(bench): add validity doc and reporting --- BENCHMARK_VALIDITY.md | 212 ++++++++++++++++++ README.md | 34 +++ packages/bench-app/src/entry.tsx | 52 ++++- packages/bench-harness/src/procSampler.ts | 38 ++-- packages/bench-runner/src/cli.ts | 59 +++++ results/bottlenecks.md | 22 ++ results/report_2026-02-27.md | 112 +++++++++ scripts/ink-compat-bench/preload.mjs | 95 ++++++++ .../ink-compat-bench/summarize-cpuprofile.mjs | 187 +++++++++++++++ 9 files changed, 787 insertions(+), 24 deletions(-) create mode 100644 BENCHMARK_VALIDITY.md create mode 100644 results/bottlenecks.md create mode 100644 results/report_2026-02-27.md create mode 100644 scripts/ink-compat-bench/preload.mjs create mode 100644 scripts/ink-compat-bench/summarize-cpuprofile.mjs diff --git a/BENCHMARK_VALIDITY.md b/BENCHMARK_VALIDITY.md new file mode 100644 index 00000000..0feec79b --- /dev/null +++ b/BENCHMARK_VALIDITY.md @@ -0,0 +1,212 @@ +# Ink vs Ink-Compat Benchmark Validity (FAIR) + +This benchmark suite compares: + +- **`real-ink`**: `@jrichman/ink` +- **`ink-compat`**: `@rezi-ui/ink-compat` (Rezi-backed Ink replacement) + +The **exact same benchmark app code** runs in both modes (`packages/bench-app`). The only difference is **module resolution for `ink`** at runtime (a symlink in `packages/bench-app/node_modules/ink`). + +If equivalence fails, the benchmark is **invalid** until fixed. + +## Renderer Selection (Same App Code) + +`bench-app` imports from `ink` normally: + +- `import { render, Box, Text } from "ink"` + +`bench-runner` switches renderers by linking: + +- `real-ink`: `packages/bench-app/node_modules/ink -> node_modules/@jrichman/ink` +- `ink-compat`: `packages/bench-app/node_modules/ink -> packages/ink-compat` + +React is resolved from the workspace root, keeping a singleton React version across both runs. + +## Determinism & Terminal Fairness + +### PTY + fixed terminal + +All runs execute inside a PTY (`node-pty`) with fixed dimensions: + +- `BENCH_COLS` (default `80`) +- `BENCH_ROWS` (default `24`) + +The harness forces a consistent terminal identity: + +- `TERM=xterm-256color` +- `COLUMNS`, `LINES` set to `BENCH_COLS/BENCH_ROWS` +- `FORCE_COLOR=1` + +### Rendering mode + +The benchmark app uses consistent Ink render options for both renderers: + +- `alternateBuffer: false` +- `incrementalRendering: true` +- `maxFps: BENCH_MAX_FPS` (default `60`) +- `patchConsole: false` + +### Offline, scripted inputs + +Scenarios are driven via a control socket (JSON lines) plus optional scripted keypresses/resizes: + +- streaming tokens / ticks are deterministic +- large-list-scroll uses scripted `↓` inputs +- no network access + +## Output Equivalence (Correctness Gate) + +For every verify run: + +1. The harness captures **raw PTY output bytes** (`pty-output.bin`). +2. Output is applied to a headless terminal (`@xterm/headless`) to reconstruct the **final screen buffer** (`screen-final.txt`). +3. `npm run verify` compares `real-ink` vs `ink-compat` final screens. + +Rules: + +- If **final screen differs**, the scenario comparison is **invalid**. +- If intermediate frames differ but the final screen matches, we allow it and report only final equivalence (intermediate equivalence is currently **UNPROVEN**). + +Known limitation: + +- `resize-storm` currently fails final-screen equivalence and is excluded from valid comparisons. + +## Settle Detection (Time To Stable) + +The harness tracks a rolling screen hash and marks the run **stable** when: + +- screen hash is unchanged for `stableWindowMs` (default `250ms`) + +Reported: + +- `timeToStableMs`: time from start until the first moment stability is satisfied +- `meanWallS`: end-to-end wall time to settle (`timeToStableMs/1000`, falling back to total duration if stability isn’t reached) + +## Meaningful Paint Signal + +The benchmark app always renders a deterministic marker: + +- `BENCH_READY ...` + +The harness reports: + +- `timeToFirstMeaningfulPaintMs`: first time any screen line contains `BENCH_READY` + +## Metrics: What’s Measured (Per Frame) + +Per-frame metrics are written as JSONL: + +- `packages/bench-app/dist/entry.js` writes `frames.jsonl` on exit. + +Each frame corresponds to one `onRender()` callback invocation. + +### Shared metrics + +- `renderTimeMs` + - from renderer `onRender({renderTime})` + - **excludes** any time spent waiting for throttling/scheduling +- `layoutTimeMs` + - **real-ink only**: Yoga layout wall time measured via preload instrumentation (see below) +- `renderTotalMs` + - `renderTimeMs + (layoutTimeMs ?? 0)` + - primary “render CPU work” accumulator for comparisons (still see UNPROVEN caveats) +- `scheduleWaitMs` + - time from “first update requested” until “frame start” + - reported separately; **excluded** from `renderTotalMs` +- `stdoutWriteMs`, `stdoutBytes`, `stdoutWrites` + - measured by wrapping `process.stdout.write()` inside the app + - **caveat**: this measures JS time spent in `write()` calls, not kernel flush completion +- `updatesRequestedDelta` + - number of app updates requested since prior frame + - used to compute coalescing stats (`updatesRequested`, `updatesPerFrameMean`, etc.) + +### Ink-compat-only phase breakdown (when enabled) + +When `BENCH_INK_COMPAT_PHASES=1`, `ink-compat` emits phase timings into the app’s frame record: + +- `translationMs` +- `percentResolveMs` +- `coreRenderMs` +- `assignLayoutsMs` +- `rectScanMs` +- `ansiMs` +- plus node/op counts + +High-cardinality counters (translation cache hits/misses, etc.) are gated by: + +- `BENCH_DETAIL=1` + +## Metrics: What’s Measured (Per Run) + +Per-run summaries are written to `run-summary.json` and `batch-summary.json`: + +Primary KPIs: + +- `meanWallS` +- `totalCpuTimeS` + - derived from `/proc//stat` sampling (user+system ticks), converted using `getconf CLK_TCK` +- `meanRenderTotalMs` (sum of per-frame `renderTotalMs`) +- `timeToFirstMeaningfulPaintMs` +- `timeToStableMs` + +Secondary KPIs: + +- render latency distribution: `renderTotalP50Ms`, `renderTotalP95Ms`, `renderTotalP99Ms`, `renderTotalMaxMs` +- scheduling distribution: `scheduleWaitP50Ms`, `scheduleWaitP95Ms`, `scheduleWaitP99Ms`, `scheduleWaitMaxMs` +- coalescing stats: `updatesRequested`, `updatesPerFrameMean`, `framesWithCoalescedUpdates`, `maxUpdatesInFrame` +- I/O stats: `writes`, `bytes`, `renderMsPerKB` +- memory: `peakRssBytes` (from `/proc` samples) + +## What `renderTime` Includes / Excludes (Renderer-Specific) + +### `real-ink` (`@jrichman/ink`) + +`renderTimeMs` comes from Ink’s `onRender` callback. + +- **Includes**: Ink’s JS-side render pipeline inside `Ink.onRender()` (output generation + stdout writes). +- **Excludes**: Yoga layout time, because Yoga layout runs via `rootNode.onComputeLayout()` during React commit (`resetAfterCommit`). + +To make comparisons fair, we instrument Yoga layout: + +- A preload script patches each Ink instance’s `rootNode.onComputeLayout` to time Yoga layout and attaches `layoutTimeMs` to the `onRender` metrics. +- The benchmark uses `renderTotalMs = renderTimeMs + layoutTimeMs`. + +### `ink-compat` (`@rezi-ui/ink-compat`) + +`renderTimeMs` is measured around `renderFrame()`: + +- **Includes** (when phases enabled): translation, percent resolve, Rezi core render/layout, ANSI serialization, stdout write. +- **Excludes**: time spent waiting on throttle / scheduling. + +## UNPROVEN / Known Gaps (and how to prove) + +### React reconcile/commit time breakdown + +We do **not** currently provide a proven, apples-to-apples split of: + +- React reconcile time +- React commit time + +for both renderers. + +Minimum instrumentation to prove: + +- Add an optional preload (both modes) that wraps `react-reconciler` scheduler entrypoints (e.g. `performWorkOnRootViaSchedulerTask`) and accumulates commit/reconcile durations per frame. +- Alternatively, instrument renderer-specific “commit complete” hooks and wall-clock around them, with care to exclude throttle waits. + +### Intermediate frame equivalence + +We only gate on **final screen** equivalence. + +Minimum instrumentation to prove: + +- During verify, snapshot and hash the reconstructed screen buffer on every frame (or every N ms) and diff sequences. + +### Stdout write latency + +`stdoutWriteMs` is JS time inside `write()`. It does not include terminal emulator processing time. + +Minimum instrumentation to prove: + +- backpressure-aware measurements (bytes accepted vs drained), plus optional `strace`/`perf` outside this suite. + diff --git a/README.md b/README.md index 2495d019..2ceff88f 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,40 @@ Numbers are from a single-replicate PTY-mode run on WSL. They are directional, n --- +## Ink-Compat Bench (Ink vs Ink-Compat) + +This repo includes a fairness-focused benchmark + profiling suite that runs the **same TUI app code** against: + +- `real-ink`: `@jrichman/ink` +- `ink-compat`: `@rezi-ui/ink-compat` + +Key commands: + +```bash +# build bench packages +npm run prebench + +# (optional) set up module resolution for bench-app explicitly +npm run prepare:real-ink +npm run prepare:ink-compat + +# run a scenario (3 replicates) +npm run -s bench -- --scenario streaming-chat --renderer real-ink --runs 3 --out results/ +npm run -s bench -- --scenario streaming-chat --renderer ink-compat --runs 3 --out results/ + +# CPU profiling (writes .cpuprofile under results/.../run_XX/cpu-prof/) +npm run -s bench -- --scenario dashboard-grid --renderer ink-compat --runs 1 --cpu-prof --out results/ + +# final-screen equivalence gate +npm run -s verify -- --scenario streaming-chat --compare real-ink,ink-compat --out results/ +``` + +Docs + reports: + +- Methodology + metric definitions: `BENCHMARK_VALIDITY.md` +- Latest report: `results/report_2026-02-27.md` +- Bottlenecks + fixes: `results/bottlenecks.md` + ## Quick Start Get running in under a minute: diff --git a/packages/bench-app/src/entry.tsx b/packages/bench-app/src/entry.tsx index dcafc1b2..68220c25 100644 --- a/packages/bench-app/src/entry.tsx +++ b/packages/bench-app/src/entry.tsx @@ -38,11 +38,20 @@ type InkCompatFrameBreakdown = Readonly<{ nodes: number; ops: number; coreRenderPasses: number; + translatedNodes?: number; + translationCacheHits?: number; + translationCacheMisses?: number; + translationCacheEmptyMisses?: number; + translationCacheStaleMisses?: number; + parseAnsiFastPathHits?: number; + parseAnsiFallbackPathHits?: number; }>; type FrameMetric = Readonly<{ frame: number; tsMs: number; + renderTimeMs: number; + layoutTimeMs: number | null; renderTotalMs: number; scheduleWaitMs: number | null; stdoutWriteMs: number; @@ -58,6 +67,13 @@ type FrameMetric = Readonly<{ nodes: number | null; ops: number | null; coreRenderPasses: number | null; + translatedNodes: number | null; + translationCacheHits: number | null; + translationCacheMisses: number | null; + translationCacheEmptyMisses: number | null; + translationCacheStaleMisses: number | null; + parseAnsiFastPathHits: number | null; + parseAnsiFallbackPathHits: number | null; }>; function resolveInkImpl(): { resolvedFrom: string; name: string; version: string } { @@ -526,8 +542,21 @@ function BenchApp(props: { return () => clearTimeout(t); }, [exit]); - (globalThis as unknown as { __BENCH_ON_RENDER?: (renderTimeMs: number) => void }).__BENCH_ON_RENDER = - (renderTimeMs: number): void => { + (globalThis as unknown as { __BENCH_ON_RENDER?: (metrics: unknown) => void }).__BENCH_ON_RENDER = ( + metrics: unknown, + ): void => { + const renderTimeMs = + metrics && typeof metrics === "object" && "renderTime" in (metrics as any) + ? (metrics as any).renderTime + : 0; + const layoutTimeMs = + metrics && typeof metrics === "object" && "layoutTimeMs" in (metrics as any) + ? (metrics as any).layoutTimeMs + : null; + const layoutMsSafe = typeof layoutTimeMs === "number" && Number.isFinite(layoutTimeMs) ? layoutTimeMs : null; + const renderTimeMsSafe = + typeof renderTimeMs === "number" && Number.isFinite(renderTimeMs) ? renderTimeMs : 0; + const now = performance.now(); const tsMs = now - startAt; @@ -539,7 +568,7 @@ function BenchApp(props: { let scheduleWaitMs: number | null = null; if (state.firstUpdateRequestedAtMs != null) { - const frameStartApprox = now - renderTimeMs; + const frameStartApprox = now - renderTimeMsSafe; scheduleWaitMs = Math.max(0, frameStartApprox - state.firstUpdateRequestedAtMs); stateRef.current = { ...stateRef.current, firstUpdateRequestedAtMs: null }; } @@ -550,7 +579,9 @@ function BenchApp(props: { framesRef.current.push({ frame: framesRef.current.length + 1, tsMs, - renderTotalMs: renderTimeMs, + renderTimeMs: renderTimeMsSafe, + layoutTimeMs: layoutMsSafe, + renderTotalMs: renderTimeMsSafe + (layoutMsSafe ?? 0), scheduleWaitMs, stdoutWriteMs: stdout.writeMs, stdoutBytes: stdout.bytes, @@ -565,6 +596,13 @@ function BenchApp(props: { nodes: compat?.nodes ?? null, ops: compat?.ops ?? null, coreRenderPasses: compat?.coreRenderPasses ?? null, + translatedNodes: compat?.translatedNodes ?? null, + translationCacheHits: compat?.translationCacheHits ?? null, + translationCacheMisses: compat?.translationCacheMisses ?? null, + translationCacheEmptyMisses: compat?.translationCacheEmptyMisses ?? null, + translationCacheStaleMisses: compat?.translationCacheStaleMisses ?? null, + parseAnsiFastPathHits: compat?.parseAnsiFastPathHits ?? null, + parseAnsiFallbackPathHits: compat?.parseAnsiFallbackPathHits ?? null, }); }; @@ -631,10 +669,10 @@ function main(): void { maxFps: Number.parseInt(process.env["BENCH_MAX_FPS"] ?? "60", 10) || 60, patchConsole: false, debug: false, - onRender: ({ renderTime }) => { - const hook = (globalThis as unknown as { __BENCH_ON_RENDER?: (rt: number) => void }) + onRender: (metrics) => { + const hook = (globalThis as unknown as { __BENCH_ON_RENDER?: (m: unknown) => void }) .__BENCH_ON_RENDER; - hook?.(renderTime); + hook?.(metrics); }, }, ); diff --git a/packages/bench-harness/src/procSampler.ts b/packages/bench-harness/src/procSampler.ts index 81a991fc..70298638 100644 --- a/packages/bench-harness/src/procSampler.ts +++ b/packages/bench-harness/src/procSampler.ts @@ -1,4 +1,5 @@ import { readFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; import { setTimeout as delay } from "node:timers/promises"; export type ProcSample = Readonly<{ @@ -13,29 +14,33 @@ export type ProcSamplerOptions = Readonly<{ intervalMs: number; }>; -function readProcStatusRssBytes(pid: number): number | null { +const PAGE_SIZE_BYTES: number = (() => { try { - const status = readFileSync(`/proc/${pid}/status`, "utf8"); - const match = status.match(/^VmRSS:\\s+(\\d+)\\s+kB\\s*$/m); - if (!match) return null; - const kb = Number.parseInt(match[1] ?? "", 10); - return Number.isFinite(kb) ? kb * 1024 : null; + const out = execFileSync("getconf", ["PAGESIZE"], { encoding: "utf8" }).trim(); + const v = Number.parseInt(out, 10); + if (Number.isFinite(v) && v > 0) return v; + return 4096; } catch { - return null; + return 4096; } -} +})(); -function readProcStatTicks(pid: number): { user: number; system: number } | null { +function readProcStat(pid: number): { user: number; system: number; rssBytes: number | null } | null { try { const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); const end = stat.lastIndexOf(")"); if (end < 0) return null; - const after = stat.slice(end + 2); - const parts = after.split(" "); + const after = stat.slice(end + 2).trim(); + const parts = after.split(/\s+/); const utime = Number.parseInt(parts[11] ?? "", 10); const stime = Number.parseInt(parts[12] ?? "", 10); + const rssPages = Number.parseInt(parts[21] ?? "", 10); if (!Number.isFinite(utime) || !Number.isFinite(stime)) return null; - return { user: utime, system: stime }; + return { + user: utime, + system: stime, + rssBytes: Number.isFinite(rssPages) && rssPages >= 0 ? rssPages * PAGE_SIZE_BYTES : null, + }; } catch { return null; } @@ -48,13 +53,12 @@ export async function sampleProcUntilExit( const samples: ProcSample[] = []; while (!isExited()) { const now = performance.now(); - const rssBytes = readProcStatusRssBytes(opts.pid); - const ticks = readProcStatTicks(opts.pid); + const stat = readProcStat(opts.pid); samples.push({ ts: now, - rssBytes, - cpuUserTicks: ticks?.user ?? null, - cpuSystemTicks: ticks?.system ?? null, + rssBytes: stat?.rssBytes ?? null, + cpuUserTicks: stat?.user ?? null, + cpuSystemTicks: stat?.system ?? null, }); await delay(opts.intervalMs); } diff --git a/packages/bench-runner/src/cli.ts b/packages/bench-runner/src/cli.ts index b68f0e5b..8a661664 100644 --- a/packages/bench-runner/src/cli.ts +++ b/packages/bench-runner/src/cli.ts @@ -59,6 +59,27 @@ function computeCpuSecondsFromProcSamples( return dt / clkTck; } +function computePeakRssBytesFromProcSamples( + samples: readonly { rssBytes: number | null }[], +): number | null { + let peak: number | null = null; + for (const s of samples) { + const v = s.rssBytes; + if (typeof v !== "number" || !Number.isFinite(v)) continue; + peak = peak == null ? v : Math.max(peak, v); + } + return peak; +} + +function percentileMs(values: readonly number[], p: number): number | null { + const xs = values.filter((v) => typeof v === "number" && Number.isFinite(v)); + if (xs.length === 0) return null; + const sorted = xs.slice().sort((a, b) => a - b); + const q = Math.min(1, Math.max(0, p)); + const idx = Math.max(0, Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * q))); + return sorted[idx] ?? null; +} + async function openControlServer(socketPath: string): Promise<{ sendLine: (obj: unknown) => void; waitForClient: (timeoutMs: number) => Promise; @@ -198,6 +219,7 @@ async function main(): Promise { const repoRoot = process.cwd(); const appEntry = path.join(repoRoot, "packages/bench-app/dist/entry.js"); + const preloadPath = path.join(repoRoot, "scripts/ink-compat-bench/preload.mjs"); const clkTck = await getClockTicksPerSecond(); mkdirSync(outRoot, { recursive: true }); @@ -221,6 +243,8 @@ async function main(): Promise { const args = [ "--no-warnings", + "--import", + preloadPath, ...(cpuProf ? [ "--cpu-prof", @@ -245,6 +269,7 @@ async function main(): Promise { BENCH_EXIT_AFTER_DONE_MS: process.env["BENCH_EXIT_AFTER_DONE_MS"] ?? String(Math.max(0, stableWindowMs + 50)), BENCH_INK_COMPAT_PHASES: process.env["BENCH_INK_COMPAT_PHASES"] ?? "1", + BENCH_DETAIL: process.env["BENCH_DETAIL"] ?? "0", BENCH_MAX_FPS: process.env["BENCH_MAX_FPS"] ?? "60", }; @@ -292,10 +317,31 @@ async function main(): Promise { const result = await Promise.all([runPromise, drivePromise]).then(([r]) => r); const frames = safeReadJsonl(path.join(runDir, "frames.jsonl")); + const renderTotalsMs = frames + .map((f) => f["renderTotalMs"]) + .filter((v): v is number => typeof v === "number" && Number.isFinite(v)); + const scheduleWaitsMs = frames + .map((f) => f["scheduleWaitMs"]) + .filter((v): v is number => typeof v === "number" && Number.isFinite(v)); + const updatesRequested = frames.reduce((a, f) => { + const v = f["updatesRequestedDelta"]; + return a + (typeof v === "number" && Number.isFinite(v) ? v : 0); + }, 0); + const framesWithCoalescedUpdates = frames.reduce((a, f) => { + const v = f["updatesRequestedDelta"]; + return a + (typeof v === "number" && Number.isFinite(v) && v > 1 ? 1 : 0); + }, 0); + const maxUpdatesInFrame = frames.reduce((a, f) => { + const v = f["updatesRequestedDelta"]; + if (typeof v !== "number" || !Number.isFinite(v)) return a; + return Math.max(a, v); + }, 0); + const renderTotalMs = frames.reduce((a, f) => a + (Number(f["renderTotalMs"]) || 0), 0); const stdoutBytes = frames.reduce((a, f) => a + (Number(f["stdoutBytes"]) || 0), 0); const stdoutWrites = frames.reduce((a, f) => a + (Number(f["stdoutWrites"]) || 0), 0); const cpuSeconds = computeCpuSecondsFromProcSamples(result.procSamples, clkTck); + const peakRssBytes = computePeakRssBytesFromProcSamples(result.procSamples); const summary = { scenario, @@ -310,6 +356,19 @@ async function main(): Promise { bytes: stdoutBytes, renderMsPerKB: stdoutBytes > 0 ? renderTotalMs / (stdoutBytes / 1024) : null, framesEmitted: frames.length, + updatesRequested, + updatesPerFrameMean: frames.length > 0 ? updatesRequested / frames.length : null, + framesWithCoalescedUpdates, + maxUpdatesInFrame, + renderTotalP50Ms: percentileMs(renderTotalsMs, 0.5), + renderTotalP95Ms: percentileMs(renderTotalsMs, 0.95), + renderTotalP99Ms: percentileMs(renderTotalsMs, 0.99), + renderTotalMaxMs: percentileMs(renderTotalsMs, 1), + scheduleWaitP50Ms: percentileMs(scheduleWaitsMs, 0.5), + scheduleWaitP95Ms: percentileMs(scheduleWaitsMs, 0.95), + scheduleWaitP99Ms: percentileMs(scheduleWaitsMs, 0.99), + scheduleWaitMaxMs: percentileMs(scheduleWaitsMs, 1), + peakRssBytes, ...result, }; diff --git a/results/bottlenecks.md b/results/bottlenecks.md new file mode 100644 index 00000000..1529f3ea --- /dev/null +++ b/results/bottlenecks.md @@ -0,0 +1,22 @@ +# Ink-Compat Bottlenecks (Ranked) + +This table is based on: + +- Per-frame JSONL timings (`frames.jsonl`) +- Per-run summaries (`run-summary.json` / `batch-summary.json`) +- CPU profiles (`--cpu-prof`, `.cpuprofile`) + +Evidence directories are local `results/ink-bench_*` batches (ignored by git; reproducible via `npm run bench`). + +| Rank | Bottleneck | Location | % cost | Evidence | Fix plan | Expected gain | Actual gain | +|---:|---|---|---:|---|---|---:|---:| +| 1 | **Extra frames (poor coalescing) on `ink-compat`** under `maxFps` | `packages/ink-compat/src/runtime/render.ts` (`createThrottle`, `scheduleRender`) | **Work multiplier** (frames/update) | Baseline `dashboard-grid`: `ink-compat` emitted ~`136` frames for `140` updates vs Ink ~`78` for `140` (renderTotal `+36%`). Batch: `results/ink-bench_dashboard-grid_ink-compat_2026-02-27T16-54-55-795Z` vs `results/ink-bench_dashboard-grid_real-ink_2026-02-27T16-54-43-532Z`. | Implement Ink-matching throttle/coalescing semantics for commit-triggered renders (debounce+maxWait), keep resize redraw correctness. | 20–50% lower `meanRenderTotalMs` in frame-heavy scenarios | Achieved: `dashboard-grid` renderTotal **121ms → 81ms** (`-33%`) and frames **136 → 72** (`-47%`). Batch: `results/ink-bench_dashboard-grid_ink-compat_2026-02-27T17-33-45-750Z`. Similar gains in `style-churn` and `streaming-chat` (frames normalized; renderTotal fell 27–37%). | +| 2 | **Translation cache invalidation (revision churn)** preventing `propsToVNode` reuse | `packages/ink-compat/src/reconciler/types.ts` (`setNodeProps`, `propsSemanticallyEqual`) + `packages/ink-compat/src/translation/propsToVNode.ts` | ~5–15% of `renderTimeMs` (scenario-dependent) | With `BENCH_DETAIL=1`, observed persistent stale-miss patterns (cache hits ~0) until semantic-equality short-circuiting was added; after fix, hits appear and `translatedNodes` drops (see `dashboard-grid` detail runs). | Keep semantic equality guardrails; expand safely to more prop shapes if needed; add correctness tests for edge props. | 5–20% lower translation time / alloc churn | Achieved: translation counters show non-zero cache hits post-fix; per-frame `translationMs` fell and stabilized (see `BENCH_DETAIL=1` runs). | +| 3 | **Unnecessary commitUpdate calls** due to non-null “no update” payload | `packages/ink-compat/src/reconciler/hostConfig.ts` (`prepareUpdate`) | Small (overhead per commit) | Unit test + reconciler behavior: returning `false` triggers commitUpdate path in React; returning `null` skips it. | Return `null` when props are shallow-equal (excluding `children`/`ref`/`key`), keep commitUpdate fast-paths. | Low single-digit % CPU | Achieved: test suite confirms `null` semantics; reduces commitUpdate dispatch work. | +| 4 | **Fairness bug: Ink `renderTime` excludes Yoga layout** (measurement only) | `scripts/ink-compat-bench/preload.mjs` (real Ink instance patching) | n/a | Verified by call stack: Yoga layout occurs in `resetAfterCommit` via `rootNode.onComputeLayout`, outside Ink’s `onRender.renderTime`. | Measure Yoga layout separately and include in `renderTotalMs` for Ink. | n/a (correctness) | Achieved: `layoutTimeMs` is now recorded for `real-ink` frames, making `renderTotalMs` comparable. | + +## Notes + +- `% cost` for “extra frames” is not a single function’s self-time: it’s a **multiplier** on all per-frame costs (translation/layout/ANSI/write). +- `.cpuprofile` captures wall-clock samples and includes idle; use it for **call stacks**, not as the sole source of %CPU. + diff --git a/results/report_2026-02-27.md b/results/report_2026-02-27.md new file mode 100644 index 00000000..8f0514de --- /dev/null +++ b/results/report_2026-02-27.md @@ -0,0 +1,112 @@ +# Ink vs Ink-Compat Benchmark Report (2026-02-27) + +This report summarizes the first benchmark matrix run and the latest post-optimization results. + +## Validity status + +- Final-screen equivalence checks pass for: + - `streaming-chat` + - `large-list-scroll` + - `dashboard-grid` + - `style-churn` +- `resize-storm` is currently **invalid** (final screen mismatch) and excluded. + +Verification command: + +```bash +for s in streaming-chat large-list-scroll dashboard-grid style-churn; do + npm run -s verify -- --scenario "$s" --compare real-ink,ink-compat --out results/ +done +``` + +Methodology + metric definitions: `BENCHMARK_VALIDITY.md`. + +## First benchmark matrix run (baseline) + +Baseline batches (3 runs each): + +- streaming-chat: `results/ink-bench_streaming-chat_real-ink_2026-02-27T16-53-44-910Z`, `results/ink-bench_streaming-chat_ink-compat_2026-02-27T16-53-58-327Z` +- large-list-scroll: `results/ink-bench_large-list-scroll_real-ink_2026-02-27T16-54-11-519Z`, `results/ink-bench_large-list-scroll_ink-compat_2026-02-27T16-54-27-799Z` +- dashboard-grid: `results/ink-bench_dashboard-grid_real-ink_2026-02-27T16-54-43-532Z`, `results/ink-bench_dashboard-grid_ink-compat_2026-02-27T16-54-55-795Z` +- style-churn: `results/ink-bench_style-churn_real-ink_2026-02-27T16-55-07-624Z`, `results/ink-bench_style-churn_ink-compat_2026-02-27T16-55-21-208Z` + +Key findings: + +- `ink-compat` already had a **large CPU advantage** vs Ink (≈ `-17%` to `-48%` totalCpuTimeS). +- However, `ink-compat` emitted **far more frames** in `dashboard-grid`, `style-churn`, and `streaming-chat`, inflating total render work: + - `dashboard-grid` renderTotal was **worse** (`+36%`) because frames were ~`+74%`. + +## Changes made (tied to measurements) + +1. **Fair measurement for Ink layout** + - Real Ink `renderTime` excludes Yoga layout; we instrumented Yoga layout via a preload and include it as `layoutTimeMs` so `renderTotalMs` is comparable. + - Code: `scripts/ink-compat-bench/preload.mjs`. + +2. **Reduce translation churn (ink-compat)** + - Avoid bumping host node revisions when props/text are semantically unchanged. + - Code: `packages/ink-compat/src/reconciler/types.ts`. + +3. **Fix throttling/coalescing mismatch (ink-compat)** + - Implement Ink-like commit render throttling (debounce+maxWait style) so `maxFps` coalesces similarly and doesn’t emit ~1 frame per update under 60Hz update streams. + - Keep resize redraw correctness with microtask coalescing (fast redraw + burst coalescing). + - Code: `packages/ink-compat/src/runtime/render.ts`. + +## Latest benchmark matrix run (current) + +Current batches (3 runs each): + +- streaming-chat: `results/ink-bench_streaming-chat_real-ink_2026-02-27T17-32-35-383Z`, `results/ink-bench_streaming-chat_ink-compat_2026-02-27T17-32-48-793Z` +- large-list-scroll: `results/ink-bench_large-list-scroll_real-ink_2026-02-27T17-33-01-880Z`, `results/ink-bench_large-list-scroll_ink-compat_2026-02-27T17-33-18-040Z` +- dashboard-grid: `results/ink-bench_dashboard-grid_real-ink_2026-02-27T17-33-33-711Z`, `results/ink-bench_dashboard-grid_ink-compat_2026-02-27T17-33-45-750Z` +- style-churn: `results/ink-bench_style-churn_real-ink_2026-02-27T17-33-57-492Z`, `results/ink-bench_style-churn_ink-compat_2026-02-27T17-34-10-830Z` + +### Summary (ink-compat vs real-ink) + +| Scenario | meanWallS | totalCpuTimeS | meanRenderTotalMs | peakRSS | framesEmitted | +|---|---:|---:|---:|---:|---:| +| streaming-chat | **-3.8%** | **-41.6%** | **-48.2%** | **-28.9%** | -2.1% | +| large-list-scroll | **-3.3%** | **-38.4%** | **-29.1%** | **-29.0%** | -0.8% | +| dashboard-grid | **-4.7%** | **-28.3%** | **-0.4%** | **-26.5%** | -4.0% | +| style-churn | **-3.0%** | **-56.1%** | **-77.6%** | **-53.7%** | -0.7% | + +Notes: + +- `dashboard-grid` renderTotal is now essentially matched (and slightly better), while CPU is still ~`-28%`. +- `style-churn` is a decisive win: large reductions in CPU, render time, and peak RSS. + +## Before/After deltas for ink-compat (baseline → current) + +The biggest single improvement came from fixing frame coalescing under `maxFps`: + +- `dashboard-grid`: + - frames: **136 → 72** (`-46.8%`) + - renderTotal: **121ms → 81ms** (`-33.1%`) + - cpu: **0.653s → 0.533s** (`-18.4%`) +- `style-churn`: + - frames: **174 → 92** (`-46.9%`) + - renderTotal: **131ms → 83ms** (`-37.0%`) + - cpu: **0.697s → 0.553s** (`-20.6%`) +- `streaming-chat`: + - frames: **178 → 122** (`-31.6%`) + - renderTotal: **122ms → 90ms** (`-26.6%`) + - cpu: **0.683s → 0.617s** (`-9.8%`) + +## Profiling artifacts + +CPU profiles are generated via: + +```bash +npm run -s bench -- --scenario dashboard-grid --renderer ink-compat --runs 1 --cpu-prof --out results/ +``` + +Latest example profiles (one-run captures): + +- Ink: `results/ink-bench_dashboard-grid_real-ink_2026-02-27T17-29-17-074Z/run_01/cpu-prof/dashboard-grid_real-ink_run1.cpuprofile` +- ink-compat: `results/ink-bench_dashboard-grid_ink-compat_2026-02-27T17-29-23-291Z/run_01/cpu-prof/dashboard-grid_ink-compat_run1.cpuprofile` + +Helper (top self-time + stacks): + +```bash +node scripts/ink-compat-bench/summarize-cpuprofile.mjs --top 25 --stacks 10 +``` + diff --git a/scripts/ink-compat-bench/preload.mjs b/scripts/ink-compat-bench/preload.mjs new file mode 100644 index 00000000..4036f7b2 --- /dev/null +++ b/scripts/ink-compat-bench/preload.mjs @@ -0,0 +1,95 @@ +import { existsSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +function findPackageJson(startPath) { + let dir = path.dirname(startPath); + for (let i = 0; i < 25; i += 1) { + const candidate = path.join(dir, "package.json"); + if (existsSync(candidate)) return candidate; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + +async function patchRealInkLayoutTiming() { + const repoRoot = process.cwd(); + const benchAppEntry = path.join(repoRoot, "packages/bench-app/dist/entry.js"); + const req = createRequire(existsSync(benchAppEntry) ? benchAppEntry : import.meta.url); + let inkEntryPath; + try { + inkEntryPath = req.resolve("ink"); + } catch { + return; + } + + const pkgPath = findPackageJson(inkEntryPath); + if (!pkgPath) return; + let pkg; + try { + pkg = JSON.parse(readFileSync(pkgPath, "utf8")); + } catch { + return; + } + if (pkg?.name !== "@jrichman/ink") return; + + const pkgRoot = path.dirname(pkgPath); + const instancesPath = path.join(pkgRoot, "build", "instances.js"); + if (!existsSync(instancesPath)) return; + + const instancesModule = await import(pathToFileURL(instancesPath).href); + const instances = instancesModule?.default; + if (!instances || typeof instances !== "object") return; + + if (instances.__reziInkBenchPatched) return; + instances.__reziInkBenchPatched = true; + + const originalSet = instances.set?.bind(instances); + if (typeof originalSet !== "function") return; + + instances.set = (key, instance) => { + try { + if (instance && typeof instance === "object" && !instance.__reziInkBenchPatched) { + instance.__reziInkBenchPatched = true; + instance.__reziInkBenchLayoutAccumMs = 0; + + const rootNode = instance.rootNode; + if (rootNode && typeof rootNode.onComputeLayout === "function") { + const originalComputeLayout = rootNode.onComputeLayout.bind(rootNode); + rootNode.onComputeLayout = () => { + const start = performance.now(); + const ret = originalComputeLayout(); + const dt = performance.now() - start; + instance.__reziInkBenchLayoutAccumMs = (instance.__reziInkBenchLayoutAccumMs ?? 0) + dt; + return ret; + }; + } + + const options = instance.options; + const originalOnRender = options?.onRender; + if (options && typeof originalOnRender === "function") { + options.onRender = (metrics) => { + const layoutTimeMs = + typeof instance.__reziInkBenchLayoutAccumMs === "number" + ? instance.__reziInkBenchLayoutAccumMs + : 0; + instance.__reziInkBenchLayoutAccumMs = 0; + + if (metrics && typeof metrics === "object") { + return originalOnRender({ ...metrics, layoutTimeMs }); + } + return originalOnRender({ renderTime: 0, output: "", staticOutput: "", layoutTimeMs }); + }; + } + } + } catch { + // ignore + } + return originalSet(key, instance); + }; +} + +await patchRealInkLayoutTiming(); diff --git a/scripts/ink-compat-bench/summarize-cpuprofile.mjs b/scripts/ink-compat-bench/summarize-cpuprofile.mjs new file mode 100644 index 00000000..633fad7b --- /dev/null +++ b/scripts/ink-compat-bench/summarize-cpuprofile.mjs @@ -0,0 +1,187 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; + +function readArg(name, fallback) { + const idx = process.argv.indexOf(`--${name}`); + if (idx === -1) return fallback; + const v = process.argv[idx + 1]; + return v ?? fallback; +} + +function hasFlag(name) { + return process.argv.includes(`--${name}`); +} + +function usage() { + process.stderr.write( + [ + "Usage:", + " node scripts/ink-compat-bench/summarize-cpuprofile.mjs [--top N] [--filter STR] [--stacks N] [--json]", + "", + "Notes:", + " - Self time is attributed to the sampled leaf frame id.", + " - `--filter` matches callFrame.url or functionName substrings.", + ].join("\n") + "\n", + ); +} + +function toDisplayUrl(url) { + if (!url) return "unknown"; + if (url.startsWith("file://")) { + try { + const fsPath = new URL(url).pathname; + url = fsPath; + } catch {} + } + const idx = url.indexOf("/packages/"); + if (idx >= 0) return url.slice(idx + 1); + const parts = url.split(/[\\/]/).filter(Boolean); + return parts.slice(Math.max(0, parts.length - 3)).join("/"); +} + +function formatFrame(node) { + const cf = node.callFrame ?? {}; + const fn = (cf.functionName && cf.functionName.length > 0 ? cf.functionName : "(anonymous)") || "(unknown)"; + const url = toDisplayUrl(cf.url ?? ""); + const line = typeof cf.lineNumber === "number" ? cf.lineNumber + 1 : null; + const col = typeof cf.columnNumber === "number" ? cf.columnNumber + 1 : null; + const loc = line == null ? url : `${url}:${line}${col == null ? "" : `:${col}`}`; + return { fn, loc }; +} + +function buildParentMap(nodes) { + const parentById = new Map(); + for (const n of nodes) { + const children = Array.isArray(n.children) ? n.children : []; + for (const child of children) { + if (typeof child !== "number") continue; + if (!parentById.has(child)) parentById.set(child, n.id); + } + } + return parentById; +} + +function buildStack(nodeId, parentById, nodeById, limit = 16) { + const out = []; + let cur = nodeId; + while (cur != null && out.length < limit) { + const node = nodeById.get(cur); + if (!node) break; + out.push(formatFrame(node)); + cur = parentById.get(cur) ?? null; + } + out.reverse(); + return out; +} + +function ms(us) { + return us / 1000; +} + +async function main() { + const file = process.argv.slice(2).find((a) => !a.startsWith("--")); + if (!file) { + usage(); + process.exitCode = 2; + return; + } + + const topN = Number.parseInt(readArg("top", "25"), 10) || 25; + const stacksN = Number.parseInt(readArg("stacks", "10"), 10) || 10; + const filter = readArg("filter", ""); + const jsonOut = hasFlag("json"); + + const raw = JSON.parse(fs.readFileSync(file, "utf8")); + const nodes = Array.isArray(raw.nodes) ? raw.nodes : []; + const samples = Array.isArray(raw.samples) ? raw.samples : []; + const timeDeltas = Array.isArray(raw.timeDeltas) ? raw.timeDeltas : null; + + const nodeById = new Map(); + for (const n of nodes) { + if (n && typeof n.id === "number") nodeById.set(n.id, n); + } + const parentById = buildParentMap(nodes); + + let totalUs = 0; + const selfUsById = new Map(); + for (let i = 0; i < samples.length; i++) { + const id = samples[i]; + if (typeof id !== "number") continue; + const dt = timeDeltas ? timeDeltas[i] : 1000; + if (typeof dt !== "number" || !Number.isFinite(dt) || dt < 0) continue; + totalUs += dt; + selfUsById.set(id, (selfUsById.get(id) ?? 0) + dt); + } + + const entries = []; + for (const [id, selfUs] of selfUsById.entries()) { + const node = nodeById.get(id); + if (!node) continue; + const { fn, loc } = formatFrame(node); + if (filter) { + const hay = `${fn} ${loc} ${(node.callFrame?.url ?? "")}`.toLowerCase(); + if (!hay.includes(filter.toLowerCase())) continue; + } + entries.push({ id, selfUs, fn, loc }); + } + entries.sort((a, b) => b.selfUs - a.selfUs); + + const top = entries.slice(0, Math.max(1, topN)); + const stackEntries = top.slice(0, Math.max(1, stacksN)).map((e) => { + const stack = buildStack(e.id, parentById, nodeById, 18); + return { + ...e, + stack: stack.map((f) => `${f.fn} (${f.loc})`), + }; + }); + + const out = { + file: path.resolve(file), + totalTimeMs: Math.round(ms(totalUs) * 1000) / 1000, + totalSamples: samples.length, + topSelf: top.map((e) => ({ + selfMs: Math.round(ms(e.selfUs) * 1000) / 1000, + selfPct: totalUs > 0 ? Math.round(((e.selfUs / totalUs) * 100) * 10) / 10 : null, + fn: e.fn, + loc: e.loc, + })), + topStacks: stackEntries.map((e) => ({ + selfMs: Math.round(ms(e.selfUs) * 1000) / 1000, + selfPct: totalUs > 0 ? Math.round(((e.selfUs / totalUs) * 100) * 10) / 10 : null, + fn: e.fn, + loc: e.loc, + stack: e.stack, + })), + }; + + if (jsonOut) { + process.stdout.write(`${JSON.stringify(out, null, 2)}\n`); + return; + } + + process.stdout.write(`cpuprofile: ${out.file}\n`); + process.stdout.write(`total: ${out.totalTimeMs}ms samples=${out.totalSamples}\n\n`); + process.stdout.write(`Top self-time frames (top ${topN}):\n`); + for (const row of out.topSelf.slice(0, topN)) { + process.stdout.write( + ` - ${String(row.selfPct ?? "?").padStart(5)}% ${String(row.selfMs).padStart(8)}ms ${row.fn} (${row.loc})\n`, + ); + } + process.stdout.write(`\nTop stacks (top ${Math.min(stacksN, out.topStacks.length)}):\n`); + for (const row of out.topStacks.slice(0, stacksN)) { + process.stdout.write( + `\n - ${String(row.selfPct ?? "?")}% ${row.selfMs}ms ${row.fn} (${row.loc})\n`, + ); + for (const line of row.stack.slice(Math.max(0, row.stack.length - 10))) { + process.stdout.write(` ${line}\n`); + } + } +} + +main().catch((err) => { + process.stderr.write(err instanceof Error ? (err.stack ?? err.message) : String(err)); + process.stderr.write("\n"); + process.exitCode = 1; +}); + From b698f206c97fdc88870e09f7b757b1a7125fc60a Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 21:43:00 +0400 Subject: [PATCH 16/29] perf(ink-compat): coalesce renders and reduce churn --- .../__tests__/reconciler/hostConfig.test.ts | 2 +- .../ink-compat/src/reconciler/hostConfig.ts | 10 +- packages/ink-compat/src/reconciler/types.ts | 57 +++++++ packages/ink-compat/src/runtime/render.ts | 157 ++++++++++++++---- .../src/translation/propsToVNode.ts | 36 +++- 5 files changed, 218 insertions(+), 44 deletions(-) diff --git a/packages/ink-compat/src/__tests__/reconciler/hostConfig.test.ts b/packages/ink-compat/src/__tests__/reconciler/hostConfig.test.ts index 91d81664..3879970f 100644 --- a/packages/ink-compat/src/__tests__/reconciler/hostConfig.test.ts +++ b/packages/ink-compat/src/__tests__/reconciler/hostConfig.test.ts @@ -103,7 +103,7 @@ test("prepareUpdate performs shallow comparison without children/ref", () => { { id: "a", children: [1], ref: {} }, { id: "a", children: [2], ref: null }, ), - false, + null, ); assert.equal(hostConfig.prepareUpdate(instance, "ink-box", { id: "a" }, { id: "b" }), true); assert.equal(hostConfig.prepareUpdate(instance, "ink-box", { id: "a" }, { id: "a", x: 1 }), true); diff --git a/packages/ink-compat/src/reconciler/hostConfig.ts b/packages/ink-compat/src/reconciler/hostConfig.ts index 0b0b9206..aa8a5dea 100644 --- a/packages/ink-compat/src/reconciler/hostConfig.ts +++ b/packages/ink-compat/src/reconciler/hostConfig.ts @@ -115,8 +115,8 @@ export const hostConfig = { newProps: unknown, _rootContainer?: InkHostContainer, _hostContext?: unknown, - ): boolean { - if (oldProps === newProps) return false; + ): unknown { + if (oldProps === newProps) return null; if (typeof oldProps !== "object" || oldProps === null) return true; if (typeof newProps !== "object" || newProps === null) return true; @@ -131,12 +131,10 @@ export const hostConfig = { if (oldKeys.length !== newKeys.length) return true; for (const key of newKeys) { - if (oldObj[key] !== newObj[key]) { - return true; - } + if (oldObj[key] !== newObj[key]) return true; } - return false; + return null; }, shouldSetTextContent(): boolean { diff --git a/packages/ink-compat/src/reconciler/types.ts b/packages/ink-compat/src/reconciler/types.ts index 886db637..ded90338 100644 --- a/packages/ink-compat/src/reconciler/types.ts +++ b/packages/ink-compat/src/reconciler/types.ts @@ -283,6 +283,57 @@ function isContainer(parent: InkHostNode | InkHostContainer): parent is InkHostC return parent.type === "ink-root" && "onCommit" in parent; } +function isPlainObject(value: unknown): value is Record { + if (typeof value !== "object" || value === null) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +function deepEqual(valueA: unknown, valueB: unknown, depth: number): boolean { + if (valueA === valueB) return true; + if (depth <= 0) return false; + + if (Array.isArray(valueA) && Array.isArray(valueB)) { + if (valueA.length !== valueB.length) return false; + for (let index = 0; index < valueA.length; index += 1) { + if (!deepEqual(valueA[index], valueB[index], depth - 1)) return false; + } + return true; + } + + if (isPlainObject(valueA) && isPlainObject(valueB)) { + const keysA = Object.keys(valueA); + const keysB = Object.keys(valueB); + if (keysA.length !== keysB.length) return false; + for (const key of keysA) { + if (!Object.hasOwn(valueB, key)) return false; + if (!deepEqual(valueA[key], valueB[key], depth - 1)) return false; + } + return true; + } + + return false; +} + +function propsSemanticallyEqual( + previousProps: Record, + nextProps: Record, +): boolean { + if (previousProps === nextProps) return true; + const previousKeys = Object.keys(previousProps); + const nextKeys = Object.keys(nextProps); + if (previousKeys.length !== nextKeys.length) return false; + for (const key of previousKeys) { + if (!Object.hasOwn(nextProps, key)) return false; + const previousValue = previousProps[key]; + const nextValue = nextProps[key]; + if (previousValue === nextValue) continue; + if (deepEqual(previousValue, nextValue, 3)) continue; + return false; + } + return true; +} + export function appendChild(parent: InkHostNode | InkHostContainer, child: InkHostNode): void { detachFromCurrentParent(child); attachToParent(parent, child, null); @@ -331,6 +382,10 @@ export function insertBefore( } export function setNodeProps(node: InkHostNode, props: Record): void { + if (propsSemanticallyEqual(node.props, props)) { + return; + } + const previousSelfStatic = node.__inkSelfHasStatic; node.props = props; @@ -345,6 +400,8 @@ export function setNodeProps(node: InkHostNode, props: Record): } export function setNodeTextContent(node: InkHostNode, textContent: string | null): void { + if (node.textContent === textContent) return; + const previousSelfAnsi = node.__inkSelfHasAnsiSgr; node.textContent = textContent; diff --git a/packages/ink-compat/src/runtime/render.ts b/packages/ink-compat/src/runtime/render.ts index 661f0afe..39ac0e88 100644 --- a/packages/ink-compat/src/runtime/render.ts +++ b/packages/ink-compat/src/runtime/render.ts @@ -13,6 +13,7 @@ import React from "react"; import { type KittyFlagName, resolveKittyFlags } from "../kitty-keyboard.js"; import type { InkHostContainer, InkHostNode } from "../reconciler/types.js"; +import { __inkCompatTranslationTestHooks } from "../translation/propsToVNode.js"; import { enableTranslationTrace, flushTranslationTrace } from "../translation/traceCollector.js"; import { checkAllResizeObservers } from "./ResizeObserver.js"; import { createBridge } from "./bridge.js"; @@ -21,6 +22,7 @@ import { advanceLayoutGeneration, readCurrentLayout, writeCurrentLayout } from " import { commitSync, createReactRoot } from "./reactHelpers.js"; const BENCH_PHASES_ENABLED = process.env["BENCH_INK_COMPAT_PHASES"] === "1"; +const BENCH_DETAIL_ENABLED = process.env["BENCH_DETAIL"] === "1"; export interface KittyKeyboardOptions { mode?: "auto" | "enabled" | "disabled"; @@ -2142,6 +2144,79 @@ function assignHostLayouts( } } +function createThrottle(fn: () => void, throttleMs: number): Readonly<{ + call: () => void; + cancel: () => void; +}> { + const waitMs = Math.max(0, Math.trunc(throttleMs)); + if (waitMs === 0) { + return { + call: fn, + cancel: () => {}, + }; + } + + let timeoutId: NodeJS.Timeout | null = null; + let pendingAt: number | null = null; + let hasPendingCall = false; + + const clearTimer = (): void => { + if (timeoutId === null) return; + clearTimeout(timeoutId); + timeoutId = null; + }; + + const cancel = (): void => { + clearTimer(); + pendingAt = null; + hasPendingCall = false; + }; + + const invoke = (): void => { + if (!hasPendingCall) return; + hasPendingCall = false; + fn(); + pendingAt = null; + }; + + const schedule = (): void => { + clearTimer(); + timeoutId = setTimeout(() => { + timeoutId = null; + invoke(); + cancel(); + }, waitMs); + timeoutId.unref?.(); + }; + + const call = (): void => { + hasPendingCall = true; + const now = performance.now(); + if (pendingAt === null) pendingAt = now; + + if (now - pendingAt >= waitMs) { + fn(); + pendingAt = now; + // Clear any pending trailing call, but keep the window open so + // subsequent calls within waitMs don't re-trigger leading behavior. + hasPendingCall = false; + clearTimer(); + schedule(); + return; + } + + const isFirstCall = timeoutId === null; + schedule(); + if (isFirstCall) { + fn(); + hasPendingCall = false; + pendingAt = null; + } + }; + + return { call, cancel }; +} + function createRenderSession(element: React.ReactElement, options: RenderOptions = {}): Instance { const stdout = options.stdout ?? process.stdout; const stdin = options.stdin ?? process.stdin; @@ -2320,10 +2395,10 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions let writeBlocked = false; const queuedOutputs: RenderWritePayload[] = []; let drainListener: (() => void) | undefined; - let throttledRenderTimer: NodeJS.Timeout | undefined; let pendingRender = false; let pendingRenderForce = false; - let lastRenderAt = 0; + let forceRenderMicrotaskScheduled = false; + let sessionClosed = false; let rawModeRefCount = 0; let rawModeActive = false; let restoreConsole: (() => void) | undefined; @@ -2713,6 +2788,15 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions const frameStartedAt = performance.now(); frameCount++; let translationMs = 0; + let translationStats: null | { + translatedNodes: number; + cacheHits: number; + cacheMisses: number; + cacheEmptyMisses: number; + cacheStaleMisses: number; + parseAnsiFastPathHits: number; + parseAnsiFallbackPathHits: number; + } = null; let percentResolveMs = 0; let coreRenderMs = 0; let coreRenderPassesThisFrame = 0; @@ -2730,10 +2814,18 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions const timePhases = phaseProfile != null || BENCH_PHASES_ENABLED; + const collectBenchDetail = BENCH_PHASES_ENABLED && BENCH_DETAIL_ENABLED; + if (collectBenchDetail) { + __inkCompatTranslationTestHooks.resetStats(); + } + const translationStartedAt = timePhases ? performance.now() : 0; const { vnode: translatedDynamic, meta: translationMeta } = bridge.translateDynamicWithMetadata(); if (timePhases) translationMs = performance.now() - translationStartedAt; + if (collectBenchDetail) { + translationStats = __inkCompatTranslationTestHooks.getStats(); + } const hasDynamicPercentMarkers = translationMeta.hasPercentMarkers; // In static-channel mode, static output is rendered above the dynamic @@ -3128,6 +3220,17 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions nodes: result.nodes.length, ops: result.ops.length, coreRenderPasses: coreRenderPassesThisFrame || 1, + ...(translationStats + ? { + translatedNodes: translationStats.translatedNodes, + translationCacheHits: translationStats.cacheHits, + translationCacheMisses: translationStats.cacheMisses, + translationCacheEmptyMisses: translationStats.cacheEmptyMisses, + translationCacheStaleMisses: translationStats.cacheStaleMisses, + parseAnsiFastPathHits: translationStats.parseAnsiFastPathHits, + parseAnsiFallbackPathHits: translationStats.parseAnsiFallbackPathHits, + } + : {}), }); } } @@ -3151,37 +3254,36 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions } }; - const flushScheduledRender = (): void => { - throttledRenderTimer = undefined; + const flushPendingRender = (): void => { if (!pendingRender) return; const force = pendingRenderForce; pendingRender = false; pendingRenderForce = false; - lastRenderAt = performance.now(); renderFrame(force); - if (pendingRender) { - scheduleRender(pendingRenderForce); - } }; - const scheduleRender = (force = false): void => { + const renderThrottle = + unthrottledRender || renderIntervalMs <= 0 ? null : createThrottle(flushPendingRender, renderIntervalMs); + + const scheduleRender = (): void => { pendingRender = true; - if (force) pendingRenderForce = true; - - if (unthrottledRender) { - const nextForce = pendingRenderForce; - pendingRender = false; - pendingRenderForce = false; - lastRenderAt = performance.now(); - renderFrame(nextForce); + if (renderThrottle) { + renderThrottle.call(); return; } + flushPendingRender(); + }; - if (throttledRenderTimer !== undefined) return; - const elapsed = performance.now() - lastRenderAt; - const waitMs = Math.max(0, renderIntervalMs - elapsed); - throttledRenderTimer = setTimeout(flushScheduledRender, waitMs); - throttledRenderTimer.unref?.(); + const scheduleForceRender = (): void => { + pendingRender = true; + pendingRenderForce = true; + if (forceRenderMicrotaskScheduled) return; + forceRenderMicrotaskScheduled = true; + queueMicrotask(() => { + forceRenderMicrotaskScheduled = false; + if (sessionClosed) return; + flushPendingRender(); + }); }; bridge.rootNode.onCommit = () => { @@ -3191,7 +3293,7 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions } lastCommitSignature = nextCommitSignature; capturePendingStaticOutput(); - scheduleRender(false); + scheduleRender(); }; let currentElement = element; @@ -3279,7 +3381,7 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions `resize source=${source} viewport=${latest.cols}x${latest.rows} timeline=${formatResizeTimeline(resizeTimeline, traceStartAt, detailResizeLimit)}`, ); } - scheduleRender(true); + scheduleForceRender(); }; const onStdoutResize = (): void => { @@ -3342,10 +3444,7 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions } process.off("SIGWINCH", onSigWinch); clearInterval(viewportPoll); - if (throttledRenderTimer !== undefined) { - clearTimeout(throttledRenderTimer); - throttledRenderTimer = undefined; - } + renderThrottle?.cancel(); removeDrainListener(); restoreStdoutWrite?.(); restoreStdoutWrite = undefined; @@ -3386,12 +3485,12 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions pendingRender = false; pendingRenderForce = false; renderFrame(true); - lastRenderAt = performance.now(); let cleanedUp = false; function cleanup(unmountTree: boolean): void { if (cleanedUp) return; cleanedUp = true; + sessionClosed = 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 1961da13..9d538ce6 100644 --- a/packages/ink-compat/src/translation/propsToVNode.ts +++ b/packages/ink-compat/src/translation/propsToVNode.ts @@ -210,6 +210,8 @@ interface TranslationPerfStats { translatedNodes: number; cacheHits: number; cacheMisses: number; + cacheEmptyMisses: number; + cacheStaleMisses: number; parseAnsiFastPathHits: number; parseAnsiFallbackPathHits: number; } @@ -219,6 +221,8 @@ const translationPerfStats: TranslationPerfStats = { translatedNodes: 0, cacheHits: 0, cacheMisses: 0, + cacheEmptyMisses: 0, + cacheStaleMisses: 0, parseAnsiFastPathHits: 0, parseAnsiFallbackPathHits: 0, }; @@ -232,6 +236,8 @@ function resetTranslationPerfStats(): void { translationPerfStats.translatedNodes = 0; translationPerfStats.cacheHits = 0; translationPerfStats.cacheMisses = 0; + translationPerfStats.cacheEmptyMisses = 0; + translationPerfStats.cacheStaleMisses = 0; translationPerfStats.parseAnsiFastPathHits = 0; translationPerfStats.parseAnsiFallbackPathHits = 0; } @@ -405,11 +411,17 @@ function translateNode(node: InkHostNode, context: TranslateContext): VNode | nu } 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; + const perNodeCache = translationCache.get(node); + const cached = perNodeCache?.get(signature); + if (cached) { + if (cached.revision === node.__inkRevision) { + translationPerfStats.cacheHits += 1; + mergeMeta(parentMeta, cached.meta); + return cached.vnode; + } + translationPerfStats.cacheStaleMisses += 1; + } else { + translationPerfStats.cacheEmptyMisses += 1; } translationPerfStats.cacheMisses += 1; @@ -417,10 +429,16 @@ function translateNode(node: InkHostNode, context: TranslateContext): VNode | nu const translated = translateNodeUncached(node, localContext); mergeMeta(parentMeta, localMeta); - let perNodeCache = translationCache.get(node); if (!perNodeCache) { - perNodeCache = new Map(); - translationCache.set(node, perNodeCache); + const nextCache = new Map(); + translationCache.set(node, nextCache); + nextCache.set(signature, { + revision: node.__inkRevision, + contextSignature: signature, + vnode: translated, + meta: localMeta, + }); + return translated; } perNodeCache.set(signature, { revision: node.__inkRevision, @@ -1578,6 +1596,8 @@ export const __inkCompatTranslationTestHooks = { translatedNodes: number; cacheHits: number; cacheMisses: number; + cacheEmptyMisses: number; + cacheStaleMisses: number; parseAnsiFastPathHits: number; parseAnsiFallbackPathHits: number; } { From 1b3e608d37e6191e9a5211b357487dbd029fcaa0 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 22:18:32 +0400 Subject: [PATCH 17/29] chore(bench): add active cpuprofile hotspot evidence --- results/bottlenecks.md | 105 ++++++++++++++++++ results/report_2026-02-27.md | 33 ++++++ .../ink-compat-bench/summarize-cpuprofile.mjs | 18 ++- 3 files changed, 152 insertions(+), 4 deletions(-) diff --git a/results/bottlenecks.md b/results/bottlenecks.md index 1529f3ea..60105b4a 100644 --- a/results/bottlenecks.md +++ b/results/bottlenecks.md @@ -20,3 +20,108 @@ Evidence directories are local `results/ink-bench_*` batches (ignored by git; re - `% cost` for “extra frames” is not a single function’s self-time: it’s a **multiplier** on all per-frame costs (translation/layout/ANSI/write). - `.cpuprofile` captures wall-clock samples and includes idle; use it for **call stacks**, not as the sole source of %CPU. +## CPU profile evidence (call stacks) + +Percentages below come from `summarize-cpuprofile.mjs --active` (i.e. **non-idle samples only**). + +### 1) Extra frames (poor coalescing) under `maxFps` + +Cpuprofile (pre-fix `dashboard-grid`, `ink-compat`, 136 frames): + +- `results/ink-bench_dashboard-grid_ink-compat_2026-02-27T16-22-32-765Z/run_01/cpu-prof/dashboard-grid_ink-compat_run1.cpuprofile` + +Representative leaf frames in `packages/ink-compat/dist/runtime/render.js`: + +- `renderOpsToAnsi` — **0.7% self** (active samples) + ```text + processTimers (node:internal/timers:504:25) + listOnTimeout (node:internal/timers:524:25) + flushScheduledRender (packages/ink-compat/dist/runtime/render.js:2574:34) + renderFrame (packages/ink-compat/dist/runtime/render.js:2233:25) + renderOpsToAnsi (packages/ink-compat/dist/runtime/render.js:1481:25) + ``` +- `bridge.rootNode.onCommit` — **0.7% self** + ```text + performWorkOnRootViaSchedulerTask (packages/ink-compat/node_modules/react-reconciler/cjs/react-reconciler.development.js:2125:47) + commitRoot (packages/ink-compat/node_modules/react-reconciler/cjs/react-reconciler.development.js:12831:24) + resetAfterCommit (packages/ink-compat/dist/reconciler/hostConfig.js:106:21) + bridge.rootNode.onCommit (packages/ink-compat/dist/runtime/render.js:2606:32) + ``` +- `readWindowSize` — **0.4% self** + ```text + flushScheduledRender (packages/ink-compat/dist/runtime/render.js:2574:34) + renderFrame (packages/ink-compat/dist/runtime/render.js:2233:25) + readViewportSize (packages/ink-compat/dist/runtime/render.js:18:26) + readWindowSize (packages/ink-compat/dist/runtime/render.js:25:28) + ``` + +### 2) Translation cache invalidation (revision churn) + +Cpuprofile (translation-heavy `style-churn`, `ink-compat`): + +- `results/ink-bench_style-churn_ink-compat_2026-02-27T17-29-35-710Z/run_01/cpu-prof/style-churn_ink-compat_run1.cpuprofile` + +Representative leaf frames in `packages/ink-compat/dist/translation/propsToVNode.js`: + +- `translateText` — **0.3% self** (active samples) + ```text + translateNodeUncached (packages/ink-compat/dist/translation/propsToVNode.js:232:31) + translateBox (packages/ink-compat/dist/translation/propsToVNode.js:314:22) + translateNode (packages/ink-compat/dist/translation/propsToVNode.js:181:23) + translateNodeUncached (packages/ink-compat/dist/translation/propsToVNode.js:232:31) + translateText (packages/ink-compat/dist/translation/propsToVNode.js:716:23) + ``` +- `flattenTextChildren` — **0.3% self** + ```text + translateNodeUncached (packages/ink-compat/dist/translation/propsToVNode.js:232:31) + translateText (packages/ink-compat/dist/translation/propsToVNode.js:716:23) + flattenTextChildren (packages/ink-compat/dist/translation/propsToVNode.js:805:29) + ``` +- `translateNodeUncached` — **0.3% self** + ```text + flushPendingRender (packages/ink-compat/dist/runtime/render.js:2658:32) + renderFrame (packages/ink-compat/dist/runtime/render.js:2298:25) + translateDynamicTreeWithMetadata (packages/ink-compat/dist/translation/propsToVNode.js:157:49) + translateNode (packages/ink-compat/dist/translation/propsToVNode.js:181:23) + translateNodeUncached (packages/ink-compat/dist/translation/propsToVNode.js:232:31) + ``` + +### 3) Unnecessary `commitUpdate` calls + +Cpuprofile (same `style-churn`, `ink-compat` capture): + +- `results/ink-bench_style-churn_ink-compat_2026-02-27T17-29-35-710Z/run_01/cpu-prof/style-churn_ink-compat_run1.cpuprofile` + +Representative leaf frames in `packages/ink-compat/dist/reconciler/hostConfig.js`: + +- `sanitizeProps` — **0.3% self** (active samples) + ```text + commitHostUpdate (packages/ink-compat/node_modules/react-reconciler/cjs/react-reconciler.development.js:9778:30) + commitUpdate (packages/ink-compat/dist/reconciler/hostConfig.js:55:17) + sanitizeProps (packages/ink-compat/dist/reconciler/hostConfig.js:9:23) + ``` +- `commitTextUpdate` — **0.3% self** + ```text + commitMutationEffectsOnFiber (packages/ink-compat/node_modules/react-reconciler/cjs/react-reconciler.development.js:10521:42) + commitTextUpdate (packages/ink-compat/dist/reconciler/hostConfig.js:68:21) + ``` + +### 4) Fairness: Ink `renderTime` excludes Yoga layout + +Cpuprofile (`dashboard-grid`, `real-ink`): + +- `results/ink-bench_dashboard-grid_real-ink_2026-02-27T17-29-17-074Z/run_01/cpu-prof/dashboard-grid_real-ink_run1.cpuprofile` + +Representative Yoga layout stacks: + +- `calculateLayout` — **0.2% self** (active samples) + ```text + resetAfterCommit (ink/build/reconciler.js:71:21) + rootNode.onComputeLayout (scripts/ink-compat-bench/preload.mjs:62:38) + calculateLayout (ink/build/ink.js:151:23) + ``` +- Yoga wasm leaf (anonymous) — **0.2% self** + ```text + resetAfterCommit (ink/build/reconciler.js:71:21) + (anonymous) (dist/binaries/yoga-wasm-base64-esm.js:32:295) + ``` diff --git a/results/report_2026-02-27.md b/results/report_2026-02-27.md index 8f0514de..9981c1df 100644 --- a/results/report_2026-02-27.md +++ b/results/report_2026-02-27.md @@ -110,3 +110,36 @@ Helper (top self-time + stacks): node scripts/ink-compat-bench/summarize-cpuprofile.mjs --top 25 --stacks 10 ``` +## Top hotspots (CPU profiles) + +Percentages below come from `summarize-cpuprofile.mjs --active` (**non-idle samples only**) on the `dashboard-grid` one-run profiles. + +### real-ink (Ink) + +Cpuprofile: + +- `results/ink-bench_dashboard-grid_real-ink_2026-02-27T17-29-17-074Z/run_01/cpu-prof/dashboard-grid_real-ink_run1.cpuprofile` + +Hotspots (`--filter ink/build`): + +- `ink/build/output.js` buffer/write path — **0.5% self** (`get`) + - stack: `resetAfterCommit -> onRender -> renderer -> get (output.js)` +- `ink/build/render-node-to-output.js` — **0.4% self** (`renderNodeToOutput`) + - stack: `resetAfterCommit -> onRender -> renderer -> renderNodeToOutput` +- Yoga layout (measured via preload) — **0.2% self** (`calculateLayout`) + - stack: `resetAfterCommit -> rootNode.onComputeLayout (preload.mjs) -> calculateLayout` + +### ink-compat (Rezi-backed) + +Cpuprofile: + +- `results/ink-bench_dashboard-grid_ink-compat_2026-02-27T17-29-23-291Z/run_01/cpu-prof/dashboard-grid_ink-compat_run1.cpuprofile` + +Hotspots (`--filter packages/`): + +- `packages/core/dist/renderer/renderToDrawlist.js` — **0.9% self** (`renderToDrawlist`) + - stack: `commitRoot -> resetAfterCommit -> bridge.rootNode.onCommit -> renderFrame -> renderToDrawlist` +- `packages/core/dist/layout/kinds/box.js` — **0.8% self** (`layoutBoxKinds`) + - stack: `layoutNode -> layoutStack -> layoutNode -> layoutBoxKinds` +- `packages/ink-compat/dist/runtime/render.js` — **0.6% self** (`flushPendingRender`) + - stack: `commitRoot -> resetAfterCommit -> bridge.rootNode.onCommit -> scheduleRender -> flushPendingRender` diff --git a/scripts/ink-compat-bench/summarize-cpuprofile.mjs b/scripts/ink-compat-bench/summarize-cpuprofile.mjs index 633fad7b..5a3095f6 100644 --- a/scripts/ink-compat-bench/summarize-cpuprofile.mjs +++ b/scripts/ink-compat-bench/summarize-cpuprofile.mjs @@ -17,11 +17,12 @@ function usage() { process.stderr.write( [ "Usage:", - " node scripts/ink-compat-bench/summarize-cpuprofile.mjs [--top N] [--filter STR] [--stacks N] [--json]", + " node scripts/ink-compat-bench/summarize-cpuprofile.mjs [--top N] [--filter STR] [--stacks N] [--active] [--json]", "", "Notes:", " - Self time is attributed to the sampled leaf frame id.", " - `--filter` matches callFrame.url or functionName substrings.", + " - `--active` excludes `(idle)` samples when computing percentages.", ].join("\n") + "\n", ); } @@ -90,6 +91,7 @@ async function main() { const topN = Number.parseInt(readArg("top", "25"), 10) || 25; const stacksN = Number.parseInt(readArg("stacks", "10"), 10) || 10; const filter = readArg("filter", ""); + const activeOnly = hasFlag("active"); const jsonOut = hasFlag("json"); const raw = JSON.parse(fs.readFileSync(file, "utf8")); @@ -104,6 +106,7 @@ async function main() { const parentById = buildParentMap(nodes); let totalUs = 0; + let idleUs = 0; const selfUsById = new Map(); for (let i = 0; i < samples.length; i++) { const id = samples[i]; @@ -112,13 +115,18 @@ async function main() { if (typeof dt !== "number" || !Number.isFinite(dt) || dt < 0) continue; totalUs += dt; selfUsById.set(id, (selfUsById.get(id) ?? 0) + dt); + const node = nodeById.get(id); + if (node?.callFrame?.functionName === "(idle)") idleUs += dt; } + const activeUs = Math.max(0, totalUs - idleUs); + const pctDenomUs = activeOnly ? activeUs : totalUs; const entries = []; for (const [id, selfUs] of selfUsById.entries()) { const node = nodeById.get(id); if (!node) continue; const { fn, loc } = formatFrame(node); + if (activeOnly && fn === "(idle)") continue; if (filter) { const hay = `${fn} ${loc} ${(node.callFrame?.url ?? "")}`.toLowerCase(); if (!hay.includes(filter.toLowerCase())) continue; @@ -139,16 +147,18 @@ async function main() { const out = { file: path.resolve(file), totalTimeMs: Math.round(ms(totalUs) * 1000) / 1000, + activeTimeMs: Math.round(ms(activeUs) * 1000) / 1000, totalSamples: samples.length, + activeOnly, topSelf: top.map((e) => ({ selfMs: Math.round(ms(e.selfUs) * 1000) / 1000, - selfPct: totalUs > 0 ? Math.round(((e.selfUs / totalUs) * 100) * 10) / 10 : null, + selfPct: pctDenomUs > 0 ? Math.round(((e.selfUs / pctDenomUs) * 100) * 10) / 10 : null, fn: e.fn, loc: e.loc, })), topStacks: stackEntries.map((e) => ({ selfMs: Math.round(ms(e.selfUs) * 1000) / 1000, - selfPct: totalUs > 0 ? Math.round(((e.selfUs / totalUs) * 100) * 10) / 10 : null, + selfPct: pctDenomUs > 0 ? Math.round(((e.selfUs / pctDenomUs) * 100) * 10) / 10 : null, fn: e.fn, loc: e.loc, stack: e.stack, @@ -162,6 +172,7 @@ async function main() { process.stdout.write(`cpuprofile: ${out.file}\n`); process.stdout.write(`total: ${out.totalTimeMs}ms samples=${out.totalSamples}\n\n`); + if (out.activeOnly) process.stdout.write(`active: ${out.activeTimeMs}ms (excludes (idle))\n\n`); process.stdout.write(`Top self-time frames (top ${topN}):\n`); for (const row of out.topSelf.slice(0, topN)) { process.stdout.write( @@ -184,4 +195,3 @@ main().catch((err) => { process.stderr.write("\n"); process.exitCode = 1; }); - From 9a9c5eaba4d9d18745d5c03e3a122ee837e074ec Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 22:28:30 +0400 Subject: [PATCH 18/29] perf(ink-compat): reduce runtime renderer overhead --- packages/core/src/index.ts | 1 + .../testing/__tests__/testRenderer.test.ts | 11 +++ packages/core/src/testing/index.ts | 1 + packages/core/src/testing/renderer.ts | 81 +++++++++++-------- packages/ink-compat/src/runtime/render.ts | 1 + 5 files changed, 61 insertions(+), 34 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0ad76087..65cb534c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1088,6 +1088,7 @@ export type { TestEventBuilderOptions, TestEventInput, TestRenderNode, + TestRendererMode, TestRenderOptions, TestRenderResult, TestRenderTraceEvent, diff --git a/packages/core/src/testing/__tests__/testRenderer.test.ts b/packages/core/src/testing/__tests__/testRenderer.test.ts index 9aa7b92e..262fcdbf 100644 --- a/packages/core/src/testing/__tests__/testRenderer.test.ts +++ b/packages/core/src/testing/__tests__/testRenderer.test.ts @@ -115,4 +115,15 @@ describe("createTestRenderer", () => { assert.equal(Array.isArray(first.ops), true); assert.equal((first.text ?? "").includes("Hello trace"), true); }); + + test("runtime mode keeps query helpers and lazy text access", () => { + const renderer = createTestRenderer({ viewport: { cols: 30, rows: 6 }, mode: "runtime" }); + const result = renderer.render( + ui.column({}, [ui.text("Runtime Mode"), ui.button({ id: "submit", label: "Submit" })]), + ); + + assert.equal(result.toText().includes("Runtime Mode"), true); + assert.equal(result.findById("submit")?.kind, "button"); + assert.equal(result.findAll("button").length, 1); + }); }); diff --git a/packages/core/src/testing/index.ts b/packages/core/src/testing/index.ts index e69c0e5b..587053dc 100644 --- a/packages/core/src/testing/index.ts +++ b/packages/core/src/testing/index.ts @@ -15,6 +15,7 @@ export type { export { createTestRenderer } from "./renderer.js"; export type { TestRenderNode, + TestRendererMode, TestRenderOptions, TestRenderResult, TestRenderTraceEvent, diff --git a/packages/core/src/testing/renderer.ts b/packages/core/src/testing/renderer.ts index d9d6324f..f03cc1ef 100644 --- a/packages/core/src/testing/renderer.ts +++ b/packages/core/src/testing/renderer.ts @@ -24,12 +24,14 @@ import type { VNode } from "../widgets/types.js"; export type TestViewport = Readonly<{ cols: number; rows: number }>; type TestNodeProps = Readonly & { id?: unknown; label?: unknown }>; +export type TestRendererMode = "test" | "runtime"; export type TestRendererOptions = Readonly<{ viewport?: TestViewport; theme?: Theme; focusedId?: string | null; tick?: number; + mode?: TestRendererMode; trace?: (event: TestRenderTraceEvent) => void; traceDetail?: boolean; }>; @@ -39,6 +41,7 @@ export type TestRenderOptions = Readonly<{ theme?: Theme; focusedId?: string | null; tick?: number; + mode?: TestRendererMode; traceDetail?: boolean; }>; @@ -115,8 +118,13 @@ function normalizeViewport(viewport: TestViewport | undefined): TestViewport { return Object.freeze({ cols: safeCols, rows: safeRows }); } -function asPropsRecord(value: unknown): TestNodeProps { - if (typeof value !== "object" || value === null) return Object.freeze({}); +const EMPTY_PROPS: TestNodeProps = Object.freeze({}); +const EMPTY_PATH: readonly number[] = Object.freeze([]); + +function asPropsRecord(value: unknown, mode: TestRendererMode): TestNodeProps { + if (typeof value !== "object" || value === null) return EMPTY_PROPS; + // Runtime mode favors lower allocation overhead for hot render paths. + if (mode === "runtime") return value as TestNodeProps; return Object.freeze({ ...(value as Record) }); } @@ -202,35 +210,40 @@ class RecordingDrawlistBuilder implements DrawlistBuilder { } } -function collectNodes(layoutTree: LayoutTree): readonly TestRenderNode[] { +function collectNodes(layoutTree: LayoutTree, mode: TestRendererMode): readonly TestRenderNode[] { const out: TestRenderNode[] = []; const walk = (node: LayoutTree, path: readonly number[]): void => { - const props = asPropsRecord((node.vnode as { props?: unknown }).props); + const props = asPropsRecord((node.vnode as { props?: unknown }).props, mode); const rawId = props.id; const id = typeof rawId === "string" ? rawId : null; - const base: TestRenderNode = Object.freeze({ + const base: TestRenderNode = { kind: node.vnode.kind, rect: node.rect, props, id, - path: Object.freeze(path.slice()), - ...(node.vnode.kind === "text" - ? { text: (node.vnode as Readonly<{ text: string }>).text } - : {}), - }); - out.push(base); + path: mode === "runtime" ? EMPTY_PATH : Object.freeze(path.slice()), + ...(mode === "runtime" || node.vnode.kind !== "text" + ? {} + : { text: (node.vnode as Readonly<{ text: string }>).text }), + }; + out.push(mode === "runtime" ? base : Object.freeze(base)); for (let i = 0; i < node.children.length; i++) { const child = node.children[i]; if (!child) continue; - walk(child, Object.freeze([...path, i])); + walk( + child, + mode === "runtime" + ? EMPTY_PATH + : Object.freeze([...path, i]), + ); } }; - walk(layoutTree, Object.freeze([])); - return Object.freeze(out); + walk(layoutTree, EMPTY_PATH); + return mode === "runtime" ? out : Object.freeze(out); } function inClipStack(x: number, y: number, clipStack: readonly ClipRect[]): boolean { @@ -296,22 +309,12 @@ function opsToText(ops: readonly TestRecordedOp[], viewport: TestViewport): stri for (const op of ops) { if (op.kind === "clear") { - fillGridRect( - grid, - viewport, - clipStack, - Object.freeze({ x: 0, y: 0, w: viewport.cols, h: viewport.rows }), - ); + fillGridRect(grid, viewport, clipStack, { x: 0, y: 0, w: viewport.cols, h: viewport.rows }); continue; } if (op.kind === "clearTo") { - fillGridRect( - grid, - viewport, - clipStack, - Object.freeze({ x: 0, y: 0, w: op.cols, h: op.rows }), - ); + fillGridRect(grid, viewport, clipStack, { x: 0, y: 0, w: op.cols, h: op.rows }); continue; } @@ -465,6 +468,7 @@ export function createTestRenderer(opts: TestRendererOptions = {}): TestRenderer const rendererTheme = opts.theme ?? coreDefaultTheme; const defaultFocusedId = opts.focusedId ?? null; const defaultTick = opts.tick ?? 0; + const defaultMode = opts.mode ?? "test"; const trace = opts.trace; const defaultTraceDetail = opts.traceDetail === true; let warnedTraceDetailWithoutTrace = false; @@ -479,6 +483,7 @@ export function createTestRenderer(opts: TestRendererOptions = {}): TestRenderer const tick = renderOpts.tick ?? defaultTick; const theme = renderOpts.theme ?? rendererTheme; const traceDetail = renderOpts.traceDetail ?? defaultTraceDetail; + const mode = renderOpts.mode ?? defaultMode; if (traceDetail && !trace && !warnedTraceDetailWithoutTrace) { warnedTraceDetailWithoutTrace = true; console.warn( @@ -513,17 +518,24 @@ export function createTestRenderer(opts: TestRendererOptions = {}): TestRenderer }); const drawMs = Date.now() - drawStartedAt; - const textStartedAt = Date.now(); - const nodes = collectNodes(layoutTree); + const nodes = collectNodes(layoutTree, mode); const ops = builder.snapshotOps(); - const screenText = opsToText(ops, viewport); + let screenTextCache: string | null = null; + const getScreenText = (): string => { + if (screenTextCache !== null) return screenTextCache; + screenTextCache = opsToText(ops, viewport); + return screenTextCache; + }; + + const textStartedAt = Date.now(); + const screenTextForTrace = trace ? getScreenText() : ""; const textMs = Date.now() - textStartedAt; const totalMs = Date.now() - startedAt; if (trace) { const opSummary = summarizeOps(ops); const nodeSummary = summarizeNodes(nodes); - const textSummary = summarizeText(screenText); + const textSummary = summarizeText(screenTextForTrace); trace( Object.freeze({ renderId, @@ -549,12 +561,12 @@ export function createTestRenderer(opts: TestRendererOptions = {}): TestRenderer maxRectBottom: nodeSummary.maxRectBottom, zeroHeightRects: nodeSummary.zeroHeightRects, detailIncluded: traceDetail, - ...(traceDetail ? { nodes, ops, text: screenText } : {}), + ...(traceDetail ? { nodes, ops, text: screenTextForTrace } : {}), }), ); } - return Object.freeze({ + const result: TestRenderResult = { viewport, focusedId, ops, @@ -562,8 +574,9 @@ export function createTestRenderer(opts: TestRendererOptions = {}): TestRenderer findText: (text: string) => findText(nodes, text), findById: (id: string) => findById(nodes, id), findAll: (kind: VNode["kind"] | string) => findAll(nodes, kind), - toText: () => screenText, - }); + toText: () => getScreenText(), + }; + return mode === "runtime" ? result : Object.freeze(result); }; const reset = (): void => { diff --git a/packages/ink-compat/src/runtime/render.ts b/packages/ink-compat/src/runtime/render.ts index 39ac0e88..b08f2645 100644 --- a/packages/ink-compat/src/runtime/render.ts +++ b/packages/ink-compat/src/runtime/render.ts @@ -2351,6 +2351,7 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions let viewport = readViewportSize(stdout, fallbackStdout); const renderer = createTestRenderer({ viewport, + mode: traceEnabled ? "test" : "runtime", ...(traceEnabled ? { traceDetail: traceDetailFull, From 529b512a4bc47de9e73d73477da9e3f7a05131a6 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 22:36:56 +0400 Subject: [PATCH 19/29] perf(ink-compat): reduce dashboard-grid tail latency --- packages/core/src/index.ts | 1 + .../testing/__tests__/testRenderer.test.ts | 5 + packages/core/src/testing/index.ts | 1 + packages/core/src/testing/renderer.ts | 47 ++++++++-- packages/ink-compat/src/runtime/render.ts | 65 +++++++------ results/bottlenecks.md | 27 ++++++ results/report_2026-02-27.md | 91 +++++++++++++++++++ 7 files changed, 197 insertions(+), 40 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 65cb534c..535175ca 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1087,6 +1087,7 @@ export { export type { TestEventBuilderOptions, TestEventInput, + TestRenderLayoutVisitor, TestRenderNode, TestRendererMode, TestRenderOptions, diff --git a/packages/core/src/testing/__tests__/testRenderer.test.ts b/packages/core/src/testing/__tests__/testRenderer.test.ts index 262fcdbf..adf39f55 100644 --- a/packages/core/src/testing/__tests__/testRenderer.test.ts +++ b/packages/core/src/testing/__tests__/testRenderer.test.ts @@ -122,6 +122,11 @@ describe("createTestRenderer", () => { ui.column({}, [ui.text("Runtime Mode"), ui.button({ id: "submit", label: "Submit" })]), ); + let visited = 0; + result.forEachLayoutNode(() => { + visited += 1; + }); + assert.ok(visited > 0); assert.equal(result.toText().includes("Runtime Mode"), true); assert.equal(result.findById("submit")?.kind, "button"); assert.equal(result.findAll("button").length, 1); diff --git a/packages/core/src/testing/index.ts b/packages/core/src/testing/index.ts index 587053dc..b7ee6807 100644 --- a/packages/core/src/testing/index.ts +++ b/packages/core/src/testing/index.ts @@ -15,6 +15,7 @@ export type { export { createTestRenderer } from "./renderer.js"; export type { TestRenderNode, + TestRenderLayoutVisitor, TestRendererMode, TestRenderOptions, TestRenderResult, diff --git a/packages/core/src/testing/renderer.ts b/packages/core/src/testing/renderer.ts index f03cc1ef..4b9eb648 100644 --- a/packages/core/src/testing/renderer.ts +++ b/packages/core/src/testing/renderer.ts @@ -83,12 +83,15 @@ export type TestRenderNode = Readonly<{ text?: string; }>; +export type TestRenderLayoutVisitor = (rect: Rect, props: TestNodeProps) => void; + export type TestRenderResult = Readonly<{ viewport: TestViewport; focusedId: string | null; /** Low-level draw ops recorded by the test builder. Useful for asserting rendering behavior. */ ops: readonly TestRecordedOp[]; nodes: readonly TestRenderNode[]; + forEachLayoutNode: (visit: TestRenderLayoutVisitor) => void; findText: (text: string) => TestRenderNode | null; findById: (id: string) => TestRenderNode | null; findAll: (kind: VNode["kind"] | string) => readonly TestRenderNode[]; @@ -246,6 +249,24 @@ function collectNodes(layoutTree: LayoutTree, mode: TestRendererMode): readonly return mode === "runtime" ? out : Object.freeze(out); } +function forEachLayoutTreeNode( + layoutTree: LayoutTree, + mode: TestRendererMode, + visit: TestRenderLayoutVisitor, +): void { + const stack: LayoutTree[] = [layoutTree]; + while (stack.length > 0) { + const node = stack.pop(); + if (!node) continue; + const props = asPropsRecord((node.vnode as { props?: unknown }).props, mode); + visit(node.rect, props); + for (let index = node.children.length - 1; index >= 0; index -= 1) { + const child = node.children[index]; + if (child) stack.push(child); + } + } +} + function inClipStack(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; @@ -518,8 +539,13 @@ export function createTestRenderer(opts: TestRendererOptions = {}): TestRenderer }); const drawMs = Date.now() - drawStartedAt; - const nodes = collectNodes(layoutTree, mode); const ops = builder.snapshotOps(); + let nodesCache: readonly TestRenderNode[] | null = mode === "test" ? collectNodes(layoutTree, mode) : null; + const getNodes = (): readonly TestRenderNode[] => { + if (nodesCache !== null) return nodesCache; + nodesCache = collectNodes(layoutTree, mode); + return nodesCache; + }; let screenTextCache: string | null = null; const getScreenText = (): string => { if (screenTextCache !== null) return screenTextCache; @@ -533,6 +559,7 @@ export function createTestRenderer(opts: TestRendererOptions = {}): TestRenderer const totalMs = Date.now() - startedAt; if (trace) { + const nodes = getNodes(); const opSummary = summarizeOps(ops); const nodeSummary = summarizeNodes(nodes); const textSummary = summarizeText(screenTextForTrace); @@ -566,16 +593,24 @@ export function createTestRenderer(opts: TestRendererOptions = {}): TestRenderer ); } - const result: TestRenderResult = { + const resultBase: Omit & { nodes?: readonly TestRenderNode[] } = { viewport, focusedId, ops, - nodes, - findText: (text: string) => findText(nodes, text), - findById: (id: string) => findById(nodes, id), - findAll: (kind: VNode["kind"] | string) => findAll(nodes, kind), + forEachLayoutNode: (visit: TestRenderLayoutVisitor) => { + forEachLayoutTreeNode(layoutTree, mode, visit); + }, + findText: (text: string) => findText(getNodes(), text), + findById: (id: string) => findById(getNodes(), id), + findAll: (kind: VNode["kind"] | string) => findAll(getNodes(), kind), toText: () => getScreenText(), }; + Object.defineProperty(resultBase, "nodes", { + enumerable: true, + configurable: false, + get: getNodes, + }); + const result = resultBase as TestRenderResult; return mode === "runtime" ? result : Object.freeze(result); }; diff --git a/packages/ink-compat/src/runtime/render.ts b/packages/ink-compat/src/runtime/render.ts index b08f2645..5bb541e8 100644 --- a/packages/ink-compat/src/runtime/render.ts +++ b/packages/ink-compat/src/runtime/render.ts @@ -1042,13 +1042,20 @@ function scanHostTreeForStaticAndAnsi(rootNode: InkHostContainer): { }; } -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)); +function rootChildRevisionsChanged(rootNode: InkHostContainer, previous: number[]): boolean { + const nextLength = rootNode.children.length; + let changed = previous.length !== nextLength; + if (previous.length !== nextLength) { + previous.length = nextLength; } - return revisions.join(","); + for (let index = 0; index < nextLength; index += 1) { + const nextRevision = rootNode.children[index]?.__inkRevision ?? 0; + if (previous[index] !== nextRevision) { + previous[index] = nextRevision; + changed = true; + } + } + return changed; } function staticRootRevisionSignature(rootNode: InkHostContainer): string { @@ -2103,18 +2110,18 @@ function resolvePercentMarkers(vnode: VNode, context: PercentResolveContext): VN function assignHostLayouts( container: InkHostContainer, - nodes: readonly { - rect?: { x?: number; y?: number; w?: number; h?: number }; - props?: Record; - }[], + forEachLayoutNode: ( + visit: ( + rect: { x?: number; y?: number; w?: number; h?: number }, + props: Readonly>, + ) => void, + ) => void, ): void { const generation = advanceLayoutGeneration(container); - for (const node of nodes) { - if (!node) continue; - const host = node.props?.["__inkHostNode"]; - if (typeof host !== "object" || host === null) continue; + forEachLayoutNode((rect, props) => { + const host = props["__inkHostNode"]; + if (typeof host !== "object" || host === null) return; const hostNode = host as HostNodeWithLayout; - const rect = node.rect; const x = rect?.x; const y = rect?.y; const w = rect?.w; @@ -2129,7 +2136,7 @@ function assignHostLayouts( typeof h !== "number" || !Number.isFinite(h) ) { - continue; + return; } writeCurrentLayout( hostNode, @@ -2141,7 +2148,7 @@ function assignHostLayouts( }, generation, ); - } + }); } function createThrottle(fn: () => void, throttleMs: number): Readonly<{ @@ -2411,7 +2418,7 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions let compatWriteDepth = 0; let restoreStdoutWrite: (() => void) | undefined; let lastCursorSignature = "hidden"; - let lastCommitSignature = ""; + const lastCommitRevisions: number[] = []; let lastStaticCaptureSignature = ""; const _s = debug ? writeErr : (_msg: string): void => {}; @@ -2874,10 +2881,7 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions const assignLayoutsStartedAt = timePhases ? performance.now() : 0; assignHostLayouts( bridge.rootNode, - result.nodes as readonly { - rect?: { x?: number; y?: number; w?: number; h?: number }; - props?: Record; - }[], + result.forEachLayoutNode, ); if (timePhases) assignLayoutsMs += performance.now() - assignLayoutsStartedAt; if (hasDynamicPercentMarkers) { @@ -2920,10 +2924,7 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions const secondAssignStartedAt = timePhases ? performance.now() : 0; assignHostLayouts( bridge.rootNode, - result.nodes as readonly { - rect?: { x?: number; y?: number; w?: number; h?: number }; - props?: Record; - }[], + result.forEachLayoutNode, ); if (timePhases) assignLayoutsMs += performance.now() - secondAssignStartedAt; } @@ -2936,16 +2937,14 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions let minRectY = Number.POSITIVE_INFINITY; let maxRectBottom = 0; let zeroHeightRects = 0; - for (const node of result.nodes as readonly { rect?: { y?: number; h?: number } }[]) { - const rect = node.rect; - if (!rect) continue; + result.forEachLayoutNode((rect) => { const y = toNumber(rect.y); const h = toNumber(rect.h); - if (y == null || h == null) continue; + if (y == null || h == null) return; minRectY = Math.min(minRectY, y); maxRectBottom = Math.max(maxRectBottom, y + h); if (h === 0) zeroHeightRects += 1; - } + }); if (timePhases) rectScanMs = performance.now() - rectScanStartedAt; // Keep non-alt output content-sized by using computed layout height. @@ -3288,11 +3287,9 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions }; bridge.rootNode.onCommit = () => { - const nextCommitSignature = rootChildRevisionSignature(bridge.rootNode); - if (nextCommitSignature === lastCommitSignature) { + if (!rootChildRevisionsChanged(bridge.rootNode, lastCommitRevisions)) { return; } - lastCommitSignature = nextCommitSignature; capturePendingStaticOutput(); scheduleRender(); }; diff --git a/results/bottlenecks.md b/results/bottlenecks.md index 60105b4a..6c77581a 100644 --- a/results/bottlenecks.md +++ b/results/bottlenecks.md @@ -14,6 +14,7 @@ Evidence directories are local `results/ink-bench_*` batches (ignored by git; re | 2 | **Translation cache invalidation (revision churn)** preventing `propsToVNode` reuse | `packages/ink-compat/src/reconciler/types.ts` (`setNodeProps`, `propsSemanticallyEqual`) + `packages/ink-compat/src/translation/propsToVNode.ts` | ~5–15% of `renderTimeMs` (scenario-dependent) | With `BENCH_DETAIL=1`, observed persistent stale-miss patterns (cache hits ~0) until semantic-equality short-circuiting was added; after fix, hits appear and `translatedNodes` drops (see `dashboard-grid` detail runs). | Keep semantic equality guardrails; expand safely to more prop shapes if needed; add correctness tests for edge props. | 5–20% lower translation time / alloc churn | Achieved: translation counters show non-zero cache hits post-fix; per-frame `translationMs` fell and stabilized (see `BENCH_DETAIL=1` runs). | | 3 | **Unnecessary commitUpdate calls** due to non-null “no update” payload | `packages/ink-compat/src/reconciler/hostConfig.ts` (`prepareUpdate`) | Small (overhead per commit) | Unit test + reconciler behavior: returning `false` triggers commitUpdate path in React; returning `null` skips it. | Return `null` when props are shallow-equal (excluding `children`/`ref`/`key`), keep commitUpdate fast-paths. | Low single-digit % CPU | Achieved: test suite confirms `null` semantics; reduces commitUpdate dispatch work. | | 4 | **Fairness bug: Ink `renderTime` excludes Yoga layout** (measurement only) | `scripts/ink-compat-bench/preload.mjs` (real Ink instance patching) | n/a | Verified by call stack: Yoga layout occurs in `resetAfterCommit` via `rootNode.onComputeLayout`, outside Ink’s `onRender.renderTime`. | Measure Yoga layout separately and include in `renderTotalMs` for Ink. | n/a (correctness) | Achieved: `layoutTimeMs` is now recorded for `real-ink` frames, making `renderTotalMs` comparable. | +| 5 | **Runtime hot-path allocation churn** (eager test-renderer node materialization + commit signature strings) | `packages/core/src/testing/renderer.ts`, `packages/ink-compat/src/runtime/render.ts` | **~0.6% self** pre-fix (`collectNodes` 0.3% + `rootChildRevisionSignature` 0.3%) | Pre-fix cpuprofile shows both symbols on the commit path (`18-23-12-600Z` run). Post-fix profile (`18-33-45-835Z`) no longer shows either symbol in filtered top output. Bench delta: dashboard-grid `renderTotalP95Ms` **1.94ms → 1.83ms** and gap vs Ink **+18.4% → +10.4%**. | Use lazy `nodes` in runtime mode + `forEachLayoutNode` traversal for hot path; replace revision-signature string building with numeric revision tracking. | 3–10% p95 tail reduction | Achieved in this increment: `meanRenderTotalMs` `-2.8%`, `renderTotalP95Ms` `-5.9%`, `totalCpuTimeS` `-3.7%` (ink-compat, dashboard-grid). | ## Notes @@ -125,3 +126,29 @@ Representative Yoga layout stacks: resetAfterCommit (ink/build/reconciler.js:71:21) (anonymous) (dist/binaries/yoga-wasm-base64-esm.js:32:295) ``` + +### 5) Runtime hot-path allocation churn + +Pre-fix cpuprofile (`dashboard-grid`, ink-compat): + +- `results/ink-bench_dashboard-grid_ink-compat_2026-02-27T18-23-12-600Z/run_01/cpu-prof/dashboard-grid_ink-compat_run1.cpuprofile` + +Representative leaf frames (active samples): + +- `collectNodes` — **0.3% self** + ```text + commitRoot -> resetAfterCommit -> bridge.rootNode.onCommit -> scheduleRender + -> flushPendingRender -> renderFrame -> render (testing/renderer.js) + -> collectNodes (testing/renderer.js) + ``` +- `rootChildRevisionSignature` — **0.3% self** + ```text + performWorkOnRootViaSchedulerTask -> commitRoot -> resetAfterCommit + -> bridge.rootNode.onCommit -> rootChildRevisionSignature (runtime/render.js) + ``` + +Post-fix cpuprofile (`dashboard-grid`, ink-compat): + +- `results/ink-bench_dashboard-grid_ink-compat_2026-02-27T18-33-45-835Z/run_01/cpu-prof/dashboard-grid_ink-compat_run1.cpuprofile` + +Filtered summaries for `packages/core/dist/testing/renderer.js` and `rootChildRevisionsChanged` return **no top self-time entries**, consistent with removing those hot-path costs from default runtime rendering. diff --git a/results/report_2026-02-27.md b/results/report_2026-02-27.md index 9981c1df..303592f6 100644 --- a/results/report_2026-02-27.md +++ b/results/report_2026-02-27.md @@ -143,3 +143,94 @@ Hotspots (`--filter packages/`): - stack: `layoutNode -> layoutStack -> layoutNode -> layoutBoxKinds` - `packages/ink-compat/dist/runtime/render.js` — **0.6% self** (`flushPendingRender`) - stack: `commitRoot -> resetAfterCommit -> bridge.rootNode.onCommit -> scheduleRender -> flushPendingRender` + +## Incremental update (runtime-path cleanup, 18:33 UTC) + +New verify runs (all valid scenarios pass final-screen equivalence): + +- `results/verify_streaming-chat_2026-02-27T18-32-23-185Z.json` +- `results/verify_large-list-scroll_2026-02-27T18-32-31-254Z.json` +- `results/verify_dashboard-grid_2026-02-27T18-32-39-124Z.json` +- `results/verify_style-churn_2026-02-27T18-32-48-767Z.json` + +New dashboard-grid batches: + +- real-ink (3 runs): `results/ink-bench_dashboard-grid_real-ink_2026-02-27T18-32-59-956Z` +- ink-compat (3 runs): `results/ink-bench_dashboard-grid_ink-compat_2026-02-27T18-33-15-205Z` +- ink-compat detail (3 runs): `results/ink-bench_dashboard-grid_ink-compat_2026-02-27T18-33-29-979Z` + +Changes in this increment: + +- `packages/core/src/testing/renderer.ts`: runtime-mode `nodes` became lazy, plus `forEachLayoutNode` hot-path traversal. +- `packages/ink-compat/src/runtime/render.ts`: host layout assignment + rect scans use `forEachLayoutNode` (no eager node materialization on default path). +- `packages/ink-compat/src/runtime/render.ts`: replaced commit signature string building with numeric revision tracking. + +### Dashboard-grid delta (pre-increment → post-increment) + +Comparison: + +- before: `results/ink-bench_dashboard-grid_real-ink_2026-02-27T18-22-36-968Z` vs `results/ink-bench_dashboard-grid_ink-compat_2026-02-27T18-22-49-054Z` +- after: `results/ink-bench_dashboard-grid_real-ink_2026-02-27T18-32-59-956Z` vs `results/ink-bench_dashboard-grid_ink-compat_2026-02-27T18-33-15-205Z` + +ink-compat vs real-ink (after): + +- `meanRenderTotalMs`: **-8.9%** (ink-compat faster) +- `renderTotalP95Ms`: **+10.4%** (tail still slower) +- `totalCpuTimeS`: **-31.1%** +- `framesEmitted`: **-4.8%** + +ink-compat before → after: + +- `meanRenderTotalMs`: **77.72ms → 75.58ms** (`-2.8%`) +- `renderTotalP95Ms`: **1.94ms → 1.83ms** (`-5.9%`) +- `renderTotalP99Ms`: **2.80ms → 2.65ms** (`-5.3%`) +- `totalCpuTimeS`: **0.543s → 0.523s** (`-3.7%`) + +### Phase evidence (detail run) + +Detail batch: `results/ink-bench_dashboard-grid_ink-compat_2026-02-27T18-33-29-979Z` + +- Outliers remain early-frame and mostly `coreRenderMs` dominated (single-pass; `coreRenderPasses=1` on >2ms frames). +- Representative spikes: + - `run_01 frame 2`: `renderTotalMs=3.96`, `coreRenderMs=3.63`, `ansiMs=0.25` + - `run_01 frame 3`: `renderTotalMs=3.52`, `coreRenderMs=2.99`, `translationMs=0.34` + - `run_03 frame 5`: `renderTotalMs=2.80`, `coreRenderMs=1.61`, `ansiMs=0.90` + +## Top hotspots (latest one-run CPU profiles) + +Latest cpuprofiles: + +- ink-compat: `results/ink-bench_dashboard-grid_ink-compat_2026-02-27T18-33-45-835Z/run_01/cpu-prof/dashboard-grid_ink-compat_run1.cpuprofile` +- real-ink: `results/ink-bench_dashboard-grid_real-ink_2026-02-27T18-33-54-236Z/run_01/cpu-prof/dashboard-grid_real-ink_run1.cpuprofile` + +Percentages below are from `summarize-cpuprofile.mjs --active`. + +### real-ink + +- `readFileUtf8` startup/module-load work — **8.2% self** +- `ink/build/output.js:get` output assembly — **0.7% self** + - stack: `resetAfterCommit -> onRender -> renderer -> get (output.js)` +- `ansi-tokenize/build/styledCharsFromTokens` — **0.9% self** + - stack: `resetAfterCommit -> onRender -> renderer -> get -> applyWriteOperation -> toStyledCharacters -> styledCharsFromTokens` +- `string-width` path in text measurement — **1.5% self** + - stack: `measureTextNode -> measureStyledChars -> styledCharsWidth -> inkCharacterWidth -> stringWidth` + +### ink-compat + +- `compileSourceTextModule` / `internalModuleReadJSON` startup/module-load work — **7.2% / 6.7% self** +- `packages/core/dist/layout/validateProps.js:validateLayoutConstraints` — **1.0% self** + - stack: `layoutStack -> planConstraintMainSizes -> measureMinContent -> measureBoxIntrinsic -> validateBoxProps -> validateLayoutConstraints` +- `packages/core/dist/layout/textMeasure.js:wrapTextToLines` — **0.7% self** + - stack: `measureLeaf -> measureTextWrapped -> wrapTextToLines` +- `packages/ink-compat/dist/translation/propsToVNode.js:translateNode` — **0.6% self** + - stack: `translateNodeUncached -> translateBox -> translateNode` + +### Eliminated hot-path items from prior profile + +Previous profile (before this increment): + +- `results/ink-bench_dashboard-grid_ink-compat_2026-02-27T18-23-12-600Z/run_01/cpu-prof/dashboard-grid_ink-compat_run1.cpuprofile` +- `collectNodes` (`packages/core/dist/testing/renderer.js`) — **0.3% self** +- `rootChildRevisionSignature` (`packages/ink-compat/dist/runtime/render.js`) — **0.3% self** + +Current profile (`18-33-45-835Z`) no longer shows either symbol in top filtered output. From 709ee5ad6858cfa08e3c439134b56c45f5437177 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 22:39:31 +0400 Subject: [PATCH 20/29] refactor(core): reduce hot-path allocation churn --- .../core/src/layout/engine/layoutEngine.ts | 79 +- packages/core/src/layout/engine/pool.ts | 9 +- packages/core/src/layout/kinds/stack.ts | 781 +++++++++--------- .../renderToDrawlist/renderPackets.ts | 277 ++++++- .../renderer/renderToDrawlist/renderTree.ts | 10 +- 5 files changed, 735 insertions(+), 421 deletions(-) diff --git a/packages/core/src/layout/engine/layoutEngine.ts b/packages/core/src/layout/engine/layoutEngine.ts index a6cfbe6d..fd42b1ea 100644 --- a/packages/core/src/layout/engine/layoutEngine.ts +++ b/packages/core/src/layout/engine/layoutEngine.ts @@ -43,9 +43,15 @@ type MeasureCacheEntry = Readonly<{ }>; type MeasureCache = WeakMap; +type LayoutCacheLeaf = Map>; +type LayoutCacheByX = Map; +type LayoutCacheByForcedH = Map; +type LayoutCacheByForcedW = Map; +type LayoutCacheByMaxH = Map; +type LayoutAxisCache = Map; type LayoutCacheEntry = Readonly<{ - row: Map>; - column: Map>; + row: LayoutAxisCache; + column: LayoutAxisCache; }>; type LayoutCache = WeakMap; @@ -80,6 +86,7 @@ const measureCacheStack: MeasureCache[] = []; let activeLayoutCache: LayoutCache | null = null; const layoutCacheStack: LayoutCache[] = []; const syntheticThemedColumnCache = new WeakMap(); +const NULL_FORCED_DIMENSION = -1; function pushMeasureCache(cache: MeasureCache): void { measureCacheStack.push(cache); @@ -103,17 +110,70 @@ function popLayoutCache(): void { layoutCacheStack.length > 0 ? (layoutCacheStack[layoutCacheStack.length - 1] ?? null) : null; } -function layoutCacheKey( +function forcedDimensionKey(value: number | null): number { + return value === null ? NULL_FORCED_DIMENSION : value; +} + +function getLayoutCacheHit( + axisMap: LayoutAxisCache, maxW: number, maxH: number, forcedW: number | null, forcedH: number | null, x: number, y: number, -): string { - return `${String(maxW)}:${String(maxH)}:${forcedW === null ? "n" : String(forcedW)}:${ - forcedH === null ? "n" : String(forcedH) - }:${String(x)}:${String(y)}`; +): LayoutResult | null { + const byMaxH = axisMap.get(maxW); + if (!byMaxH) return null; + const byForcedW = byMaxH.get(maxH); + if (!byForcedW) return null; + const byForcedH = byForcedW.get(forcedDimensionKey(forcedW)); + if (!byForcedH) return null; + const byX = byForcedH.get(forcedDimensionKey(forcedH)); + if (!byX) return null; + const byY = byX.get(x); + if (!byY) return null; + return byY.get(y) ?? null; +} + +function setLayoutCacheValue( + axisMap: LayoutAxisCache, + maxW: number, + maxH: number, + forcedW: number | null, + forcedH: number | null, + x: number, + y: number, + value: LayoutResult, +): void { + let byMaxH = axisMap.get(maxW); + if (!byMaxH) { + byMaxH = new Map(); + axisMap.set(maxW, byMaxH); + } + let byForcedW = byMaxH.get(maxH); + if (!byForcedW) { + byForcedW = new Map(); + byMaxH.set(maxH, byForcedW); + } + const forcedWKey = forcedDimensionKey(forcedW); + let byForcedH = byForcedW.get(forcedWKey); + if (!byForcedH) { + byForcedH = new Map(); + byForcedW.set(forcedWKey, byForcedH); + } + const forcedHKey = forcedDimensionKey(forcedH); + let byX = byForcedH.get(forcedHKey); + if (!byX) { + byX = new Map(); + byForcedH.set(forcedHKey, byX); + } + let byY = byX.get(x); + if (!byY) { + byY = new Map(); + byX.set(x, byY); + } + byY.set(y, value); } function getSyntheticThemedColumn(vnode: ThemedVNode): VNode { @@ -378,14 +438,13 @@ function layoutNode( } const cache = activeLayoutCache; - const cacheKey = layoutCacheKey(maxW, maxH, forcedW, forcedH, x, y); const dirtySet = getActiveDirtySet(); let cacheHit: LayoutResult | null = null; if (cache) { const entry = cache.get(vnode); if (entry) { const axisMap = axis === "row" ? entry.row : entry.column; - cacheHit = axisMap.get(cacheKey) ?? null; + cacheHit = getLayoutCacheHit(axisMap, maxW, maxH, forcedW, forcedH, x, y); if (cacheHit && (dirtySet === null || !dirtySet.has(vnode))) { if (__layoutProfile.enabled) __layoutProfile.layoutCacheHits++; return cacheHit; @@ -697,7 +756,7 @@ function layoutNode( cache.set(vnode, entry); } const axisMap = axis === "row" ? entry.row : entry.column; - axisMap.set(cacheKey, computed); + setLayoutCacheValue(axisMap, maxW, maxH, forcedW, forcedH, x, y, computed); } return computed; diff --git a/packages/core/src/layout/engine/pool.ts b/packages/core/src/layout/engine/pool.ts index 280830f5..ecf5b237 100644 --- a/packages/core/src/layout/engine/pool.ts +++ b/packages/core/src/layout/engine/pool.ts @@ -4,7 +4,7 @@ /** Pool of reusable number arrays for layout computation. */ const arrayPool: number[][] = []; -const MAX_POOL_SIZE = 8; +const MAX_POOL_SIZE = 32; /** * Get or create a number array of the specified length, zeroed. @@ -15,7 +15,12 @@ export function acquireArray(length: number): number[] { for (let i = 0; i < arrayPool.length; i++) { const arr = arrayPool[i]; if (arr !== undefined && arr.length >= length) { - arrayPool.splice(i, 1); + const lastIndex = arrayPool.length - 1; + if (i !== lastIndex) { + const last = arrayPool[lastIndex]; + if (last !== undefined) arrayPool[i] = last; + } + arrayPool.length = lastIndex; // Zero the portion we'll use arr.fill(0, 0, length); return arr; diff --git a/packages/core/src/layout/kinds/stack.ts b/packages/core/src/layout/kinds/stack.ts index 08711801..16abad6a 100644 --- a/packages/core/src/layout/kinds/stack.ts +++ b/packages/core/src/layout/kinds/stack.ts @@ -24,7 +24,7 @@ import { getConstraintProps, } from "../engine/guards.js"; import { measureMaxContent, measureMinContent } from "../engine/intrinsic.js"; -import { releaseArray } from "../engine/pool.js"; +import { acquireArray, releaseArray } from "../engine/pool.js"; import { ok } from "../engine/result.js"; import type { LayoutTree } from "../engine/types.js"; import { resolveResponsiveValue } from "../responsive.js"; @@ -470,208 +470,218 @@ function computeWrapConstraintLine( const gapTotal = lineChildCount <= 1 ? 0 : gap * (lineChildCount - 1); const availableForChildren = clampNonNegative(mainLimit - gapTotal); - const mainSizes = new Array(lineChildCount).fill(0); - const measureMaxMain = new Array(lineChildCount).fill(0); - const crossSizes = includeChildren ? new Array(lineChildCount).fill(0) : null; + const mainSizes = acquireArray(lineChildCount); + const measureMaxMain = acquireArray(lineChildCount); + const crossSizes = includeChildren ? acquireArray(lineChildCount) : null; + const crossPass1 = acquireArray(lineChildCount); - const flexItems: FlexItem[] = []; - let remaining = availableForChildren; + try { + const flexItems: FlexItem[] = []; + let remaining = availableForChildren; - for (let i = 0; i < lineChildCount; i++) { - const child = lineChildren[i]; - if (!child || childHasAbsolutePosition(child)) continue; + for (let i = 0; i < lineChildCount; i++) { + const child = lineChildren[i]; + if (!child || childHasAbsolutePosition(child)) continue; + + if (child.kind === "spacer") { + const sp = validateSpacerProps(child.props); + if (!sp.ok) return sp; + + const maxMain = availableForChildren; + if (remaining === 0) { + mainSizes[i] = 0; + measureMaxMain[i] = 0; + continue; + } + + if (sp.value.flex > 0) { + flexItems.push({ + index: i, + flex: sp.value.flex, + shrink: 0, + basis: 0, + min: sp.value.size, + max: maxMain, + }); + continue; + } - if (child.kind === "spacer") { - const sp = validateSpacerProps(child.props); - if (!sp.ok) return sp; + const size = Math.min(sp.value.size, remaining); + mainSizes[i] = size; + measureMaxMain[i] = size; + remaining = clampNonNegative(remaining - size); + continue; + } + + const childProps = getConstraintProps(child) ?? {}; + const resolved = resolveLayoutConstraints(childProps as never, parentRect, axis.axis); + + const fixedMain = resolved[axis.mainProp]; + const minMain = resolved[axis.minMainProp]; + const maxMain = Math.min( + toFiniteMax(resolved[axis.maxMainProp], availableForChildren), + availableForChildren, + ); + const flex = resolved.flex; + + const rawMain = (childProps as ConstraintPropBag)[axis.mainProp]; + const mainIsPercent = isPercentString(rawMain); - const maxMain = availableForChildren; if (remaining === 0) { mainSizes[i] = 0; measureMaxMain[i] = 0; continue; } - if (sp.value.flex > 0) { + if (fixedMain !== null) { + const desired = clampWithin(fixedMain, minMain, maxMain); + const size = Math.min(desired, remaining); + mainSizes[i] = size; + measureMaxMain[i] = mainIsPercent ? mainLimit : size; + remaining = clampNonNegative(remaining - size); + continue; + } + + if (flex > 0) { flexItems.push({ index: i, - flex: sp.value.flex, + flex, shrink: 0, basis: 0, - min: sp.value.size, + min: minMain, max: maxMain, }); continue; } - const size = Math.min(sp.value.size, remaining); - mainSizes[i] = size; - measureMaxMain[i] = size; - remaining = clampNonNegative(remaining - size); - continue; + const childRes = measureNodeOnAxis(axis, child, remaining, crossLimit, measureNode); + if (!childRes.ok) return childRes; + const childMain = mainFromSize(axis, childRes.value); + mainSizes[i] = childMain; + measureMaxMain[i] = childMain; + remaining = clampNonNegative(remaining - childMain); } - const childProps = getConstraintProps(child) ?? {}; - const resolved = resolveLayoutConstraints(childProps as never, parentRect, axis.axis); + if (flexItems.length > 0 && remaining > 0) { + const alloc = distributeFlex(remaining, flexItems); + for (let j = 0; j < flexItems.length; j++) { + const it = flexItems[j]; + if (!it) continue; + const size = alloc[j] ?? 0; + mainSizes[it.index] = size; + const child = lineChildren[it.index]; + if (child?.kind === "spacer") { + measureMaxMain[it.index] = size; + continue; + } + const childProps = getConstraintProps(child as VNode) ?? {}; + const rawMain = (childProps as ConstraintPropBag)[axis.mainProp]; + measureMaxMain[it.index] = isPercentString(rawMain) ? mainLimit : size; + } + releaseArray(alloc); + } - const fixedMain = resolved[axis.mainProp]; - const minMain = resolved[axis.minMainProp]; - const maxMain = Math.min( - toFiniteMax(resolved[axis.maxMainProp], availableForChildren), + maybeRebalanceNearFullPercentChildren( + axis, + lineChildren, + mainSizes, + measureMaxMain, availableForChildren, + parentRect, ); - const flex = resolved.flex; - - const rawMain = (childProps as ConstraintPropBag)[axis.mainProp]; - const mainIsPercent = isPercentString(rawMain); - - if (remaining === 0) { - mainSizes[i] = 0; - measureMaxMain[i] = 0; - continue; - } - - if (fixedMain !== null) { - const desired = clampWithin(fixedMain, minMain, maxMain); - const size = Math.min(desired, remaining); - mainSizes[i] = size; - measureMaxMain[i] = mainIsPercent ? mainLimit : size; - remaining = clampNonNegative(remaining - size); - continue; - } - if (flex > 0) { - flexItems.push({ - index: i, - flex, - shrink: 0, - basis: 0, - min: minMain, - max: maxMain, - }); - continue; + let lineMain = 0; + for (let i = 0; i < lineChildCount; i++) { + lineMain += mainSizes[i] ?? 0; } + lineMain += lineChildCount <= 1 ? 0 : gap * (lineChildCount - 1); - const childRes = measureNodeOnAxis(axis, child, remaining, crossLimit, measureNode); - if (!childRes.ok) return childRes; - const childMain = mainFromSize(axis, childRes.value); - mainSizes[i] = childMain; - measureMaxMain[i] = childMain; - remaining = clampNonNegative(remaining - childMain); - } + let lineCross = 0; + const sizeCache = new Array(lineChildCount).fill(null); + const mayFeedback = new Array(lineChildCount).fill(false); + let feedbackCandidate = false; - if (flexItems.length > 0 && remaining > 0) { - const alloc = distributeFlex(remaining, flexItems); - for (let j = 0; j < flexItems.length; j++) { - const it = flexItems[j]; - if (!it) continue; - const size = alloc[j] ?? 0; - mainSizes[it.index] = size; - const child = lineChildren[it.index]; - if (child?.kind === "spacer") { - measureMaxMain[it.index] = size; - continue; - } - const childProps = getConstraintProps(child as VNode) ?? {}; + for (let i = 0; i < lineChildCount; i++) { + const child = lineChildren[i]; + if (!child || childHasAbsolutePosition(child)) continue; + const main = mainSizes[i] ?? 0; + const mm = measureMaxMain[i] ?? 0; + const childSizeRes = + main === 0 + ? measureNodeOnAxis(axis, child, 0, 0, measureNode) + : measureNodeOnAxis(axis, child, mm, crossLimit, measureNode); + if (!childSizeRes.ok) return childSizeRes; + const childCross = crossFromSize(axis, childSizeRes.value); + if (crossSizes) crossSizes[i] = childCross; + sizeCache[i] = childSizeRes.value; + const childProps = getConstraintProps(child) ?? {}; const rawMain = (childProps as ConstraintPropBag)[axis.mainProp]; - measureMaxMain[it.index] = isPercentString(rawMain) ? mainLimit : size; + const needsFeedback = + main > 0 && + mm !== main && + !isPercentString(rawMain) && + childMayNeedCrossAxisFeedback(child); + mayFeedback[i] = needsFeedback; + crossPass1[i] = childCross; + if (needsFeedback) feedbackCandidate = true; + if (childCross > lineCross) lineCross = childCross; } - releaseArray(alloc); - } - maybeRebalanceNearFullPercentChildren( - axis, - lineChildren, - mainSizes, - measureMaxMain, - availableForChildren, - parentRect, - ); + if (feedbackCandidate) { + lineCross = 0; + for (let i = 0; i < lineChildCount; i++) { + const child = lineChildren[i]; + if (!child || childHasAbsolutePosition(child)) continue; - let lineMain = 0; - for (let i = 0; i < mainSizes.length; i++) { - lineMain += mainSizes[i] ?? 0; - } - lineMain += lineChildCount <= 1 ? 0 : gap * (lineChildCount - 1); + const needsFeedback = mayFeedback[i] === true; + let size = sizeCache[i] ?? null; + if (needsFeedback) { + const main = mainSizes[i] ?? 0; + const nextSizeRes = + main === 0 + ? measureNodeOnAxis(axis, child, 0, 0, measureNode) + : measureNodeOnAxis(axis, child, main, crossLimit, measureNode); + if (!nextSizeRes.ok) return nextSizeRes; + const nextCross = crossFromSize(axis, nextSizeRes.value); + if (nextCross !== (crossPass1[i] ?? 0)) { + size = nextSizeRes.value; + sizeCache[i] = size; + if (crossSizes) crossSizes[i] = nextCross; + crossPass1[i] = nextCross; + } + } - let lineCross = 0; - const sizeCache = new Array(lineChildCount).fill(null); - const mayFeedback = new Array(lineChildCount).fill(false); - const crossPass1 = new Array(lineChildCount).fill(0); - let feedbackCandidate = false; + const cross = + crossSizes?.[i] ?? crossPass1[i] ?? (size === null ? 0 : crossFromSize(axis, size)); + if (cross > lineCross) lineCross = cross; + } + } - for (let i = 0; i < lineChildCount; i++) { - const child = lineChildren[i]; - if (!child || childHasAbsolutePosition(child)) continue; - const main = mainSizes[i] ?? 0; - const mm = measureMaxMain[i] ?? 0; - const childSizeRes = - main === 0 - ? measureNodeOnAxis(axis, child, 0, 0, measureNode) - : measureNodeOnAxis(axis, child, mm, crossLimit, measureNode); - if (!childSizeRes.ok) return childSizeRes; - const childCross = crossFromSize(axis, childSizeRes.value); - if (crossSizes) crossSizes[i] = childCross; - sizeCache[i] = childSizeRes.value; - const childProps = getConstraintProps(child) ?? {}; - const rawMain = (childProps as ConstraintPropBag)[axis.mainProp]; - const needsFeedback = - main > 0 && mm !== main && !isPercentString(rawMain) && childMayNeedCrossAxisFeedback(child); - mayFeedback[i] = needsFeedback; - crossPass1[i] = childCross; - if (needsFeedback) feedbackCandidate = true; - if (childCross > lineCross) lineCross = childCross; - } + if (!includeChildren) return ok({ main: lineMain, cross: lineCross }); - if (feedbackCandidate) { - lineCross = 0; + const plannedChildren: WrapLineChildLayout[] = []; for (let i = 0; i < lineChildCount; i++) { const child = lineChildren[i]; if (!child || childHasAbsolutePosition(child)) continue; - - const needsFeedback = mayFeedback[i] === true; - let size = sizeCache[i] ?? null; - if (needsFeedback) { - const main = mainSizes[i] ?? 0; - const nextSizeRes = - main === 0 - ? measureNodeOnAxis(axis, child, 0, 0, measureNode) - : measureNodeOnAxis(axis, child, main, crossLimit, measureNode); - if (!nextSizeRes.ok) return nextSizeRes; - const nextCross = crossFromSize(axis, nextSizeRes.value); - if (nextCross !== (crossPass1[i] ?? 0)) { - size = nextSizeRes.value; - sizeCache[i] = size; - if (crossSizes) crossSizes[i] = nextCross; - crossPass1[i] = nextCross; - } - } - - const cross = - crossSizes?.[i] ?? crossPass1[i] ?? (size === null ? 0 : crossFromSize(axis, size)); - if (cross > lineCross) lineCross = cross; + plannedChildren.push({ + child, + main: mainSizes[i] ?? 0, + measureMaxMain: measureMaxMain[i] ?? 0, + cross: crossSizes?.[i] ?? 0, + }); } - } - if (!includeChildren) return ok({ main: lineMain, cross: lineCross }); - - const plannedChildren: WrapLineChildLayout[] = []; - for (let i = 0; i < lineChildCount; i++) { - const child = lineChildren[i]; - if (!child || childHasAbsolutePosition(child)) continue; - plannedChildren.push({ - child, - main: mainSizes[i] ?? 0, - measureMaxMain: measureMaxMain[i] ?? 0, - cross: crossSizes?.[i] ?? 0, + return ok({ + children: Object.freeze(plannedChildren), + main: lineMain, + cross: lineCross, }); + } finally { + releaseArray(mainSizes); + releaseArray(measureMaxMain); + if (crossSizes) releaseArray(crossSizes); + releaseArray(crossPass1); } - - return ok({ - children: Object.freeze(plannedChildren), - main: lineMain, - cross: lineCross, - }); } function measureWrapConstraintLine( @@ -889,183 +899,191 @@ function planConstraintMainSizes( } // Advanced path: supports flexShrink/flexBasis while keeping legacy defaults. - const minMains = new Array(children.length).fill(0); - const maxMains = new Array(children.length).fill(availableForChildren); - const shrinkFactors = new Array(children.length).fill(0); + const minMains = acquireArray(children.length); + const maxMains = acquireArray(children.length); + maxMains.fill(availableForChildren, 0, children.length); + const shrinkFactors = acquireArray(children.length); - const growItems: FlexItem[] = []; - let totalMain = 0; + try { + const growItems: FlexItem[] = []; + let totalMain = 0; - for (let i = 0; i < children.length; i++) { - const child = children[i]; - if (!child || childHasAbsolutePosition(child)) continue; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (!child || childHasAbsolutePosition(child)) continue; + + if (child.kind === "spacer") { + const sp = validateSpacerProps(child.props); + if (!sp.ok) return sp; + + const basis = sp.value.flex > 0 ? 0 : sp.value.size; + mainSizes[i] = basis; + measureMaxMain[i] = basis; + minMains[i] = 0; + maxMains[i] = availableForChildren; + shrinkFactors[i] = 0; + totalMain += basis; + + if (sp.value.flex > 0) { + growItems.push({ + index: i, + flex: sp.value.flex, + shrink: 0, + basis: 0, + min: sp.value.size, + max: availableForChildren, + }); + } + continue; + } - if (child.kind === "spacer") { - const sp = validateSpacerProps(child.props); - if (!sp.ok) return sp; + const childProps = (getConstraintProps(child) ?? {}) as Record & FlexPropBag; + const resolved = resolveLayoutConstraints(childProps as never, parentRect, axis.axis); + + const fixedMain = resolved[axis.mainProp]; + const maxMain = Math.min( + toFiniteMax(resolved[axis.maxMainProp], availableForChildren), + availableForChildren, + ); + let minMain = Math.min(resolved[axis.minMainProp], availableForChildren); + + const rawMain = childProps[axis.mainProp]; + const rawMinMain = childProps[axis.minMainProp]; + const rawFlexBasis = childProps.flexBasis; + const mainPercent = isPercentString(rawMain); + const flexBasisIsAuto = resolveResponsiveValue(rawFlexBasis) === "auto"; + + if (rawMinMain === undefined && resolved.flexShrink > 0) { + const intrinsicMinRes = measureMinContent(child, axis.axis, measureNode); + if (!intrinsicMinRes.ok) return intrinsicMinRes; + const intrinsicMain = mainFromSize(axis, intrinsicMinRes.value); + minMain = Math.max(minMain, Math.min(intrinsicMain, availableForChildren)); + } + + const normalizedMinMain = Math.min(minMain, maxMain); + minMains[i] = normalizedMinMain; + maxMains[i] = maxMain; + shrinkFactors[i] = resolved.flexShrink; + + let measuredSize: Size | null = null; + let basis: number; + if (fixedMain !== null) { + basis = clampWithin(fixedMain, normalizedMinMain, maxMain); + } else if (resolved.flexBasis !== null) { + basis = clampWithin(resolved.flexBasis, normalizedMinMain, maxMain); + } else if (flexBasisIsAuto) { + const intrinsicMaxRes = measureMaxContent(child, axis.axis, measureNode); + if (!intrinsicMaxRes.ok) return intrinsicMaxRes; + const intrinsicMain = mainFromSize(axis, intrinsicMaxRes.value); + basis = clampWithin(intrinsicMain, normalizedMinMain, maxMain); + } else if (resolved.flex > 0) { + basis = 0; + } else { + const childRes = measureNodeOnAxis( + axis, + child, + availableForChildren, + crossLimit, + measureNode, + ); + if (!childRes.ok) return childRes; + measuredSize = childRes.value; + basis = clampWithin(mainFromSize(axis, childRes.value), normalizedMinMain, maxMain); + } - const basis = sp.value.flex > 0 ? 0 : sp.value.size; mainSizes[i] = basis; - measureMaxMain[i] = basis; - minMains[i] = 0; - maxMains[i] = availableForChildren; - shrinkFactors[i] = 0; + measureMaxMain[i] = mainPercent ? mainLimit : basis; + if (collectPrecomputed && measuredSize !== null) precomputedSizes[i] = measuredSize; totalMain += basis; - if (sp.value.flex > 0) { + if (fixedMain === null && resolved.flex > 0) { + const growMin = Math.max(0, normalizedMinMain - basis); + const growCap = Math.max(0, maxMain - basis); growItems.push({ index: i, - flex: sp.value.flex, + flex: resolved.flex, shrink: 0, basis: 0, - min: sp.value.size, - max: availableForChildren, + min: growMin, + max: growCap, }); } - continue; } - const childProps = (getConstraintProps(child) ?? {}) as Record & FlexPropBag; - const resolved = resolveLayoutConstraints(childProps as never, parentRect, axis.axis); - - const fixedMain = resolved[axis.mainProp]; - const maxMain = Math.min( - toFiniteMax(resolved[axis.maxMainProp], availableForChildren), - availableForChildren, - ); - let minMain = Math.min(resolved[axis.minMainProp], availableForChildren); - - const rawMain = childProps[axis.mainProp]; - const rawMinMain = childProps[axis.minMainProp]; - const rawFlexBasis = childProps.flexBasis; - const mainPercent = isPercentString(rawMain); - const flexBasisIsAuto = resolveResponsiveValue(rawFlexBasis) === "auto"; - - if (rawMinMain === undefined && resolved.flexShrink > 0) { - const intrinsicMinRes = measureMinContent(child, axis.axis, measureNode); - if (!intrinsicMinRes.ok) return intrinsicMinRes; - const intrinsicMain = mainFromSize(axis, intrinsicMinRes.value); - minMain = Math.max(minMain, Math.min(intrinsicMain, availableForChildren)); - } - - const normalizedMinMain = Math.min(minMain, maxMain); - minMains[i] = normalizedMinMain; - maxMains[i] = maxMain; - shrinkFactors[i] = resolved.flexShrink; - - let measuredSize: Size | null = null; - let basis: number; - if (fixedMain !== null) { - basis = clampWithin(fixedMain, normalizedMinMain, maxMain); - } else if (resolved.flexBasis !== null) { - basis = clampWithin(resolved.flexBasis, normalizedMinMain, maxMain); - } else if (flexBasisIsAuto) { - const intrinsicMaxRes = measureMaxContent(child, axis.axis, measureNode); - if (!intrinsicMaxRes.ok) return intrinsicMaxRes; - const intrinsicMain = mainFromSize(axis, intrinsicMaxRes.value); - basis = clampWithin(intrinsicMain, normalizedMinMain, maxMain); - } else if (resolved.flex > 0) { - basis = 0; - } else { - const childRes = measureNodeOnAxis( - axis, - child, - availableForChildren, - crossLimit, - measureNode, - ); - if (!childRes.ok) return childRes; - measuredSize = childRes.value; - basis = clampWithin(mainFromSize(axis, childRes.value), normalizedMinMain, maxMain); + let didResize = false; + const growRemaining = availableForChildren - totalMain; + if (growItems.length > 0 && growRemaining > 0) { + const alloc = distributeFlex(growRemaining, growItems); + for (let i = 0; i < growItems.length; i++) { + const item = growItems[i]; + if (!item) continue; + const add = alloc[i] ?? 0; + if (add <= 0) continue; + const current = mainSizes[item.index] ?? 0; + const next = Math.min(maxMains[item.index] ?? availableForChildren, current + add); + if (next !== current) didResize = true; + mainSizes[item.index] = next; + } + releaseArray(alloc); } - mainSizes[i] = basis; - measureMaxMain[i] = mainPercent ? mainLimit : basis; - if (collectPrecomputed && measuredSize !== null) precomputedSizes[i] = measuredSize; - totalMain += basis; - - if (fixedMain === null && resolved.flex > 0) { - const growMin = Math.max(0, normalizedMinMain - basis); - const growCap = Math.max(0, maxMain - basis); - growItems.push({ - index: i, - flex: resolved.flex, - shrink: 0, - basis: 0, - min: growMin, - max: growCap, - }); + totalMain = 0; + for (let i = 0; i < mainSizes.length; i++) { + totalMain += mainSizes[i] ?? 0; } - } - let didResize = false; - const growRemaining = availableForChildren - totalMain; - if (growItems.length > 0 && growRemaining > 0) { - const alloc = distributeFlex(growRemaining, growItems); - for (let i = 0; i < growItems.length; i++) { - const item = growItems[i]; - if (!item) continue; - const add = alloc[i] ?? 0; - if (add <= 0) continue; - const current = mainSizes[item.index] ?? 0; - const next = Math.min(maxMains[item.index] ?? availableForChildren, current + add); - if (next !== current) didResize = true; - mainSizes[item.index] = next; + if (totalMain > availableForChildren) { + const shrinkItems: FlexItem[] = []; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (!child || childHasAbsolutePosition(child)) continue; + shrinkItems.push({ + index: i, + flex: 0, + shrink: shrinkFactors[i] ?? 0, + basis: mainSizes[i] ?? 0, + min: minMains[i] ?? 0, + max: maxMains[i] ?? availableForChildren, + }); + } + if (shrinkItems.length > 0) { + const shrunk = shrinkFlex(availableForChildren, shrinkItems); + for (let i = 0; i < shrinkItems.length; i++) { + const item = shrinkItems[i]; + if (!item) continue; + const current = mainSizes[item.index] ?? 0; + const next = clampWithin(shrunk[i] ?? 0, item.min, item.max); + if (next !== current) didResize = true; + mainSizes[item.index] = next; + } + releaseArray(shrunk); + } } - releaseArray(alloc); - } - - totalMain = 0; - for (let i = 0; i < mainSizes.length; i++) { - totalMain += mainSizes[i] ?? 0; - } - if (totalMain > availableForChildren) { - const shrinkItems: FlexItem[] = []; for (let i = 0; i < children.length; i++) { - const child = children[i]; - if (!child || childHasAbsolutePosition(child)) continue; - shrinkItems.push({ - index: i, - flex: 0, - shrink: shrinkFactors[i] ?? 0, - basis: mainSizes[i] ?? 0, - min: minMains[i] ?? 0, - max: maxMains[i] ?? availableForChildren, - }); + if (!children[i]) continue; + if (didResize && collectPrecomputed) precomputedSizes[i] = null; } - if (shrinkItems.length > 0) { - const shrunk = shrinkFlex(availableForChildren, shrinkItems); - for (let i = 0; i < shrinkItems.length; i++) { - const item = shrinkItems[i]; - if (!item) continue; - const current = mainSizes[item.index] ?? 0; - const next = clampWithin(shrunk[i] ?? 0, item.min, item.max); - if (next !== current) didResize = true; - mainSizes[item.index] = next; - } - } - } - for (let i = 0; i < children.length; i++) { - if (!children[i]) continue; - if (didResize && collectPrecomputed) precomputedSizes[i] = null; - } - - maybeRebalanceNearFullPercentChildren( - axis, - children, - mainSizes, - measureMaxMain, - availableForChildren, - parentRect, - ); + maybeRebalanceNearFullPercentChildren( + axis, + children, + mainSizes, + measureMaxMain, + availableForChildren, + parentRect, + ); - return ok({ - mainSizes, - measureMaxMain, - precomputedSizes, - }); + return ok({ + mainSizes, + measureMaxMain, + precomputedSizes, + }); + } finally { + releaseArray(minMains); + releaseArray(maxMains); + releaseArray(shrinkFactors); + } } function planConstraintCrossSizes( @@ -1683,82 +1701,87 @@ function layoutStack( } } } else if (!needsConstraintPass) { - const mainSizes = new Array(count).fill(0); - const crossSizes = new Array(count).fill(0); + const mainSizes = acquireArray(count); + const crossSizes = acquireArray(count); - let rem = mainLimit; - for (let i = 0; i < count; i++) { - const child = vnode.children[i]; - if (!child || childHasAbsolutePosition(child)) continue; - if (rem === 0) continue; + try { + let rem = mainLimit; + for (let i = 0; i < count; i++) { + const child = vnode.children[i]; + if (!child || childHasAbsolutePosition(child)) continue; + if (rem === 0) continue; - const childSizeRes = measureNodeOnAxis(axis, child, rem, crossLimit, measureNode); - if (!childSizeRes.ok) return childSizeRes; - mainSizes[i] = mainFromSize(axis, childSizeRes.value); - crossSizes[i] = crossFromSize(axis, childSizeRes.value); - rem = clampNonNegative(rem - (mainSizes[i] ?? 0) - gap); - } + const childSizeRes = measureNodeOnAxis(axis, child, rem, crossLimit, measureNode); + if (!childSizeRes.ok) return childSizeRes; + mainSizes[i] = mainFromSize(axis, childSizeRes.value); + crossSizes[i] = crossFromSize(axis, childSizeRes.value); + rem = clampNonNegative(rem - (mainSizes[i] ?? 0) - gap); + } - let usedMain = 0; - for (let i = 0; i < mainSizes.length; i++) { - usedMain += mainSizes[i] ?? 0; - } - usedMain += childCount <= 1 ? 0 : gap * (childCount - 1); - const extra = clampNonNegative(mainLimit - usedMain); - const startOffset = computeJustifyStartOffset(justify, extra, childCount); + let usedMain = 0; + for (let i = 0; i < count; i++) { + usedMain += mainSizes[i] ?? 0; + } + usedMain += childCount <= 1 ? 0 : gap * (childCount - 1); + const extra = clampNonNegative(mainLimit - usedMain); + const startOffset = computeJustifyStartOffset(justify, extra, childCount); - let cursorMain = mainOrigin + startOffset; - let remainingMain = clampNonNegative(mainLimit - startOffset); - let childOrdinal = 0; + let cursorMain = mainOrigin + startOffset; + let remainingMain = clampNonNegative(mainLimit - startOffset); + let childOrdinal = 0; - for (let i = 0; i < count; i++) { - const child = vnode.children[i]; - if (!child || childHasAbsolutePosition(child)) continue; + for (let i = 0; i < count; i++) { + const child = vnode.children[i]; + if (!child || childHasAbsolutePosition(child)) continue; - if (remainingMain === 0) { - const childRes = layoutNodeOnAxis(axis, child, cursorMain, crossOrigin, 0, 0, layoutNode); + if (remainingMain === 0) { + const childRes = layoutNodeOnAxis(axis, child, cursorMain, crossOrigin, 0, 0, layoutNode); + if (!childRes.ok) return childRes; + children.push(childRes.value); + childOrdinal++; + continue; + } + + const childMain = mainSizes[i] ?? 0; + const childCross = crossSizes[i] ?? 0; + + let childCrossPos = crossOrigin; + let forceCross: number | null = null; + const effectiveAlign = resolveEffectiveAlign(child, align); + if (effectiveAlign === "center") { + childCrossPos = crossOrigin + Math.floor((crossLimit - childCross) / 2); + } else if (effectiveAlign === "end") { + childCrossPos = crossOrigin + (crossLimit - childCross); + } else if (effectiveAlign === "stretch") { + forceCross = crossLimit; + } + + const childRes = layoutNodeOnAxis( + axis, + child, + cursorMain, + childCrossPos, + remainingMain, + crossLimit, + layoutNode, + null, + forceCross, + ); if (!childRes.ok) return childRes; children.push(childRes.value); - childOrdinal++; - continue; - } - const childMain = mainSizes[i] ?? 0; - const childCross = crossSizes[i] ?? 0; - - let childCrossPos = crossOrigin; - let forceCross: number | null = null; - const effectiveAlign = resolveEffectiveAlign(child, align); - if (effectiveAlign === "center") { - childCrossPos = crossOrigin + Math.floor((crossLimit - childCross) / 2); - } else if (effectiveAlign === "end") { - childCrossPos = crossOrigin + (crossLimit - childCross); - } else if (effectiveAlign === "stretch") { - forceCross = crossLimit; + const hasNextChild = childOrdinal < childCount - 1; + const extraGap = hasNextChild + ? computeJustifyExtraGap(justify, extra, childCount, childOrdinal) + : 0; + const step = childMain + (hasNextChild ? gap + extraGap : 0); + cursorMain = cursorMain + step; + remainingMain = clampNonNegative(remainingMain - step); + childOrdinal++; } - - const childRes = layoutNodeOnAxis( - axis, - child, - cursorMain, - childCrossPos, - remainingMain, - crossLimit, - layoutNode, - null, - forceCross, - ); - if (!childRes.ok) return childRes; - children.push(childRes.value); - - const hasNextChild = childOrdinal < childCount - 1; - const extraGap = hasNextChild - ? computeJustifyExtraGap(justify, extra, childCount, childOrdinal) - : 0; - const step = childMain + (hasNextChild ? gap + extraGap : 0); - cursorMain = cursorMain + step; - remainingMain = clampNonNegative(remainingMain - step); - childOrdinal++; + } finally { + releaseArray(mainSizes); + releaseArray(crossSizes); } } else { const parentRect: Rect = { x: 0, y: 0, w: cw, h: ch }; diff --git a/packages/core/src/renderer/renderToDrawlist/renderPackets.ts b/packages/core/src/renderer/renderToDrawlist/renderPackets.ts index 8d74ba94..a08cf754 100644 --- a/packages/core/src/renderer/renderToDrawlist/renderPackets.ts +++ b/packages/core/src/renderer/renderToDrawlist/renderPackets.ts @@ -19,6 +19,21 @@ const refIds = new WeakMap(); let nextRefId = 1; const themeHashCache = new WeakMap(); const styleHashCache = new WeakMap(); +const textValueHashCache = new Map(); +const TEXT_VALUE_HASH_CACHE_MAX = 8_192; +const TEXT_VALUE_HASH_CACHE_MIN_LEN = 24; +type TextPacketKeyMemo = Readonly<{ + kind: string; + text: string; + props: Readonly>; + theme: Theme; + parentStyle: ResolvedTextStyle; + rectWidth: number; + rectHeight: number; + focusBits: number; + key: number; +}>; +const textPacketKeyMemo = new WeakMap(); function hashString(value: string): number { let hash = FNV_OFFSET; @@ -29,6 +44,21 @@ function hashString(value: string): number { return hash >>> 0; } +function hashTextValue(value: string): number { + if (value.length < TEXT_VALUE_HASH_CACHE_MIN_LEN) { + return hashString(value); + } + const cached = textValueHashCache.get(value); + if (cached !== undefined) return cached; + const hashed = hashString(value); + if (textValueHashCache.size >= TEXT_VALUE_HASH_CACHE_MAX) { + // Bound memory under high-cardinality dynamic text workloads. + textValueHashCache.clear(); + } + textValueHashCache.set(value, hashed); + return hashed; +} + function mixHash(hash: number, value: number): number { const mixed = Math.imul((hash ^ (value >>> 0)) >>> 0, FNV_PRIME); return mixed >>> 0; @@ -54,6 +84,46 @@ function hashUnknown(value: unknown): number { return 0; } +/** + * Hash a value by content for primitives, by identity for objects/functions. + * Returns null if the value contains unhashable content (signals uncacheable). + */ +function hashPropValue(hash: number, value: unknown): number { + if (value === null || value === undefined) return mixHash(hash, 0); + if (typeof value === "boolean") return mixHash(hash, value ? 1 : 2); + if (typeof value === "number") return mixHash(hash, (Math.trunc(value) & HASH_MASK_32) >>> 0); + if (typeof value === "string") return mixHash(hash, hashString(value)); + // Functions (callbacks) don't affect visual output — skip with stable sentinel. + if (typeof value === "function") return mixHash(hash, 0xcafe_0001); + // Arrays: hash element count + each element. + if (Array.isArray(value)) { + let out = mixHash(hash, value.length); + for (let i = 0; i < value.length; i++) { + out = hashPropValue(out, value[i]); + } + return out; + } + // Plain objects: hash by identity (preserving correctness for complex nested objects). + return mixHash(hash, hashUnknown(value)); +} + +/** + * Hash all own enumerable props of an object by content (primitives) or identity (objects). + * Callbacks (functions) receive a stable sentinel so re-created closures don't bust the cache. + */ +function hashPropsShallow(hash: number, props: Readonly>): number { + let out = hash; + const keys = Object.keys(props); + out = mixHash(out, keys.length); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (key === undefined) continue; + out = mixHash(out, hashString(key)); + out = hashPropValue(out, props[key]); + } + return out; +} + function hashBoolFlag(value: boolean | undefined): number { if (value === true) return 1; if (value === false) return 2; @@ -174,6 +244,68 @@ function isTickDrivenKind(kind: RuntimeInstance["vnode"]["kind"]): boolean { return kind === "spinner"; } +/** + * Fast-path prop hashing for text/richText nodes. + * Only hashes the visual-relevant props (style, maxWidth, wrap, variant, dim, + * textOverflow) to avoid the cost of iterating all keys via hashPropsShallow. + * The text content itself is already hashed separately. + */ +function hashTextProps(hash: number, props: Readonly>): number { + 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 ( + style === undefined && + maxWidth === undefined && + wrap === undefined && + variant === undefined && + dim === undefined && + textOverflow === undefined + ) { + return mixHash(hash, 0x7458_7430); + } + + let out = hash; + if (style !== undefined) { + out = mixHash(out, 1); + out = mixHash(out, hashUnknown(style)); + } + if (maxWidth !== undefined) { + out = mixHash(out, 2); + out = hashPropValue(out, maxWidth); + } + if (wrap !== undefined) { + out = mixHash(out, 3); + out = hashPropValue(out, wrap); + } + if (variant !== undefined) { + out = mixHash(out, 4); + out = hashPropValue(out, variant); + } + if (dim !== undefined) { + out = mixHash(out, 5); + out = hashPropValue(out, dim); + } + if (textOverflow !== undefined) { + out = mixHash(out, 6); + out = hashPropValue(out, textOverflow); + } + return out; +} + export function computeRenderPacketKey( node: RuntimeInstance, theme: Theme, @@ -187,24 +319,66 @@ export function computeRenderPacketKey( ): number { if (!isRenderPacketCacheable(node, cursorInfo)) return 0; + const focusBits = focusPressedBits(node, focusState, pressedId); + const kind = node.vnode.kind; + const vnodeText = (node.vnode as { text?: string }).text; + + if ((kind === "text" || kind === "richText") && vnodeText !== undefined) { + const props = node.vnode.props as Readonly>; + const memo = textPacketKeyMemo.get(node); + if ( + memo !== undefined && + memo.kind === kind && + memo.text === vnodeText && + memo.props === props && + memo.theme === theme && + memo.parentStyle === parentStyle && + memo.rectWidth === rectWidth && + memo.rectHeight === rectHeight && + memo.focusBits === focusBits + ) { + return memo.key; + } + } + let hash = FNV_OFFSET; - hash = mixHash(hash, hashString(node.vnode.kind)); - hash = mixHash(hash, hashUnknown(node.vnode)); - hash = mixHash(hash, hashUnknown(node.vnode.props)); + hash = mixHash(hash, hashString(kind)); + if (vnodeText !== undefined) { + hash = mixHash(hash, hashTextValue(vnodeText)); + } + if (kind === "text" || kind === "richText") { + hash = hashTextProps(hash, node.vnode.props as Readonly>); + } else { + hash = hashPropsShallow(hash, node.vnode.props as Readonly>); + } hash = mixHash(hash, hashTheme(theme)); hash = mixHash(hash, hashResolvedStyle(parentStyle)); hash = mixHash(hash, (Math.trunc(rectWidth) & HASH_MASK_32) >>> 0); hash = mixHash(hash, (Math.trunc(rectHeight) & HASH_MASK_32) >>> 0); - hash = mixHash(hash, focusPressedBits(node, focusState, pressedId)); + hash = mixHash(hash, focusBits); if (isTickDrivenKind(node.vnode.kind)) { hash = mixHash(hash, tick >>> 0); } - return hash === 0 ? 1 : hash >>> 0; + const out = hash === 0 ? 1 : hash >>> 0; + if ((kind === "text" || kind === "richText") && vnodeText !== undefined) { + textPacketKeyMemo.set(node, { + kind, + text: vnodeText, + props: node.vnode.props as Readonly>, + theme, + parentStyle, + rectWidth, + rectHeight, + focusBits, + key: out, + }); + } + return out; } export class RenderPacketRecorder implements DrawlistBuilder { - private readonly ops: RenderPacketOp[] = []; - private readonly resources: Uint8Array[] = []; + private ops: RenderPacketOp[] = []; + private resources: Uint8Array[] = []; private readonly blobResourceById = new Map(); private readonly textRunByBlobId = new Map(); private valid = true; @@ -217,9 +391,15 @@ export class RenderPacketRecorder implements DrawlistBuilder { buildPacket(): RenderPacket | null { if (!this.valid) return null; + const ops = this.ops; + const resources = this.resources; + this.ops = []; + this.resources = []; + this.blobResourceById.clear(); + this.textRunByBlobId.clear(); return Object.freeze({ - ops: Object.freeze(this.ops.slice()), - resources: Object.freeze(this.resources.slice()), + ops: Object.freeze(ops), + resources: Object.freeze(resources), }); } @@ -253,8 +433,11 @@ export class RenderPacketRecorder implements DrawlistBuilder { style?: Parameters[4], ): void { this.target.fillRect(x, y, w, h, style); - const local = { op: "FILL_RECT", x: this.localX(x), y: this.localY(y), w, h } as const; - this.ops.push(style === undefined ? local : { ...local, style }); + if (style === undefined) { + this.ops.push({ op: "FILL_RECT", x: this.localX(x), y: this.localY(y), w, h }); + return; + } + this.ops.push({ op: "FILL_RECT", x: this.localX(x), y: this.localY(y), w, h, style }); } blitRect(srcX: number, srcY: number, w: number, h: number, dstX: number, dstY: number): void { @@ -269,13 +452,22 @@ export class RenderPacketRecorder implements DrawlistBuilder { style?: Parameters[3], ): void { this.target.drawText(x, y, text, style); - const local = { + if (style === undefined) { + this.ops.push({ + op: "DRAW_TEXT_SLICE", + x: this.localX(x), + y: this.localY(y), + text, + }); + return; + } + this.ops.push({ op: "DRAW_TEXT_SLICE", x: this.localX(x), y: this.localY(y), text, - } as const; - this.ops.push(style === undefined ? local : { ...local, style }); + style, + }); } pushClip(x: number, y: number, w: number, h: number): void { @@ -347,7 +539,17 @@ export class RenderPacketRecorder implements DrawlistBuilder { this.invalidatePacket(); return; } - this.ops.push({ + const op: { + op: "DRAW_CANVAS"; + x: number; + y: number; + w: number; + h: number; + resourceId: number; + blitter: Parameters[5]; + pxWidth?: number; + pxHeight?: number; + } = { op: "DRAW_CANVAS", x: this.localX(x), y: this.localY(y), @@ -355,9 +557,10 @@ export class RenderPacketRecorder implements DrawlistBuilder { h, resourceId, blitter, - ...(pxWidth !== undefined ? { pxWidth } : {}), - ...(pxHeight !== undefined ? { pxHeight } : {}), - }); + }; + if (pxWidth !== undefined) op.pxWidth = pxWidth; + if (pxHeight !== undefined) op.pxHeight = pxHeight; + this.ops.push(op); } drawImage(...args: Parameters): void { @@ -368,7 +571,21 @@ export class RenderPacketRecorder implements DrawlistBuilder { this.invalidatePacket(); return; } - this.ops.push({ + const op: { + op: "DRAW_IMAGE"; + x: number; + y: number; + w: number; + h: number; + resourceId: number; + format: Parameters[5]; + protocol: Parameters[6]; + zLayer: Parameters[7]; + fit: Parameters[8]; + imageId: Parameters[9]; + pxWidth?: number; + pxHeight?: number; + } = { op: "DRAW_IMAGE", x: this.localX(x), y: this.localY(y), @@ -380,9 +597,10 @@ export class RenderPacketRecorder implements DrawlistBuilder { zLayer, fit, imageId, - ...(pxWidth !== undefined ? { pxWidth } : {}), - ...(pxHeight !== undefined ? { pxHeight } : {}), - }); + }; + if (pxWidth !== undefined) op.pxWidth = pxWidth; + if (pxHeight !== undefined) op.pxHeight = pxHeight; + this.ops.push(op); } buildInto(dst: Uint8Array): DrawlistBuildResult { @@ -409,10 +627,13 @@ export function emitRenderPacket( originX: number, originY: number, ): void { - const blobByResourceId: (number | null)[] = new Array(packet.resources.length); - for (let i = 0; i < packet.resources.length; i++) { - const resource = packet.resources[i]; - blobByResourceId[i] = resource ? builder.addBlob(resource) : null; + let blobByResourceId: (number | null)[] | null = null; + if (packet.resources.length > 0) { + blobByResourceId = new Array(packet.resources.length); + for (let i = 0; i < packet.resources.length; i++) { + const resource = packet.resources[i]; + blobByResourceId[i] = resource ? builder.addBlob(resource) : null; + } } for (const op of packet.ops) { @@ -444,7 +665,7 @@ export function emitRenderPacket( builder.popClip(); break; case "DRAW_CANVAS": { - const blobId = blobByResourceId[op.resourceId]; + const blobId = blobByResourceId?.[op.resourceId]; if (blobId === null || blobId === undefined) break; builder.drawCanvas( originX + op.x, @@ -459,7 +680,7 @@ export function emitRenderPacket( break; } case "DRAW_IMAGE": { - const blobId = blobByResourceId[op.resourceId]; + const blobId = blobByResourceId?.[op.resourceId]; if (blobId === null || blobId === undefined) break; builder.drawImage( originX + op.x, diff --git a/packages/core/src/renderer/renderToDrawlist/renderTree.ts b/packages/core/src/renderer/renderToDrawlist/renderTree.ts index 36594da2..4f39f302 100644 --- a/packages/core/src/renderer/renderToDrawlist/renderTree.ts +++ b/packages/core/src/renderer/renderToDrawlist/renderTree.ts @@ -151,7 +151,10 @@ export function renderTree( let renderTheme = currentTheme; if (vnode.kind === "themed") { const props = vnode.props as { theme?: unknown }; - renderTheme = mergeThemeOverride(currentTheme, props.theme); + const themeOverride = props.theme; + if (themeOverride !== undefined) { + renderTheme = mergeThemeOverride(currentTheme, themeOverride); + } } else if ( vnode.kind === "row" || vnode.kind === "column" || @@ -159,7 +162,10 @@ export function renderTree( vnode.kind === "box" ) { const props = vnode.props as { theme?: unknown }; - renderTheme = mergeThemeOverride(currentTheme, props.theme); + const themeOverride = props.theme; + if (themeOverride !== undefined) { + renderTheme = mergeThemeOverride(currentTheme, themeOverride); + } } const nodeStackLenBeforePush = nodeStack.length; From e248a553bf4f74b19914b5d44b90cadd7cd7d92a Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 22:41:19 +0400 Subject: [PATCH 21/29] chore(ink-compat): safety checkpoint before perf refactors From e3f5d671cea9c707b71f1623c659da10a8330266 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 22:53:35 +0400 Subject: [PATCH 22/29] fix(layout): guard forced dimension cache keys --- packages/core/src/layout/engine/layoutEngine.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/src/layout/engine/layoutEngine.ts b/packages/core/src/layout/engine/layoutEngine.ts index fd42b1ea..219ca20f 100644 --- a/packages/core/src/layout/engine/layoutEngine.ts +++ b/packages/core/src/layout/engine/layoutEngine.ts @@ -111,7 +111,11 @@ function popLayoutCache(): void { } function forcedDimensionKey(value: number | null): number { - return value === null ? NULL_FORCED_DIMENSION : value; + if (value === null) return NULL_FORCED_DIMENSION; + if (value < 0) { + throw new RangeError("layout: forced dimensions must be >= 0"); + } + return value; } function getLayoutCacheHit( From 1068458edb405f2450b1020ee1f8c1ee1c75741c Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Sat, 28 Feb 2026 05:18:45 +0400 Subject: [PATCH 23/29] perf(ink-compat): apply claude optimization batch --- .../ink-compat/src/reconciler/hostConfig.ts | 31 ++- .../src/runtime/createInkRenderer.ts | 38 ++- packages/ink-compat/src/runtime/render.ts | 135 +++++++--- .../ink-compat/src/translation/colorMap.ts | 15 ++ .../src/translation/propsToVNode.ts | 243 ++++++++++-------- results/bottlenecks.md | 88 +++++++ results/report_2026-02-27.md | 77 ++++++ 7 files changed, 458 insertions(+), 169 deletions(-) diff --git a/packages/ink-compat/src/reconciler/hostConfig.ts b/packages/ink-compat/src/reconciler/hostConfig.ts index aa8a5dea..ade4f8f6 100644 --- a/packages/ink-compat/src/reconciler/hostConfig.ts +++ b/packages/ink-compat/src/reconciler/hostConfig.ts @@ -22,10 +22,13 @@ function mapNodeType(type: string): InkNodeType { function sanitizeProps(props: unknown): Record { if (typeof props !== "object" || props === null) return {}; + const source = props as Record; const out: Record = {}; - for (const [key, value] of Object.entries(props)) { + const keys = Object.keys(source); + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]!; if (key === "children" || key === "key" || key === "ref") continue; - out[key] = value; + out[key] = source[key]; } return out; } @@ -122,18 +125,24 @@ export const hostConfig = { const oldObj = oldProps as Record; const newObj = newProps as Record; - const oldKeys = Object.keys(oldObj).filter( - (key) => key !== "children" && key !== "key" && key !== "ref", - ); - const newKeys = Object.keys(newObj).filter( - (key) => key !== "children" && key !== "key" && key !== "ref", - ); - - if (oldKeys.length !== newKeys.length) return true; - for (const key of newKeys) { + let newCount = 0; + const newKeys = Object.keys(newObj); + for (let index = 0; index < newKeys.length; index += 1) { + const key = newKeys[index]!; + if (key === "children" || key === "key" || key === "ref") continue; + newCount += 1; if (oldObj[key] !== newObj[key]) return true; } + let oldCount = 0; + const oldKeys = Object.keys(oldObj); + for (let index = 0; index < oldKeys.length; index += 1) { + const key = oldKeys[index]!; + if (key === "children" || key === "key" || key === "ref") continue; + oldCount += 1; + } + + if (oldCount !== newCount) return true; return null; }, diff --git a/packages/ink-compat/src/runtime/createInkRenderer.ts b/packages/ink-compat/src/runtime/createInkRenderer.ts index f053875e..6d2313c7 100644 --- a/packages/ink-compat/src/runtime/createInkRenderer.ts +++ b/packages/ink-compat/src/runtime/createInkRenderer.ts @@ -120,6 +120,7 @@ export type InkRenderer = Readonly<{ class RecordingDrawlistBuilder implements DrawlistBuilder { private _ops: InkRenderOp[] = []; + private _prevOps: InkRenderOp[] = []; private readonly textRunBlobs: Array = []; clear(): void { @@ -127,15 +128,27 @@ class RecordingDrawlistBuilder implements DrawlistBuilder { } clearTo(cols: number, rows: number, style?: TextStyle): void { - this._ops.push({ kind: "clearTo", cols, rows, ...(style ? { style } : {}) }); + if (style === undefined) { + this._ops.push({ kind: "clearTo", cols, rows }); + return; + } + this._ops.push({ kind: "clearTo", cols, rows, style }); } fillRect(x: number, y: number, w: number, h: number, style?: TextStyle): void { - this._ops.push({ kind: "fillRect", x, y, w, h, ...(style ? { style } : {}) }); + if (style === undefined) { + this._ops.push({ kind: "fillRect", x, y, w, h }); + return; + } + this._ops.push({ kind: "fillRect", x, y, w, h, style }); } drawText(x: number, y: number, text: string, style?: TextStyle): void { - this._ops.push({ kind: "drawText", x, y, text, ...(style ? { style } : {}) }); + if (style === undefined) { + this._ops.push({ kind: "drawText", x, y, text }); + return; + } + this._ops.push({ kind: "drawText", x, y, text, style }); } pushClip(x: number, y: number, w: number, h: number): void { @@ -152,7 +165,7 @@ class RecordingDrawlistBuilder implements DrawlistBuilder { addTextRunBlob(segments: readonly TextRunSegment[]): number | null { const index = this.textRunBlobs.length; - this.textRunBlobs.push(segments.slice()); + this.textRunBlobs.push(segments); return index; } @@ -165,7 +178,11 @@ class RecordingDrawlistBuilder implements DrawlistBuilder { const text = segment.text; if (text.length > 0) { const style = segment.style; - this._ops.push({ kind: "drawText", x: cursorX, y, text, ...(style ? { style } : {}) }); + if (style === undefined) { + this._ops.push({ kind: "drawText", x: cursorX, y, text }); + } else { + this._ops.push({ kind: "drawText", x: cursorX, y, text, style }); + } cursorX += measureTextCells(text); } } @@ -228,6 +245,7 @@ class RecordingDrawlistBuilder implements DrawlistBuilder { reset(): void { this._ops.length = 0; + this._prevOps.length = 0; this.textRunBlobs.length = 0; } @@ -237,8 +255,12 @@ class RecordingDrawlistBuilder implements DrawlistBuilder { this.textRunBlobs.length = 0; } - snapshotOps(): readonly InkRenderOp[] { - return this._ops.slice(); + swapAndGetOps(): readonly InkRenderOp[] { + const out = this._ops; + this._ops = this._prevOps; + this._ops.length = 0; + this._prevOps = out; + return out; } } @@ -627,7 +649,7 @@ export function createInkRenderer(opts: InkRendererOptions = {}): InkRenderer { const drawMs = performance.now() - drawStartedAt; // ─── COLLECT ─── - const ops = builder.snapshotOps(); + const ops = builder.swapAndGetOps(); const nodes = collectNodes(cachedLayoutTree); cachedOps = ops; cachedNodes = nodes; diff --git a/packages/ink-compat/src/runtime/render.ts b/packages/ink-compat/src/runtime/render.ts index 5bb541e8..1ac56cdd 100644 --- a/packages/ink-compat/src/runtime/render.ts +++ b/packages/ink-compat/src/runtime/render.ts @@ -1397,45 +1397,62 @@ function styleToSgr(style: CellStyle | undefined, colorSupport: ColorSupport): s 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"); - if (style.italic) codes.push("3"); - if (style.underline) codes.push("4"); - if (style.inverse) codes.push("7"); - if (style.strikethrough) codes.push("9"); + let sgr = "\u001b[0"; + let hasCodes = false; + if (style.bold) { + sgr += ";1"; + hasCodes = true; + } + if (style.dim) { + sgr += ";2"; + hasCodes = true; + } + if (style.italic) { + sgr += ";3"; + hasCodes = true; + } + if (style.underline) { + sgr += ";4"; + hasCodes = true; + } + if (style.inverse) { + sgr += ";7"; + hasCodes = true; + } + if (style.strikethrough) { + sgr += ";9"; + hasCodes = true; + } if (colorSupport.level > 0) { if (style.fg != null) { if (colorSupport.level >= 3) { - codes.push( - `38;2;${clampByte(rgbR(style.fg))};${clampByte(rgbG(style.fg))};${clampByte(rgbB(style.fg))}`, - ); + sgr += `;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)}`); + sgr += `;38;5;${toAnsi256Code(style.fg)}`; } else { - codes.push(String(toAnsi16Code(style.fg, false))); + sgr += `;${toAnsi16Code(style.fg, false)}`; } + hasCodes = true; } if (style.bg != null) { if (colorSupport.level >= 3) { - codes.push( - `48;2;${clampByte(rgbR(style.bg))};${clampByte(rgbG(style.bg))};${clampByte(rgbB(style.bg))}`, - ); + sgr += `;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)}`); + sgr += `;48;5;${toAnsi256Code(style.bg)}`; } else { - codes.push(String(toAnsi16Code(style.bg, true))); + sgr += `;${toAnsi16Code(style.bg, true)}`; } + hasCodes = true; } } let result: string; - if (codes.length === 0) { + if (!hasCodes) { 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`; + result = `${sgr}m`; } sgrCache.set(style, result); @@ -1770,14 +1787,8 @@ function renderOpsToAnsi( ops: readonly RenderOp[], viewport: ViewportSize, colorSupport: ColorSupport, + 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 = new Array(viewport.cols); - row.fill(BLANK_CELL); - grid[rowIndex] = row; - } - const clipStack: ClipRect[] = []; let effectiveClip: ClipRect | null = null; @@ -1851,6 +1862,7 @@ function renderOpsToAnsi( let firstNonBlankLine = -1; let lastNonBlankLine = -1; let widestLine = 0; + const lineParts: string[] = []; for (let rowIndex = 0; rowIndex < grid.length; rowIndex += 1) { const row = grid[rowIndex]!; @@ -1875,20 +1887,20 @@ function renderOpsToAnsi( if (firstNonBlankLine === -1) firstNonBlankLine = rowIndex; lastNonBlankLine = rowIndex; - let line = ""; + lineParts.length = 0; let activeStyle: CellStyle | undefined; for (let colIndex = 0; colIndex <= lastUsefulCol; colIndex += 1) { const cell = row[colIndex]!; if (!stylesEqual(activeStyle, cell.style)) { - line += styleToSgr(cell.style, colorSupport); + lineParts.push(styleToSgr(cell.style, colorSupport)); activeStyle = cell.style; } - line += cell.char; + lineParts.push(cell.char); } - if (activeStyle) line += "\u001b[0m"; - lines.push(line); + if (activeStyle) lineParts.push("\u001b[0m"); + lines.push(lineParts.join("")); } while (lines.length > 1 && lines[lines.length - 1] === "") lines.pop(); @@ -1919,6 +1931,7 @@ interface PercentResolveContext { parentSize: ViewportSize; parentMainAxis: FlexMainAxis; deps?: PercentResolveDeps; + markerCache?: WeakMap; } interface PercentParentDep { @@ -1972,29 +1985,38 @@ function readNodeMainAxis(kind: unknown): FlexMainAxis { return "column"; } -function hasPercentMarkers(vnode: VNode): boolean { +function hasPercentMarkers(vnode: VNode, markerCache?: WeakMap): boolean { if (typeof vnode !== "object" || vnode === null) return false; + const vnodeObject = vnode as object; + if (markerCache && markerCache.has(vnodeObject)) { + return markerCache.get(vnodeObject) === true; + } const candidate = vnode as { props?: unknown; children?: unknown }; const props = typeof candidate.props === "object" && candidate.props !== null ? (candidate.props as Record) : undefined; - if ( + const hasDirectMarkers = props && (typeof props["__inkPercentWidth"] === "number" || typeof props["__inkPercentHeight"] === "number" || typeof props["__inkPercentMinWidth"] === "number" || typeof props["__inkPercentMinHeight"] === "number" || - typeof props["__inkPercentFlexBasis"] === "number") - ) { + typeof props["__inkPercentFlexBasis"] === "number"); + if (hasDirectMarkers) { + markerCache?.set(vnodeObject, true); return true; } const children = Array.isArray(candidate.children) ? (candidate.children as VNode[]) : []; for (const child of children) { - if (hasPercentMarkers(child)) return true; + if (hasPercentMarkers(child, markerCache)) { + markerCache?.set(vnodeObject, true); + return true; + } } + markerCache?.set(vnodeObject, false); return false; } @@ -2002,6 +2024,10 @@ function resolvePercentMarkers(vnode: VNode, context: PercentResolveContext): VN if (typeof vnode !== "object" || vnode === null) { return vnode; } + const markerCache = context.markerCache ?? new WeakMap(); + if (!hasPercentMarkers(vnode, markerCache)) { + return vnode; + } const candidate = vnode as { kind?: unknown; @@ -2096,10 +2122,15 @@ function resolvePercentMarkers(vnode: VNode, context: PercentResolveContext): VN parentSize: nextParentSize, parentMainAxis: readNodeMainAxis(candidate.kind), ...(context.deps ? { deps: context.deps } : {}), + markerCache, }; const originalChildren = Array.isArray(candidate.children) ? (candidate.children as VNode[]) : []; - const nextChildren = originalChildren.map((child) => resolvePercentMarkers(child, nextContext)); + const nextChildren: VNode[] = new Array(originalChildren.length); + for (let index = 0; index < originalChildren.length; index += 1) { + const child = originalChildren[index]!; + nextChildren[index] = resolvePercentMarkers(child, nextContext); + } return { ...(vnode as Record), @@ -2389,6 +2420,28 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions } : {}), }); + let pooledAnsiGrid: StyledCell[][] = []; + let pooledAnsiGridCols = 0; + let pooledAnsiGridRows = 0; + const getOrResizeAnsiGrid = (cols: number, rows: number): StyledCell[][] => { + if (cols === pooledAnsiGridCols && rows === pooledAnsiGridRows) { + for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) { + pooledAnsiGrid[rowIndex]!.fill(BLANK_CELL); + } + return pooledAnsiGrid; + } + + const nextGrid: StyledCell[][] = new Array(rows); + for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) { + const row = new Array(cols); + row.fill(BLANK_CELL); + nextGrid[rowIndex] = row; + } + pooledAnsiGrid = nextGrid; + pooledAnsiGridCols = cols; + pooledAnsiGridRows = rows; + return pooledAnsiGrid; + }; let lastOutput = ""; let lastStableOutput = ""; @@ -2780,6 +2833,7 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions staticResult.ops as readonly RenderOp[], viewport, staticColorSupport, + getOrResizeAnsiGrid(viewport.cols, viewport.rows), ); const staticTrimmed = trimAnsiToNonBlankBlock(staticAnsi); @@ -2961,7 +3015,12 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions ansi: rawAnsiOutput, grid: cellGrid, shape: outputShape, - } = renderOpsToAnsi(result.ops as readonly RenderOp[], gridViewport, frameColorSupport); + } = renderOpsToAnsi( + result.ops as readonly RenderOp[], + gridViewport, + frameColorSupport, + getOrResizeAnsiGrid(gridViewport.cols, gridViewport.rows), + ); if (timePhases) ansiMs = performance.now() - ansiStartedAt; // In alternate-buffer mode the output fills the full layoutViewport. diff --git a/packages/ink-compat/src/translation/colorMap.ts b/packages/ink-compat/src/translation/colorMap.ts index c07caa51..45505623 100644 --- a/packages/ink-compat/src/translation/colorMap.ts +++ b/packages/ink-compat/src/translation/colorMap.ts @@ -26,6 +26,9 @@ for (const [name, value] of Object.entries(NAMED_COLORS)) { NAMED_COLORS_LOWER[name.toLowerCase()] = value; } +const COLOR_CACHE = new Map(); +const COLOR_CACHE_MAX = 256; + function rgbR(value: Rgb24): number { return (value >>> 16) & 0xff; } @@ -62,6 +65,18 @@ export function parseColor(color: string | undefined): Rgb24 | undefined { } function parseColorInner(color: string): Rgb24 | undefined { + const cached = COLOR_CACHE.get(color); + if (cached !== undefined || COLOR_CACHE.has(color)) return cached; + + const result = parseColorUncached(color); + if (COLOR_CACHE.size >= COLOR_CACHE_MAX) { + COLOR_CACHE.clear(); + } + COLOR_CACHE.set(color, result); + return result; +} + +function parseColorUncached(color: string): Rgb24 | undefined { if (color in NAMED_COLORS) return NAMED_COLORS[color]; const lower = color.toLowerCase(); diff --git a/packages/ink-compat/src/translation/propsToVNode.ts b/packages/ink-compat/src/translation/propsToVNode.ts index 9d538ce6..a1a506e2 100644 --- a/packages/ink-compat/src/translation/propsToVNode.ts +++ b/packages/ink-compat/src/translation/propsToVNode.ts @@ -201,9 +201,8 @@ const ESC = "\u001b"; interface CachedTranslation { revision: number; - contextSignature: string; vnode: VNode | null; - meta: TranslationMetadata; + metaMask: number; } interface TranslationPerfStats { @@ -216,7 +215,7 @@ interface TranslationPerfStats { parseAnsiFallbackPathHits: number; } -let translationCache = new WeakMap>(); +let translationCache = new WeakMap>(); const translationPerfStats: TranslationPerfStats = { translatedNodes: 0, cacheHits: 0, @@ -229,7 +228,7 @@ const translationPerfStats: TranslationPerfStats = { let translationCacheEnabled = process.env["INK_COMPAT_DISABLE_TRANSLATION_CACHE"] !== "1"; function clearTranslationCache(): void { - translationCache = new WeakMap>(); + translationCache = new WeakMap>(); } function resetTranslationPerfStats(): void { @@ -242,19 +241,37 @@ function resetTranslationPerfStats(): void { 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}`; +const META_MASK_STATIC_NODES = 1 << 0; +const META_MASK_PERCENT_MARKERS = 1 << 1; +const META_MASK_ANSI_SGR = 1 << 2; + +function toMetaMask(meta: TranslationMetadata): number { + let mask = 0; + if (meta.hasStaticNodes) mask |= META_MASK_STATIC_NODES; + if (meta.hasPercentMarkers) mask |= META_MASK_PERCENT_MARKERS; + if (meta.hasAnsiSgr) mask |= META_MASK_ANSI_SGR; + return mask; } -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 applyMetaMask(meta: TranslationMetadata, mask: number): void { + if ((mask & META_MASK_STATIC_NODES) !== 0) meta.hasStaticNodes = true; + if ((mask & META_MASK_PERCENT_MARKERS) !== 0) meta.hasPercentMarkers = true; + if ((mask & META_MASK_ANSI_SGR) !== 0) meta.hasAnsiSgr = true; +} + +function contextKey(context: TranslateContext): number { + const modeBits = context.mode === "dynamic" ? 1 : context.mode === "static" ? 2 : 0; + const directionBit = context.parentDirection === "column" ? 1 : 0; + const parentMainDefiniteBit = context.parentMainDefinite ? 1 : 0; + const isRootBit = context.isRoot ? 1 : 0; + const inStaticSubtreeBit = context.inStaticSubtree ? 1 : 0; + return ( + modeBits | + (directionBit << 2) | + (parentMainDefiniteBit << 3) | + (isRootBit << 4) | + (inStaticSubtreeBit << 5) + ); } function hasDisallowedControlChars(text: string): boolean { @@ -280,22 +297,6 @@ function parsePercentValue(value: unknown): number | undefined { return Number.isFinite(parsed) ? parsed : undefined; } -function attachHostNode(vnode: VNode | null, node: InkHostNode): VNode | null { - if (!vnode || typeof vnode !== "object") return vnode; - const candidate = vnode as { props?: unknown }; - const props = - typeof candidate.props === "object" && candidate.props !== null - ? (candidate.props as Record) - : {}; - return { - ...(vnode as Record), - props: { - ...props, - __inkHostNode: node, - }, - } as unknown as VNode; -} - function readAccessibilityLabel(props: Record): string | undefined { const candidates = [props["aria-label"], props["ariaLabel"], props["accessibilityLabel"]]; for (const candidate of candidates) { @@ -329,6 +330,20 @@ function createMeta(): TranslationMetadata { return { hasStaticNodes: false, hasPercentMarkers: false, hasAnsiSgr: false }; } +function collectTranslatedChildren( + children: readonly InkHostNode[], + context: TranslateContext, +): VNode[] { + const out: VNode[] = []; + for (let index = 0; index < children.length; index += 1) { + const child = children[index]; + if (!child) continue; + const translated = translateNode(child, context); + if (translated !== null) out.push(translated); + } + return out; +} + /** * Translate the entire InkHostNode tree into a Rezi VNode tree. */ @@ -346,9 +361,7 @@ export function translateTree( inStaticSubtree: false, meta, }; - const children = container.children - .map((child) => translateNode(child, rootContext)) - .filter(Boolean) as VNode[]; + const children = collectTranslatedChildren(container.children, rootContext); if (children.length === 0) return ui.text(""); if (children.length === 1) return children[0]!; return ui.column({ gap: 0 }, children); @@ -382,9 +395,7 @@ export function translateDynamicTreeWithMetadata(container: InkHostContainer): { meta, }; - const children = container.children - .map((child) => translateNode(child, rootContext)) - .filter(Boolean) as VNode[]; + const children = collectTranslatedChildren(container.children, rootContext); let vnode: VNode; if (children.length === 0) vnode = ui.text(""); @@ -395,59 +406,62 @@ export function translateDynamicTreeWithMetadata(container: InkHostContainer): { } function translateNode(node: InkHostNode, context: TranslateContext): VNode | null { - const parentMeta = context.meta; - const localMeta = createMeta(); - const localContext: TranslateContext = { - ...context, - meta: localMeta, - }; + const savedParentDirection = context.parentDirection; + const savedParentMainDefinite = context.parentMainDefinite; + const savedIsRoot = context.isRoot; + const savedInStaticSubtree = context.inStaticSubtree; + const metaBeforeMask = toMetaMask(context.meta); + + try { + if (!translationCacheEnabled) { + translationPerfStats.cacheMisses += 1; + translationPerfStats.translatedNodes += 1; + return translateNodeUncached(node, context); + } + + const key = contextKey(context); + const perNodeCache = translationCache.get(node); + const cached = perNodeCache?.get(key); + if (cached) { + if (cached.revision === node.__inkRevision) { + translationPerfStats.cacheHits += 1; + applyMetaMask(context.meta, cached.metaMask); + return cached.vnode; + } + translationPerfStats.cacheStaleMisses += 1; + } else { + translationPerfStats.cacheEmptyMisses += 1; + } - if (!translationCacheEnabled) { translationPerfStats.cacheMisses += 1; translationPerfStats.translatedNodes += 1; - const translated = translateNodeUncached(node, localContext); - mergeMeta(parentMeta, localMeta); - return translated; - } - - const signature = contextSignature(context); - const perNodeCache = translationCache.get(node); - const cached = perNodeCache?.get(signature); - if (cached) { - if (cached.revision === node.__inkRevision) { - translationPerfStats.cacheHits += 1; - mergeMeta(parentMeta, cached.meta); - return cached.vnode; + const translated = translateNodeUncached(node, context); + const metaAfterMask = toMetaMask(context.meta); + const metaMask = metaAfterMask & ~metaBeforeMask; + + if (!perNodeCache) { + const nextCache = new Map(); + translationCache.set(node, nextCache); + nextCache.set(key, { + revision: node.__inkRevision, + vnode: translated, + metaMask, + }); + return translated; } - translationPerfStats.cacheStaleMisses += 1; - } else { - translationPerfStats.cacheEmptyMisses += 1; - } - - translationPerfStats.cacheMisses += 1; - translationPerfStats.translatedNodes += 1; - const translated = translateNodeUncached(node, localContext); - mergeMeta(parentMeta, localMeta); - - if (!perNodeCache) { - const nextCache = new Map(); - translationCache.set(node, nextCache); - nextCache.set(signature, { + perNodeCache.set(key, { revision: node.__inkRevision, - contextSignature: signature, vnode: translated, - meta: localMeta, + metaMask, }); + return translated; + } finally { + context.parentDirection = savedParentDirection; + context.parentMainDefinite = savedParentMainDefinite; + context.isRoot = savedIsRoot; + context.inStaticSubtree = savedInStaticSubtree; } - perNodeCache.set(signature, { - revision: node.__inkRevision, - contextSignature: signature, - vnode: translated, - meta: localMeta, - }); - - return translated; } function translateNodeUncached(node: InkHostNode, context: TranslateContext): VNode | null { @@ -485,9 +499,9 @@ function translateNodeUncached(node: InkHostNode, context: TranslateContext): VN switch (node.type) { case "ink-box": - return attachHostNode(translateBox(node, context), node); + return translateBox(node, context); case "ink-text": - return attachHostNode(translateText(node), node); + return translateText(node); default: return translateChildren(node, context); } @@ -584,15 +598,6 @@ function translateBox(node: InkHostNode, context: TranslateContext): VNode | nul inheritsMainDefinite; const inStaticSubtree = context.inStaticSubtree || p.__inkStatic === true; - const childContext: TranslateContext = { - parentDirection: isRow ? "row" : "column", - parentMainDefinite: nodeMainDefinite, - isRoot: false, - mode: context.mode, - inStaticSubtree, - meta: context.meta, - }; - if (p.display === "none") return null; if (p.__inkStatic === true) { @@ -614,14 +619,13 @@ function translateBox(node: InkHostNode, context: TranslateContext): VNode | nul ...node, props: staticProps, }; - const staticContext = context.inStaticSubtree - ? context - : { - ...context, - inStaticSubtree: true, - }; - - return translateBox(staticNode, staticContext); + const savedInStaticSubtree = context.inStaticSubtree; + if (!savedInStaticSubtree) { + context.inStaticSubtree = true; + } + const translated = translateBox(staticNode, context); + context.inStaticSubtree = savedInStaticSubtree; + return translated; } if (p.__inkType === "spacer") { @@ -631,9 +635,19 @@ function translateBox(node: InkHostNode, context: TranslateContext): VNode | nul }); } - const children = node.children - .map((child) => translateNode(child, childContext)) - .filter(Boolean) as VNode[]; + const savedParentDirection = context.parentDirection; + const savedParentMainDefinite = context.parentMainDefinite; + const savedIsRoot = context.isRoot; + const savedInStaticSubtree = context.inStaticSubtree; + context.parentDirection = isRow ? "row" : "column"; + context.parentMainDefinite = nodeMainDefinite; + context.isRoot = false; + context.inStaticSubtree = inStaticSubtree; + const children = collectTranslatedChildren(node.children, context); + context.parentDirection = savedParentDirection; + context.parentMainDefinite = savedParentMainDefinite; + context.isRoot = savedIsRoot; + context.inStaticSubtree = savedInStaticSubtree; const hasBorder = p.borderStyle != null; const hasBg = p.backgroundColor != null; @@ -835,6 +849,7 @@ function translateBox(node: InkHostNode, context: TranslateContext): VNode | nul if (layoutProps.gap == null) layoutProps.gap = 0; if (accessibilityLabel) layoutProps.accessibilityLabel = accessibilityLabel; + layoutProps["__inkHostNode"] = node; if (hasBorder || hasBg) { if (!hasBorder) { @@ -990,7 +1005,9 @@ function translateText(node: InkHostNode): VNode { const { spans, isSingleSpan, fullText } = flattenTextChildren(node, style); - const textProps: Record = {}; + const textProps: Record = { + __inkHostNode: node, + }; if (Object.keys(style).length > 0) textProps["style"] = style; const accessibilityLabel = readAccessibilityLabel(p); if (accessibilityLabel) { @@ -1012,17 +1029,21 @@ function translateText(node: InkHostNode): VNode { } if (fullText.includes("\n")) { - return translateMultilineRichText( - spans, - accessibilityLabel ? { accessibilityLabel } : undefined, - ); + const rootProps: Record = { __inkHostNode: node }; + if (accessibilityLabel) { + rootProps["accessibilityLabel"] = accessibilityLabel; + } + return translateMultilineRichText(spans, rootProps); } if (isSingleSpan) { return Object.keys(textProps).length > 0 ? ui.text(fullText, textProps) : ui.text(fullText); } - return ui.richText(spans.map((span) => ({ text: span.text, style: span.style }))); + return ui.richText( + spans.map((span) => ({ text: span.text, style: span.style })), + textProps, + ); } function translateMultilineRichText( @@ -1577,9 +1598,7 @@ function isByte(value: unknown): value is number { } function translateChildren(node: InkHostNode, context: TranslateContext): VNode | null { - const children = node.children - .map((child) => translateNode(child, context)) - .filter(Boolean) as VNode[]; + const children = collectTranslatedChildren(node.children, context); if (children.length === 0) return null; if (children.length === 1) return children[0]!; return ui.column({ gap: 0 }, children); diff --git a/results/bottlenecks.md b/results/bottlenecks.md index 6c77581a..50cb2e91 100644 --- a/results/bottlenecks.md +++ b/results/bottlenecks.md @@ -152,3 +152,91 @@ Post-fix cpuprofile (`dashboard-grid`, ink-compat): - `results/ink-bench_dashboard-grid_ink-compat_2026-02-27T18-33-45-835Z/run_01/cpu-prof/dashboard-grid_ink-compat_run1.cpuprofile` Filtered summaries for `packages/core/dist/testing/renderer.js` and `rootChildRevisionsChanged` return **no top self-time entries**, consistent with removing those hot-path costs from default runtime rendering. + +## Latest call-stack refresh (2026-02-27 18:50 UTC) + +Profiles used: + +- ink-compat: `results/claude-improvements-after/ink-bench_dashboard-grid_ink-compat_2026-02-27T18-50-32-433Z/run_01/cpu-prof/dashboard-grid_ink-compat_run1.cpuprofile` +- real-ink: `results/claude-improvements-after/ink-bench_dashboard-grid_real-ink_2026-02-27T18-50-38-677Z/run_01/cpu-prof/dashboard-grid_real-ink_run1.cpuprofile` + +Percentages below are from `summarize-cpuprofile.mjs --active`. + +### 1) Dashboard-grid tail latency remains a bottleneck (frame pacing + render path) + +Benchmark evidence: + +- Repeat pair: `renderTotalP95Ms` is still **+14.2%** for ink-compat vs real-ink (`results/claude-improvements-after-repeat/...`). +- Full matrix pair: `renderTotalP95Ms` is **+20.9%** for ink-compat vs real-ink (`results/claude-improvements-after-fullmatrix/...`). + +Representative runtime stacks (`--filter packages/ink-compat/dist/runtime/render.js`): + +- `renderFrame` — **0.6% self** + ```text + commitRoot -> resetAfterCommit -> bridge.rootNode.onCommit + -> scheduleRender -> call -> flushPendingRender -> renderFrame + ``` +- `renderOpsToAnsi` — **0.3% self** + ```text + ... -> renderFrame -> renderOpsToAnsi + ``` +- `styleToSgr` — **0.3% self** + ```text + ... -> renderFrame -> renderOpsToAnsi -> styleToSgr + ``` + +### 2) Translation still contributes measurable per-frame CPU + +Representative stacks (`--filter packages/ink-compat/dist/translation/propsToVNode.js`): + +- `translateText` — **0.3% self** + ```text + translateDynamicWithMetadata -> translateDynamicTreeWithMetadata + -> collectTranslatedChildren -> translateNode -> translateNodeUncached -> translateText + ``` +- `translateBox` — **0.3% self** + ```text + translateNodeUncached -> translateBox -> collectTranslatedChildren -> translateNode -> ... + ``` +- `collectTranslatedChildren` — **0.3% self** + ```text + translateBox -> collectTranslatedChildren -> translateNode -> translateNodeUncached -> ... + ``` + +### 3) Core layout remains the largest non-startup hot path + +Representative stacks (`--filter packages/core/dist/layout`): + +- `measureStack` — **0.6% self** + ```text + layout -> measureNode -> measureStackKinds -> measureStack + ``` +- `layoutNode` — **0.3% self** + ```text + layout -> layoutNode -> layoutStackKinds -> layoutStack -> layoutNode + ``` +- `measureTextWrapped` — **0.3% self** + ```text + layout -> measureNode -> measureLeaf -> measureTextWrapped + ``` + +### 4) Real-ink comparator hotspots (same scenario) + +Representative stacks (`--filter ink/build`): + +- `ink/build/output.js:get` — **0.6% self** + ```text + resetAfterCommit -> onRender -> renderer -> get + ``` +- `ink/build/render-border.js:renderBorder` — **0.4% self** + ```text + resetAfterCommit -> onRender -> renderer -> renderNodeToOutput -> renderBorder + ``` +- `ink/build/output.js:applyWriteOperation` — **0.4% self** + ```text + resetAfterCommit -> onRender -> renderer -> get -> applyWriteOperation + ``` + +### 5) Host config overhead is no longer a top dashboard-grid sample + +Filtered profile (`--filter packages/ink-compat/dist/reconciler/hostConfig.js`) shows no top self-time entries in this run, so `sanitizeProps`/`prepareUpdate` are not current top dashboard-grid hotspots. diff --git a/results/report_2026-02-27.md b/results/report_2026-02-27.md index 303592f6..27353a58 100644 --- a/results/report_2026-02-27.md +++ b/results/report_2026-02-27.md @@ -234,3 +234,80 @@ Previous profile (before this increment): - `rootChildRevisionSignature` (`packages/ink-compat/dist/runtime/render.js`) — **0.3% self** Current profile (`18-33-45-835Z`) no longer shows either symbol in top filtered output. + +## Incremental update (Claude optimization batch, 18:50–18:55 UTC) + +Code changes applied: + +- `packages/ink-compat/src/translation/propsToVNode.ts` +- `packages/ink-compat/src/translation/colorMap.ts` +- `packages/ink-compat/src/reconciler/hostConfig.ts` +- `packages/ink-compat/src/runtime/createInkRenderer.ts` +- `packages/ink-compat/src/runtime/render.ts` + +Validity re-check (all pass final-screen equivalence): + +- `results/claude-improvements-after/verify_streaming-chat_2026-02-27T18-49-25-067Z.json` +- `results/claude-improvements-after/verify_large-list-scroll_2026-02-27T18-49-32-960Z.json` +- `results/claude-improvements-after/verify_dashboard-grid_2026-02-27T18-49-40-877Z.json` +- `results/claude-improvements-after/verify_style-churn_2026-02-27T18-49-48-815Z.json` + +### Dashboard-grid (focused A/B) + +Baseline pair: + +- real-ink: `results/claude-improvements-baseline/ink-bench_dashboard-grid_real-ink_2026-02-27T18-42-42-241Z` +- ink-compat: `results/claude-improvements-baseline/ink-bench_dashboard-grid_ink-compat_2026-02-27T18-42-54-324Z` + +Post-change pair: + +- real-ink: `results/claude-improvements-after-repeat/ink-bench_dashboard-grid_real-ink_2026-02-27T18-55-03-180Z` +- ink-compat: `results/claude-improvements-after-repeat/ink-bench_dashboard-grid_ink-compat_2026-02-27T18-55-15-281Z` + +ink-compat baseline → post-change: + +- `meanRenderTotalMs`: `77.08ms → 74.40ms` (`-3.5%`) +- `renderTotalP95Ms`: `1.88ms → 1.86ms` (`-1.0%`, essentially flat) +- `renderTotalP99Ms`: `2.73ms → 2.72ms` (`-0.4%`) +- `totalCpuTimeS`: `0.517s → 0.527s` (`+1.9%`, within run noise) + +Post-change ink-compat vs real-ink (repeat pair): + +- `meanRenderTotalMs`: **-7.7%** (ink-compat faster) +- `renderTotalP95Ms`: **+14.2%** (ink-compat tail still worse) +- `renderTotalP99Ms`: **-29.7%** (ink-compat better) +- `totalCpuTimeS`: **-29.2%** + +### Phase/detail evidence (ink-compat only) + +Detail baseline: `results/claude-improvements-baseline/ink-bench_dashboard-grid_ink-compat_2026-02-27T18-43-06-133Z` +Detail post-change: `results/claude-improvements-after/ink-bench_dashboard-grid_ink-compat_2026-02-27T18-50-20-662Z` + +- `renderTotalP95Ms`: `2.58ms → 2.02ms` (`-21.9%`) +- `translationP95Ms`: `0.165ms → 0.122ms` (`-26.0%`) +- `coreRenderP95Ms`: `1.81ms → 1.58ms` (`-13.1%`) +- `ansiP95Ms`: `0.162ms → 0.159ms` (flat) + +Largest >2ms outliers remain early frames and are mostly `coreRenderMs` + `translationMs`, not `ansiMs`. + +### Top hotspots (new CPU profiles, both renderers) + +Cpuprofiles: + +- ink-compat: `results/claude-improvements-after/ink-bench_dashboard-grid_ink-compat_2026-02-27T18-50-32-433Z/run_01/cpu-prof/dashboard-grid_ink-compat_run1.cpuprofile` +- real-ink: `results/claude-improvements-after/ink-bench_dashboard-grid_real-ink_2026-02-27T18-50-38-677Z/run_01/cpu-prof/dashboard-grid_real-ink_run1.cpuprofile` + +`summarize-cpuprofile.mjs --active` highlights: + +- **ink-compat runtime path** (`--filter packages/ink-compat/dist/runtime/render.js`) + - `renderFrame` **0.6% self** + - `styleToSgr` **0.3% self** + - `renderOpsToAnsi` **0.3% self** +- **ink-compat translation path** (`--filter packages/ink-compat/dist/translation/propsToVNode.js`) + - `translateText` **0.3% self** + - `translateBox` **0.3% self** + - `collectTranslatedChildren` **0.3% self** +- **real-ink output/layout path** (`--filter ink/build`) + - `ink/build/output.js:get` **0.6% self** + - `ink/build/render-border.js:renderBorder` **0.4% self** + - `ink/build/output.js:applyWriteOperation` **0.4% self** From 3ff9494c12dd6fe13135684ce3bf6857f96e8cfe Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Sat, 28 Feb 2026 05:43:36 +0400 Subject: [PATCH 24/29] chore(bench): safety checkpoint before long-run harness refactor From 7f4341381ad409d24fb10a46c0f8bbbb383533fc Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Sat, 28 Feb 2026 07:30:14 +0400 Subject: [PATCH 25/29] fix(ink-compat): harden ansi transform rendering and bench wiring --- docs/architecture/ink-compat.md | 18 + packages/bench-app/src/entry.tsx | 151 ++++--- packages/bench-runner/src/cli.ts | 77 ++-- packages/bench-runner/src/verify.ts | 29 +- .../renderer/__tests__/renderer.text.test.ts | 54 +++ .../widgets/renderTextWidgets.ts | 378 +++++++++++++++++- packages/ink-compat/README.md | 116 +++++- 7 files changed, 692 insertions(+), 131 deletions(-) diff --git a/docs/architecture/ink-compat.md b/docs/architecture/ink-compat.md index db9cd7d8..883db5b2 100644 --- a/docs/architecture/ink-compat.md +++ b/docs/architecture/ink-compat.md @@ -81,6 +81,24 @@ You can also import shim implementations from `@rezi-ui/ink-compat` directly: - `@rezi-ui/ink-compat/shims/ink-gradient` - `@rezi-ui/ink-compat/shims/ink-spinner` +## Wiring verification (recommended in CI) + +To ensure you are not silently running real Ink: + +1. Verify resolved package identity: + +```bash +node -e "const p=require('ink/package.json'); if(p.name!=='@rezi-ui/ink-compat') throw new Error('ink resolves to '+p.name); console.log('ink-compat active:', p.version);" +``` + +2. Verify resolved module path: + +```bash +node -e "const fs=require('node:fs'); const path=require('node:path'); const pkg=require.resolve('ink/package.json'); console.log(fs.realpathSync(path.dirname(pkg)));" +``` + +3. For bundled CLIs, rebuild the bundle after aliasing and validate expected compat-only markers in generated output. + ## Public compatibility surface ### Components diff --git a/packages/bench-app/src/entry.tsx b/packages/bench-app/src/entry.tsx index 68220c25..73f35eb3 100644 --- a/packages/bench-app/src/entry.tsx +++ b/packages/bench-app/src/entry.tsx @@ -1,11 +1,12 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import path from "node:path"; -import net from "node:net"; +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; +import net from "node:net"; +import path from "node:path"; import { performance } from "node:perf_hooks"; -import React, { useEffect, useMemo, useRef, useState } from "react"; import { Box, Text, render, useApp, useInput } from "ink"; +import type React from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; type RendererName = "real-ink" | "ink-compat"; type ScenarioName = @@ -76,6 +77,25 @@ type FrameMetric = Readonly<{ parseAnsiFallbackPathHits: number | null; }>; +function isObjectRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isControlMsg(value: unknown): value is ControlMsg { + if (!isObjectRecord(value)) return false; + const type = value.type; + if (type === "init") return typeof value.seed === "number"; + if (type === "tick") return value.n === undefined || typeof value.n === "number"; + if (type === "token") return typeof value.text === "string"; + return type === "done"; +} + +function readMetricNumber(metrics: unknown, key: "renderTime" | "layoutTimeMs"): number | null { + if (!isObjectRecord(metrics)) return null; + const value = metrics[key]; + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + function resolveInkImpl(): { resolvedFrom: string; name: string; version: string } { const req = createRequire(import.meta.url); const inkEntryPath = req.resolve("ink"); @@ -115,14 +135,18 @@ function createStdoutWriteProbe(): { const original = process.stdout.write.bind(process.stdout); const install = (): void => { + const originalWrite = original as unknown as ( + chunk: unknown, + encoding?: unknown, + cb?: unknown, + ) => boolean; (process.stdout as unknown as { write: typeof process.stdout.write }).write = (( chunk: unknown, encoding?: unknown, cb?: unknown, ): boolean => { const start = performance.now(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - const ret = (original as any)(chunk, encoding, cb) as boolean; + const ret = originalWrite(chunk, encoding, cb); const end = performance.now(); writeMs += end - start; writes += 1; @@ -143,10 +167,7 @@ function createStdoutWriteProbe(): { return { install, readAndReset }; } -function useControlSocket( - socketPath: string | undefined, - onMsg: (msg: ControlMsg) => void, -): void { +function useControlSocket(socketPath: string | undefined, onMsg: (msg: ControlMsg) => void): void { useEffect(() => { if (!socketPath) return; let buf = ""; @@ -163,9 +184,7 @@ function useControlSocket( if (!line) continue; try { const parsed = JSON.parse(line) as unknown; - if (parsed && typeof parsed === "object" && "type" in (parsed as any)) { - onMsg(parsed as ControlMsg); - } + if (isControlMsg(parsed)) onMsg(parsed); } catch { // ignore } @@ -241,7 +260,9 @@ function StreamingChatScenario(props: { const visible = scrollLock ? lines.slice(-8) : lines.slice(0, 8); return ( - BENCH_READY streaming-chat tokens={tokenCount} scrollLock={String(scrollLock)} + + BENCH_READY streaming-chat tokens={tokenCount} scrollLock={String(scrollLock)} + {visible.map((line, i) => { if (line.startsWith("```")) { @@ -314,7 +335,9 @@ function LargeListScrollScenario(props: { return ( - BENCH_READY large-list-scroll scroll={scroll} tick={tick} + + BENCH_READY large-list-scroll scroll={scroll} tick={tick} + {rows} Keys: ↑/↓ (scripted) to scroll @@ -326,7 +349,7 @@ function DashboardGridScenario(props: { setController: (c: ScenarioController) => void; }): React.ReactElement { const [tick, setTick] = useState(0); - const cols = Number.parseInt(process.env["BENCH_COLS"] ?? "80", 10) || 80; + const cols = Number.parseInt(process.env.BENCH_COLS ?? "80", 10) || 80; const gap = 2; const topW = Math.max(18, Math.floor((cols - gap * 2) / 3)); const bottomW = Math.max(18, Math.floor((cols - gap) / 2)); @@ -450,7 +473,9 @@ function ResizeStormScenario(props: { return ( - BENCH_READY resize-storm tick={tick} resizes={resizesSeen} + + BENCH_READY resize-storm tick={tick} resizes={resizesSeen} + Viewport is driven by PTY resizes (runner). tick={tick} @@ -471,8 +496,19 @@ function BenchApp(props: { const { exit } = useApp(); const stdoutProbe = props.stdoutProbe; const framesRef = useRef([]); + const frameWriteBufferRef = useRef([]); + const frameCountRef = useRef(0); const startAt = useMemo(() => performance.now(), []); const lastUpdatesRequestedRef = useRef(0); + const streamFrames = process.env.BENCH_STREAM_FRAMES === "1"; + const framesPath = useMemo(() => path.join(props.outDir, "frames.jsonl"), [props.outDir]); + + const flushFrameWriteBuffer = useCallback((): void => { + if (!streamFrames) return; + if (frameWriteBufferRef.current.length === 0) return; + appendFileSync(framesPath, frameWriteBufferRef.current.join("")); + frameWriteBufferRef.current = []; + }, [framesPath, streamFrames]); const stateRef = useRef({ seed: 1, @@ -482,7 +518,7 @@ function BenchApp(props: { const compatFrameRef = useRef(null); useEffect(() => { - if (process.env["BENCH_INK_COMPAT_PHASES"] === "1") { + if (process.env.BENCH_INK_COMPAT_PHASES === "1") { globalThis.__INK_COMPAT_BENCH_ON_FRAME = (m) => { compatFrameRef.current = m as InkCompatFrameBreakdown; }; @@ -529,33 +565,25 @@ function BenchApp(props: { useEffect(() => { if (doneSeq <= 0) return; - const ms = - Number.parseInt(process.env["BENCH_EXIT_AFTER_DONE_MS"] ?? "300", 10) || - 300; + const ms = Number.parseInt(process.env.BENCH_EXIT_AFTER_DONE_MS ?? "300", 10) || 300; const t = setTimeout(() => exit(), Math.max(0, ms)); return () => clearTimeout(t); }, [doneSeq, exit]); useEffect(() => { - const ms = Number.parseInt(process.env["BENCH_TIMEOUT_MS"] ?? "15000", 10) || 15000; + const ms = Number.parseInt(process.env.BENCH_TIMEOUT_MS ?? "15000", 10) || 15000; const t = setTimeout(() => exit(new Error(`bench timeout ${ms}ms`)), ms); return () => clearTimeout(t); }, [exit]); - (globalThis as unknown as { __BENCH_ON_RENDER?: (metrics: unknown) => void }).__BENCH_ON_RENDER = ( - metrics: unknown, - ): void => { - const renderTimeMs = - metrics && typeof metrics === "object" && "renderTime" in (metrics as any) - ? (metrics as any).renderTime - : 0; - const layoutTimeMs = - metrics && typeof metrics === "object" && "layoutTimeMs" in (metrics as any) - ? (metrics as any).layoutTimeMs - : null; - const layoutMsSafe = typeof layoutTimeMs === "number" && Number.isFinite(layoutTimeMs) ? layoutTimeMs : null; - const renderTimeMsSafe = - typeof renderTimeMs === "number" && Number.isFinite(renderTimeMs) ? renderTimeMs : 0; + (globalThis as unknown as { __BENCH_ON_RENDER?: (metrics: unknown) => void }).__BENCH_ON_RENDER = + (metrics: unknown): void => { + const renderTimeMs = readMetricNumber(metrics, "renderTime") ?? 0; + const layoutTimeMs = readMetricNumber(metrics, "layoutTimeMs"); + const layoutMsSafe = + typeof layoutTimeMs === "number" && Number.isFinite(layoutTimeMs) ? layoutTimeMs : null; + const renderTimeMsSafe = + typeof renderTimeMs === "number" && Number.isFinite(renderTimeMs) ? renderTimeMs : 0; const now = performance.now(); const tsMs = now - startAt; @@ -576,8 +604,10 @@ function BenchApp(props: { const compat = compatFrameRef.current; compatFrameRef.current = null; - framesRef.current.push({ - frame: framesRef.current.length + 1, + const frameNumber = frameCountRef.current + 1; + frameCountRef.current = frameNumber; + const frameMetric: FrameMetric = { + frame: frameNumber, tsMs, renderTimeMs: renderTimeMsSafe, layoutTimeMs: layoutMsSafe, @@ -603,24 +633,39 @@ function BenchApp(props: { translationCacheStaleMisses: compat?.translationCacheStaleMisses ?? null, parseAnsiFastPathHits: compat?.parseAnsiFastPathHits ?? null, parseAnsiFallbackPathHits: compat?.parseAnsiFallbackPathHits ?? null, - }); + }; + if (streamFrames) { + frameWriteBufferRef.current.push(`${JSON.stringify(frameMetric)}\n`); + if (frameWriteBufferRef.current.length >= 64) { + flushFrameWriteBuffer(); + } + } else { + framesRef.current.push(frameMetric); + } }; useEffect(() => { return () => { - delete (globalThis as any).__BENCH_ON_RENDER; + ( + globalThis as unknown as { __BENCH_ON_RENDER?: (metrics: unknown) => void } + ).__BENCH_ON_RENDER = undefined; }; }, []); useEffect(() => { + mkdirSync(props.outDir, { recursive: true }); + if (streamFrames) { + writeFileSync(framesPath, ""); + } return () => { - mkdirSync(props.outDir, { recursive: true }); - writeFileSync( - path.join(props.outDir, "frames.jsonl"), - `${framesRef.current.map((x) => JSON.stringify(x)).join("\n")}\n`, - ); + if (streamFrames) { + flushFrameWriteBuffer(); + return; + } + const lines = framesRef.current.map((x) => JSON.stringify(x)).join("\n"); + writeFileSync(framesPath, lines.length > 0 ? `${lines}\n` : ""); }; - }, [props.outDir]); + }, [flushFrameWriteBuffer, framesPath, props.outDir, streamFrames]); if (props.scenario === "streaming-chat") { return ; @@ -638,12 +683,12 @@ function BenchApp(props: { } function main(): void { - const scenario = (process.env["BENCH_SCENARIO"] as ScenarioName | undefined) ?? "streaming-chat"; - const renderer = (process.env["BENCH_RENDERER"] as RendererName | undefined) ?? "real-ink"; - const outDir = process.env["BENCH_OUT_DIR"] ?? "results/tmp"; - const cols = Number.parseInt(process.env["BENCH_COLS"] ?? "80", 10) || 80; - const rows = Number.parseInt(process.env["BENCH_ROWS"] ?? "24", 10) || 24; - const controlSocketPath = process.env["BENCH_CONTROL_SOCKET"]; + const scenario = (process.env.BENCH_SCENARIO as ScenarioName | undefined) ?? "streaming-chat"; + const renderer = (process.env.BENCH_RENDERER as RendererName | undefined) ?? "real-ink"; + const outDir = process.env.BENCH_OUT_DIR ?? "results/tmp"; + const cols = Number.parseInt(process.env.BENCH_COLS ?? "80", 10) || 80; + const rows = Number.parseInt(process.env.BENCH_ROWS ?? "24", 10) || 24; + const controlSocketPath = process.env.BENCH_CONTROL_SOCKET; const inkImpl = resolveInkImpl(); mkdirSync(outDir, { recursive: true }); @@ -666,7 +711,7 @@ function main(): void { { alternateBuffer: false, incrementalRendering: true, - maxFps: Number.parseInt(process.env["BENCH_MAX_FPS"] ?? "60", 10) || 60, + maxFps: Number.parseInt(process.env.BENCH_MAX_FPS ?? "60", 10) || 60, patchConsole: false, debug: false, onRender: (metrics) => { diff --git a/packages/bench-runner/src/cli.ts b/packages/bench-runner/src/cli.ts index 8a661664..be45896b 100644 --- a/packages/bench-runner/src/cli.ts +++ b/packages/bench-runner/src/cli.ts @@ -1,11 +1,11 @@ +import { execFile } from "node:child_process"; import { mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; -import path from "node:path"; import net from "node:net"; import os from "node:os"; +import path from "node:path"; +import { performance } from "node:perf_hooks"; import { setTimeout as delay } from "node:timers/promises"; -import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { performance } from "node:perf_hooks"; import { runInPty } from "@rezi-ui/ink-compat-bench-harness"; @@ -34,6 +34,14 @@ function parseIntArg(name: string, fallback: number): number { return Number.isFinite(v) ? v : fallback; } +function readPositiveNumberEnv(name: string): number | null { + const raw = process.env[name]; + if (!raw) return null; + const v = Number.parseFloat(raw); + if (!Number.isFinite(v) || v <= 0) return null; + return v; +} + async function getClockTicksPerSecond(): Promise { try { const pExec = promisify(execFile); @@ -53,9 +61,15 @@ function computeCpuSecondsFromProcSamples( const last = [...samples] .reverse() .find((s) => s.cpuUserTicks != null && s.cpuSystemTicks != null); - if (!first || !last) return null; - const dt = - (last.cpuUserTicks! + last.cpuSystemTicks!) - (first.cpuUserTicks! + first.cpuSystemTicks!); + if ( + first?.cpuUserTicks == null || + first.cpuSystemTicks == null || + last?.cpuUserTicks == null || + last.cpuSystemTicks == null + ) { + return null; + } + const dt = last.cpuUserTicks + last.cpuSystemTicks - (first.cpuUserTicks + first.cpuSystemTicks); return dt / clkTck; } @@ -160,11 +174,18 @@ async function driveScenario( if (childFinished()) return; control.sendLine({ type: "init", seed }); + const longRunSeconds = readPositiveNumberEnv("BENCH_LONG_RUN_SECONDS"); + const longRunMultiplier = readPositiveNumberEnv("BENCH_LONG_RUN_MULTIPLIER"); if (scenario === "streaming-chat") { - const total = 360; + let total = 360; const ratePerSecond = 120; const intervalMs = Math.round(1000 / ratePerSecond); + if (longRunSeconds != null) { + total = Math.max(total, Math.ceil((longRunSeconds * 1000) / intervalMs)); + } else if (longRunMultiplier != null) { + total = Math.max(1, Math.ceil(total * longRunMultiplier)); + } for (let i = 0; i < total; i++) { if (childFinished()) break; control.sendLine({ type: "token", text: `t=${i} ${seededToken(i)} ${seededToken(i + 1)}` }); @@ -174,9 +195,8 @@ async function driveScenario( return; } - const tickMs = - scenario === "large-list-scroll" ? 33 : scenario === "resize-storm" ? 25 : 16; - const tickCount = + const tickMs = scenario === "large-list-scroll" ? 33 : scenario === "resize-storm" ? 25 : 16; + let tickCount = scenario === "large-list-scroll" ? 120 : scenario === "dashboard-grid" @@ -186,6 +206,11 @@ async function driveScenario( : scenario === "resize-storm" ? 40 : 60; + if (longRunSeconds != null) { + tickCount = Math.max(tickCount, Math.ceil((longRunSeconds * 1000) / tickMs)); + } else if (longRunMultiplier != null) { + tickCount = Math.max(1, Math.ceil(tickCount * longRunMultiplier)); + } for (let i = 0; i < tickCount; i++) { if (childFinished()) break; @@ -216,6 +241,9 @@ async function main(): Promise { const rows = parseIntArg("rows", 24); const stableWindowMs = parseIntArg("stable-ms", 250); const cpuProf = hasFlag("cpu-prof"); + const longRunEnabled = + readPositiveNumberEnv("BENCH_LONG_RUN_SECONDS") != null || + readPositiveNumberEnv("BENCH_LONG_RUN_MULTIPLIER") != null; const repoRoot = process.cwd(); const appEntry = path.join(repoRoot, "packages/bench-app/dist/entry.js"); @@ -265,12 +293,13 @@ async function main(): Promise { BENCH_COLS: String(cols), BENCH_ROWS: String(rows), BENCH_CONTROL_SOCKET: controlSocket, - BENCH_TIMEOUT_MS: process.env["BENCH_TIMEOUT_MS"] ?? "15000", + BENCH_TIMEOUT_MS: process.env.BENCH_TIMEOUT_MS ?? "15000", BENCH_EXIT_AFTER_DONE_MS: - process.env["BENCH_EXIT_AFTER_DONE_MS"] ?? String(Math.max(0, stableWindowMs + 50)), - BENCH_INK_COMPAT_PHASES: process.env["BENCH_INK_COMPAT_PHASES"] ?? "1", - BENCH_DETAIL: process.env["BENCH_DETAIL"] ?? "0", - BENCH_MAX_FPS: process.env["BENCH_MAX_FPS"] ?? "60", + process.env.BENCH_EXIT_AFTER_DONE_MS ?? String(Math.max(0, stableWindowMs + 50)), + BENCH_INK_COMPAT_PHASES: process.env.BENCH_INK_COMPAT_PHASES ?? "1", + BENCH_DETAIL: process.env.BENCH_DETAIL ?? "0", + BENCH_MAX_FPS: process.env.BENCH_MAX_FPS ?? "60", + BENCH_STREAM_FRAMES: process.env.BENCH_STREAM_FRAMES ?? (longRunEnabled ? "1" : "0"), }; const inputScript = @@ -318,28 +347,28 @@ async function main(): Promise { const frames = safeReadJsonl(path.join(runDir, "frames.jsonl")); const renderTotalsMs = frames - .map((f) => f["renderTotalMs"]) + .map((f) => f.renderTotalMs) .filter((v): v is number => typeof v === "number" && Number.isFinite(v)); const scheduleWaitsMs = frames - .map((f) => f["scheduleWaitMs"]) + .map((f) => f.scheduleWaitMs) .filter((v): v is number => typeof v === "number" && Number.isFinite(v)); const updatesRequested = frames.reduce((a, f) => { - const v = f["updatesRequestedDelta"]; + const v = f.updatesRequestedDelta; return a + (typeof v === "number" && Number.isFinite(v) ? v : 0); }, 0); const framesWithCoalescedUpdates = frames.reduce((a, f) => { - const v = f["updatesRequestedDelta"]; + const v = f.updatesRequestedDelta; return a + (typeof v === "number" && Number.isFinite(v) && v > 1 ? 1 : 0); }, 0); const maxUpdatesInFrame = frames.reduce((a, f) => { - const v = f["updatesRequestedDelta"]; + const v = f.updatesRequestedDelta; if (typeof v !== "number" || !Number.isFinite(v)) return a; return Math.max(a, v); }, 0); - const renderTotalMs = frames.reduce((a, f) => a + (Number(f["renderTotalMs"]) || 0), 0); - const stdoutBytes = frames.reduce((a, f) => a + (Number(f["stdoutBytes"]) || 0), 0); - const stdoutWrites = frames.reduce((a, f) => a + (Number(f["stdoutWrites"]) || 0), 0); + const renderTotalMs = frames.reduce((a, f) => a + (Number(f.renderTotalMs) || 0), 0); + const stdoutBytes = frames.reduce((a, f) => a + (Number(f.stdoutBytes) || 0), 0); + const stdoutWrites = frames.reduce((a, f) => a + (Number(f.stdoutWrites) || 0), 0); const cpuSeconds = computeCpuSecondsFromProcSamples(result.procSamples, clkTck); const peakRssBytes = computePeakRssBytesFromProcSamples(result.procSamples); @@ -380,7 +409,7 @@ async function main(): Promise { } main().catch((err: unknown) => { - const msg = err instanceof Error ? err.stack ?? err.message : String(err); + const msg = err instanceof Error ? (err.stack ?? err.message) : String(err); console.error(msg); process.exitCode = 1; }); diff --git a/packages/bench-runner/src/verify.ts b/packages/bench-runner/src/verify.ts index b32dcc21..d116bfba 100644 --- a/packages/bench-runner/src/verify.ts +++ b/packages/bench-runner/src/verify.ts @@ -1,9 +1,9 @@ import { mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; -import path from "node:path"; import net from "node:net"; import os from "node:os"; -import { setTimeout as delay } from "node:timers/promises"; +import path from "node:path"; import { performance } from "node:perf_hooks"; +import { setTimeout as delay } from "node:timers/promises"; import { diffScreens, runInPty } from "@rezi-ui/ink-compat-bench-harness"; @@ -107,8 +107,9 @@ async function runOnce( ): Promise { linkInkForRenderer(repoRoot, renderer); const appEntry = path.join(repoRoot, "packages/bench-app/dist/entry.js"); - const cols = Number.parseInt(process.env["BENCH_COLS"] ?? "80", 10) || 80; - const rows = Number.parseInt(process.env["BENCH_ROWS"] ?? "24", 10) || 24; + const preloadPath = path.join(repoRoot, "scripts/ink-compat-bench/preload.mjs"); + const cols = Number.parseInt(process.env.BENCH_COLS ?? "80", 10) || 80; + const rows = Number.parseInt(process.env.BENCH_ROWS ?? "24", 10) || 24; const controlSocket = path.join( os.tmpdir(), `inkbench_verify_${process.pid}_${Math.trunc(performance.now())}_${renderer}.sock`, @@ -118,7 +119,7 @@ async function runOnce( const runPromise = runInPty({ cwd: repoRoot, command: process.execPath, - args: ["--no-warnings", appEntry], + args: ["--no-warnings", "--import", preloadPath, appEntry], env: { ...process.env, BENCH_SCENARIO: scenario, @@ -127,10 +128,10 @@ async function runOnce( BENCH_COLS: String(cols), BENCH_ROWS: String(rows), BENCH_CONTROL_SOCKET: controlSocket, - BENCH_TIMEOUT_MS: process.env["BENCH_TIMEOUT_MS"] ?? "15000", - BENCH_EXIT_AFTER_DONE_MS: process.env["BENCH_EXIT_AFTER_DONE_MS"] ?? "300", - BENCH_INK_COMPAT_PHASES: process.env["BENCH_INK_COMPAT_PHASES"] ?? "1", - BENCH_MAX_FPS: process.env["BENCH_MAX_FPS"] ?? "60", + BENCH_TIMEOUT_MS: process.env.BENCH_TIMEOUT_MS ?? "15000", + BENCH_EXIT_AFTER_DONE_MS: process.env.BENCH_EXIT_AFTER_DONE_MS ?? "300", + BENCH_INK_COMPAT_PHASES: process.env.BENCH_INK_COMPAT_PHASES ?? "1", + BENCH_MAX_FPS: process.env.BENCH_MAX_FPS ?? "60", }, cols, rows, @@ -154,9 +155,7 @@ async function main(): Promise { const scenario = requireArg("scenario"); const rawCompare = requireArg("compare").split(","); if (rawCompare.length !== 2 || !rawCompare[0] || !rawCompare[1]) { - throw new Error( - `--compare must be "real-ink,ink-compat" (got ${JSON.stringify(rawCompare)})`, - ); + throw new Error(`--compare must be "real-ink,ink-compat" (got ${JSON.stringify(rawCompare)})`); } const compare = [parseRendererName(rawCompare[0]), parseRendererName(rawCompare[1])] as const; @@ -173,8 +172,8 @@ async function main(): Promise { const aScreen = await runOnce(repoRoot, scenario, compare[0], runA); const bScreen = await runOnce(repoRoot, scenario, compare[1], runB); - const cols = Number.parseInt(process.env["BENCH_COLS"] ?? "80", 10) || 80; - const rows = Number.parseInt(process.env["BENCH_ROWS"] ?? "24", 10) || 24; + const cols = Number.parseInt(process.env.BENCH_COLS ?? "80", 10) || 80; + const rows = Number.parseInt(process.env.BENCH_ROWS ?? "24", 10) || 24; const toSnap = (screen: string) => { const lines = screen.split("\n"); @@ -198,6 +197,6 @@ async function main(): Promise { } main().catch((err: unknown) => { - console.error(err instanceof Error ? err.stack ?? err.message : String(err)); + console.error(err instanceof Error ? (err.stack ?? err.message) : String(err)); process.exitCode = 1; }); diff --git a/packages/core/src/renderer/__tests__/renderer.text.test.ts b/packages/core/src/renderer/__tests__/renderer.text.test.ts index 1a243dd7..862697f8 100644 --- a/packages/core/src/renderer/__tests__/renderer.text.test.ts +++ b/packages/core/src/renderer/__tests__/renderer.text.test.ts @@ -296,6 +296,60 @@ function expectBlob(frame: ParsedFrame, blobIndex: number): TextRunBlob { return blob; } +describe("renderer text - transform ANSI styling", () => { + const gradientTransform = (): string => "\u001b[31mA\u001b[32mB\u001b[0m"; + + test("single-line transform with ANSI emits visible glyphs without escape bytes", () => { + const frame = parseFrame( + renderBytes( + textVNode("AB", { + __inkTransform: gradientTransform, + }), + { cols: 20, rows: 1 }, + ), + ); + + assert.equal( + frame.drawTexts.some((cmd) => cmd.text.includes("\u001b[")), + false, + ); + const run = expectSingleDrawTextRun(frame); + const blob = expectBlob(frame, run.blobIndex); + assert.equal(blob.segments.length, 2); + const first = blob.segments[0]; + const second = blob.segments[1]; + assert.ok(first !== undefined); + assert.ok(second !== undefined); + assert.equal(first.text, "A"); + assert.equal(second.text, "B"); + assert.equal(first.fg !== second.fg, true); + }); + + test("wrapped transform with ANSI does not render literal CSI tokens", () => { + const frame = parseFrame( + renderBytes( + textVNode("AB", { + wrap: true, + __inkTransform: gradientTransform, + }), + { cols: 20, rows: 3 }, + ), + ); + + assert.equal( + frame.drawTexts.some((cmd) => cmd.text.includes("[39;") || cmd.text.includes("\u001b")), + false, + ); + const run = expectSingleDrawTextRun(frame); + const blob = expectBlob(frame, run.blobIndex); + assert.equal( + blob.segments.every((segment) => segment.text.includes("\u001b") === false), + true, + ); + assert.equal(blob.segments.map((segment) => segment.text).join(""), "AB"); + }); +}); + describe("renderer text - CJK cell width and positioning", () => { test("cjk text fits exactly at width boundary", () => { const frame = parseFrame( diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/renderTextWidgets.ts b/packages/core/src/renderer/renderToDrawlist/widgets/renderTextWidgets.ts index 6274d495..8523ca3d 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/renderTextWidgets.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/renderTextWidgets.ts @@ -16,6 +16,7 @@ import type { RuntimeInstance } from "../../../runtime/commit.js"; import type { FocusState } from "../../../runtime/focus.js"; import type { Theme } from "../../../theme/theme.js"; import { linkLabel } from "../../../widgets/link.js"; +import { type Rgb24, rgb } from "../../../widgets/style.js"; import { asTextStyle } from "../../styles.js"; import { isVisibleRect } from "../indices.js"; import { mergeTextStyle } from "../textStyle.js"; @@ -28,6 +29,23 @@ export type StyledSegment = Readonly<{ style: ResolvedTextStyle; }>; +type MutableAnsiSgrStyle = { + fg?: Rgb24; + bg?: Rgb24; + bold?: boolean; + dim?: boolean; + italic?: boolean; + underline?: boolean; + inverse?: boolean; + strikethrough?: boolean; +}; + +type ParsedAnsiTransformText = Readonly<{ + segments: readonly StyledSegment[]; + visibleText: string; + hasAnsi: boolean; +}>; + type ResolvedCursor = Readonly<{ x: number; y: number; @@ -51,6 +69,294 @@ function isAsciiText(text: string): boolean { return true; } +const ANSI_ESCAPE = String.fromCharCode(0x1b); +const ANSI_SGR_REGEX = new RegExp(`${ANSI_ESCAPE}\\[([0-9:;]*)m`, "g"); +const ANSI_16_PALETTE: readonly Rgb24[] = [ + rgb(0, 0, 0), + rgb(205, 0, 0), + rgb(0, 205, 0), + rgb(205, 205, 0), + rgb(0, 0, 238), + rgb(205, 0, 205), + rgb(0, 205, 205), + rgb(229, 229, 229), + rgb(127, 127, 127), + rgb(255, 0, 0), + rgb(0, 255, 0), + rgb(255, 255, 0), + rgb(92, 92, 255), + rgb(255, 0, 255), + rgb(0, 255, 255), + rgb(255, 255, 255), +]; + +function ansiPaletteColor(index: number): Rgb24 { + return ANSI_16_PALETTE[index] ?? ANSI_16_PALETTE[0] ?? rgb(0, 0, 0); +} + +function unsetAnsiStyleValue(style: MutableAnsiSgrStyle, key: keyof MutableAnsiSgrStyle): void { + Reflect.deleteProperty(style, key); +} + +function appendStyledSegment( + segments: StyledSegment[], + text: string, + style: ResolvedTextStyle, +): void { + if (text.length === 0) return; + const previous = segments[segments.length - 1]; + if (previous && previous.style === style) { + segments[segments.length - 1] = { text: `${previous.text}${text}`, style }; + return; + } + segments.push({ text, style }); +} + +function parseAnsiSgrCodes(raw: string): number[] { + if (raw.length === 0) return [0]; + + const normalizedRaw = raw + .replace(/([34]8):2::(\d{1,3}):(\d{1,3}):(\d{1,3})/g, "$1;2;$2;$3;$4") + .replace(/([34]8):2:\d{1,3}:(\d{1,3}):(\d{1,3}):(\d{1,3})/g, "$1;2;$2;$3;$4") + .replace(/([34]8):5:(\d{1,3})/g, "$1;5;$2") + .replaceAll(":", ";"); + + const codes: number[] = []; + for (const part of normalizedRaw.split(";")) { + if (part.length === 0) { + codes.push(0); + continue; + } + const parsed = Number.parseInt(part, 10); + if (Number.isFinite(parsed)) codes.push(parsed); + } + return codes.length > 0 ? codes : [0]; +} + +function isByte(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 255; +} + +function decodeAnsi256Color(index: number): Rgb24 { + if (index < 16) return ansiPaletteColor(index); + if (index <= 231) { + const offset = index - 16; + const rLevel = Math.floor(offset / 36); + const gLevel = Math.floor((offset % 36) / 6); + const bLevel = offset % 6; + const toChannel = (level: number): number => (level === 0 ? 0 : 55 + level * 40); + return rgb(toChannel(rLevel), toChannel(gLevel), toChannel(bLevel)); + } + const gray = 8 + (index - 232) * 10; + return rgb(gray, gray, gray); +} + +function clearAnsiStyleOverride(style: MutableAnsiSgrStyle): void { + unsetAnsiStyleValue(style, "fg"); + unsetAnsiStyleValue(style, "bg"); + unsetAnsiStyleValue(style, "bold"); + unsetAnsiStyleValue(style, "dim"); + unsetAnsiStyleValue(style, "italic"); + unsetAnsiStyleValue(style, "underline"); + unsetAnsiStyleValue(style, "inverse"); + unsetAnsiStyleValue(style, "strikethrough"); +} + +function applyExtendedAnsiColor( + channel: "fg" | "bg", + codes: readonly number[], + index: number, + style: MutableAnsiSgrStyle, +): number { + const mode = codes[index + 1]; + if (mode === 5) { + const colorIndex = codes[index + 2]; + if ( + typeof colorIndex === "number" && + Number.isInteger(colorIndex) && + colorIndex >= 0 && + colorIndex <= 255 + ) { + style[channel] = decodeAnsi256Color(colorIndex); + } + return index + 2; + } + + if (mode === 2) { + const directR = codes[index + 2]; + const directG = codes[index + 3]; + const directB = codes[index + 4]; + if (isByte(directR) && isByte(directG) && isByte(directB)) { + style[channel] = rgb(directR, directG, directB); + return index + 4; + } + + const colorSpace = codes[index + 2]; + const r = codes[index + 3]; + const g = codes[index + 4]; + const b = codes[index + 5]; + if ( + (colorSpace == null || + (typeof colorSpace === "number" && Number.isInteger(colorSpace) && colorSpace >= 0)) && + isByte(r) && + isByte(g) && + isByte(b) + ) { + style[channel] = rgb(r, g, b); + return index + 5; + } + + return index + 4; + } + + if (mode === 0) { + if (channel === "fg") { + unsetAnsiStyleValue(style, "fg"); + } else { + unsetAnsiStyleValue(style, "bg"); + } + return index + 1; + } + + return index; +} + +function applyAnsiSgrCodes(codes: readonly number[], style: MutableAnsiSgrStyle): void { + const normalized = codes.length > 0 ? codes : [0]; + for (let index = 0; index < normalized.length; index += 1) { + const code = normalized[index]; + if (code == null) continue; + if (code === 0) { + clearAnsiStyleOverride(style); + continue; + } + if (code === 1) { + style.bold = true; + continue; + } + if (code === 2) { + style.dim = true; + continue; + } + if (code === 3) { + style.italic = true; + continue; + } + if (code === 4) { + style.underline = true; + continue; + } + if (code === 7) { + style.inverse = true; + continue; + } + if (code === 9) { + style.strikethrough = true; + continue; + } + if (code === 22) { + style.bold = false; + style.dim = false; + continue; + } + if (code === 23) { + style.italic = false; + continue; + } + if (code === 24) { + style.underline = false; + continue; + } + if (code === 27) { + style.inverse = false; + continue; + } + if (code === 29) { + style.strikethrough = false; + continue; + } + if (code === 39) { + unsetAnsiStyleValue(style, "fg"); + continue; + } + if (code === 49) { + unsetAnsiStyleValue(style, "bg"); + continue; + } + if (code >= 30 && code <= 37) { + style.fg = ansiPaletteColor(code - 30); + continue; + } + if (code >= 40 && code <= 47) { + style.bg = ansiPaletteColor(code - 40); + continue; + } + if (code >= 90 && code <= 97) { + style.fg = ansiPaletteColor(code - 90 + 8); + continue; + } + if (code >= 100 && code <= 107) { + style.bg = ansiPaletteColor(code - 100 + 8); + continue; + } + if (code === 38 || code === 48) { + index = applyExtendedAnsiColor(code === 38 ? "fg" : "bg", normalized, index, style); + } + } +} + +function parseAnsiTransformText( + text: string, + baseStyle: ResolvedTextStyle, +): ParsedAnsiTransformText { + if (text.length === 0 || text.indexOf("\u001b[") === -1) { + return { + segments: text.length === 0 ? [] : [{ text, style: baseStyle }], + visibleText: text, + hasAnsi: false, + }; + } + + const segments: StyledSegment[] = []; + let visibleText = ""; + let lastIndex = 0; + let hasAnsi = false; + const activeStyleOverride: MutableAnsiSgrStyle = {}; + let activeStyle = baseStyle; + + ANSI_SGR_REGEX.lastIndex = 0; + for (const match of text.matchAll(ANSI_SGR_REGEX)) { + const index = match.index; + if (index == null) continue; + hasAnsi = true; + const plainText = text.slice(lastIndex, index); + if (plainText.length > 0) { + visibleText += plainText; + appendStyledSegment(segments, plainText, activeStyle); + } + + applyAnsiSgrCodes(parseAnsiSgrCodes(match[1] ?? ""), activeStyleOverride); + activeStyle = mergeTextStyle(baseStyle, activeStyleOverride); + lastIndex = index + match[0].length; + } + + const trailing = text.slice(lastIndex); + if (trailing.length > 0) { + visibleText += trailing; + appendStyledSegment(segments, trailing, activeStyle); + } + + if (!hasAnsi) { + return { + segments: [{ text, style: baseStyle }], + visibleText: text, + hasAnsi: false, + }; + } + + return { segments, visibleText, hasAnsi: true }; +} + function readNumber(v: unknown): number | undefined { if (typeof v !== "number" || !Number.isFinite(v)) return undefined; return v; @@ -303,34 +609,42 @@ export function renderTextWidgets( for (let i = 0; i < visibleCount; i++) { const rawLine = lines[i] ?? ""; + const ansiLine = transform ? parseAnsiTransformText(rawLine, style) : undefined; + const baseLine = ansiLine?.hasAnsi ? ansiLine.visibleText : rawLine; const isLastVisible = i === visibleCount - 1; const hasHiddenLines = lines.length > visibleCount; - let line = rawLine; + let line = baseLine; + let lineSegments = ansiLine?.hasAnsi ? ansiLine.segments : undefined; if (isLastVisible) { switch (textOverflow) { case "ellipsis": { if (!hasHiddenLines) { - line = truncateWithEllipsis(rawLine, overflowW); + line = truncateWithEllipsis(baseLine, overflowW); + lineSegments = undefined; break; } if (overflowW <= 1) { line = "…"; + lineSegments = undefined; break; } const reservedWidth = overflowW - 1; const base = - measureTextCells(rawLine) <= reservedWidth - ? rawLine - : truncateWithEllipsis(rawLine, reservedWidth); + measureTextCells(baseLine) <= reservedWidth + ? baseLine + : truncateWithEllipsis(baseLine, reservedWidth); line = base.endsWith("…") ? base : `${base}…`; + lineSegments = undefined; break; } case "middle": - line = truncateMiddle(hasHiddenLines ? `${rawLine}…` : rawLine, overflowW); + line = truncateMiddle(hasHiddenLines ? `${baseLine}…` : baseLine, overflowW); + lineSegments = undefined; break; case "start": - line = truncateStart(hasHiddenLines ? `…${rawLine}` : rawLine, overflowW); + line = truncateStart(hasHiddenLines ? `…${baseLine}` : baseLine, overflowW); + lineSegments = undefined; break; case "clip": break; @@ -339,7 +653,11 @@ export function renderTextWidgets( const clipWidth = transform ? Math.max(overflowW, measureTextCells(line)) : overflowW; builder.pushClip(rect.x, rect.y + i, clipWidth, 1); - builder.drawText(rect.x, rect.y + i, line, style); + if (lineSegments) { + drawSegments(builder, rect.x, rect.y + i, clipWidth, lineSegments); + } else { + builder.drawText(rect.x, rect.y + i, line, style); + } builder.popClip(); } @@ -368,19 +686,29 @@ export function renderTextWidgets( } const transformedText = transformLine(text, 0); + const ansiText = transform ? parseAnsiTransformText(transformedText, style) : undefined; + const visibleTransformedText = + ansiText?.hasAnsi === true ? ansiText.visibleText : transformedText; // Avoid measuring in the common ASCII case. const fits = - (isAsciiText(transformedText) && transformedText.length <= overflowW) || - measureTextCells(transformedText) <= overflowW; + (isAsciiText(visibleTransformedText) && visibleTransformedText.length <= overflowW) || + measureTextCells(visibleTransformedText) <= overflowW; if (fits) { - builder.drawText(rect.x, rect.y, transformedText, style); + if (ansiText?.hasAnsi) { + drawSegments(builder, rect.x, rect.y, overflowW, ansiText.segments); + } else { + builder.drawText(rect.x, rect.y, visibleTransformedText, style); + } if (!transform && cursorInfo && cursorMeta.focused) { const cursorX = Math.min( overflowW, measureTextCells( - transformedText.slice(0, Math.min(cursorOffset, transformedText.length)), + visibleTransformedText.slice( + 0, + Math.min(cursorOffset, visibleTransformedText.length), + ), ), ); resolvedCursor = { @@ -393,18 +721,22 @@ export function renderTextWidgets( break; } - let displayText = transformedText; + let displayText = visibleTransformedText; let useClip = false; + let useStyledSegments = ansiText?.hasAnsi === true; switch (textOverflow) { case "ellipsis": - displayText = truncateWithEllipsis(transformedText, overflowW); + displayText = truncateWithEllipsis(visibleTransformedText, overflowW); + useStyledSegments = false; break; case "middle": - displayText = truncateMiddle(transformedText, overflowW); + displayText = truncateMiddle(visibleTransformedText, overflowW); + useStyledSegments = false; break; case "start": - displayText = truncateStart(transformedText, overflowW); + displayText = truncateStart(visibleTransformedText, overflowW); + useStyledSegments = false; break; case "clip": useClip = true; @@ -412,16 +744,24 @@ export function renderTextWidgets( } if (useClip) { builder.pushClip(rect.x, rect.y, overflowW, rect.h); - builder.drawText(rect.x, rect.y, displayText, style); + if (useStyledSegments && ansiText) { + drawSegments(builder, rect.x, rect.y, overflowW, ansiText.segments); + } else { + builder.drawText(rect.x, rect.y, displayText, style); + } builder.popClip(); } else { - builder.drawText(rect.x, rect.y, displayText, style); + if (useStyledSegments && ansiText) { + drawSegments(builder, rect.x, rect.y, overflowW, ansiText.segments); + } else { + builder.drawText(rect.x, rect.y, displayText, style); + } } if (!transform && cursorInfo && cursorMeta.focused) { const cursorX = Math.min( overflowW, measureTextCells( - transformedText.slice(0, Math.min(cursorOffset, transformedText.length)), + visibleTransformedText.slice(0, Math.min(cursorOffset, visibleTransformedText.length)), ), ); resolvedCursor = { diff --git a/packages/ink-compat/README.md b/packages/ink-compat/README.md index 0d063cd3..8fa67bc8 100644 --- a/packages/ink-compat/README.md +++ b/packages/ink-compat/README.md @@ -2,7 +2,14 @@ `@rezi-ui/ink-compat` is an Ink API compatibility layer powered by Rezi. -It preserves the Ink component and hook model while rendering through Rezi's engine. +It keeps the Ink component/hook model, but replaces Ink's renderer backend with +Rezi's deterministic layout + draw pipeline. + +## Why use it + +- Keep existing Ink app code and mental model. +- Migrate incrementally (explicit import swap or package aliasing). +- Get deterministic, env-gated diagnostics for parity and performance triage. ## Install @@ -10,9 +17,15 @@ It preserves the Ink component and hook model while rendering through Rezi's eng npm install @rezi-ui/ink-compat ``` -## Use +If your app uses `ink-gradient` or `ink-spinner`, install matching shims: + +```bash +npm install ink-gradient-shim ink-spinner-shim +``` -Swap imports: +## Migration options + +### Option A: explicit import swap ```ts // Before @@ -22,11 +35,68 @@ import { render, Box, Text } from "ink"; import { render, Box, Text } from "@rezi-ui/ink-compat"; ``` -For ecosystems that pin `ink` directly, use package manager overrides/resolutions to redirect `ink` to `@rezi-ui/ink-compat` and companion shims (`ink-gradient`, `ink-spinner`). +### Option B: no-source-change package aliasing + +Keep `import "ink"` in app code and alias dependencies: + +```bash +npm install \ + ink@npm:@rezi-ui/ink-compat@latest \ + ink-gradient@npm:ink-gradient-shim@latest \ + ink-spinner@npm:ink-spinner-shim@latest +``` + +Equivalent with `pnpm`: + +```bash +pnpm add \ + ink@npm:@rezi-ui/ink-compat@latest \ + ink-gradient@npm:ink-gradient-shim@latest \ + ink-spinner@npm:ink-spinner-shim@latest +``` + +Equivalent with `yarn`: + +```bash +yarn add \ + ink@npm:@rezi-ui/ink-compat@latest \ + ink-gradient@npm:ink-gradient-shim@latest \ + ink-spinner@npm:ink-spinner-shim@latest +``` + +## Verify wiring (avoid silent fallback to real Ink) + +Run this in the app root: + +```bash +node -e "const p=require('ink/package.json'); if(p.name!=='@rezi-ui/ink-compat') throw new Error('ink resolves to '+p.name); console.log('ink-compat active:', p.version);" +``` + +And confirm resolved path: + +```bash +node -e "const fs=require('node:fs'); const path=require('node:path'); const pkg=require.resolve('ink/package.json'); console.log(fs.realpathSync(path.dirname(pkg)));" +``` + +## How it works + +At runtime, ink-compat runs this pipeline: + +1. React reconciles to an `InkHostNode` tree (compat host config). +2. Translation maps Ink props/components to Rezi VNodes. +3. Rezi layout + render generate draw ops, then ANSI output is serialized to terminal streams. + +Key behavior: + +- `` is handled as a dedicated scrollback-oriented channel. +- Input/focus/cursor are bridged through compat context/hooks. +- Diagnostics and heavy instrumentation are env-gated. -## Supported surface +For full architecture details, see `docs/architecture/ink-compat.md`. -Components: +## Supported API surface + +### Components - `Box` - `Text` @@ -35,7 +105,7 @@ Components: - `Static` - `Transform` -Hooks: +### Hooks - `useApp` - `useInput` @@ -47,7 +117,7 @@ Hooks: - `useIsScreenReaderEnabled` - `useCursor` -Runtime APIs: +### Runtime APIs - `render` - `renderToString` @@ -57,25 +127,23 @@ Runtime APIs: - `getInnerHeight` - `getScrollHeight` -Keyboard utilities: +### Keyboard helpers - `kittyFlags` - `kittyModifiers` -Testing utilities: +### Testing entrypoint - `@rezi-ui/ink-compat/testing` -## Render options - -`render(element, options)` supports: +## `render(element, options)` options - `stdout`, `stdin`, `stderr` - `exitOnCtrlC` - `patchConsole` - `debug` - `maxFps` -- `concurrent` +- `concurrent` (compatibility flag; not an upstream-concurrency semantic toggle) - `kittyKeyboard` - `isScreenReaderEnabled` - `onRender` @@ -84,21 +152,29 @@ Testing utilities: ## Diagnostics -Trace output is environment-gated: +Trace output is env-gated: - `INK_COMPAT_TRACE=1` - `INK_COMPAT_TRACE_FILE=/path/log` +- `INK_COMPAT_TRACE_STDERR=1` - `INK_COMPAT_TRACE_DETAIL=1` - `INK_COMPAT_TRACE_DETAIL_FULL=1` +- `INK_COMPAT_TRACE_ALL_FRAMES=1` +- `INK_COMPAT_TRACE_IO=1` +- `INK_COMPAT_TRACE_RESIZE_VERBOSE=1` - `INK_GRADIENT_TRACE=1` -Reference docs: +Debugging runbook: -- `docs/architecture/ink-compat.md` - `docs/dev/ink-compat-debugging.md` ## Known boundaries -- Minor visual differences can occur across terminal emulators and OS TTY behavior. -- App-version and install-mode messaging differences are expected and not renderer bugs. -- Gradient interpolation can differ slightly from upstream while preserving overall behavior. +- Minor visual differences can occur across terminal emulators / OS TTY behavior. +- App/version-specific messaging differences are expected and are not renderer bugs. +- Gradient interpolation can differ slightly while preserving overall behavior. + +## Documentation + +- Architecture and internals: `docs/architecture/ink-compat.md` +- Debugging and parity runbook: `docs/dev/ink-compat-debugging.md` From 3d17344972f64d575ed4a58676509e8ecd33288b Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Sat, 28 Feb 2026 07:41:23 +0400 Subject: [PATCH 26/29] docs(ink-compat): overhaul migration and discovery docs --- README.md | 6 +- docs/architecture/ink-compat.md | 2 + docs/getting-started/faq.md | 5 +- docs/index.md | 1 + docs/migration/ink-to-ink-compat.md | 157 ++++++++++++++++++++++++++++ docs/migration/ink-to-rezi.md | 2 + mkdocs.yml | 1 + packages/ink-compat/README.md | 8 +- 8 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 docs/migration/ink-to-ink-compat.md diff --git a/README.md b/README.md index 2ceff88f..0a1a19f9 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Rezi is a high-performance terminal UI framework for TypeScript. You write decla - **Inline image rendering** — display PNG, JPEG, and raw RGBA buffers using Kitty, Sixel, or iTerm2 graphics protocols, with automatic blitter fallback - **Terminal auto-detection** — identifies Kitty, WezTerm, iTerm2, Ghostty, Windows Terminal, and tmux; enables the best graphics protocol automatically, with env-var overrides for any capability - **Performance-focused architecture** — binary drawlists + native C framebuffer diffing; benchmark details and caveats are documented in the Benchmarks section -- **Ink compatibility layer** — run existing Ink CLIs on Rezi with minimal changes (import swap or dependency aliasing), plus deterministic parity diagnostics; details: [Ink Compat docs](docs/architecture/ink-compat.md) +- **Ink compatibility layer** — run existing Ink CLIs on Rezi with minimal changes (import swap or dependency aliasing), plus deterministic parity diagnostics; details: [Porting guide](docs/migration/ink-to-ink-compat.md) · [Ink Compat architecture](docs/architecture/ink-compat.md) - **JSX without React** — optional `@rezi-ui/jsx` maps JSX directly to Rezi VNodes with zero React runtime overhead - **Deterministic rendering** — same state + same events = same frames; versioned binary protocol, pinned Unicode tables - **Hot state-preserving reload** — swap widget views or route tables in-process during development without losing app state or focus context @@ -148,6 +148,10 @@ Docs + reports: - Methodology + metric definitions: `BENCHMARK_VALIDITY.md` - Latest report: `results/report_2026-02-27.md` - Bottlenecks + fixes: `results/bottlenecks.md` +- Porting and architecture docs: + - `docs/migration/ink-to-ink-compat.md` + - `docs/architecture/ink-compat.md` + - `docs/dev/ink-compat-debugging.md` ## Quick Start diff --git a/docs/architecture/ink-compat.md b/docs/architecture/ink-compat.md index 883db5b2..8a98e1d7 100644 --- a/docs/architecture/ink-compat.md +++ b/docs/architecture/ink-compat.md @@ -4,6 +4,8 @@ It is designed for practical compatibility: keep React + Ink component/hook semantics, but replace Ink's renderer backend with Rezi's deterministic layout and draw pipeline. +If you are actively migrating an app, start with [Ink to Ink-Compat Migration](../migration/ink-to-ink-compat.md) and use this page as the runtime/internals reference. + ## What this gives you - Reuse existing Ink app code with minimal migration. diff --git a/docs/getting-started/faq.md b/docs/getting-started/faq.md index 1dc31351..8305c9c6 100644 --- a/docs/getting-started/faq.md +++ b/docs/getting-started/faq.md @@ -19,7 +19,10 @@ Rezi is designed specifically for terminal UIs, not as a React port. ### I'm using Ink. How do I migrate? -Use the [Ink to Rezi migration guide](../migration/ink-to-rezi.md) for a direct mental-model map and practical migration recipes. +Choose the migration path that matches your goal: + +- For minimal app-code churn, use [Ink to Ink-Compat Migration](../migration/ink-to-ink-compat.md). +- For a full Rezi-native rewrite (`createNodeApp`, `ui.*`), use [Ink to Rezi migration guide](../migration/ink-to-rezi.md). ### What platforms does Rezi support? diff --git a/docs/index.md b/docs/index.md index def0d301..99d05702 100644 --- a/docs/index.md +++ b/docs/index.md @@ -220,6 +220,7 @@ User feedback: `spinner`, `skeleton`, `callout`, `errorDisplay`, `empty` ## Learn More - [Concepts](guide/concepts.md) - Understanding Rezi's architecture +- [Ink to Ink-Compat Migration](migration/ink-to-ink-compat.md) - Port existing Ink apps with minimal code churn - [Beautiful Defaults migration](migration/beautiful-defaults.md) - Design system styling defaults and manual overrides - [Ink to Rezi Migration](migration/ink-to-rezi.md) - Mental model mapping and migration recipes - [Lifecycle & Updates](guide/lifecycle-and-updates.md) - State management patterns diff --git a/docs/migration/ink-to-ink-compat.md b/docs/migration/ink-to-ink-compat.md new file mode 100644 index 00000000..194d437a --- /dev/null +++ b/docs/migration/ink-to-ink-compat.md @@ -0,0 +1,157 @@ +# Porting Ink Apps to Ink-Compat + +This guide is for teams that already have an Ink app and want to move to Rezi's Ink compatibility runtime with minimal app-code churn. + +If you want to migrate to Rezi-native APIs (`createNodeApp`, `ui.*`, widget graph), use [Ink to Rezi](ink-to-rezi.md) instead. + +## Choose your migration path + +| Goal | Recommended path | App code changes | +|---|---|---| +| Keep existing Ink component/hook model and switch runtime backend | Ink -> Ink-Compat (this guide) | Low | +| Adopt Rezi-native architecture and APIs | [Ink to Rezi](ink-to-rezi.md) | High | + +## What changes when you move to Ink-Compat + +- Your app still uses Ink-style components and hooks. +- Rendering runs through Rezi's compat runtime and renderer pipeline. +- You can keep `import "ink"` via dependency aliasing, or swap imports explicitly. +- You get deterministic, env-gated parity diagnostics. + +For internals and compatibility surface details, see [Ink Compat Layer](../architecture/ink-compat.md). + +## Preflight checklist + +1. Identify your current Ink usage and version. +2. Identify ecosystem dependencies (`ink-gradient`, `ink-spinner`, custom Ink wrappers). +3. Confirm your app has at least one smoke flow you can run end-to-end before and after migration. +4. Decide migration mode: + - explicit imports (`@rezi-ui/ink-compat`) + - package aliasing (`ink -> @rezi-ui/ink-compat`) + +## Option A: explicit import swap + +Use this when you can touch source imports. + +```ts +// Before +import { render, Box, Text } from "ink"; + +// After +import { render, Box, Text } from "@rezi-ui/ink-compat"; +``` + +Pros: +- Explicit and easy to audit +- No package-manager alias complexity + +Cons: +- Requires source edits + +## Option B: alias `ink` to Ink-Compat + +Use this when you want to keep existing `import "ink"` calls. + +### npm + +```bash +npm install \ + ink@npm:@rezi-ui/ink-compat@latest \ + ink-gradient@npm:ink-gradient-shim@latest \ + ink-spinner@npm:ink-spinner-shim@latest +``` + +### pnpm + +```bash +pnpm add \ + ink@npm:@rezi-ui/ink-compat@latest \ + ink-gradient@npm:ink-gradient-shim@latest \ + ink-spinner@npm:ink-spinner-shim@latest +``` + +### yarn + +```bash +yarn add \ + ink@npm:@rezi-ui/ink-compat@latest \ + ink-gradient@npm:ink-gradient-shim@latest \ + ink-spinner@npm:ink-spinner-shim@latest +``` + +Pros: +- Usually no app-source changes +- Fastest initial rollout + +Cons: +- Easy to misconfigure if lockfile/resolution rules drift + +## Verify wiring (do this in CI) + +Do not assume aliasing/import swaps worked. Verify package identity directly. + +```bash +node -e "const p=require('ink/package.json'); if(p.name!=='@rezi-ui/ink-compat') throw new Error('ink resolves to '+p.name); console.log('ink-compat active:', p.version);" +``` + +Optional path check: + +```bash +node -e "const fs=require('node:fs'); const path=require('node:path'); const pkg=require.resolve('ink/package.json'); console.log(fs.realpathSync(path.dirname(pkg)));" +``` + +## API coverage that matters when porting + +Ink-Compat supports the core app surface most Ink CLIs depend on: + +- Components: `Box`, `Text`, `Static`, `Transform`, `Spacer`, `Newline` +- Hooks: `useApp`, `useInput`, `useFocus`, `useFocusManager`, stream hooks, `useCursor` +- Runtime: `render`, `renderToString`, `measureElement`, `ResizeObserver` +- Helpers: `kittyFlags`, `kittyModifiers` + +See the full up-to-date surface in [Ink Compat Layer](../architecture/ink-compat.md#public-compatibility-surface). + +## Porting workflow (recommended) + +1. Baseline your current app behavior on real Ink. +2. Switch wiring (import swap or aliasing). +3. Add CI wiring guard (commands above). +4. Run smoke flows and compare critical screens/interaction loops. +5. Enable compat tracing only when debugging parity issues. +6. Roll out in stages (internal users -> beta channel -> full rollout). + +## Debug parity issues + +Start with lightweight trace mode: + +```bash +INK_COMPAT_TRACE=1 INK_COMPAT_TRACE_FILE=/tmp/ink-compat.trace.log node dist/cli.js +``` + +Useful debug env vars: + +| Env var | Purpose | +|---|---| +| `INK_COMPAT_TRACE=1` | Enable compat tracing | +| `INK_COMPAT_TRACE_FILE=/path/log` | Write traces to file | +| `INK_COMPAT_TRACE_DETAIL=1` | Include additional node/op details | +| `INK_COMPAT_TRACE_DETAIL_FULL=1` | Include full VNode/grid snapshots | +| `INK_COMPAT_TRACE_IO=1` | Include write/backpressure diagnostics | +| `INK_COMPAT_TRACE_RESIZE_VERBOSE=1` | Include resize timeline diagnostics | + +Full troubleshooting workflow: [Ink Compat Debugging Runbook](../dev/ink-compat-debugging.md). + +## Rollout checklist + +1. Install compat and required shims. +2. Switch imports or add alias rules. +3. Verify `ink` resolves to `@rezi-ui/ink-compat` in CI. +4. Validate key flows (startup, navigation, input, resize, exit). +5. Capture and triage parity diffs with traces when needed. +6. Lock dependency versions before wider release. + +## Related docs + +- [Ink Compat Layer](../architecture/ink-compat.md) +- [Ink Compat Debugging Runbook](../dev/ink-compat-debugging.md) +- [Ink to Rezi](ink-to-rezi.md) diff --git a/docs/migration/ink-to-rezi.md b/docs/migration/ink-to-rezi.md index 2293321e..7c2cc6b8 100644 --- a/docs/migration/ink-to-rezi.md +++ b/docs/migration/ink-to-rezi.md @@ -2,6 +2,8 @@ This guide maps common Ink mental models to Rezi and gives practical migration recipes. +If you want to keep the Ink component/hook model and only switch runtime backend first, use [Ink to Ink-Compat Migration](ink-to-ink-compat.md) instead. + ## Read this first Rezi is not a drop-in replacement for Ink. diff --git a/mkdocs.yml b/mkdocs.yml index bcc9b91b..dd45264f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -105,6 +105,7 @@ nav: - Examples: getting-started/examples.md - FAQ: getting-started/faq.md - Migration: + - Ink to Ink-Compat: migration/ink-to-ink-compat.md - Ink to Rezi: migration/ink-to-rezi.md - Beautiful Defaults: migration/beautiful-defaults.md - Guides: diff --git a/packages/ink-compat/README.md b/packages/ink-compat/README.md index 8fa67bc8..cce06b1a 100644 --- a/packages/ink-compat/README.md +++ b/packages/ink-compat/README.md @@ -92,7 +92,8 @@ Key behavior: - Input/focus/cursor are bridged through compat context/hooks. - Diagnostics and heavy instrumentation are env-gated. -For full architecture details, see `docs/architecture/ink-compat.md`. +For full architecture details, see `https://rezitui.dev/docs/architecture/ink-compat/`. +For a practical migration workflow, see `https://rezitui.dev/docs/migration/ink-to-ink-compat/`. ## Supported API surface @@ -176,5 +177,6 @@ Debugging runbook: ## Documentation -- Architecture and internals: `docs/architecture/ink-compat.md` -- Debugging and parity runbook: `docs/dev/ink-compat-debugging.md` +- Porting guide: `https://rezitui.dev/docs/migration/ink-to-ink-compat/` +- Architecture and internals: `https://rezitui.dev/docs/architecture/ink-compat/` +- Debugging and parity runbook: `https://rezitui.dev/docs/dev/ink-compat-debugging/` From 5ee0c54bd1eeadcd6c15776386551c8212c111fa Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Sat, 28 Feb 2026 07:45:08 +0400 Subject: [PATCH 27/29] fix(pr): address review feedback on harness and ink-compat --- packages/bench-harness/src/screen.ts | 19 ++++++-- packages/bench-runner/src/cli.ts | 2 +- .../testing/__tests__/testRenderer.test.ts | 1 + packages/core/src/testing/renderer.ts | 16 +++---- .../src/runtime/createInkRenderer.ts | 2 +- .../src/translation/propsToVNode.ts | 43 ++++++++++++++----- 6 files changed, 57 insertions(+), 26 deletions(-) diff --git a/packages/bench-harness/src/screen.ts b/packages/bench-harness/src/screen.ts index 798f718c..b01561ba 100644 --- a/packages/bench-harness/src/screen.ts +++ b/packages/bench-harness/src/screen.ts @@ -1,5 +1,5 @@ -import xtermHeadless from "@xterm/headless"; import { createHash } from "node:crypto"; +import xtermHeadless from "@xterm/headless"; export type ScreenSnapshot = Readonly<{ cols: number; @@ -14,6 +14,20 @@ export function createScreen(opts: Readonly<{ cols: number; rows: number }>): { resize: (cols: number, rows: number) => Promise; snapshot: () => ScreenSnapshot; } { + type HeadlessLine = { translateToString(trimRight?: boolean): string }; + type HeadlessTerminal = { + write(data: string, callback?: () => void): void; + resize(cols: number, rows: number): void; + buffer: { active: { getLine(row: number): HeadlessLine | undefined } }; + }; + type HeadlessTerminalCtor = new (opts: { + cols: number; + rows: number; + allowProposedApi: boolean; + convertEol: boolean; + scrollback: number; + }) => HeadlessTerminal; + const Terminal = (xtermHeadless as unknown as { Terminal?: unknown }).Terminal; if (typeof Terminal !== "function") { throw new Error("Unexpected @xterm/headless shape: missing Terminal export"); @@ -21,8 +35,7 @@ export function createScreen(opts: Readonly<{ cols: number; rows: number }>): { let cols = opts.cols; let rows = opts.rows; - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - const term = new (Terminal as any)({ + const term = new (Terminal as HeadlessTerminalCtor)({ cols, rows, allowProposedApi: true, diff --git a/packages/bench-runner/src/cli.ts b/packages/bench-runner/src/cli.ts index be45896b..5ab75c7b 100644 --- a/packages/bench-runner/src/cli.ts +++ b/packages/bench-runner/src/cli.ts @@ -307,7 +307,7 @@ async function main(): Promise { ? Array.from({ length: 40 }, (_, j) => ({ kind: "write" as const, atMs: 250 + j * 35, - data: "\\u001b[B", + data: "\u001b[B", })) : scenario === "resize-storm" ? [ diff --git a/packages/core/src/testing/__tests__/testRenderer.test.ts b/packages/core/src/testing/__tests__/testRenderer.test.ts index adf39f55..802e502d 100644 --- a/packages/core/src/testing/__tests__/testRenderer.test.ts +++ b/packages/core/src/testing/__tests__/testRenderer.test.ts @@ -128,6 +128,7 @@ describe("createTestRenderer", () => { }); assert.ok(visited > 0); assert.equal(result.toText().includes("Runtime Mode"), true); + assert.notEqual(result.findText("Runtime Mode"), null); assert.equal(result.findById("submit")?.kind, "button"); assert.equal(result.findAll("button").length, 1); }); diff --git a/packages/core/src/testing/renderer.ts b/packages/core/src/testing/renderer.ts index 4b9eb648..2ea8622e 100644 --- a/packages/core/src/testing/renderer.ts +++ b/packages/core/src/testing/renderer.ts @@ -227,21 +227,16 @@ function collectNodes(layoutTree: LayoutTree, mode: TestRendererMode): readonly props, id, path: mode === "runtime" ? EMPTY_PATH : Object.freeze(path.slice()), - ...(mode === "runtime" || node.vnode.kind !== "text" - ? {} - : { text: (node.vnode as Readonly<{ text: string }>).text }), + ...(node.vnode.kind === "text" + ? { text: (node.vnode as Readonly<{ text: string }>).text } + : {}), }; out.push(mode === "runtime" ? base : Object.freeze(base)); for (let i = 0; i < node.children.length; i++) { const child = node.children[i]; if (!child) continue; - walk( - child, - mode === "runtime" - ? EMPTY_PATH - : Object.freeze([...path, i]), - ); + walk(child, mode === "runtime" ? EMPTY_PATH : Object.freeze([...path, i])); } }; @@ -540,7 +535,8 @@ export function createTestRenderer(opts: TestRendererOptions = {}): TestRenderer const drawMs = Date.now() - drawStartedAt; const ops = builder.snapshotOps(); - let nodesCache: readonly TestRenderNode[] | null = mode === "test" ? collectNodes(layoutTree, mode) : null; + let nodesCache: readonly TestRenderNode[] | null = + mode === "test" ? collectNodes(layoutTree, mode) : null; const getNodes = (): readonly TestRenderNode[] => { if (nodesCache !== null) return nodesCache; nodesCache = collectNodes(layoutTree, mode); diff --git a/packages/ink-compat/src/runtime/createInkRenderer.ts b/packages/ink-compat/src/runtime/createInkRenderer.ts index 6d2313c7..d1504ce7 100644 --- a/packages/ink-compat/src/runtime/createInkRenderer.ts +++ b/packages/ink-compat/src/runtime/createInkRenderer.ts @@ -260,7 +260,7 @@ class RecordingDrawlistBuilder implements DrawlistBuilder { this._ops = this._prevOps; this._ops.length = 0; this._prevOps = out; - return out; + return out.slice(); } } diff --git a/packages/ink-compat/src/translation/propsToVNode.ts b/packages/ink-compat/src/translation/propsToVNode.ts index a1a506e2..8fc1327e 100644 --- a/packages/ink-compat/src/translation/propsToVNode.ts +++ b/packages/ink-compat/src/translation/propsToVNode.ts @@ -410,13 +410,17 @@ function translateNode(node: InkHostNode, context: TranslateContext): VNode | nu const savedParentMainDefinite = context.parentMainDefinite; const savedIsRoot = context.isRoot; const savedInStaticSubtree = context.inStaticSubtree; - const metaBeforeMask = toMetaMask(context.meta); + const parentMeta = context.meta; + const localMeta = createMeta(); + context.meta = localMeta; try { if (!translationCacheEnabled) { translationPerfStats.cacheMisses += 1; translationPerfStats.translatedNodes += 1; - return translateNodeUncached(node, context); + const translated = translateNodeUncached(node, context); + applyMetaMask(parentMeta, toMetaMask(localMeta)); + return translated; } const key = contextKey(context); @@ -425,7 +429,7 @@ function translateNode(node: InkHostNode, context: TranslateContext): VNode | nu if (cached) { if (cached.revision === node.__inkRevision) { translationPerfStats.cacheHits += 1; - applyMetaMask(context.meta, cached.metaMask); + applyMetaMask(parentMeta, cached.metaMask); return cached.vnode; } translationPerfStats.cacheStaleMisses += 1; @@ -436,8 +440,8 @@ function translateNode(node: InkHostNode, context: TranslateContext): VNode | nu translationPerfStats.cacheMisses += 1; translationPerfStats.translatedNodes += 1; const translated = translateNodeUncached(node, context); - const metaAfterMask = toMetaMask(context.meta); - const metaMask = metaAfterMask & ~metaBeforeMask; + const metaMask = toMetaMask(localMeta); + applyMetaMask(parentMeta, metaMask); if (!perNodeCache) { const nextCache = new Map(); @@ -457,6 +461,7 @@ function translateNode(node: InkHostNode, context: TranslateContext): VNode | nu return translated; } finally { + context.meta = parentMeta; context.parentDirection = savedParentDirection; context.parentMainDefinite = savedParentMainDefinite; context.isRoot = savedIsRoot; @@ -1033,7 +1038,7 @@ function translateText(node: InkHostNode): VNode { if (accessibilityLabel) { rootProps["accessibilityLabel"] = accessibilityLabel; } - return translateMultilineRichText(spans, rootProps); + return translateMultilineRichText(spans, rootProps, textProps); } if (isSingleSpan) { @@ -1049,6 +1054,7 @@ function translateText(node: InkHostNode): VNode { function translateMultilineRichText( spans: readonly TextSpan[], rootProps?: Record, + textProps?: Record, ): VNode { const lines: TextSpan[][] = [[]]; @@ -1066,15 +1072,30 @@ function translateMultilineRichText( } } + const lineTextProps = + textProps == null + ? undefined + : Object.fromEntries( + Object.entries(textProps).filter( + ([key]) => key !== "__inkHostNode" && key !== "accessibilityLabel", + ), + ); + const hasLineTextProps = lineTextProps != null && Object.keys(lineTextProps).length > 0; + const lineNodes = lines.map((line) => { - if (line.length === 0) return ui.text(""); + if (line.length === 0) { + return hasLineTextProps ? ui.text("", lineTextProps) : ui.text(""); + } if (line.length === 1) { const only = line[0]!; - return Object.keys(only.style).length > 0 - ? ui.text(only.text, { style: only.style }) - : ui.text(only.text); + const lineProps: Record = hasLineTextProps ? { ...lineTextProps } : {}; + if (Object.keys(only.style).length > 0) { + lineProps["style"] = only.style; + } + return Object.keys(lineProps).length > 0 ? ui.text(only.text, lineProps) : ui.text(only.text); } - return ui.richText(line.map((span) => ({ text: span.text, style: span.style }))); + const richLine = line.map((span) => ({ text: span.text, style: span.style })); + return hasLineTextProps ? ui.richText(richLine, lineTextProps) : ui.richText(richLine); }); const hasRootProps = rootProps != null && Object.keys(rootProps).length > 0; From a8208020f8a9290865b7f7f5555a000800d3a246 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Sat, 28 Feb 2026 07:46:16 +0400 Subject: [PATCH 28/29] docs(ink-compat): align debug links and parity verification note --- docs/architecture/ink-compat.md | 2 ++ packages/ink-compat/README.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/architecture/ink-compat.md b/docs/architecture/ink-compat.md index 8a98e1d7..f847c5bd 100644 --- a/docs/architecture/ink-compat.md +++ b/docs/architecture/ink-compat.md @@ -101,6 +101,8 @@ node -e "const fs=require('node:fs'); const path=require('node:path'); const pkg 3. For bundled CLIs, rebuild the bundle after aliasing and validate expected compat-only markers in generated output. +4. For rendering/layout/theme parity checks, run a live PTY with `REZI_FRAME_AUDIT=1` and generate evidence with `node scripts/frame-audit-report.mjs`. + ## Public compatibility surface ### Components diff --git a/packages/ink-compat/README.md b/packages/ink-compat/README.md index cce06b1a..f3c1d295 100644 --- a/packages/ink-compat/README.md +++ b/packages/ink-compat/README.md @@ -167,7 +167,7 @@ Trace output is env-gated: Debugging runbook: -- `docs/dev/ink-compat-debugging.md` +- `https://rezitui.dev/docs/dev/ink-compat-debugging/` ## Known boundaries From ff58d10db63a714e26abb70dfb75e07db79babd9 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Sat, 28 Feb 2026 07:54:22 +0400 Subject: [PATCH 29/29] fix(lint): resolve ci biome diagnostics --- packages/bench-app/src/types/ink-module.d.ts | 1 - packages/bench-app/tsconfig.json | 1 - packages/bench-harness/src/index.ts | 1 - packages/bench-harness/src/procSampler.ts | 6 +++-- packages/bench-harness/src/ptyRun.ts | 23 ++++++++++-------- packages/bench-harness/src/screenDiff.ts | 1 - packages/bench-harness/tsconfig.json | 1 - packages/bench-runner/tsconfig.json | 1 - .../ink-compat/src/reconciler/hostConfig.ts | 12 +++------- packages/ink-compat/src/runtime/render.ts | 23 +++++++++--------- .../ink-compat-bench/prepare-ink-compat.mjs | 1 - scripts/ink-compat-bench/prepare-real-ink.mjs | 1 - .../ink-compat-bench/summarize-cpuprofile.mjs | 24 +++++++++++-------- 13 files changed, 45 insertions(+), 51 deletions(-) diff --git a/packages/bench-app/src/types/ink-module.d.ts b/packages/bench-app/src/types/ink-module.d.ts index 56f04562..a076de13 100644 --- a/packages/bench-app/src/types/ink-module.d.ts +++ b/packages/bench-app/src/types/ink-module.d.ts @@ -1,4 +1,3 @@ declare module "ink" { export * from "@jrichman/ink"; } - diff --git a/packages/bench-app/tsconfig.json b/packages/bench-app/tsconfig.json index 36e53478..28558b3b 100644 --- a/packages/bench-app/tsconfig.json +++ b/packages/bench-app/tsconfig.json @@ -9,4 +9,3 @@ }, "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"] } - diff --git a/packages/bench-harness/src/index.ts b/packages/bench-harness/src/index.ts index eb429f4b..ddb92123 100644 --- a/packages/bench-harness/src/index.ts +++ b/packages/bench-harness/src/index.ts @@ -6,4 +6,3 @@ export type { ScreenDiff } from "./screenDiff.js"; export { diffScreens } from "./screenDiff.js"; export type { ProcSample, ProcSamplerOptions } from "./procSampler.js"; export { sampleProcUntilExit } from "./procSampler.js"; - diff --git a/packages/bench-harness/src/procSampler.ts b/packages/bench-harness/src/procSampler.ts index 70298638..7691f1c3 100644 --- a/packages/bench-harness/src/procSampler.ts +++ b/packages/bench-harness/src/procSampler.ts @@ -1,5 +1,5 @@ -import { readFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; import { setTimeout as delay } from "node:timers/promises"; export type ProcSample = Readonly<{ @@ -25,7 +25,9 @@ const PAGE_SIZE_BYTES: number = (() => { } })(); -function readProcStat(pid: number): { user: number; system: number; rssBytes: number | null } | null { +function readProcStat( + pid: number, +): { user: number; system: number; rssBytes: number | null } | null { try { const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); const end = stat.lastIndexOf(")"); diff --git a/packages/bench-harness/src/ptyRun.ts b/packages/bench-harness/src/ptyRun.ts index 34a26fb4..0d9c4baa 100644 --- a/packages/bench-harness/src/ptyRun.ts +++ b/packages/bench-harness/src/ptyRun.ts @@ -3,7 +3,7 @@ import path from "node:path"; import pty from "node-pty"; -import { sampleProcUntilExit, type ProcSample } from "./procSampler.js"; +import { type ProcSample, sampleProcUntilExit } from "./procSampler.js"; import { createScreen } from "./screen.js"; export type PtyRunOptions = Readonly<{ @@ -95,15 +95,18 @@ export async function runInPty(opts: PtyRunOptions): Promise { const script = opts.inputScript ?? []; for (const step of script) { - setTimeout(() => { - try { - if (step.kind === "write") term.write(step.data); - else { - term.resize(step.cols, step.rows); - void screen.resize(step.cols, step.rows); - } - } catch {} - }, Math.max(0, step.atMs)); + setTimeout( + () => { + try { + if (step.kind === "write") term.write(step.data); + else { + term.resize(step.cols, step.rows); + void screen.resize(step.cols, step.rows); + } + } catch {} + }, + Math.max(0, step.atMs), + ); } await new Promise((resolve) => { diff --git a/packages/bench-harness/src/screenDiff.ts b/packages/bench-harness/src/screenDiff.ts index 905e00b9..5a500fe2 100644 --- a/packages/bench-harness/src/screenDiff.ts +++ b/packages/bench-harness/src/screenDiff.ts @@ -25,4 +25,3 @@ export function diffScreens(a: ScreenSnapshot, b: ScreenSnapshot): ScreenDiff { } return { equal: true, firstDiffRow: null, aLine: null, bLine: null }; } - diff --git a/packages/bench-harness/tsconfig.json b/packages/bench-harness/tsconfig.json index 5d737447..f1c330c2 100644 --- a/packages/bench-harness/tsconfig.json +++ b/packages/bench-harness/tsconfig.json @@ -8,4 +8,3 @@ }, "include": ["src/**/*.ts"] } - diff --git a/packages/bench-runner/tsconfig.json b/packages/bench-runner/tsconfig.json index 5d737447..f1c330c2 100644 --- a/packages/bench-runner/tsconfig.json +++ b/packages/bench-runner/tsconfig.json @@ -8,4 +8,3 @@ }, "include": ["src/**/*.ts"] } - diff --git a/packages/ink-compat/src/reconciler/hostConfig.ts b/packages/ink-compat/src/reconciler/hostConfig.ts index ade4f8f6..109a9c60 100644 --- a/packages/ink-compat/src/reconciler/hostConfig.ts +++ b/packages/ink-compat/src/reconciler/hostConfig.ts @@ -24,9 +24,7 @@ function sanitizeProps(props: unknown): Record { const source = props as Record; const out: Record = {}; - const keys = Object.keys(source); - for (let index = 0; index < keys.length; index += 1) { - const key = keys[index]!; + for (const key of Object.keys(source)) { if (key === "children" || key === "key" || key === "ref") continue; out[key] = source[key]; } @@ -126,18 +124,14 @@ export const hostConfig = { const oldObj = oldProps as Record; const newObj = newProps as Record; let newCount = 0; - const newKeys = Object.keys(newObj); - for (let index = 0; index < newKeys.length; index += 1) { - const key = newKeys[index]!; + for (const key of Object.keys(newObj)) { if (key === "children" || key === "key" || key === "ref") continue; newCount += 1; if (oldObj[key] !== newObj[key]) return true; } let oldCount = 0; - const oldKeys = Object.keys(oldObj); - for (let index = 0; index < oldKeys.length; index += 1) { - const key = oldKeys[index]!; + for (const key of Object.keys(oldObj)) { if (key === "children" || key === "key" || key === "ref") continue; oldCount += 1; } diff --git a/packages/ink-compat/src/runtime/render.ts b/packages/ink-compat/src/runtime/render.ts index 1ac56cdd..3414e653 100644 --- a/packages/ink-compat/src/runtime/render.ts +++ b/packages/ink-compat/src/runtime/render.ts @@ -1988,8 +1988,8 @@ function readNodeMainAxis(kind: unknown): FlexMainAxis { function hasPercentMarkers(vnode: VNode, markerCache?: WeakMap): boolean { if (typeof vnode !== "object" || vnode === null) return false; const vnodeObject = vnode as object; - if (markerCache && markerCache.has(vnodeObject)) { - return markerCache.get(vnodeObject) === true; + if (markerCache?.has(vnodeObject)) { + return markerCache?.get(vnodeObject) === true; } const candidate = vnode as { props?: unknown; children?: unknown }; const props = @@ -2182,7 +2182,10 @@ function assignHostLayouts( }); } -function createThrottle(fn: () => void, throttleMs: number): Readonly<{ +function createThrottle( + fn: () => void, + throttleMs: number, +): Readonly<{ call: () => void; cancel: () => void; }> { @@ -2933,10 +2936,7 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions if (timePhases) coreRenderMs += performance.now() - renderStartedAt; const assignLayoutsStartedAt = timePhases ? performance.now() : 0; - assignHostLayouts( - bridge.rootNode, - result.forEachLayoutNode, - ); + assignHostLayouts(bridge.rootNode, result.forEachLayoutNode); if (timePhases) assignLayoutsMs += performance.now() - assignLayoutsStartedAt; if (hasDynamicPercentMarkers) { // Percent sizing is resolved against parent layout. On the first pass we only have @@ -2976,10 +2976,7 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions if (timePhases) coreRenderMs += performance.now() - secondRenderStartedAt; const secondAssignStartedAt = timePhases ? performance.now() : 0; - assignHostLayouts( - bridge.rootNode, - result.forEachLayoutNode, - ); + assignHostLayouts(bridge.rootNode, result.forEachLayoutNode); if (timePhases) assignLayoutsMs += performance.now() - secondAssignStartedAt; } } @@ -3322,7 +3319,9 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions }; const renderThrottle = - unthrottledRender || renderIntervalMs <= 0 ? null : createThrottle(flushPendingRender, renderIntervalMs); + unthrottledRender || renderIntervalMs <= 0 + ? null + : createThrottle(flushPendingRender, renderIntervalMs); const scheduleRender = (): void => { pendingRender = true; diff --git a/scripts/ink-compat-bench/prepare-ink-compat.mjs b/scripts/ink-compat-bench/prepare-ink-compat.mjs index 71b4e2bd..4bd9d528 100644 --- a/scripts/ink-compat-bench/prepare-ink-compat.mjs +++ b/scripts/ink-compat-bench/prepare-ink-compat.mjs @@ -15,4 +15,3 @@ const compatPath = path.join(repoRoot, "packages/ink-compat"); rmSync(inkLinkPath, { force: true }); symlinkSync(compatPath, inkLinkPath, "junction"); console.log(`[ink-compat-bench] linked packages/bench-app/node_modules/ink -> ${compatPath}`); - diff --git a/scripts/ink-compat-bench/prepare-real-ink.mjs b/scripts/ink-compat-bench/prepare-real-ink.mjs index ff30523c..d461e7a3 100644 --- a/scripts/ink-compat-bench/prepare-real-ink.mjs +++ b/scripts/ink-compat-bench/prepare-real-ink.mjs @@ -15,4 +15,3 @@ const realInkPath = path.join(repoRoot, "node_modules/@jrichman/ink"); rmSync(inkLinkPath, { force: true }); symlinkSync(realInkPath, inkLinkPath, "junction"); console.log(`[ink-compat-bench] linked packages/bench-app/node_modules/ink -> ${realInkPath}`); - diff --git a/scripts/ink-compat-bench/summarize-cpuprofile.mjs b/scripts/ink-compat-bench/summarize-cpuprofile.mjs index 5a3095f6..89345834 100644 --- a/scripts/ink-compat-bench/summarize-cpuprofile.mjs +++ b/scripts/ink-compat-bench/summarize-cpuprofile.mjs @@ -6,6 +6,7 @@ function readArg(name, fallback) { const idx = process.argv.indexOf(`--${name}`); if (idx === -1) return fallback; const v = process.argv[idx + 1]; + if (v == null || v.startsWith("--")) return fallback; return v ?? fallback; } @@ -15,7 +16,7 @@ function hasFlag(name) { function usage() { process.stderr.write( - [ + `${[ "Usage:", " node scripts/ink-compat-bench/summarize-cpuprofile.mjs [--top N] [--filter STR] [--stacks N] [--active] [--json]", "", @@ -23,27 +24,30 @@ function usage() { " - Self time is attributed to the sampled leaf frame id.", " - `--filter` matches callFrame.url or functionName substrings.", " - `--active` excludes `(idle)` samples when computing percentages.", - ].join("\n") + "\n", + ].join("\n")}\n`, ); } function toDisplayUrl(url) { if (!url) return "unknown"; + let normalized = url; if (url.startsWith("file://")) { try { const fsPath = new URL(url).pathname; - url = fsPath; + normalized = fsPath; } catch {} } - const idx = url.indexOf("/packages/"); - if (idx >= 0) return url.slice(idx + 1); - const parts = url.split(/[\\/]/).filter(Boolean); + const idx = normalized.indexOf("/packages/"); + if (idx >= 0) return normalized.slice(idx + 1); + const parts = normalized.split(/[\\/]/).filter(Boolean); return parts.slice(Math.max(0, parts.length - 3)).join("/"); } function formatFrame(node) { const cf = node.callFrame ?? {}; - const fn = (cf.functionName && cf.functionName.length > 0 ? cf.functionName : "(anonymous)") || "(unknown)"; + const fn = + (cf.functionName && cf.functionName.length > 0 ? cf.functionName : "(anonymous)") || + "(unknown)"; const url = toDisplayUrl(cf.url ?? ""); const line = typeof cf.lineNumber === "number" ? cf.lineNumber + 1 : null; const col = typeof cf.columnNumber === "number" ? cf.columnNumber + 1 : null; @@ -128,7 +132,7 @@ async function main() { const { fn, loc } = formatFrame(node); if (activeOnly && fn === "(idle)") continue; if (filter) { - const hay = `${fn} ${loc} ${(node.callFrame?.url ?? "")}`.toLowerCase(); + const hay = `${fn} ${loc} ${node.callFrame?.url ?? ""}`.toLowerCase(); if (!hay.includes(filter.toLowerCase())) continue; } entries.push({ id, selfUs, fn, loc }); @@ -152,13 +156,13 @@ async function main() { activeOnly, topSelf: top.map((e) => ({ selfMs: Math.round(ms(e.selfUs) * 1000) / 1000, - selfPct: pctDenomUs > 0 ? Math.round(((e.selfUs / pctDenomUs) * 100) * 10) / 10 : null, + selfPct: pctDenomUs > 0 ? Math.round((e.selfUs / pctDenomUs) * 100 * 10) / 10 : null, fn: e.fn, loc: e.loc, })), topStacks: stackEntries.map((e) => ({ selfMs: Math.round(ms(e.selfUs) * 1000) / 1000, - selfPct: pctDenomUs > 0 ? Math.round(((e.selfUs / pctDenomUs) * 100) * 10) / 10 : null, + selfPct: pctDenomUs > 0 ? Math.round((e.selfUs / pctDenomUs) * 100 * 10) / 10 : null, fn: e.fn, loc: e.loc, stack: e.stack,