diff --git a/packages/runtime-tests/integration/lifecycle/on-render.test.tsx b/packages/runtime-tests/integration/lifecycle/on-render.test.tsx index 9a117e5..0346eb6 100644 --- a/packages/runtime-tests/integration/lifecycle/on-render.test.tsx +++ b/packages/runtime-tests/integration/lifecycle/on-render.test.tsx @@ -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(() => () => Hello); + + 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 diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index 3ee35cd..23ef7a4 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -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