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/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/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 diff --git a/README.md b/README.md index 2495d019..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 @@ -115,6 +115,44 @@ 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` +- Porting and architecture docs: + - `docs/migration/ink-to-ink-compat.md` + - `docs/architecture/ink-compat.md` + - `docs/dev/ink-compat-debugging.md` + ## Quick Start Get running in under a minute: diff --git a/docs/architecture/ink-compat.md b/docs/architecture/ink-compat.md index db9cd7d8..f847c5bd 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. @@ -81,6 +83,26 @@ 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. + +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/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/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..73f35eb3 --- /dev/null +++ b/packages/bench-app/src/entry.tsx @@ -0,0 +1,726 @@ +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 { 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 = + | "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; + 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; + 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; + translatedNodes: number | null; + translationCacheHits: number | null; + translationCacheMisses: number | null; + translationCacheEmptyMisses: number | null; + translationCacheStaleMisses: number | null; + parseAnsiFastPathHits: number | null; + 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"); + + 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 => { + 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(); + const ret = originalWrite(chunk, encoding, cb); + 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 (isControlMsg(parsed)) onMsg(parsed); + } 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(), + }; +} + +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 ((next[lastIndex] ?? "").length > 120) { + next.push(""); + } + return next; + }); + markUpdateRequested(props.stateRef); + }, + }); + }, [props]); + + const visible = scrollLock ? lines.slice(-8) : lines.slice(0, 8); + return ( + + + BENCH_READY streaming-chat tokens={tokenCount} scrollLock={String(scrollLock)} + + + {visible.map((line, i) => { + if (line.startsWith("```")) { + return ( + + {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 ( + + {clean} + + ); + })} + + 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); + 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({ + 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 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 + + + + ); +} + +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 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, + 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?: (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; + + 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 - renderTimeMsSafe; + scheduleWaitMs = Math.max(0, frameStartApprox - state.firstUpdateRequestedAtMs); + stateRef.current = { ...stateRef.current, firstUpdateRequestedAtMs: null }; + } + + const compat = compatFrameRef.current; + compatFrameRef.current = null; + + const frameNumber = frameCountRef.current + 1; + frameCountRef.current = frameNumber; + const frameMetric: FrameMetric = { + frame: frameNumber, + tsMs, + renderTimeMs: renderTimeMsSafe, + layoutTimeMs: layoutMsSafe, + renderTotalMs: renderTimeMsSafe + (layoutMsSafe ?? 0), + 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, + 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, + }; + if (streamFrames) { + frameWriteBufferRef.current.push(`${JSON.stringify(frameMetric)}\n`); + if (frameWriteBufferRef.current.length >= 64) { + flushFrameWriteBuffer(); + } + } else { + framesRef.current.push(frameMetric); + } + }; + + useEffect(() => { + return () => { + ( + 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 () => { + if (streamFrames) { + flushFrameWriteBuffer(); + return; + } + const lines = framesRef.current.map((x) => JSON.stringify(x)).join("\n"); + writeFileSync(framesPath, lines.length > 0 ? `${lines}\n` : ""); + }; + }, [flushFrameWriteBuffer, framesPath, props.outDir, streamFrames]); + + 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: (metrics) => { + const hook = (globalThis as unknown as { __BENCH_ON_RENDER?: (m: unknown) => void }) + .__BENCH_ON_RENDER; + hook?.(metrics); + }, + }, + ); +} + +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..a076de13 --- /dev/null +++ b/packages/bench-app/src/types/ink-module.d.ts @@ -0,0 +1,3 @@ +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..28558b3b --- /dev/null +++ b/packages/bench-app/tsconfig.json @@ -0,0 +1,11 @@ +{ + "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..ddb92123 --- /dev/null +++ b/packages/bench-harness/src/index.ts @@ -0,0 +1,8 @@ +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..7691f1c3 --- /dev/null +++ b/packages/bench-harness/src/procSampler.ts @@ -0,0 +1,68 @@ +import { execFileSync } from "node:child_process"; +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; +}>; + +const PAGE_SIZE_BYTES: number = (() => { + try { + 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 4096; + } +})(); + +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).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, + rssBytes: Number.isFinite(rssPages) && rssPages >= 0 ? rssPages * PAGE_SIZE_BYTES : null, + }; + } catch { + return null; + } +} + +export async function sampleProcUntilExit( + opts: ProcSamplerOptions, + isExited: () => boolean, +): Promise { + const samples: ProcSample[] = []; + while (!isExited()) { + const now = performance.now(); + const stat = readProcStat(opts.pid); + samples.push({ + ts: now, + rssBytes: stat?.rssBytes ?? null, + cpuUserTicks: stat?.user ?? null, + cpuSystemTicks: stat?.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..0d9c4baa --- /dev/null +++ b/packages/bench-harness/src/ptyRun.ts @@ -0,0 +1,141 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +import pty from "node-pty"; + +import { type ProcSample, sampleProcUntilExit } 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); + void screen.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..b01561ba --- /dev/null +++ b/packages/bench-harness/src/screen.ts @@ -0,0 +1,82 @@ +import { createHash } from "node:crypto"; +import xtermHeadless from "@xterm/headless"; + +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; + 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"); + } + let cols = opts.cols; + let rows = opts.rows; + + const term = new (Terminal as HeadlessTerminalCtor)({ + cols, + 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 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 < rows; r++) { + const line = term.buffer.active.getLine(r); + const text = line?.translateToString(false) ?? ""; + lines.push(text.padEnd(cols, " ").slice(0, cols)); + } + const hash = createHash("sha256").update(lines.join("\n")).digest("hex"); + return { cols, rows, lines, hash }; + }; + + return { write, flush, resize, snapshot }; +} diff --git a/packages/bench-harness/src/screenDiff.ts b/packages/bench-harness/src/screenDiff.ts new file mode 100644 index 00000000..5a500fe2 --- /dev/null +++ b/packages/bench-harness/src/screenDiff.ts @@ -0,0 +1,27 @@ +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..f1c330c2 --- /dev/null +++ b/packages/bench-harness/tsconfig.json @@ -0,0 +1,10 @@ +{ + "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..5ab75c7b --- /dev/null +++ b/packages/bench-runner/src/cli.ts @@ -0,0 +1,426 @@ +import { execFile } from "node:child_process"; +import { mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +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 { promisify } from "node:util"; + +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; +} + +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); + 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?.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; +} + +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; + 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 }); + const longRunSeconds = readPositiveNumberEnv("BENCH_LONG_RUN_SECONDS"); + const longRunMultiplier = readPositiveNumberEnv("BENCH_LONG_RUN_MULTIPLIER"); + + if (scenario === "streaming-chat") { + 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)}` }); + await delay(intervalMs); + } + control.sendLine({ type: "done" }); + return; + } + + const tickMs = scenario === "large-list-scroll" ? 33 : scenario === "resize-storm" ? 25 : 16; + let tickCount = + scenario === "large-list-scroll" + ? 120 + : scenario === "dashboard-grid" + ? 140 + : scenario === "style-churn" + ? 180 + : 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; + 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 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"); + const preloadPath = path.join(repoRoot, "scripts/ink-compat-bench/preload.mjs"); + 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", + "--import", + preloadPath, + ...(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_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 = + 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 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, + 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, + 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, + }; + + 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..d116bfba --- /dev/null +++ b/packages/bench-runner/src/verify.ts @@ -0,0 +1,202 @@ +import { mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +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 { 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 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`, + ); + const controlServer = await openControlServer(controlSocket); + + const runPromise = runInPty({ + cwd: repoRoot, + command: process.execPath, + args: ["--no-warnings", "--import", preloadPath, 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..f1c330c2 --- /dev/null +++ b/packages/bench-runner/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": "./src", + "outDir": "./dist", + "lib": ["ES2022"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0ad76087..535175ca 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1087,7 +1087,9 @@ export { export type { TestEventBuilderOptions, TestEventInput, + TestRenderLayoutVisitor, TestRenderNode, + TestRendererMode, TestRenderOptions, TestRenderResult, TestRenderTraceEvent, diff --git a/packages/core/src/layout/engine/layoutEngine.ts b/packages/core/src/layout/engine/layoutEngine.ts index a6cfbe6d..219ca20f 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,74 @@ function popLayoutCache(): void { layoutCacheStack.length > 0 ? (layoutCacheStack[layoutCacheStack.length - 1] ?? null) : null; } -function layoutCacheKey( +function forcedDimensionKey(value: number | null): number { + if (value === null) return NULL_FORCED_DIMENSION; + if (value < 0) { + throw new RangeError("layout: forced dimensions must be >= 0"); + } + return value; +} + +function getLayoutCacheHit( + axisMap: LayoutAxisCache, + maxW: number, + maxH: number, + forcedW: number | null, + forcedH: number | null, + x: number, + y: number, +): 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, -): string { - return `${String(maxW)}:${String(maxH)}:${forcedW === null ? "n" : String(forcedW)}:${ - forcedH === null ? "n" : String(forcedH) - }:${String(x)}:${String(y)}`; + 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 +442,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 +760,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/__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/renderPackets.ts b/packages/core/src/renderer/renderToDrawlist/renderPackets.ts index ec9eb32f..a08cf754 100644 --- a/packages/core/src/renderer/renderToDrawlist/renderPackets.ts +++ b/packages/core/src/renderer/renderToDrawlist/renderPackets.ts @@ -259,7 +259,6 @@ function hashTextProps(hash: number, props: Readonly>): dim?: unknown; textOverflow?: unknown; }>; - const style = textProps.style; const maxWidth = textProps.maxWidth; const wrap = textProps.wrap; @@ -378,8 +377,8 @@ export function computeRenderPacketKey( } 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; @@ -392,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), }); } @@ -428,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 { @@ -444,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 { @@ -522,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), @@ -530,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 { @@ -543,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), @@ -555,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 { @@ -584,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) { @@ -619,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, @@ -634,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 a021fbcb..66653526 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; 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/core/src/testing/__tests__/testRenderer.test.ts b/packages/core/src/testing/__tests__/testRenderer.test.ts index 9aa7b92e..802e502d 100644 --- a/packages/core/src/testing/__tests__/testRenderer.test.ts +++ b/packages/core/src/testing/__tests__/testRenderer.test.ts @@ -115,4 +115,21 @@ 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" })]), + ); + + let visited = 0; + result.forEachLayoutNode(() => { + visited += 1; + }); + 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/index.ts b/packages/core/src/testing/index.ts index e69c0e5b..b7ee6807 100644 --- a/packages/core/src/testing/index.ts +++ b/packages/core/src/testing/index.ts @@ -15,6 +15,8 @@ export type { export { createTestRenderer } from "./renderer.js"; export type { TestRenderNode, + TestRenderLayoutVisitor, + TestRendererMode, TestRenderOptions, TestRenderResult, TestRenderTraceEvent, diff --git a/packages/core/src/testing/renderer.ts b/packages/core/src/testing/renderer.ts index d9d6324f..2ea8622e 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; }>; @@ -80,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[]; @@ -115,8 +121,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 +213,53 @@ 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()), + path: mode === "runtime" ? EMPTY_PATH : Object.freeze(path.slice()), ...(node.vnode.kind === "text" ? { text: (node.vnode as Readonly<{ text: string }>).text } : {}), - }); - out.push(base); + }; + 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 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 { @@ -296,22 +325,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 +484,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 +499,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 +534,31 @@ export function createTestRenderer(opts: TestRendererOptions = {}): TestRenderer }); const drawMs = Date.now() - drawStartedAt; - const textStartedAt = Date.now(); - const nodes = collectNodes(layoutTree); const ops = builder.snapshotOps(); - const screenText = opsToText(ops, viewport); + 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; + 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 nodes = getNodes(); const opSummary = summarizeOps(ops); const nodeSummary = summarizeNodes(nodes); - const textSummary = summarizeText(screenText); + const textSummary = summarizeText(screenTextForTrace); trace( Object.freeze({ renderId, @@ -549,21 +584,30 @@ 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 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), - toText: () => screenText, + 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); }; const reset = (): void => { diff --git a/packages/ink-compat/README.md b/packages/ink-compat/README.md index 0d063cd3..f3c1d295 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,69 @@ 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 `https://rezitui.dev/docs/architecture/ink-compat/`. +For a practical migration workflow, see `https://rezitui.dev/docs/migration/ink-to-ink-compat/`. -Components: +## Supported API surface + +### Components - `Box` - `Text` @@ -35,7 +106,7 @@ Components: - `Static` - `Transform` -Hooks: +### Hooks - `useApp` - `useInput` @@ -47,7 +118,7 @@ Hooks: - `useIsScreenReaderEnabled` - `useCursor` -Runtime APIs: +### Runtime APIs - `render` - `renderToString` @@ -57,25 +128,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 +153,30 @@ 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` +- `https://rezitui.dev/docs/dev/ink-compat-debugging/` ## 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 + +- 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/` 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..109a9c60 100644 --- a/packages/ink-compat/src/reconciler/hostConfig.ts +++ b/packages/ink-compat/src/reconciler/hostConfig.ts @@ -22,10 +22,11 @@ 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)) { + for (const key of Object.keys(source)) { if (key === "children" || key === "key" || key === "ref") continue; - out[key] = value; + out[key] = source[key]; } return out; } @@ -115,28 +116,28 @@ 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; 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) { - if (oldObj[key] !== newObj[key]) { - return true; - } + let newCount = 0; + for (const key of Object.keys(newObj)) { + if (key === "children" || key === "key" || key === "ref") continue; + newCount += 1; + if (oldObj[key] !== newObj[key]) return true; } - return false; + let oldCount = 0; + for (const key of Object.keys(oldObj)) { + if (key === "children" || key === "key" || key === "ref") continue; + oldCount += 1; + } + + if (oldCount !== newCount) return true; + 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/createInkRenderer.ts b/packages/ink-compat/src/runtime/createInkRenderer.ts index f053875e..d1504ce7 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.slice(); } } @@ -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 c5a08df1..3414e653 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"; @@ -20,6 +21,9 @@ 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"; +const BENCH_DETAIL_ENABLED = process.env["BENCH_DETAIL"] === "1"; + export interface KittyKeyboardOptions { mode?: "auto" | "enabled" | "disabled"; flags?: readonly KittyFlagName[]; @@ -1038,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 { @@ -1386,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); @@ -1759,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; @@ -1840,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]!; @@ -1864,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(); @@ -1908,6 +1931,7 @@ interface PercentResolveContext { parentSize: ViewportSize; parentMainAxis: FlexMainAxis; deps?: PercentResolveDeps; + markerCache?: WeakMap; } interface PercentParentDep { @@ -1961,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?.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; } @@ -1991,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; @@ -2085,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), @@ -2099,18 +2141,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; @@ -2125,7 +2167,7 @@ function assignHostLayouts( typeof h !== "number" || !Number.isFinite(h) ) { - continue; + return; } writeCurrentLayout( hostNode, @@ -2137,7 +2179,83 @@ function assignHostLayouts( }, generation, ); + }); +} + +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 { @@ -2184,7 +2302,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; @@ -2274,6 +2392,7 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions let viewport = readViewportSize(stdout, fallbackStdout); const renderer = createTestRenderer({ viewport, + mode: traceEnabled ? "test" : "runtime", ...(traceEnabled ? { traceDetail: traceDetailFull, @@ -2304,6 +2423,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 = ""; @@ -2318,10 +2459,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; @@ -2333,7 +2474,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 => {}; @@ -2695,6 +2836,7 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions staticResult.ops as readonly RenderOp[], viewport, staticColorSupport, + getOrResizeAnsiGrid(viewport.cols, viewport.rows), ); const staticTrimmed = trimAnsiToNonBlankBlock(staticAnsi); @@ -2711,6 +2853,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; @@ -2718,7 +2869,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; @@ -2726,10 +2877,20 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions viewport = nextViewport; } - const translationStartedAt = phaseProfile ? performance.now() : 0; + 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 (phaseProfile) translationMs = performance.now() - translationStartedAt; + 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 @@ -2748,7 +2909,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 }; @@ -2765,24 +2926,18 @@ 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; - - const assignLayoutsStartedAt = 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() - assignLayoutsStartedAt; + if (timePhases) coreRenderMs += performance.now() - renderStartedAt; + + const assignLayoutsStartedAt = timePhases ? performance.now() : 0; + 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 // the previous generation's layouts, so we run a second render only when a percent @@ -2806,7 +2961,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", @@ -2814,42 +2969,34 @@ 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; - - 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; + if (timePhases) coreRenderMs += performance.now() - secondRenderStartedAt; + + const secondAssignStartedAt = timePhases ? performance.now() : 0; + assignHostLayouts(bridge.rootNode, result.forEachLayoutNode); + 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; 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 (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 @@ -2860,13 +3007,18 @@ 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; + } = 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. // In non-alternate-buffer mode the grid is content-sized so the @@ -3107,6 +3259,37 @@ 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, + ...(translationStats + ? { + translatedNodes: translationStats.translatedNodes, + translationCacheHits: translationStats.cacheHits, + translationCacheMisses: translationStats.cacheMisses, + translationCacheEmptyMisses: translationStats.cacheEmptyMisses, + translationCacheStaleMisses: translationStats.cacheStaleMisses, + parseAnsiFastPathHits: translationStats.parseAnsiFastPathHits, + parseAnsiFallbackPathHits: translationStats.parseAnsiFallbackPathHits, + } + : {}), + }); + } + } writeOutput({ output, staticOutput }); options.onRender?.({ renderTime, @@ -3127,47 +3310,46 @@ 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 = Date.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 = Date.now(); - renderFrame(nextForce); + if (renderThrottle) { + renderThrottle.call(); return; } + flushPendingRender(); + }; - if (throttledRenderTimer !== undefined) return; - const elapsed = Date.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 = () => { - const nextCommitSignature = rootChildRevisionSignature(bridge.rootNode); - if (nextCommitSignature === lastCommitSignature) { + if (!rootChildRevisionsChanged(bridge.rootNode, lastCommitRevisions)) { return; } - lastCommitSignature = nextCommitSignature; capturePendingStaticOutput(); - scheduleRender(false); + scheduleRender(); }; let currentElement = element; @@ -3223,7 +3405,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, @@ -3239,7 +3421,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, @@ -3255,7 +3437,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 => { @@ -3318,10 +3500,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; @@ -3362,12 +3541,12 @@ function createRenderSession(element: React.ReactElement, options: RenderOptions pendingRender = false; pendingRenderForce = false; renderFrame(true); - lastRenderAt = Date.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/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 ed77e21d..8fc1327e 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; @@ -196,54 +201,77 @@ const ESC = "\u001b"; interface CachedTranslation { revision: number; - contextSignature: string; vnode: VNode | null; - meta: TranslationMetadata; + metaMask: number; } interface TranslationPerfStats { translatedNodes: number; cacheHits: number; cacheMisses: number; + cacheEmptyMisses: number; + cacheStaleMisses: number; parseAnsiFastPathHits: number; parseAnsiFallbackPathHits: number; } -let translationCache = new WeakMap>(); +let translationCache = new WeakMap>(); const translationPerfStats: TranslationPerfStats = { translatedNodes: 0, cacheHits: 0, cacheMisses: 0, + cacheEmptyMisses: 0, + cacheStaleMisses: 0, parseAnsiFastPathHits: 0, parseAnsiFallbackPathHits: 0, }; let translationCacheEnabled = process.env["INK_COMPAT_DISABLE_TRANSLATION_CACHE"] !== "1"; function clearTranslationCache(): void { - translationCache = new WeakMap>(); + translationCache = new WeakMap>(); } function resetTranslationPerfStats(): void { translationPerfStats.translatedNodes = 0; translationPerfStats.cacheHits = 0; translationPerfStats.cacheMisses = 0; + translationPerfStats.cacheEmptyMisses = 0; + translationPerfStats.cacheStaleMisses = 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}`; +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 { @@ -269,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) { @@ -318,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. */ @@ -335,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); @@ -371,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(""); @@ -384,47 +406,67 @@ export function translateDynamicTreeWithMetadata(container: InkHostContainer): { } function translateNode(node: InkHostNode, context: TranslateContext): VNode | null { + const savedParentDirection = context.parentDirection; + const savedParentMainDefinite = context.parentMainDefinite; + const savedIsRoot = context.isRoot; + const savedInStaticSubtree = context.inStaticSubtree; const parentMeta = context.meta; const localMeta = createMeta(); - const localContext: TranslateContext = { - ...context, - meta: localMeta, - }; + context.meta = localMeta; + + try { + if (!translationCacheEnabled) { + translationPerfStats.cacheMisses += 1; + translationPerfStats.translatedNodes += 1; + const translated = translateNodeUncached(node, context); + applyMetaMask(parentMeta, toMetaMask(localMeta)); + return translated; + } + + 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(parentMeta, 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 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); + const translated = translateNodeUncached(node, context); + const metaMask = toMetaMask(localMeta); + applyMetaMask(parentMeta, metaMask); + + if (!perNodeCache) { + const nextCache = new Map(); + translationCache.set(node, nextCache); + nextCache.set(key, { + revision: node.__inkRevision, + vnode: translated, + metaMask, + }); + return translated; + } + perNodeCache.set(key, { + revision: node.__inkRevision, + vnode: translated, + metaMask, + }); - let perNodeCache = translationCache.get(node); - if (!perNodeCache) { - perNodeCache = new Map(); - translationCache.set(node, perNodeCache); + return translated; + } finally { + context.meta = parentMeta; + 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 { @@ -462,9 +504,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); } @@ -561,15 +603,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) { @@ -591,14 +624,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") { @@ -608,9 +640,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; @@ -812,6 +854,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) { @@ -967,7 +1010,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) { @@ -977,6 +1022,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") { @@ -986,22 +1034,27 @@ 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, textProps); } 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( spans: readonly TextSpan[], rootProps?: Record, + textProps?: Record, ): VNode { const lines: TextSpan[][] = [[]]; @@ -1019,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; @@ -1551,9 +1619,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); @@ -1570,6 +1636,8 @@ export const __inkCompatTranslationTestHooks = { translatedNodes: number; cacheHits: number; cacheMisses: number; + cacheEmptyMisses: number; + cacheStaleMisses: number; parseAnsiFastPathHits: number; parseAnsiFallbackPathHits: number; } { diff --git a/results/bottlenecks.md b/results/bottlenecks.md new file mode 100644 index 00000000..50cb2e91 --- /dev/null +++ b/results/bottlenecks.md @@ -0,0 +1,242 @@ +# 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. | +| 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 + +- `% 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) + ``` + +### 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. + +## 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 new file mode 100644 index 00000000..27353a58 --- /dev/null +++ b/results/report_2026-02-27.md @@ -0,0 +1,313 @@ +# 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 +``` + +## 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` + +## 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. + +## 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** 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/prepare-ink-compat.mjs b/scripts/ink-compat-bench/prepare-ink-compat.mjs new file mode 100644 index 00000000..4bd9d528 --- /dev/null +++ b/scripts/ink-compat-bench/prepare-ink-compat.mjs @@ -0,0 +1,17 @@ +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..d461e7a3 --- /dev/null +++ b/scripts/ink-compat-bench/prepare-real-ink.mjs @@ -0,0 +1,17 @@ +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}`); diff --git a/scripts/ink-compat-bench/summarize-cpuprofile.mjs b/scripts/ink-compat-bench/summarize-cpuprofile.mjs new file mode 100644 index 00000000..89345834 --- /dev/null +++ b/scripts/ink-compat-bench/summarize-cpuprofile.mjs @@ -0,0 +1,201 @@ +#!/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]; + if (v == null || v.startsWith("--")) return fallback; + 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] [--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`, + ); +} + +function toDisplayUrl(url) { + if (!url) return "unknown"; + let normalized = url; + if (url.startsWith("file://")) { + try { + const fsPath = new URL(url).pathname; + normalized = fsPath; + } catch {} + } + 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 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 activeOnly = hasFlag("active"); + 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; + let idleUs = 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 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; + } + 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, + activeTimeMs: Math.round(ms(activeUs) * 1000) / 1000, + totalSamples: samples.length, + 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, + 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, + 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`); + 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( + ` - ${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; +});