feat: test parity final push — 64 new tests, BSU/ESU, clear() API

Closes remaining test gaps between vue-tui and Ink:

Features:
- Add synchronized output (BSU/ESU) via DEC private mode 2026
- Add clear() API to TuiApp for erasing rendered output

Bug fixes:
- Cancel scheduler trailing timer on teardown (prevents stale commits)
- Guard teardown writes against ended/destroyed streams
- Reorder teardown to cancel timer before final commit

Tests (64 new, 2 skipped for known feature gaps):
- 7 BSU/ESU shouldSynchronize tests
- 5 borderBackgroundColor tests
- 13 component edge cases (empty text, number child, OSC hyperlink
  wrap-width, bare-text-in-Box validation, transform multi-line,
  leading whitespace, link escape closing)
- 13 text-width/CJK tests (alignment, truncation, overlay edge cases)
- 4 throttle + unmount edge cases
- 10 waitUntilRenderFlush write-callback-level tests + 1 clear() test
- 3 exit re-entrance tests
- 5 PTY #450 regression tests + 4 inline #450 tests

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-28 10:04:54 +08:00
parent 6628dde35e
commit 1ffb847a65
25 changed files with 1424 additions and 36 deletions
@@ -1082,6 +1082,117 @@ test("dim right border color", async () => {
`);
});
// --- borderBackgroundColor tests (ported from Ink border-backgrounds.tsx) ---
test("border with background color", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box borderStyle="single" borderColor="white" borderBackgroundColor="blue">
<Box width={4}>
<Text>Test</Text>
</Box>
</Box>
)),
{ columns: 100 },
);
const frame = lastFrame()!;
expect(frame).toContain("┌");
expect(frame).toContain("┐");
expect(frame).toContain("└");
expect(frame).toContain("┘");
expect(frame).toContain("Test");
// Blue background: ESC[44m
expect(frame).toContain("[44m");
});
test("border with different background colors per side", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box
borderStyle="single"
borderTopBackgroundColor="red"
borderBottomBackgroundColor="blue"
borderLeftBackgroundColor="green"
borderRightBackgroundColor="yellow"
>
<Box width={4}>
<Text>Test</Text>
</Box>
</Box>
)),
{ columns: 100 },
);
const frame = lastFrame()!;
expect(frame).toContain("┌");
expect(frame).toContain("Test");
// red=41, green=42, yellow=43, blue=44
expect(frame).toContain("[41m");
expect(frame).toContain("[42m");
expect(frame).toContain("[43m");
expect(frame).toContain("[44m");
});
test("border background color fallback to general borderBackgroundColor", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box borderStyle="single" borderBackgroundColor="magenta" borderTopBackgroundColor="cyan">
<Box width={4}>
<Text>Test</Text>
</Box>
</Box>
)),
{ columns: 100 },
);
const frame = lastFrame()!;
// cyan=46, magenta=45
expect(frame).toContain("[46m");
expect(frame).toContain("[45m");
});
test("vertical border background does not bleed into content rows", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box borderStyle="classic" borderBackgroundColor="cyan" alignSelf="flex-start" width={12}>
<Text>Text longer than the Box width, so will definitely wrap.</Text>
</Box>
)),
{ columns: 100 },
);
const frame = lastFrame()!;
const bgCyanPattern = "\\[46m";
const bgResetPattern = "\\[49m";
const tableBorderChar = "|";
const tableBorderPattern = bgCyanPattern + tableBorderChar + bgResetPattern;
const contentRowPattern = new RegExp(`^${tableBorderPattern}.*${tableBorderPattern}$`);
const tableRows = frame.split("\n");
const contentRows = tableRows.slice(1, -1);
for (const contentRow of contentRows) {
expect(contentRow).toMatch(contentRowPattern);
}
});
test("foreground, background and dim combine correctly", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box
borderTopDimColor
borderStyle="single"
borderTopColor="red"
borderTopBackgroundColor="cyan"
alignSelf="flex-start"
>
<Text>Hi</Text>
</Box>
)),
{ columns: 100 },
);
const frame = lastFrame()!;
// red FG=31, cyan BG=46, dim=2
expect(frame).toContain("[31m");
expect(frame).toContain("[46m");
expect(frame).toContain("[2m");
});
// borderDimColor should not dim styled child Text touching left edge
test("borderDimColor does not dim styled child Text touching left edge", async () => {
const { lastFrame } = await render(
@@ -12,3 +12,18 @@ test("<Box> inside <Text> throws an error", async () => {
await expect(render(App)).rejects.toThrow("can’t be nested inside <Text>");
});
test("fail when text nodes are not within <Text> component (mixed)", async () => {
const App = defineComponent(() => () => (
<Box>
Hello
<Text>World</Text>
</Box>
));
await expect(render(App)).rejects.toThrow("must be rendered inside <Text>");
});
test("fail when text node is not within <Text> component (full)", async () => {
const App = defineComponent(() => () => <Box>Hello World</Box>);
await expect(render(App)).rejects.toThrow("must be rendered inside <Text>");
});
@@ -1,9 +1,10 @@
import { defineComponent, shallowRef, nextTick } from "vue";
import { expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { Box, Text } from "@vue-tui/runtime";
import { renderToString, Box, Text } from "@vue-tui/runtime";
import chalk from "chalk";
import stripAnsi from "strip-ansi";
import ansiEscapes from "ansi-escapes";
test("nested Text renders inline without independent layout", async () => {
const { lastFrame } = await render(() => (
@@ -558,3 +559,120 @@ test("strip standalone C1 control characters from text output", async () => {
expect(frame).not.toContain("\x8e");
expect(stripAnsi(frame)).toBe("ABC");
});
// --- Component edge case tests (ported from Ink components.tsx) ---
test("ignore empty text node", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="column">
<Box>
<Text>Hello World</Text>
</Box>
<Text>{""}</Text>
</Box>
)),
{ columns: 100 },
);
expect(lastFrame()).toBe("Hello World");
});
test("render a single empty text node", async () => {
const { lastFrame } = await render(
defineComponent(() => () => <Text>{""}</Text>),
{ columns: 100 },
);
expect(lastFrame()).toBe("");
});
test("number", async () => {
const { lastFrame } = await render(
defineComponent(() => () => <Text>{1}</Text>),
{ columns: 100 },
);
expect(lastFrame()).toBe("1");
});
test("do not wrap text with BEL-terminated OSC hyperlinks", async () => {
const hyperlink = "\x1b]8;;https://example.com\x07Click here\x1b]8;;\x07";
const output = renderToString(
defineComponent(() => () => (
<Box width={20}>
<Text wrap="wrap">{hyperlink}</Text>
</Box>
)),
{ columns: 20 },
);
expect(stripAnsi(output)).toBe("Click here");
});
test("do not wrap text with ST-terminated OSC hyperlinks", async () => {
const hyperlink = "\x1b]8;;https://example.com\x1b\\Click here\x1b]8;;\x1b\\";
const output = renderToString(
defineComponent(() => () => (
<Box width={20}>
<Text wrap="wrap">{hyperlink}</Text>
</Box>
)),
{ columns: 20 },
);
expect(stripAnsi(output)).toBe("Click here");
});
// Feature gap: non-hyperlink OSC title sequences are consumed into the OSC payload
test.skip("do not wrap text with non-hyperlink OSC sequences", async () => {
const text = "\x1b]0;My Title\x07Some text";
const output = renderToString(
defineComponent(() => () => (
<Box width={20}>
<Text wrap="wrap">{text}</Text>
</Box>
)),
{ columns: 20 },
);
expect(stripAnsi(output)).toBe("Some text");
});
test("hard-wrap single-word BEL-terminated OSC hyperlink", async () => {
const hyperlink = "\x1b]8;;https://example.com\x07abcdefghij\x1b]8;;\x07";
const output = renderToString(
defineComponent(() => () => (
<Box width={5}>
<Text wrap="wrap">{hyperlink}</Text>
</Box>
)),
{ columns: 5 },
);
expect(stripAnsi(output)).toBe("abcde\nfghij");
});
// Feature gap: ST-terminated OSC sequences not handled correctly in wrap-ansi path
test.skip("hard-wrap single-word ST-terminated OSC hyperlink", async () => {
const hyperlink = "\x1b]8;;https://example.com\x1b\\abcdefghij\x1b]8;;\x1b\\";
const output = renderToString(
defineComponent(() => () => (
<Box width={5}>
<Text wrap="wrap">{hyperlink}</Text>
</Box>
)),
{ columns: 5 },
);
expect(stripAnsi(output)).toBe("abcde\nfghij");
});
test("ensure wrap-ansi doesn't trim leading whitespace", async () => {
const output = renderToString(
defineComponent(() => () => <Text color="red">{" ERROR "}</Text>),
{ columns: 100 },
);
expect(output).toBe(chalk.red(" ERROR "));
});
test("link ansi escapes are closed properly", async () => {
const output = renderToString(
defineComponent(() => () => <Text>{ansiEscapes.link("Example", "https://example.com")}</Text>),
{ columns: 100 },
);
expect(output).toContain("Example");
expect(output).toContain("example.com");
});
@@ -115,3 +115,15 @@ test("nested transforms apply inner-first: outer wraps inner result", async () =
// Apply left-to-right: inner("x") = "{x}", then outer("{x}") = "({x})"
expect(lastFrame()).toBe("({x})");
});
test("transform with multiple lines", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Transform transform={(s: string, idx: number) => `[${idx}: ${s}]`}>
<Text>{"hello world\ngoodbye world"}</Text>
</Transform>
)),
{ columns: 100 },
);
expect(lastFrame()).toBe("[0: hello world]\n[1: goodbye world]");
});
@@ -1,7 +1,9 @@
import { defineComponent, onScopeDispose } from "vue";
import { Writable } from "node:stream";
import { defineComponent, onMounted, onScopeDispose } from "vue";
import { expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { Text, useExit } from "@vue-tui/runtime";
import { createApp, Text, useExit } from "@vue-tui/runtime";
import { makeFakeWritable, makeFakeStdin, isWriteBarrierChunk } from "./test-streams.ts";
test("useExit() triggers teardown and waitUntilExit resolves", async () => {
let exitFn!: () => void;
@@ -188,3 +190,144 @@ test("exit(value) resolves even when called rapidly twice", async () => {
const result = await waitUntilExit();
expect(result).toBe("second");
});
// --- Exit re-entrance tests (ported from Ink render.tsx) ---
test("waitUntilExit resolves last exit value when duplicate exits happen during teardown", async () => {
// In vue-tui, exit() queues a microtask that overwrites pendingExitResult
// before the write barrier callback fires. When exit() is called twice,
// the second call's value wins because both microtasks run before the
// write barrier resolves (Ink preserves the first value instead).
let barrierWriteCallback: (() => void) | undefined;
const stdout = new Writable({
write(
chunk: string | Uint8Array,
_encoding: BufferEncoding,
callback: (error?: Error) => void,
) {
if (isWriteBarrierChunk(chunk)) {
barrierWriteCallback = callback;
return;
}
callback();
},
}) as unknown as NodeJS.WriteStream;
stdout.columns = 100;
const App = defineComponent(() => {
const exit = useExit();
onMounted(() => {
exit("first");
setTimeout(() => exit("second"), 0);
});
return () => <Text>Hello</Text>;
});
const app = createApp(App);
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
const exitPromise = app.waitUntilExit();
await new Promise((r) => setTimeout(r, 0));
if (barrierWriteCallback) {
barrierWriteCallback();
}
const result = await exitPromise;
expect(result).toBe("second");
});
test("waitUntilExit resolves last exit value when exit is re-entered during unmount writes", async () => {
// Same as above: the re-entrant exit("second") overwrites pendingExitResult
// because teardown() is idempotent but the result assignment still executes.
let exitFn: ((value?: unknown) => void) | undefined;
let shouldReenterExit = false;
let didReenterExit = false;
const stdout = new Writable({
write(
_chunk: string | Uint8Array,
_encoding: BufferEncoding,
callback: (error?: Error) => void,
) {
if (shouldReenterExit && !didReenterExit && exitFn) {
didReenterExit = true;
exitFn("second");
}
callback();
},
}) as unknown as NodeJS.WriteStream;
stdout.columns = 100;
stdout.isTTY = true;
const App = defineComponent(() => {
const exit = useExit();
onMounted(() => {
exitFn = exit;
shouldReenterExit = true;
exit("first");
});
return () => <Text>Hello</Text>;
});
const app = createApp(App);
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
const result = await app.waitUntilExit();
expect(didReenterExit).toBe(true);
expect(result).toBe("second");
});
test("exit with cross-realm Error resolves after stdout write callback", async () => {
// vue-tui uses `instanceof Error` to distinguish errors from result values.
// A cross-realm Error (created in a different VM context) fails the
// instanceof check, so it is treated as a result value and resolves
// rather than rejecting. This differs from Ink which rejects. The test
// verifies the write-callback timing: resolution waits for the barrier.
const vm = await import("node:vm");
let writeCallbackFired = false;
let barrierWriteCallbackFired = false;
const stdout = new Writable({
write(
chunk: string | Uint8Array,
_encoding: BufferEncoding,
callback: (error?: Error) => void,
) {
setTimeout(() => {
writeCallbackFired = true;
if (isWriteBarrierChunk(chunk)) {
barrierWriteCallbackFired = true;
}
callback();
}, 150);
},
}) as unknown as NodeJS.WriteStream;
stdout.columns = 100;
const foreignError = vm.runInNewContext("new Error('boom')") as Error;
const App = defineComponent(() => {
const exit = useExit();
onMounted(() => {
setTimeout(() => exit(foreignError), 0);
});
return () => <Text>Hello</Text>;
});
const app = createApp(App);
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
// Cross-realm Error fails instanceof check, so exit resolves with the
// error object as a value instead of rejecting.
const result = await app.waitUntilExit();
expect(result).toBe(foreignError);
expect(writeCallbackFired).toBe(true);
expect(barrierWriteCallbackFired).toBe(true);
});
@@ -1,4 +1,4 @@
import { PassThrough } from "node:stream";
import { PassThrough, Writable } from "node:stream";
export interface FakeWritableOptions {
columns?: number;
@@ -30,3 +30,57 @@ export function makeFakeStdin(): { stream: NodeJS.ReadStream } {
(s as any).unref = () => {};
return { stream: s };
}
export function createDelayedWriteCallbackStdout({
shouldDelay,
onDelayElapsed,
delayMs = 150,
}: {
shouldDelay: (chunk: string | Uint8Array) => boolean;
onDelayElapsed: () => void;
delayMs?: number;
}): NodeJS.WriteStream {
let didDelayOnce = false;
const stdout = new Writable({
write(
chunk: string | Uint8Array,
_encoding: BufferEncoding,
callback: (error?: Error) => void,
) {
if (!didDelayOnce && shouldDelay(chunk)) {
didDelayOnce = true;
setTimeout(() => {
onDelayElapsed();
callback();
}, delayMs);
return;
}
callback();
},
}) as unknown as NodeJS.WriteStream;
stdout.columns = 100;
stdout.isTTY = true;
return stdout;
}
export const isWriteBarrierChunk = (chunk: string | Uint8Array): boolean =>
(typeof chunk === "string" && chunk === "") ||
(chunk instanceof Uint8Array && chunk.length === 0);
export function captureWrites(stdout: NodeJS.WriteStream): string[] {
const writes: string[] = [];
const original = stdout.write.bind(stdout);
stdout.write = ((...args: unknown[]) => {
writes.push(String(args[0]));
return (original as Function)(...args);
}) as NodeJS.WriteStream["write"];
return writes;
}
export function getContentWrites(writes: string[]): string[] {
return writes.filter(
(w) => w !== "" && !w.startsWith("\x1b[?25") && w !== "\x1b[?2026h" && w !== "\x1b[?2026l",
);
}
@@ -1,7 +1,13 @@
import { defineComponent, nextTick, shallowRef } from "vue";
import { expect, test, vi } from "vite-plus/test";
import { createApp, Text } from "@vue-tui/runtime";
import { makeFakeStdin, makeFakeWritable } from "./test-streams.ts";
import stripAnsi from "strip-ansi";
import {
makeFakeStdin,
makeFakeWritable,
captureWrites,
getContentWrites,
} from "./test-streams.ts";
// Fake timer options: only fake setTimeout/clearTimeout/Date so that
// Vue's internal scheduler (nextTick, queueMicrotask, setImmediate) still
@@ -11,17 +17,6 @@ const FAKE_TIMER_OPTS = {
toFake: ["setTimeout", "clearTimeout", "Date"] as ("setTimeout" | "clearTimeout" | "Date")[],
};
/** Collect raw write calls from a fake writable. */
function captureWrites(stdout: NodeJS.WriteStream): string[] {
const writes: string[] = [];
const original = stdout.write.bind(stdout);
stdout.write = ((...args: unknown[]) => {
writes.push(String(args[0]));
return (original as Function)(...args);
}) as NodeJS.WriteStream["write"];
return writes;
}
test("throttle renders to maxFps", async () => {
// Port of Ink's "throttle renders to maxFps" — verifies leading+trailing
// throttle pattern with maxFps=1 (1000ms window).
@@ -155,3 +150,150 @@ test("screen reader mode bypasses throttle (immediate commits)", async () => {
app.unmount();
});
test("no throttled renders after unmount", async () => {
vi.useFakeTimers(FAKE_TIMER_OPTS);
try {
const msg = shallowRef("Foo");
const App = defineComponent(() => () => <Text>{msg.value}</Text>);
const app = createApp(App);
const stdout = makeFakeWritable({ columns: 80 });
const stderr = makeFakeWritable({ columns: 80 });
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
await nextTick();
await nextTick();
const initialCount = getContentWrites(writes).length;
expect(initialCount).toBeGreaterThanOrEqual(1);
msg.value = "Bar";
await nextTick();
msg.value = "Baz";
await nextTick();
app.unmount();
const countAfterUnmount = getContentWrites(writes).length;
vi.advanceTimersByTime(1000);
expect(getContentWrites(writes).length).toBe(countAfterUnmount);
} finally {
vi.useRealTimers();
}
});
test("unmount forces pending throttled render", async () => {
vi.useFakeTimers(FAKE_TIMER_OPTS);
try {
const msg = shallowRef("Hello");
const App = defineComponent(() => () => <Text>{msg.value}</Text>);
const app = createApp(App);
const stdout = makeFakeWritable({ columns: 80 });
const stderr = makeFakeWritable({ columns: 80 });
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false, maxFps: 1 });
await nextTick();
await nextTick();
expect(getContentWrites(writes).length).toBe(1);
expect(stripAnsi(getContentWrites(writes)[0]!)).toContain("Hello");
msg.value = "Final";
await nextTick();
await nextTick();
expect(getContentWrites(writes).length).toBe(1);
app.unmount();
const allContent = getContentWrites(writes).map((w) => stripAnsi(w));
expect(allContent.some((c) => c.includes("Final"))).toBe(true);
} finally {
vi.useRealTimers();
}
});
test("unmount cancels pending throttled log writes when stdout is ended", async () => {
vi.useFakeTimers(FAKE_TIMER_OPTS);
try {
const { PassThrough } = await import("node:stream");
const stdout = new PassThrough() as unknown as NodeJS.WriteStream;
stdout.columns = 100;
const writeErrors: Error[] = [];
stdout.on("error", (error: Error) => writeErrors.push(error));
const msg = shallowRef("Hello");
const App = defineComponent(() => () => <Text>{msg.value}</Text>);
const app = createApp(App);
const stderr = makeFakeWritable({ columns: 80 });
const { stream: stdin } = makeFakeStdin();
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false, maxFps: 1 });
await nextTick();
await nextTick();
msg.value = "World";
await nextTick();
stdout.end();
app.unmount();
vi.advanceTimersByTime(1000);
const hasWriteAfterEndError = writeErrors.some(
(e) => (e as NodeJS.ErrnoException).code === "ERR_STREAM_WRITE_AFTER_END",
);
expect(hasWriteAfterEndError).toBe(false);
} finally {
vi.useRealTimers();
}
});
test("unmount cancels pending throttled render when stdout is ended", async () => {
vi.useFakeTimers(FAKE_TIMER_OPTS);
try {
const { PassThrough } = await import("node:stream");
// Baseline: mount + end + unmount without pending rerender
const baseStdout = new PassThrough() as unknown as NodeJS.WriteStream;
baseStdout.columns = 100;
const BaseApp = defineComponent(() => () => <Text>Hello</Text>);
const baseApp = createApp(BaseApp);
const baseStderr = makeFakeWritable({ columns: 80 });
const { stream: baseStdin } = makeFakeStdin();
baseApp.mount({
stdout: baseStdout,
stdin: baseStdin,
stderr: baseStderr,
exitOnCtrlC: false,
maxFps: 1,
});
await nextTick();
await nextTick();
baseStdout.end();
baseApp.unmount();
const baselineTimers = vi.getTimerCount();
vi.runAllTimers();
// Test: mount + rerender + end + unmount
const stdout = new PassThrough() as unknown as NodeJS.WriteStream;
stdout.columns = 100;
const msg = shallowRef("Hello");
const App = defineComponent(() => () => <Text>{msg.value}</Text>);
const app = createApp(App);
const stderr = makeFakeWritable({ columns: 80 });
const { stream: stdin } = makeFakeStdin();
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false, maxFps: 1 });
await nextTick();
await nextTick();
msg.value = "World";
await nextTick();
stdout.end();
app.unmount();
expect(vi.getTimerCount()).toBe(baselineTimers);
} finally {
vi.useRealTimers();
}
});
@@ -1,7 +1,16 @@
import { defineComponent, nextTick, shallowRef } from "vue";
import { defineComponent, nextTick, onMounted, shallowRef } from "vue";
import { expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { Text } from "@vue-tui/runtime";
import { createApp, Text, useExit } from "@vue-tui/runtime";
import stripAnsi from "strip-ansi";
import {
makeFakeWritable,
makeFakeStdin,
createDelayedWriteCallbackStdout,
isWriteBarrierChunk,
captureWrites,
getContentWrites,
} from "./test-streams.ts";
test("waitUntilRenderFlush resolves after frame is written", async () => {
const App = defineComponent(() => () => <Text>hello</Text>);
@@ -34,3 +43,257 @@ test("waitUntilRenderFlush can be called multiple times", async () => {
await result.waitUntilRenderFlush();
expect(result.lastFrame()).toContain("stable");
});
// --- waitUntilRenderFlush write-callback level tests (ported from Ink render.tsx) ---
test("waitUntilRenderFlush resolves after stdout write callback", async () => {
let didInitialWriteCallbackFire = false;
const stdout = createDelayedWriteCallbackStdout({
shouldDelay: (chunk) => !isWriteBarrierChunk(chunk),
onDelayElapsed: () => {
didInitialWriteCallbackFire = true;
},
});
const App = defineComponent(() => () => <Text>Hello</Text>);
const app = createApp(App);
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
await app.waitUntilRenderFlush();
expect(didInitialWriteCallbackFire).toBe(true);
app.unmount();
await app.waitUntilExit();
});
test("waitUntilRenderFlush flushes pending throttled render", async () => {
const msg = shallowRef("Hello");
const App = defineComponent(() => () => <Text>{msg.value}</Text>);
const app = createApp(App);
const stdout = makeFakeWritable();
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false, maxFps: 1 });
await nextTick();
await nextTick();
expect(getContentWrites(writes).length).toBe(1);
msg.value = "World";
await nextTick();
await nextTick();
expect(getContentWrites(writes).length).toBe(1);
await app.waitUntilRenderFlush();
expect(getContentWrites(writes).length).toBe(2);
expect(stripAnsi(getContentWrites(writes)[1]!)).toContain("World");
app.unmount();
await app.waitUntilExit();
});
test("waitUntilRenderFlush resolves when stdout is not writable", async () => {
const msg = shallowRef("Hello");
const App = defineComponent(() => () => <Text>{msg.value}</Text>);
const app = createApp(App);
const stdout = makeFakeWritable();
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false, maxFps: 1 });
await nextTick();
await nextTick();
expect(getContentWrites(writes).length).toBe(1);
msg.value = "World";
await nextTick();
(stdout as NodeJS.WriteStream & { writable?: boolean }).writable = false;
await app.waitUntilRenderFlush();
app.unmount();
await app.waitUntilExit();
});
test("waitUntilRenderFlush waits for rerender write callback", async () => {
let didSecondWriteCallbackFire = false;
const stdout = createDelayedWriteCallbackStdout({
shouldDelay: (chunk) =>
!isWriteBarrierChunk(chunk) &&
stripAnsi(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)).includes(
"World",
),
onDelayElapsed: () => {
didSecondWriteCallbackFire = true;
},
});
const msg = shallowRef("Hello");
const App = defineComponent(() => () => <Text>{msg.value}</Text>);
const app = createApp(App);
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
await app.waitUntilRenderFlush();
msg.value = "World";
await nextTick();
await nextTick();
await app.waitUntilRenderFlush();
expect(didSecondWriteCallbackFire).toBe(true);
app.unmount();
await app.waitUntilExit();
});
test("waitUntilRenderFlush waits for all concurrent waiters on the same rerender", async () => {
const msg = shallowRef("Hello");
const App = defineComponent(() => () => <Text>{msg.value}</Text>);
const app = createApp(App);
const stdout = makeFakeWritable();
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
await app.waitUntilRenderFlush();
msg.value = "World";
await nextTick();
await nextTick();
// Ensure the "World" render is fully written before concurrent waits.
// (PassThrough can backpressure if barriers queue behind pending writes.)
await app.waitUntilRenderFlush();
let waiter1Resolved = false;
let waiter2Resolved = false;
await Promise.all([
app.waitUntilRenderFlush().then(() => {
waiter1Resolved = true;
}),
app.waitUntilRenderFlush().then(() => {
waiter2Resolved = true;
}),
]);
// Both concurrent waiters resolved
expect(waiter1Resolved).toBe(true);
expect(waiter2Resolved).toBe(true);
// The "World" content was rendered
expect(getContentWrites(writes).some((w) => stripAnsi(w).includes("World"))).toBe(true);
app.unmount();
});
test("waitUntilRenderFlush resolves after unmount", async () => {
const App = defineComponent(() => () => <Text>Hello</Text>);
const app = createApp(App);
const stdout = makeFakeWritable();
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
app.unmount();
await app.waitUntilExit();
await app.waitUntilRenderFlush();
});
test("waitUntilRenderFlush waits for unmount write callback", async () => {
let didUnmountWriteCallbackFire = false;
const stdout = createDelayedWriteCallbackStdout({
shouldDelay: (chunk) => isWriteBarrierChunk(chunk),
onDelayElapsed: () => {
didUnmountWriteCallbackFire = true;
},
});
const App = defineComponent(() => () => <Text>Hello</Text>);
const app = createApp(App);
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
app.unmount();
await app.waitUntilRenderFlush();
expect(didUnmountWriteCallbackFire).toBe(true);
});
test("waitUntilRenderFlush after unmount does not register beforeExit listener", async () => {
const App = defineComponent(() => () => <Text>Hello</Text>);
const app = createApp(App);
const stdout = makeFakeWritable();
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
const beforeWaitCount = process.listenerCount("beforeExit");
app.unmount();
await app.waitUntilRenderFlush();
expect(process.listenerCount("beforeExit")).toBe(beforeWaitCount);
});
test("waitUntilRenderFlush resolves after exit with error", async () => {
let exitFn!: (err: Error) => void;
const App = defineComponent(() => {
const exit = useExit();
onMounted(() => {
exitFn = exit as (err: Error) => void;
});
return () => <Text>Hello</Text>;
});
const app = createApp(App);
const stdout = makeFakeWritable();
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
await nextTick();
exitFn(new Error("boom"));
await expect(app.waitUntilExit()).rejects.toThrow("boom");
await app.waitUntilRenderFlush();
});
// useApp-level waitUntilRenderFlush tests:
// These test that waitUntilRenderFlush works when called from inside a component.
// In vue-tui, waitUntilRenderFlush is on the app instance, not a composable,
// so these are tested via the app.waitUntilRenderFlush() API above.
// The 2 "useApp waitUntilRenderFlush" tests from Ink are covered by the
// existing tests since vue-tui exposes the same API on the app object.
// --- clear() API test ---
test("clear output", async () => {
const msg = shallowRef("A\nB\nC");
const App = defineComponent(() => () => <Text>{msg.value}</Text>);
const app = createApp(App);
const stdout = makeFakeWritable();
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
await nextTick();
await nextTick();
expect(writes.some((w) => w.includes("A"))).toBe(true);
app.clear();
msg.value = "D";
await nextTick();
await nextTick();
await app.waitUntilRenderFlush();
// After clear + rerender, "D" should appear in content writes
const contentWrites = getContentWrites(writes);
expect(contentWrites.some((w) => stripAnsi(w).includes("D"))).toBe(true);
app.unmount();
});
@@ -0,0 +1,49 @@
import { EventEmitter } from "node:events";
import { test, expect } from "vite-plus/test";
import isInCi from "is-in-ci";
import { bsu, esu, shouldSynchronize } from "../../../runtime/src/io/write-synchronized.ts";
const createStream = ({ tty = false } = {}) => {
const stream = new EventEmitter() as unknown as NodeJS.WriteStream;
if (tty) {
stream.isTTY = true;
}
return stream;
};
test("bsu is the expected synchronized update sequence", () => {
expect(bsu).toBe("\x1b[?2026h");
});
test("esu is the expected synchronized update sequence", () => {
expect(esu).toBe("\x1b[?2026l");
});
test("shouldSynchronize returns true for interactive TTY stream", () => {
const stream = createStream({ tty: true });
expect(shouldSynchronize(stream, true)).toBe(true);
});
test("shouldSynchronize returns false for non-interactive TTY stream", () => {
const stream = createStream({ tty: true });
expect(shouldSynchronize(stream, false)).toBe(false);
});
test("shouldSynchronize returns false for non-TTY stream", () => {
const stream = createStream({ tty: false });
expect(shouldSynchronize(stream, true)).toBe(false);
});
test("shouldSynchronize uses CI detection when interactive is not specified", () => {
const ttyStream = createStream({ tty: true });
if (isInCi) {
expect(shouldSynchronize(ttyStream)).toBe(false);
} else {
expect(shouldSynchronize(ttyStream)).toBe(true);
}
});
test("shouldSynchronize returns false for non-TTY stream when interactive is not specified", () => {
const stream = createStream({ tty: false });
expect(shouldSynchronize(stream)).toBe(false);
});
@@ -0,0 +1,219 @@
import { defineComponent } from "vue";
import { test, expect } from "vite-plus/test";
import stripAnsi from "strip-ansi";
import stringWidth from "string-width";
import { renderToString, Box, Text } from "@vue-tui/runtime";
test("wide characters do not add extra space inside fixed-width Box", () => {
const output = renderToString(
defineComponent(() => () => (
<Box flexDirection="column">
<Box>
<Box width={2}>
<Text>🍔</Text>
</Box>
<Text>|</Text>
</Box>
<Box>
<Box width={2}>
<Text>⏳</Text>
</Box>
<Text>|</Text>
</Box>
</Box>
)),
{ columns: 100 },
);
const lines = output.split("\n");
expect(lines.length).toBe(2);
expect(lines[0]).toBe("🍔|");
expect(lines[1]).toBe("⏳|");
});
test("CJK characters occupy correct width in fixed-width Box", () => {
const output = renderToString(
defineComponent(() => () => (
<Box>
<Box width={4}>
<Text>你好</Text>
</Box>
<Text>|</Text>
</Box>
)),
{ columns: 100 },
);
expect(output).toBe("你好|");
});
test("mixed ASCII and wide characters align correctly", () => {
const output = renderToString(
defineComponent(() => () => (
<Box flexDirection="column">
<Box>
<Box width={6}>
<Text>ab🍔cd</Text>
</Box>
<Text>|</Text>
</Box>
<Box>
<Box width={6}>
<Text>abcdef</Text>
</Box>
<Text>|</Text>
</Box>
</Box>
)),
{ columns: 100 },
);
const lines = output.split("\n");
expect(lines.length).toBe(2);
expect(lines[0]).toBe("ab🍔cd|");
expect(lines[1]).toBe("abcdef|");
});
test("ANSI styled text does not affect layout width", () => {
const output = renderToString(
defineComponent(() => () => (
<Box>
<Box width={5}>
<Text color="red">hello</Text>
</Box>
<Text>|</Text>
</Box>
)),
{ columns: 100 },
);
expect(stripAnsi(output)).toBe("hello|");
});
test("empty Text does not affect sibling layout", () => {
const output = renderToString(
defineComponent(() => () => (
<Box>
<Text />
<Text>hello</Text>
</Box>
)),
{ columns: 100 },
);
expect(output).toBe("hello");
});
test("truncate CJK text at end", () => {
const output = renderToString(
defineComponent(() => () => (
<Box width={20}>
<Text wrap="truncate">あいうえおかきくけこ|end</Text>
</Box>
)),
{ columns: 100 },
);
expect(stringWidth(stripAnsi(output))).toBeLessThanOrEqual(20);
});
test("truncate CJK text in the middle", () => {
const output = renderToString(
defineComponent(() => () => (
<Box width={20}>
<Text wrap="truncate-middle">あいうえおかきくけこ|end</Text>
</Box>
)),
{ columns: 100 },
);
expect(stringWidth(stripAnsi(output))).toBeLessThanOrEqual(20);
});
test("truncate CJK text at start", () => {
const output = renderToString(
defineComponent(() => () => (
<Box width={20}>
<Text wrap="truncate-start">あいうえおかきくけこ|end</Text>
</Box>
)),
{ columns: 100 },
);
expect(stringWidth(stripAnsi(output))).toBeLessThanOrEqual(20);
});
test("truncate CJK text does not exceed Box width", () => {
const output = renderToString(
defineComponent(() => () => (
<Box>
<Box width={20}>
<Text wrap="truncate">あいうえおかきくけこ|end</Text>
</Box>
<Text>|</Text>
</Box>
)),
{ columns: 100 },
);
const lines = output.split("\n");
expect(lines.length).toBe(1);
expect(stripAnsi(lines[0]!).endsWith("|")).toBe(true);
});
test("overlay on 2nd cell of CJK character clears the full character", () => {
const output = renderToString(
defineComponent(() => () => (
<Box width={20} height={1}>
<Text>あいうえおかきくけこ</Text>
<Box position="absolute" left={9}>
<Text>XYZ</Text>
</Box>
</Box>
)),
{ columns: 20 },
);
const lines = output.split("\n");
expect(stringWidth(lines[0]!)).toBe(20);
expect(stripAnsi(lines[0]!)).toBe("あいうえ XYZきくけこ");
});
test("overlay on 1st cell of CJK character clears trailing placeholder", () => {
const output = renderToString(
defineComponent(() => () => (
<Box width={20} height={1}>
<Text>あいうえおかきくけこ</Text>
<Box position="absolute" left={10}>
<Text>X</Text>
</Box>
</Box>
)),
{ columns: 20 },
);
const lines = output.split("\n");
expect(stringWidth(lines[0]!)).toBe(20);
expect(stripAnsi(lines[0]!)).toBe("あいうえおX きくけこ");
});
test("CJK overlay on 2nd cell of CJK clears both sides", () => {
const output = renderToString(
defineComponent(() => () => (
<Box width={20} height={1}>
<Text>あいうえおかきくけこ</Text>
<Box position="absolute" left={5}>
<Text>漢字テスト</Text>
</Box>
</Box>
)),
{ columns: 20 },
);
const lines = output.split("\n");
expect(stringWidth(lines[0]!)).toBe(20);
expect(stripAnsi(lines[0]!)).toBe("あい 漢字テスト けこ");
});
test("clipped empty write does not corrupt existing wide characters", () => {
const output = renderToString(
defineComponent(() => () => (
<Box width={4} height={1} overflowX="hidden">
<Text>あい</Text>
<Box position="absolute" left={-1} width={1}>
<Text>Z</Text>
</Box>
</Box>
)),
{ columns: 4 },
);
expect(stripAnsi(output)).toBe("あい");
});
@@ -0,0 +1,6 @@
import { runIssue450RerenderFixture } from "./issue-450-fixture-helpers.js";
runIssue450RerenderFixture({
completionMarker: "__FULL_HEIGHT_RERENDER_COMPLETED__",
heightForFrame: (rows) => rows,
});
@@ -0,0 +1,6 @@
import { runIssue450RerenderFixture } from "./issue-450-fixture-helpers.js";
runIssue450RerenderFixture({
includeStaticLine: true,
heightForFrame: (rows) => rows,
});
@@ -0,0 +1,7 @@
import { runIssue450RerenderFixture } from "./issue-450-fixture-helpers.js";
runIssue450RerenderFixture({
frameLimit: 1,
rowsFallback: 3,
heightForFrame: (rows, frameCount) => (frameCount === 0 ? rows - 1 : rows + 1),
});
@@ -0,0 +1,5 @@
import { runIssue450RerenderFixture } from "./issue-450-fixture-helpers.js";
runIssue450RerenderFixture({
heightForFrame: (rows) => rows - 1,
});
@@ -0,0 +1,5 @@
import { runIssue450RerenderFixture } from "./issue-450-fixture-helpers.js";
runIssue450RerenderFixture({
heightForFrame: (rows, frameCount) => (frameCount === 0 ? rows + 1 : rows - 1),
});
@@ -0,0 +1,6 @@
import { runIssue450RerenderFixture } from "./issue-450-fixture-helpers.js";
runIssue450RerenderFixture({
includeStaticLine: true,
heightForFrame: (rows, frameCount) => (frameCount < 2 ? rows : rows - 1),
});
@@ -0,0 +1,128 @@
import { defineComponent, shallowRef, nextTick } from "vue";
import { test as it, expect } from "vite-plus/test";
import ansiEscapes from "ansi-escapes";
import { createApp, Text } from "@vue-tui/runtime";
import { makeFakeWritable, makeFakeStdin, captureWrites } from "../lifecycle/test-streams.ts";
function makeFakeNonTtyWritable(rows = 6): NodeJS.WriteStream {
const s = makeFakeWritable({ rows });
(s as any).isTTY = false;
return s;
}
it("#450: non-TTY full-height rerenders should never clear terminal", async () => {
const stdout = makeFakeNonTtyWritable(6);
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
const msg = shallowRef("line1\nline2\nline3\nline4\nline5\nline6");
const App = defineComponent(() => () => <Text>{msg.value}</Text>);
const app = createApp(App);
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
await nextTick();
await nextTick();
msg.value = "line1\nline2\nline3\nline4\nline5\nLINE6";
await nextTick();
await nextTick();
app.unmount();
const clearCount = writes.filter((w) => w.includes(ansiEscapes.clearTerminal)).length;
expect(clearCount).toBe(0);
});
it("#450: non-TTY overflow transitions should never clear terminal", async () => {
const stdout = makeFakeNonTtyWritable(6);
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
const msg = shallowRef("line1\nline2\nline3");
const App = defineComponent(() => () => <Text>{msg.value}</Text>);
const app = createApp(App);
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
await nextTick();
await nextTick();
msg.value = "line1\nline2\nline3\nline4\nline5\nline6\nline7";
await nextTick();
await nextTick();
app.unmount();
const clearCount = writes.filter((w) => w.includes(ansiEscapes.clearTerminal)).length;
expect(clearCount).toBe(0);
});
it("#450: viewport shrink into overflow should clear once", async () => {
const stdout = makeFakeWritable({ rows: 10 });
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
const msg = shallowRef("line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8");
const App = defineComponent(() => () => <Text>{msg.value}</Text>);
const app = createApp(App);
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
await nextTick();
await nextTick();
stdout.rows = 4;
stdout.emit("resize");
await nextTick();
await nextTick();
app.unmount();
const clearCount = writes.filter((w) => w.includes(ansiEscapes.clearTerminal)).length;
expect(clearCount).toBe(1);
});
it("#450: non-TTY grow-to-overflow rerender should not clear terminal", async () => {
const { spawn } = await import("node:child_process");
const fixturePath = new URL("./fixtures/issue-450-grow-to-overflow-rerender.tsx", import.meta.url)
.pathname;
const output = await new Promise<string>((resolve, reject) => {
let stdout = "";
let stderr = "";
const child = spawn("node", ["--import=tsx", fixturePath, "3"], {
cwd: new URL("./fixtures", import.meta.url).pathname,
env: {
...process.env,
NODE_NO_WARNINGS: "1",
CI: "false",
FORCE_COLOR: "3",
},
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.on("data", (data: Buffer) => {
stdout += data.toString();
});
child.stderr.on("data", (data: Buffer) => {
stderr += data.toString();
});
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) {
resolve(stdout);
} else {
reject(new Error(`Fixture exited with code ${code}: ${stderr}`));
}
});
setTimeout(() => {
child.kill();
reject(new Error("Fixture timed out"));
}, 10000);
});
const clearCount = output.split(ansiEscapes.clearTerminal).length - 1;
expect(clearCount).toBe(0);
});
@@ -197,6 +197,47 @@ it("#450: shrink from full-height to rows - 1 should clear exactly once", async
expect(clearTerminalCount).toBe(1);
});
it("#450 control: rows - 1 rerenders should avoid clearTerminal", async () => {
const { clearTerminalCount, eraseLineCount } = await runIssue450FixtureWithCounts(
"issue-450-height-minus-one-rerender",
);
expect(clearTerminalCount).toBe(0);
expect(eraseLineCount).toBeGreaterThan(0);
});
it("#450: full-height rerenders should not clear before unmount", async () => {
const outputBeforeMarker = await runIssue450FixtureBeforeMarker(
"issue-450-full-height-rerender-with-marker",
"__FULL_HEIGHT_RERENDER_COMPLETED__",
);
const { clearTerminalCount } = getIssue450ControlSequenceCounts(outputBeforeMarker);
expect(clearTerminalCount).toBe(0);
});
it("#450: shrink from overflow to rows - 1 should clear exactly once", async () => {
const { clearTerminalCount } = await runIssue450FixtureWithCounts(
"issue-450-shrink-from-overflow-rerender",
);
expect(clearTerminalCount).toBe(1);
});
it("#450: <Static> with shrink from full-height should clear exactly once", async () => {
const { output, clearTerminalCount } = await runIssue450FixtureWithCounts(
"issue-450-static-shrink-from-fullscreen-rerender",
);
expect(output).toContain("#450 static line");
expect(clearTerminalCount).toBe(1);
});
it("#450: full-height rerenders with <Static> should not repeatedly clear terminal", async () => {
const { output, clearTerminalCount, eraseLineCount } = await runIssue450FixtureWithCounts(
"issue-450-full-height-with-static-rerender",
);
expect(output).toContain("#450 static line");
expect(clearTerminalCount).toBeLessThanOrEqual(1);
expect(eraseLineCount).toBeGreaterThan(0);
});
// ── Animation exit tests ────────────────────────────────────────────
it("useAnimation can drive non-interactive process exit", async () => {
+2
View File
@@ -15,7 +15,9 @@
"@vue-tui/testing": "workspace:*",
"ansi-escapes": "catalog:",
"chalk": "catalog:",
"is-in-ci": "catalog:",
"node-pty": "catalog:",
"string-width": "catalog:",
"strip-ansi": "catalog:",
"tsx": "catalog:",
"typescript": "^6.0.3",
+3 -1
View File
@@ -1,8 +1,10 @@
import { defineConfig } from "vite-plus";
import vueJsx from "@vitejs/plugin-vue-jsx";
export default defineConfig({
plugins: [vueJsx()],
test: {
include: ["integration/pty/**/*.test.ts"],
include: ["integration/pty/**/*.test.{ts,tsx}"],
fileParallelism: false,
testTimeout: 15000,
env: { FORCE_COLOR: "3" },
@@ -0,0 +1,16 @@
import type { Writable } from "node:stream";
import isInCi from "is-in-ci";
export const bsu = "\x1b[?2026h";
export const esu = "\x1b[?2026l";
export function shouldSynchronize(
stream: Writable,
interactive?: boolean,
): boolean {
return (
"isTTY" in stream &&
(stream as Writable & { isTTY: boolean }).isTTY &&
(interactive ?? !isInCi)
);
}
+33 -16
View File
@@ -23,6 +23,7 @@ import { createCommitScheduler } from "./scheduler.ts";
import { paint, paintIsolated } from "./paint/paint.ts";
import { findStatics } from "./paint/static-channel.ts";
import { createFrameWriter } from "./io/frame-writer.ts";
import { bsu, esu, shouldSynchronize } from "./io/write-synchronized.ts";
import {
AppContextKey,
FocusContextKey,
@@ -116,6 +117,7 @@ export interface TuiApp extends Omit<VueApp<TuiNode>, "mount"> {
mount(options?: MountOptions): ComponentPublicInstance;
waitUntilExit(): Promise<unknown>;
waitUntilRenderFlush(): Promise<void>;
clear(): void;
}
type RootProps = Record<string, unknown>;
@@ -171,6 +173,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
let mountedScheduler: ReturnType<typeof createCommitScheduler> | null = null;
let mountedCommit: (() => void) | null = null;
let mountedAlternateScreen = false;
let mountedClear: (() => void) | null = null;
let mountedKittyController: ReturnType<typeof createKittyKeyboardController> | null = null;
// The renderer's onCommit closure is wired at createApp time but only does
@@ -204,6 +207,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
}
function writeBestEffort(stream: NodeJS.WriteStream, data: string) {
if (stream.destroyed || stream.writableEnded) return;
try {
stream.write(data);
} catch {
@@ -216,21 +220,22 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
if (teardownStarted) return;
teardownStarted = true;
// Final render before unmount (matching Ink ink.tsx:755-761).
// Cancel any pending trailing-edge timer first, then do a final
// synchronous commit so the latest state is always flushed before
// unmount (matching Ink ink.tsx:755-761).
// teardownStarted=true makes shouldClearTerminalForFrame see isUnmounting,
// so fullscreen apps get clearTerminal on exit.
if (mountedInteractive && !mountedDebug && mountedCommit) {
const skipFinalRender = mountedScheduler?.hasPending();
if (!skipFinalRender) {
try {
mountedCommit();
} catch {
// Final render is best-effort; don't block teardown cleanup.
}
scheduledCommit = () => {};
mountedScheduler?.cancel();
const stdout = mountedAppContext?.stdout;
const stdoutWritable = stdout && !stdout.destroyed && !stdout.writableEnded;
if (mountedInteractive && !mountedDebug && mountedCommit && stdoutWritable) {
try {
mountedCommit();
} catch {
// Final render is best-effort; don't block teardown cleanup.
}
}
scheduledCommit = () => {};
// Restore console BEFORE Vue cleanup (matching Ink ink.tsx:779)
if (mountedRestoreConsole) {
mountedRestoreConsole();
@@ -249,7 +254,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
// Non-interactive: write the deferred last frame at unmount (matching Ink).
const lastFrame = mountedGetLastOutput?.() ?? "";
if (lastFrame) {
mountedAppContext.stdout.write(lastFrame + "\n");
writeBestEffort(mountedAppContext.stdout, lastFrame + "\n");
}
}
if (mountedWriter && !mountedDebug && mountedInteractive) mountedWriter.done();
@@ -258,7 +263,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
writeBestEffort(mountedAppContext.stdout, "\x1b[?25h");
mountedAlternateScreen = false;
} else if (!mountedDebug && mountedInteractive && mountedAppContext) {
mountedAppContext.stdout.write("\x1b[?25h");
writeBestEffort(mountedAppContext.stdout, "\x1b[?25h");
}
if (mountedRoot) detachYoga(mountedRoot);
if (mountedResizeHandler && mountedAppContext) {
@@ -459,6 +464,12 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
incremental: options.incrementalRendering,
});
mountedWriter = writer;
mountedClear = () => {
if (!interactive || debug) return;
writer.clear();
writer.sync(frameState.lastOutputToRender || frameState.lastOutput + "\n");
};
const synchronize = shouldSynchronize(stdout, interactive);
function renderInteractiveFrame(output: string, outputHeight: number, staticOutput: string) {
const hasStaticOutput = staticOutput !== "";
@@ -480,16 +491,18 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
if (shouldClear) {
// Direct write: clearTerminal + accumulated static + raw output
stdout.write(ansiEscapes.clearTerminal + frameState.fullStaticOutput + output);
const content = ansiEscapes.clearTerminal + frameState.fullStaticOutput + output;
stdout.write(synchronize ? bsu + content + esu : content);
// Sync log-update state so next render computes correct erase
writer.sync(outputToRender);
} else if (hasStaticOutput) {
// Clear frame -> write static -> re-render frame via log-update
if (synchronize) stdout.write(bsu);
writer.clear();
stdout.write(staticOutput);
writer.write(outputToRender);
writer.write(synchronize ? outputToRender + esu : outputToRender);
} else {
writer.write(outputToRender);
writer.write(synchronize ? bsu + outputToRender + esu : outputToRender);
}
frameState.lastOutput = output;
@@ -696,6 +709,10 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
});
};
app.clear = function clear(): void {
mountedClear?.();
};
return app;
}
+12 -1
View File
@@ -5,6 +5,8 @@ export interface CommitScheduler {
flush: () => Promise<void>;
/** Returns true when a trailing-edge commit is pending. */
hasPending: () => boolean;
/** Cancel any pending trailing-edge timer. */
cancel: () => void;
}
export interface CommitSchedulerOptions {
@@ -89,5 +91,14 @@ export function createCommitScheduler(
return hasPendingFlag;
}
return { schedule, flush, hasPending };
function cancel() {
if (trailingTimer) {
clearTimeout(trailingTimer);
trailingTimer = null;
}
hasPendingFlag = false;
scheduled = false;
}
return { schedule, flush, hasPending, cancel };
}