Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ The format is based on Keep a Changelog and the project follows Semantic Version

## [Unreleased]

### Bug Fixes

- `internal_onRender` and runtime breadcrumb render timing now use the always-on monotonic clock even when perf instrumentation is disabled.

### Tests

- Added deterministic regressions for widget-mode breadcrumb render timing and draw-mode `internal_onRender` timing.

## [0.1.0-alpha.60] - 2026-03-14

### Bug Fixes
Expand Down
31 changes: 31 additions & 0 deletions packages/core/src/app/__tests__/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,34 @@ export async function flushMicrotasks(count: number): Promise<void> {
await new Promise<void>((resolve) => queueMicrotask(resolve));
}
}

export async function withMockPerformanceNow<T>(
values: readonly number[],
fn: () => Promise<T>,
): Promise<T> {
const originalDescriptor = Object.getOwnPropertyDescriptor(globalThis, "performance");
let index = 0;
const lastValue = values[values.length - 1] ?? 0;
const performanceMock = Object.freeze({
now(): number {
const next = values[index] ?? lastValue;
index++;
return next;
},
});

Object.defineProperty(globalThis, "performance", {
configurable: true,
value: performanceMock,
});

try {
return await fn();
} finally {
if (originalDescriptor) {
Object.defineProperty(globalThis, "performance", originalDescriptor);
} else {
Reflect.deleteProperty(globalThis, "performance");
}
}
}
44 changes: 43 additions & 1 deletion packages/core/src/app/__tests__/runtimeBreadcrumbs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ import { createApp } from "../createApp.js";
import type { RuntimeBreadcrumbSnapshot } from "../runtimeBreadcrumbs.js";
import { isRuntimeBreadcrumbEventKind } from "../runtimeBreadcrumbs.js";
import { WidgetRenderer } from "../widgetRenderer.js";
import { encodeZrevBatchV1, flushMicrotasks, makeBackendBatch } from "./helpers.js";
import {
encodeZrevBatchV1,
flushMicrotasks,
makeBackendBatch,
withMockPerformanceNow,
} from "./helpers.js";
import { StubBackend } from "./stubBackend.js";

function noRenderHooks(): { enterRender: () => void; exitRender: () => void } {
Expand Down Expand Up @@ -244,6 +249,43 @@ test("runtime breadcrumbs refresh focus announcements when field metadata change
await settleNextFrame(backend);
});

test("runtime breadcrumbs keep monotonic render timings when perf instrumentation is off", async () => {
const backend = new StubBackend();
const renderMetrics: number[] = [];
const renderSnapshots: RuntimeBreadcrumbSnapshot[] = [];

const app = createApp({
backend,
initialState: 0,
config: {
internal_onRender: (metrics) => {
renderMetrics.push(metrics.renderTime);
const breadcrumbs = (
metrics as Readonly<{ runtimeBreadcrumbs?: RuntimeBreadcrumbSnapshot }>
).runtimeBreadcrumbs;
if (breadcrumbs) renderSnapshots.push(breadcrumbs);
},
},
});

app.view(() => ui.text("timing"));

await app.start();
await withMockPerformanceNow([10, 15, 23], async () => {
await pushEvents(backend, [{ kind: "resize", timeMs: 1, cols: 40, rows: 10 }]);
});

assert.equal(renderMetrics.length, 1);
const renderTime = renderMetrics[0];
assert.ok(renderTime !== undefined);
assert.equal(renderTime > 0, true);
assert.equal(renderTime, 8);
assert.equal(renderSnapshots.length, 1);
assert.equal(renderSnapshots[0]?.frame.renderTimeMs, 8);

await settleNextFrame(backend);
});

test("enabling breadcrumb capture does not change widget routing outcomes", () => {
const backend = createNoopBackend();
const rendererWithout = new WidgetRenderer<void>({
Expand Down
30 changes: 29 additions & 1 deletion packages/core/src/app/__tests__/updates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { assert, test } from "@rezi-ui/testkit";
import { ZrUiError } from "../../abi.js";
import { darkTheme } from "../../theme/presets.js";
import { createApp } from "../createApp.js";
import { encodeZrevBatchV1, flushMicrotasks, makeBackendBatch } from "./helpers.js";
import {
encodeZrevBatchV1,
flushMicrotasks,
makeBackendBatch,
withMockPerformanceNow,
} from "./helpers.js";
import { StubBackend } from "./stubBackend.js";

test("update() commits at end of explicit user turn (#57)", async () => {
Expand Down Expand Up @@ -159,3 +164,26 @@ test("explicit null initialState is preserved", async () => {

app.dispose();
});

test("draw mode internal_onRender reports monotonic render timing", async () => {
const backend = new StubBackend();
const renderTimes: number[] = [];
const app = createApp({
backend,
initialState: 0,
config: {
internal_onRender: (metrics) => {
renderTimes.push(metrics.renderTime);
},
},
});

app.draw((g) => g.clear());

await withMockPerformanceNow([30, 37], async () => {
await app.start();
await flushMicrotasks(5);
});

assert.deepEqual(renderTimes, [7]);
});
8 changes: 4 additions & 4 deletions packages/core/src/app/createApp/renderLoop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,15 +322,15 @@ export function createRenderLoop<S>(options: CreateRenderLoopOptions<S>): AppRen
const drawFn = options.getDrawFn();
if (!drawFn) return;

const renderStart = perfNow();
const renderStartMs = monotonicNowMs();
const submitToken = perfMarkStart("submit_frame");
const res = options.rawRenderer.submitFrame(drawFn, hooks);
perfMarkEnd("submit_frame", submitToken);
if (!res.ok) {
options.fatalNowOrEnqueue(res.code, res.detail);
return;
}
if (!emitInternalRenderMetrics(perfNow() - renderStart)) return;
if (!emitInternalRenderMetrics(monotonicNowMs() - renderStartMs)) return;

const submitStartMs = PERF_ENABLED ? submitToken : null;
const buildEndMs = PERF_ENABLED ? perfNow() : null;
Expand Down Expand Up @@ -373,7 +373,7 @@ export function createRenderLoop<S>(options: CreateRenderLoopOptions<S>): AppRen
}
};

const renderStart = perfNow();
const renderStartMs = monotonicNowMs();
const submitToken = perfMarkStart("submit_frame");
const frameView: ViewFn<S> = options.getDebugLayoutEnabled()
? (state) => {
Expand All @@ -397,7 +397,7 @@ export function createRenderLoop<S>(options: CreateRenderLoopOptions<S>): AppRen
return;
}
if (!options.emitFocusChangeIfNeeded()) return;
const renderTime = perfNow() - renderStart;
const renderTime = monotonicNowMs() - renderStartMs;
const runtimeBreadcrumbs = options.buildRuntimeBreadcrumbSnapshot(Math.max(0, renderTime));
if (!emitInternalRenderMetrics(renderTime, runtimeBreadcrumbs)) return;
if (!emitInternalLayoutSnapshot(runtimeBreadcrumbs)) return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ function mergeCellStyles_CURRENT(
// ─── Benchmarking harness ───

const FIXED_WARMUP_ITERATIONS = 500;
const GRID_REUSE_MEDIAN_SAMPLES = 7;
const GRID_REUSE_REGRESSION_TOLERANCE = process.platform === "win32" ? 1.35 : 1.2;

function bench(name: string, fn: () => void, iterations: number): number {
// Warmup
Expand All @@ -222,6 +224,19 @@ function bench(name: string, fn: () => void, iterations: number): number {
return perOp;
}

function median(values: readonly number[]): number {
const sorted = [...values].sort((left, right) => left - right);
return sorted[Math.floor(sorted.length / 2)]!;
}

function benchMedian(fn: () => void, iterations: number, samples: number): number {
const runs: number[] = [];
for (let index = 0; index < samples; index += 1) {
runs.push(bench(`sample-${String(index)}`, fn, iterations));
}
return median(runs);
}

describe("ink-compat bottleneck profiling", () => {
it("Bottleneck 1: stylesEqual — JSON.stringify vs direct comparison", () => {
const a: CellStyle = {
Expand Down Expand Up @@ -307,16 +322,28 @@ describe("ink-compat bottleneck profiling", () => {
const rows = 40;
const N = 1_000;

const currentNs = bench("current", () => allocateGrid_CURRENT(cols, rows), N);
const fixedNs = bench("fixed", () => allocateGrid_REUSE(cols, rows), N);
const expectedGrid = allocateGrid_CURRENT(cols, rows);
reusableCols = 0;
const actualGrid = allocateGrid_REUSE(cols, rows);
assert.deepEqual(actualGrid, expectedGrid);

const currentNs = benchMedian(
() => allocateGrid_CURRENT(cols, rows),
N,
GRID_REUSE_MEDIAN_SAMPLES,
);
const fixedNs = benchMedian(() => allocateGrid_REUSE(cols, rows), N, GRID_REUSE_MEDIAN_SAMPLES);
const speedup = currentNs / fixedNs;
const slowdown = fixedNs / currentNs;

console.log(` Grid allocation (${cols}x${rows} = ${cols * rows} cells):`);
console.log(` CURRENT (new objects): ${(currentNs / 1000).toFixed(0)} µs/frame`);
console.log(` FIXED (reuse): ${(fixedNs / 1000).toFixed(0)} µs/frame`);
console.log(` CURRENT (new objects, median): ${(currentNs / 1000).toFixed(0)} µs/frame`);
console.log(` FIXED (reuse, median): ${(fixedNs / 1000).toFixed(0)} µs/frame`);
console.log(` Speedup: ${speedup.toFixed(1)}x`);

assert.ok(speedup > 1.1, `Expected at least 1.1x speedup, got ${speedup.toFixed(1)}x`);
assert.ok(
slowdown <= GRID_REUSE_REGRESSION_TOLERANCE,
`Expected reuse path to stay within ${GRID_REUSE_REGRESSION_TOLERANCE.toFixed(2)}x of fresh allocation, got ${slowdown.toFixed(2)}x`,
);
});

it("Bottleneck 7: mergeCellStyles — fast path when base is undefined", () => {
Expand Down
Loading