fix(runtime): call onRender before frame writes
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -111,6 +111,58 @@ test("no onRender callback when option is not provided", async () => {
|
|||||||
app.unmount();
|
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 () => {
|
test("onRender fires on input-triggered state update", async () => {
|
||||||
// Mirrors the third assertion in Ink's "outputs renderTime when onRender is passed":
|
// 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
|
// after an initial render and a manual rerender, a useInput-driven state
|
||||||
|
|||||||
@@ -916,11 +916,8 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!interactive && !debug) {
|
if (!interactive && !debug) {
|
||||||
// Non-interactive: write static output immediately, defer dynamic frame.
|
// Non-interactive: compute the dynamic frame now, write static output
|
||||||
if (hasStaticOutput) {
|
// after onRender, and defer dynamic frame output until unmount.
|
||||||
stdout.write(staticOutput);
|
|
||||||
}
|
|
||||||
|
|
||||||
tuiRoot.yoga.setWidth(w);
|
tuiRoot.yoga.setWidth(w);
|
||||||
tuiRoot.yoga.calculateLayout(w, undefined, Yoga.DIRECTION_LTR);
|
tuiRoot.yoga.calculateLayout(w, undefined, Yoga.DIRECTION_LTR);
|
||||||
emitLayoutListeners(tuiRoot);
|
emitLayoutListeners(tuiRoot);
|
||||||
@@ -929,6 +926,9 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
|||||||
frameState.lastOutputToRender = frame + "\n";
|
frameState.lastOutputToRender = frame + "\n";
|
||||||
frameState.outputHeight = frame === "" ? 0 : frame.split("\n").length;
|
frameState.outputHeight = frame === "" ? 0 : frame.split("\n").length;
|
||||||
if (onRender) onRender({ renderTime: performance.now() - start });
|
if (onRender) onRender({ renderTime: performance.now() - start });
|
||||||
|
if (hasStaticOutput) {
|
||||||
|
stdout.write(staticOutput);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -978,6 +978,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
|||||||
frameState.lastOutput = frame;
|
frameState.lastOutput = frame;
|
||||||
frameState.lastOutputToRender = frame;
|
frameState.lastOutputToRender = frame;
|
||||||
frameState.outputHeight = outputHeight;
|
frameState.outputHeight = outputHeight;
|
||||||
|
if (onRender) onRender({ renderTime: performance.now() - start });
|
||||||
// Ink writes `fullStaticOutput + output` with NO trailing newline
|
// Ink writes `fullStaticOutput + output` with NO trailing newline
|
||||||
// (ink.tsx:558; `output` is \n-joined and returned WITHOUT a trailing
|
// (ink.tsx:558; `output` is \n-joined and returned WITHOUT a trailing
|
||||||
// \n — output.ts:305-312). Writing `frame` (not `frame + "\n"`) makes
|
// \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
|
// 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[].
|
// append a duplicate of the final frame to the live frames[].
|
||||||
if (!teardownStarted) frameSink?.(frame);
|
if (!teardownStarted) frameSink?.(frame);
|
||||||
if (onRender) onRender({ renderTime: performance.now() - start });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1009,6 +1009,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
|||||||
// `frame` is already the wrapped SR output (renderFrame -> wrapAnsi), so
|
// `frame` is already the wrapped SR output (renderFrame -> wrapAnsi), so
|
||||||
// it plays the role of Ink's `wrappedOutput`.
|
// it plays the role of Ink's `wrappedOutput`.
|
||||||
const sync = synchronize;
|
const sync = synchronize;
|
||||||
|
if (onRender) onRender({ renderTime: performance.now() - start });
|
||||||
if (sync) stdout.write(bsu);
|
if (sync) stdout.write(bsu);
|
||||||
|
|
||||||
if (hasStaticOutput) {
|
if (hasStaticOutput) {
|
||||||
@@ -1023,7 +1024,6 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
|||||||
if (frame === frameState.lastOutput && !hasStaticOutput) {
|
if (frame === frameState.lastOutput && !hasStaticOutput) {
|
||||||
// Unchanged frame and no new static: nothing to write (ink.tsx:590-596).
|
// Unchanged frame and no new static: nothing to write (ink.tsx:590-596).
|
||||||
if (sync) stdout.write(esu);
|
if (sync) stdout.write(esu);
|
||||||
if (onRender) onRender({ renderTime: performance.now() - start });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1044,13 +1044,12 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
|||||||
frameState.outputHeight = frame === "" ? 0 : frame.split("\n").length;
|
frameState.outputHeight = frame === "" ? 0 : frame.split("\n").length;
|
||||||
|
|
||||||
if (sync) stdout.write(esu);
|
if (sync) stdout.write(esu);
|
||||||
if (onRender) onRender({ renderTime: performance.now() - start });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interactive path
|
// Interactive path
|
||||||
renderInteractiveFrame(frame, outputHeight, hasStaticOutput ? staticOutput : "");
|
|
||||||
if (onRender) onRender({ renderTime: performance.now() - start });
|
if (onRender) onRender({ renderTime: performance.now() - start });
|
||||||
|
renderInteractiveFrame(frame, outputHeight, hasStaticOutput ? staticOutput : "");
|
||||||
}
|
}
|
||||||
|
|
||||||
// A single render-throttle window derived from maxFps drives BOTH the
|
// A single render-throttle window derived from maxFps drives BOTH the
|
||||||
|
|||||||
Reference in New Issue
Block a user