Skip to content

Commit 625c38c

Browse files
fix(ink-compat): optimize translation and layout hot paths (#227)
* fix(ink-compat): optimize translation and layout hot paths * fix(ink-compat): handle packed rgb and stabilize review regressions * chore(lint): resolve biome violations in ci files * fix(core): satisfy strict index-signature access in hashTextProps
1 parent 6dabb93 commit 625c38c

File tree

22 files changed

+3080
-460
lines changed

22 files changed

+3080
-460
lines changed

packages/bench/src/io.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,8 @@ export async function createBenchBackend(): Promise<BenchFrameBackend> {
119119
}
120120

121121
const NodeBackend = await import("@rezi-ui/node");
122-
const executionModeEnv = (
123-
process.env as Readonly<{ REZI_BENCH_REZI_EXECUTION_MODE?: string }>
124-
).REZI_BENCH_REZI_EXECUTION_MODE;
122+
const executionModeEnv = (process.env as Readonly<{ REZI_BENCH_REZI_EXECUTION_MODE?: string }>)
123+
.REZI_BENCH_REZI_EXECUTION_MODE;
125124
const executionMode = executionModeEnv === "worker" ? "worker" : "inline";
126125
const inner = NodeBackend.createNodeBackend({
127126
// PTY mode already runs in a dedicated process, so prefer inline execution

packages/bench/src/reziProfile.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,4 +63,3 @@ export function emitReziPerfSnapshot(
6363
// Profiling is optional and must never affect benchmark execution.
6464
}
6565
}
66-

packages/bench/src/scenarios/terminalVirtualList.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,13 @@ async function runRezi(
135135

136136
metrics.framesProduced = backend.frameCount - frameBase;
137137
metrics.bytesProduced = backend.totalFrameBytes - bytesBase;
138-
emitReziPerfSnapshot(core, "terminal-virtual-list", { items: totalItems, viewport }, config, metrics);
138+
emitReziPerfSnapshot(
139+
core,
140+
"terminal-virtual-list",
141+
{ items: totalItems, viewport },
142+
config,
143+
metrics,
144+
);
139145
return metrics;
140146
} finally {
141147
await app.stop();

packages/core/src/app/widgetRenderer/submitFramePipeline.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,8 @@ const HASH_FNV_PRIME = 0x01000193;
1212
const EMPTY_INSTANCE_IDS: readonly InstanceId[] = Object.freeze([]);
1313
const LAYOUT_SIG_INCLUDE_TEXT_WIDTH = (() => {
1414
try {
15-
const raw = (
16-
globalThis as { process?: { env?: { REZI_LAYOUT_SIG_TEXT_WIDTH?: string } } }
17-
).process?.env?.REZI_LAYOUT_SIG_TEXT_WIDTH;
15+
const raw = (globalThis as { process?: { env?: { REZI_LAYOUT_SIG_TEXT_WIDTH?: string } } })
16+
.process?.env?.REZI_LAYOUT_SIG_TEXT_WIDTH;
1817
// Default: treat plain (non-wrapped, unconstrained) text width changes as
1918
// paint-only, not layout-affecting. This avoids full relayout churn for
2019
// high-frequency text updates.

packages/core/src/renderer/__tests__/renderPackets.test.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -229,11 +229,7 @@ describe("render packet retention", () => {
229229
firstKey,
230230
"key should remain stable when visual fields are unchanged despite new object identity",
231231
);
232-
assert.equal(
233-
root.renderPacket,
234-
firstPacket,
235-
"packet should be reused when key matches",
236-
);
232+
assert.equal(root.renderPacket, firstPacket, "packet should be reused when key matches");
237233
});
238234

239235
test("packet invalidates when visual field changes", () => {

packages/core/src/renderer/renderToDrawlist/renderPackets.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,8 @@ function hashPropsShallow(hash: number, props: Readonly<Record<string, unknown>>
116116
const keys = Object.keys(props);
117117
out = mixHash(out, keys.length);
118118
for (let i = 0; i < keys.length; i++) {
119-
const key = keys[i]!;
119+
const key = keys[i];
120+
if (key === undefined) continue;
120121
out = mixHash(out, hashString(key));
121122
out = hashPropValue(out, props[key]);
122123
}
@@ -250,12 +251,21 @@ function isTickDrivenKind(kind: RuntimeInstance["vnode"]["kind"]): boolean {
250251
* The text content itself is already hashed separately.
251252
*/
252253
function hashTextProps(hash: number, props: Readonly<Record<string, unknown>>): number {
253-
const style = props["style"];
254-
const maxWidth = props["maxWidth"];
255-
const wrap = props["wrap"];
256-
const variant = props["variant"];
257-
const dim = props["dim"];
258-
const textOverflow = props["textOverflow"];
254+
const textProps = props as Readonly<{
255+
style?: unknown;
256+
maxWidth?: unknown;
257+
wrap?: unknown;
258+
variant?: unknown;
259+
dim?: unknown;
260+
textOverflow?: unknown;
261+
}>;
262+
263+
const style = textProps.style;
264+
const maxWidth = textProps.maxWidth;
265+
const wrap = textProps.wrap;
266+
const variant = textProps.variant;
267+
const dim = textProps.dim;
268+
const textOverflow = textProps.textOverflow;
259269

260270
// Common case for plain text nodes with no explicit props.
261271
if (

packages/ink-compat/src/__tests__/integration/basic.test.ts

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,100 @@ test("runtime render resolves nested percent sizing from resolved parent layout"
577577
}
578578
});
579579

580+
test("runtime render re-resolves percent sizing when parent layout changes (no frame lag)", async () => {
581+
const stdin = new PassThrough() as PassThrough & { setRawMode: (enabled: boolean) => void };
582+
stdin.setRawMode = () => {};
583+
584+
const stdout = new PassThrough() as PassThrough & {
585+
columns?: number;
586+
rows?: number;
587+
};
588+
stdout.columns = 80;
589+
stdout.rows = 24;
590+
591+
const stderr = new PassThrough();
592+
593+
let parentNode: InkHostNode | null = null;
594+
let childNode: InkHostNode | null = null;
595+
596+
function App(props: { parentWidth: number }): React.ReactElement {
597+
const parentRef = React.useRef<InkHostNode | null>(null);
598+
const childRef = React.useRef<InkHostNode | null>(null);
599+
600+
useEffect(() => {
601+
parentNode = parentRef.current;
602+
childNode = childRef.current;
603+
});
604+
605+
return React.createElement(
606+
Box,
607+
{ ref: parentRef, width: props.parentWidth, flexDirection: "row" },
608+
React.createElement(
609+
Box,
610+
{ ref: childRef, width: "50%" },
611+
React.createElement(Text, null, "Child"),
612+
),
613+
);
614+
}
615+
616+
const instance = runtimeRender(React.createElement(App, { parentWidth: 20 }), {
617+
stdin,
618+
stdout,
619+
stderr,
620+
});
621+
try {
622+
await new Promise((resolve) => setTimeout(resolve, 60));
623+
assert.ok(parentNode != null, "parent ref should be set");
624+
assert.ok(childNode != null, "child ref should be set");
625+
assert.equal(measureElement(parentNode).width, 20);
626+
assert.equal(measureElement(childNode).width, 10);
627+
628+
instance.rerender(React.createElement(App, { parentWidth: 30 }));
629+
await new Promise((resolve) => setTimeout(resolve, 60));
630+
assert.equal(measureElement(parentNode).width, 30);
631+
assert.equal(measureElement(childNode).width, 15);
632+
} finally {
633+
instance.unmount();
634+
instance.cleanup();
635+
}
636+
});
637+
638+
test("runtime render layout generations hide stale layout for removed nodes", async () => {
639+
const stdin = new PassThrough() as PassThrough & { setRawMode: (enabled: boolean) => void };
640+
stdin.setRawMode = () => {};
641+
const stdout = new PassThrough();
642+
const stderr = new PassThrough();
643+
644+
let removedNode: InkHostNode | null = null;
645+
646+
function Before(): React.ReactElement {
647+
const removedRef = React.useRef<InkHostNode | null>(null);
648+
useEffect(() => {
649+
removedNode = removedRef.current;
650+
});
651+
return React.createElement(
652+
Box,
653+
{ ref: removedRef, width: 22 },
654+
React.createElement(Text, null, "Before"),
655+
);
656+
}
657+
658+
const instance = runtimeRender(React.createElement(Before), { stdin, stdout, stderr });
659+
try {
660+
await new Promise((resolve) => setTimeout(resolve, 40));
661+
assert.ok(removedNode != null, "removed node ref should be set");
662+
assert.equal(measureElement(removedNode).width, 22);
663+
664+
instance.rerender(React.createElement(Text, null, "After"));
665+
await new Promise((resolve) => setTimeout(resolve, 40));
666+
667+
assert.deepEqual(measureElement(removedNode), { width: 0, height: 0 });
668+
} finally {
669+
instance.unmount();
670+
instance.cleanup();
671+
}
672+
});
673+
580674
test("render option isScreenReaderEnabled flows to hook context", async () => {
581675
const stdin = new PassThrough() as PassThrough & { setRawMode: (enabled: boolean) => void };
582676
stdin.setRawMode = () => {};
@@ -948,6 +1042,40 @@ test("rerender updates output", () => {
9481042
assert.match(result.lastFrame(), /New/);
9491043
});
9501044

1045+
test("rendering identical tree keeps ANSI frame bytes stable", async () => {
1046+
const element = React.createElement(
1047+
Box,
1048+
{ flexDirection: "row" },
1049+
React.createElement(Text, { color: "green", bold: true }, "Left"),
1050+
React.createElement(Text, null, " "),
1051+
React.createElement(Text, null, "\u001b[31mRight\u001b[0m"),
1052+
);
1053+
1054+
const captureFrame = async (): Promise<string> => {
1055+
const stdin = new PassThrough() as PassThrough & { setRawMode: (enabled: boolean) => void };
1056+
stdin.setRawMode = () => {};
1057+
const stdout = new PassThrough();
1058+
const stderr = new PassThrough();
1059+
let writes = "";
1060+
stdout.on("data", (chunk) => {
1061+
writes += chunk.toString("utf-8");
1062+
});
1063+
1064+
const instance = runtimeRender(element, { stdin, stdout, stderr });
1065+
try {
1066+
await new Promise((resolve) => setTimeout(resolve, 30));
1067+
return latestFrameFromWrites(writes);
1068+
} finally {
1069+
instance.unmount();
1070+
instance.cleanup();
1071+
}
1072+
};
1073+
1074+
const firstFrame = await captureFrame();
1075+
const secondFrame = await captureFrame();
1076+
assert.equal(secondFrame, firstFrame);
1077+
});
1078+
9511079
test("runtime Static emits only new items on rerender", async () => {
9521080
interface Item {
9531081
id: string;
@@ -1138,6 +1266,45 @@ test("ANSI output resets attributes between differently-styled cells", () => {
11381266

11391267
// ─── Regression: text inherits background from underlying fillRect ───
11401268

1269+
test("nested non-overlapping clips do not leak text", async () => {
1270+
const stdin = new PassThrough() as PassThrough & { setRawMode: (enabled: boolean) => void };
1271+
stdin.setRawMode = () => {};
1272+
const stdout = new PassThrough();
1273+
const stderr = new PassThrough();
1274+
let writes = "";
1275+
stdout.on("data", (chunk) => {
1276+
writes += chunk.toString("utf-8");
1277+
});
1278+
1279+
const instance = runtimeRender(
1280+
React.createElement(
1281+
Box,
1282+
{ width: 4, height: 1, overflow: "hidden" },
1283+
React.createElement(
1284+
Box,
1285+
{ position: "absolute", left: 10, top: 0, width: 4, height: 1, overflow: "hidden" },
1286+
React.createElement(Text, null, "LEAK"),
1287+
),
1288+
),
1289+
{ stdin, stdout, stderr },
1290+
);
1291+
1292+
try {
1293+
await new Promise<void>((resolve) => {
1294+
if (writes.length > 0) {
1295+
resolve();
1296+
return;
1297+
}
1298+
stdout.once("data", () => resolve());
1299+
});
1300+
const latest = stripTerminalEscapes(latestFrameFromWrites(writes));
1301+
assert.equal(latest.includes("LEAK"), false, `unexpected clipped leak in output: ${latest}`);
1302+
} finally {
1303+
instance.unmount();
1304+
instance.cleanup();
1305+
}
1306+
});
1307+
11411308
test("text over backgroundColor box preserves box background in ANSI output", () => {
11421309
const previousNoColor = process.env["NO_COLOR"];
11431310
const previousForceColor = process.env["FORCE_COLOR"];

0 commit comments

Comments
 (0)