fix(runtime): call onRender before frame writes

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-05 21:04:34 +08:00
committed by GitHub
parent 65ac088001
commit 1340c049b2
2 changed files with 60 additions and 9 deletions
@@ -111,6 +111,58 @@ test("no onRender callback when option is not provided", async () => {
app.unmount();
});
async function expectOnRenderWriteBeforeFrame(
options: {
debug?: boolean;
isScreenReaderEnabled?: boolean;
} = {},
) {
const App = defineComponent(() => () => <Text>Hello</Text>);
const app = createApp(App);
const stdout = makeFakeWritable({ columns: 80 });
const stderr = makeFakeWritable({ columns: 80 });
const { stream: stdin } = makeFakeStdin();
const writes: string[] = [];
(stdout as unknown as PassThrough).on("data", (chunk: Buffer) => {
writes.push(chunk.toString());
});
app.mount({
stdout,
stdin,
stderr,
exitOnCtrlC: false,
...options,
onRender: () => {
stdout.write("R");
},
});
await nextTick();
await nextTick();
const output = writes.join("");
expect(output.indexOf("R")).toBeGreaterThanOrEqual(0);
expect(output.indexOf("Hello")).toBeGreaterThanOrEqual(0);
expect(output.indexOf("R")).toBeLessThan(output.indexOf("Hello"));
app.unmount();
}
test("onRender fires before debug output is written", async () => {
await expectOnRenderWriteBeforeFrame({ debug: true });
});
test("onRender fires before interactive output is written", async () => {
await expectOnRenderWriteBeforeFrame();
});
test("onRender fires before screen-reader output is written", async () => {
await expectOnRenderWriteBeforeFrame({ isScreenReaderEnabled: true });
});
test("onRender fires on input-triggered state update", async () => {
// Mirrors the third assertion in Ink's "outputs renderTime when onRender is passed":
// after an initial render and a manual rerender, a useInput-driven state
+8 -9
View File
@@ -916,11 +916,8 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
}
if (!interactive && !debug) {
// Non-interactive: write static output immediately, defer dynamic frame.
if (hasStaticOutput) {
stdout.write(staticOutput);
}
// Non-interactive: compute the dynamic frame now, write static output
// after onRender, and defer dynamic frame output until unmount.
tuiRoot.yoga.setWidth(w);
tuiRoot.yoga.calculateLayout(w, undefined, Yoga.DIRECTION_LTR);
emitLayoutListeners(tuiRoot);
@@ -929,6 +926,9 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
frameState.lastOutputToRender = frame + "\n";
frameState.outputHeight = frame === "" ? 0 : frame.split("\n").length;
if (onRender) onRender({ renderTime: performance.now() - start });
if (hasStaticOutput) {
stdout.write(staticOutput);
}
return;
}
@@ -978,6 +978,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
frameState.lastOutput = frame;
frameState.lastOutputToRender = frame;
frameState.outputHeight = outputHeight;
if (onRender) onRender({ renderTime: performance.now() - start });
// Ink writes `fullStaticOutput + output` with NO trailing newline
// (ink.tsx:558; `output` is \n-joined and returned WITHOUT a trailing
// \n — output.ts:305-312). Writing `frame` (not `frame + "\n"`) makes
@@ -991,7 +992,6 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
// flushes to stdout for byte parity but is not a render, so it must not
// append a duplicate of the final frame to the live frames[].
if (!teardownStarted) frameSink?.(frame);
if (onRender) onRender({ renderTime: performance.now() - start });
return;
}
@@ -1009,6 +1009,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
// `frame` is already the wrapped SR output (renderFrame -> wrapAnsi), so
// it plays the role of Ink's `wrappedOutput`.
const sync = synchronize;
if (onRender) onRender({ renderTime: performance.now() - start });
if (sync) stdout.write(bsu);
if (hasStaticOutput) {
@@ -1023,7 +1024,6 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
if (frame === frameState.lastOutput && !hasStaticOutput) {
// Unchanged frame and no new static: nothing to write (ink.tsx:590-596).
if (sync) stdout.write(esu);
if (onRender) onRender({ renderTime: performance.now() - start });
return;
}
@@ -1044,13 +1044,12 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
frameState.outputHeight = frame === "" ? 0 : frame.split("\n").length;
if (sync) stdout.write(esu);
if (onRender) onRender({ renderTime: performance.now() - start });
return;
}
// Interactive path
renderInteractiveFrame(frame, outputHeight, hasStaticOutput ? staticOutput : "");
if (onRender) onRender({ renderTime: performance.now() - start });
renderInteractiveFrame(frame, outputHeight, hasStaticOutput ? staticOutput : "");
}
// A single render-throttle window derived from maxFps drives BOTH the