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
5 changes: 2 additions & 3 deletions packages/bench/src/io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,8 @@ export async function createBenchBackend(): Promise<BenchFrameBackend> {
}

const NodeBackend = await import("@rezi-ui/node");
const executionModeEnv = (
process.env as Readonly<{ REZI_BENCH_REZI_EXECUTION_MODE?: string }>
).REZI_BENCH_REZI_EXECUTION_MODE;
const executionModeEnv = (process.env as Readonly<{ REZI_BENCH_REZI_EXECUTION_MODE?: string }>)
.REZI_BENCH_REZI_EXECUTION_MODE;
const executionMode = executionModeEnv === "worker" ? "worker" : "inline";
const inner = NodeBackend.createNodeBackend({
// PTY mode already runs in a dedicated process, so prefer inline execution
Expand Down
1 change: 0 additions & 1 deletion packages/bench/src/reziProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,3 @@ export function emitReziPerfSnapshot(
// Profiling is optional and must never affect benchmark execution.
}
}

8 changes: 7 additions & 1 deletion packages/bench/src/scenarios/terminalVirtualList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,13 @@ async function runRezi(

metrics.framesProduced = backend.frameCount - frameBase;
metrics.bytesProduced = backend.totalFrameBytes - bytesBase;
emitReziPerfSnapshot(core, "terminal-virtual-list", { items: totalItems, viewport }, config, metrics);
emitReziPerfSnapshot(
core,
"terminal-virtual-list",
{ items: totalItems, viewport },
config,
metrics,
);
return metrics;
} finally {
await app.stop();
Expand Down
5 changes: 2 additions & 3 deletions packages/core/src/app/widgetRenderer/submitFramePipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,8 @@ const HASH_FNV_PRIME = 0x01000193;
const EMPTY_INSTANCE_IDS: readonly InstanceId[] = Object.freeze([]);
const LAYOUT_SIG_INCLUDE_TEXT_WIDTH = (() => {
try {
const raw = (
globalThis as { process?: { env?: { REZI_LAYOUT_SIG_TEXT_WIDTH?: string } } }
).process?.env?.REZI_LAYOUT_SIG_TEXT_WIDTH;
const raw = (globalThis as { process?: { env?: { REZI_LAYOUT_SIG_TEXT_WIDTH?: string } } })
.process?.env?.REZI_LAYOUT_SIG_TEXT_WIDTH;
// Default: treat plain (non-wrapped, unconstrained) text width changes as
// paint-only, not layout-affecting. This avoids full relayout churn for
// high-frequency text updates.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,11 +229,7 @@ describe("render packet retention", () => {
firstKey,
"key should remain stable when visual fields are unchanged despite new object identity",
);
assert.equal(
root.renderPacket,
firstPacket,
"packet should be reused when key matches",
);
assert.equal(root.renderPacket, firstPacket, "packet should be reused when key matches");
});

test("packet invalidates when visual field changes", () => {
Expand Down
24 changes: 17 additions & 7 deletions packages/core/src/renderer/renderToDrawlist/renderPackets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ function hashPropsShallow(hash: number, props: Readonly<Record<string, unknown>>
const keys = Object.keys(props);
out = mixHash(out, keys.length);
for (let i = 0; i < keys.length; i++) {
const key = keys[i]!;
const key = keys[i];
if (key === undefined) continue;
out = mixHash(out, hashString(key));
out = hashPropValue(out, props[key]);
}
Expand Down Expand Up @@ -250,12 +251,21 @@ function isTickDrivenKind(kind: RuntimeInstance["vnode"]["kind"]): boolean {
* The text content itself is already hashed separately.
*/
function hashTextProps(hash: number, props: Readonly<Record<string, unknown>>): number {
const style = props["style"];
const maxWidth = props["maxWidth"];
const wrap = props["wrap"];
const variant = props["variant"];
const dim = props["dim"];
const textOverflow = props["textOverflow"];
const textProps = props as Readonly<{
style?: unknown;
maxWidth?: unknown;
wrap?: unknown;
variant?: unknown;
dim?: unknown;
textOverflow?: unknown;
}>;

const style = textProps.style;
const maxWidth = textProps.maxWidth;
const wrap = textProps.wrap;
const variant = textProps.variant;
const dim = textProps.dim;
const textOverflow = textProps.textOverflow;

// Common case for plain text nodes with no explicit props.
if (
Expand Down
167 changes: 167 additions & 0 deletions packages/ink-compat/src/__tests__/integration/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,100 @@ test("runtime render resolves nested percent sizing from resolved parent layout"
}
});

test("runtime render re-resolves percent sizing when parent layout changes (no frame lag)", async () => {
const stdin = new PassThrough() as PassThrough & { setRawMode: (enabled: boolean) => void };
stdin.setRawMode = () => {};

const stdout = new PassThrough() as PassThrough & {
columns?: number;
rows?: number;
};
stdout.columns = 80;
stdout.rows = 24;

const stderr = new PassThrough();

let parentNode: InkHostNode | null = null;
let childNode: InkHostNode | null = null;

function App(props: { parentWidth: number }): React.ReactElement {
const parentRef = React.useRef<InkHostNode | null>(null);
const childRef = React.useRef<InkHostNode | null>(null);

useEffect(() => {
parentNode = parentRef.current;
childNode = childRef.current;
});

return React.createElement(
Box,
{ ref: parentRef, width: props.parentWidth, flexDirection: "row" },
React.createElement(
Box,
{ ref: childRef, width: "50%" },
React.createElement(Text, null, "Child"),
),
);
}

const instance = runtimeRender(React.createElement(App, { parentWidth: 20 }), {
stdin,
stdout,
stderr,
});
try {
await new Promise((resolve) => setTimeout(resolve, 60));
assert.ok(parentNode != null, "parent ref should be set");
assert.ok(childNode != null, "child ref should be set");
assert.equal(measureElement(parentNode).width, 20);
assert.equal(measureElement(childNode).width, 10);

instance.rerender(React.createElement(App, { parentWidth: 30 }));
await new Promise((resolve) => setTimeout(resolve, 60));
assert.equal(measureElement(parentNode).width, 30);
assert.equal(measureElement(childNode).width, 15);
} finally {
instance.unmount();
instance.cleanup();
}
});

test("runtime render layout generations hide stale layout for removed nodes", async () => {
const stdin = new PassThrough() as PassThrough & { setRawMode: (enabled: boolean) => void };
stdin.setRawMode = () => {};
const stdout = new PassThrough();
const stderr = new PassThrough();

let removedNode: InkHostNode | null = null;

function Before(): React.ReactElement {
const removedRef = React.useRef<InkHostNode | null>(null);
useEffect(() => {
removedNode = removedRef.current;
});
return React.createElement(
Box,
{ ref: removedRef, width: 22 },
React.createElement(Text, null, "Before"),
);
}

const instance = runtimeRender(React.createElement(Before), { stdin, stdout, stderr });
try {
await new Promise((resolve) => setTimeout(resolve, 40));
assert.ok(removedNode != null, "removed node ref should be set");
assert.equal(measureElement(removedNode).width, 22);

instance.rerender(React.createElement(Text, null, "After"));
await new Promise((resolve) => setTimeout(resolve, 40));

assert.deepEqual(measureElement(removedNode), { width: 0, height: 0 });
} finally {
instance.unmount();
instance.cleanup();
}
});

test("render option isScreenReaderEnabled flows to hook context", async () => {
const stdin = new PassThrough() as PassThrough & { setRawMode: (enabled: boolean) => void };
stdin.setRawMode = () => {};
Expand Down Expand Up @@ -948,6 +1042,40 @@ test("rerender updates output", () => {
assert.match(result.lastFrame(), /New/);
});

test("rendering identical tree keeps ANSI frame bytes stable", async () => {
const element = React.createElement(
Box,
{ flexDirection: "row" },
React.createElement(Text, { color: "green", bold: true }, "Left"),
React.createElement(Text, null, " "),
React.createElement(Text, null, "\u001b[31mRight\u001b[0m"),
);

const captureFrame = async (): Promise<string> => {
const stdin = new PassThrough() as PassThrough & { setRawMode: (enabled: boolean) => void };
stdin.setRawMode = () => {};
const stdout = new PassThrough();
const stderr = new PassThrough();
let writes = "";
stdout.on("data", (chunk) => {
writes += chunk.toString("utf-8");
});

const instance = runtimeRender(element, { stdin, stdout, stderr });
try {
await new Promise((resolve) => setTimeout(resolve, 30));
return latestFrameFromWrites(writes);
} finally {
instance.unmount();
instance.cleanup();
}
};

const firstFrame = await captureFrame();
const secondFrame = await captureFrame();
assert.equal(secondFrame, firstFrame);
});

test("runtime Static emits only new items on rerender", async () => {
interface Item {
id: string;
Expand Down Expand Up @@ -1138,6 +1266,45 @@ test("ANSI output resets attributes between differently-styled cells", () => {

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

test("nested non-overlapping clips do not leak text", async () => {
const stdin = new PassThrough() as PassThrough & { setRawMode: (enabled: boolean) => void };
stdin.setRawMode = () => {};
const stdout = new PassThrough();
const stderr = new PassThrough();
let writes = "";
stdout.on("data", (chunk) => {
writes += chunk.toString("utf-8");
});

const instance = runtimeRender(
React.createElement(
Box,
{ width: 4, height: 1, overflow: "hidden" },
React.createElement(
Box,
{ position: "absolute", left: 10, top: 0, width: 4, height: 1, overflow: "hidden" },
React.createElement(Text, null, "LEAK"),
),
),
{ stdin, stdout, stderr },
);

try {
await new Promise<void>((resolve) => {
if (writes.length > 0) {
resolve();
return;
}
stdout.once("data", () => resolve());
});
const latest = stripTerminalEscapes(latestFrameFromWrites(writes));
assert.equal(latest.includes("LEAK"), false, `unexpected clipped leak in output: ${latest}`);
} finally {
instance.unmount();
instance.cleanup();
}
});

test("text over backgroundColor box preserves box background in ANSI output", () => {
const previousNoColor = process.env["NO_COLOR"];
const previousForceColor = process.env["FORCE_COLOR"];
Expand Down
Loading
Loading