fix(runtime): sync log-update with the bytes actually written on the clear path (#199)
The clear-terminal branch of renderInteractiveFrame writes raw `output` (no trailing newline) via a direct stdout.write, but then called `writer.sync(outputToRender)`. `outputToRender` appends "\n" for non-fullscreen frames, so on a fullscreen→non-fullscreen transition (a fullscreen frame shrinking below the viewport) sync recorded a state that didn't match the screen: with a declared cursor (useCursor) it placed the persistent caret one row too high (buildCursorSuffix with hasTrailingNewline=true, basing the caret on row `visibleLineCount` instead of the real `visibleLineCount - 1`), and it recorded previousLineCount off by one so the next frame's erase was eraseLines(N+1) (G46 residue). This is the fullscreen→non-fullscreen sibling of #198. Fix: sync the SAME string just written (`output`). `outputToRender === output` whenever the frame is fullscreen or screen-reader, so steady-state fullscreen and SR are byte-for-byte unchanged (G17: an empty SR frame still syncs "" → zero lines). Leaving-fullscreen, overflowing, and unmount-clear all write raw `output`, so syncing `output` is consistent for every clear sub-case. TDD: a new integration test mounts a fullscreen TTY frame with a declared cursor, shrinks it below the viewport, and asserts the emitted caret row and the following frame's erase count. Red before the fix (cursorUp(3)/eraseLines(4)), green after (cursorUp(2)/eraseLines(3)). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
// When an app LEAVES fullscreen (a fullscreen frame whose height >= viewport
|
||||
// shrinks to a non-fullscreen frame), render.ts takes the shouldClear branch:
|
||||
// it writes `clearTerminal + fullStaticOutput + output` (RAW output, NO trailing
|
||||
// newline) and then calls `writer.sync(outputToRender)`. But for a
|
||||
// non-fullscreen frame `outputToRender = output + "\n"`. So the bytes on screen
|
||||
// have NO trailing newline (the caret rests on the last visible row), while
|
||||
// writer.sync records a TRAILING-newline state (and a line count off by one).
|
||||
//
|
||||
// With an active declared cursor (useCursor → setCursorPosition), sync() then
|
||||
// emits buildCursorSuffix(..., hasTrailingNewline=true), which places the caret
|
||||
// one row too HIGH (it moves up from row `visibleLineCount` instead of the real
|
||||
// `visibleLineCount - 1`), and previousLineCount is off by one so the NEXT
|
||||
// frame's return-to-bottom / erase is off by one (G46-style residue).
|
||||
//
|
||||
// This is the fullscreen→non-fullscreen sibling of #198 (which fixed the
|
||||
// steady-state fullscreen caret row). Observable only at the interactive stream
|
||||
// level — @vue-tui/testing lastFrame() is content-only and never sees the cursor
|
||||
// escapes — so we mount a REAL interactive TTY and assert the raw bytes.
|
||||
import { PassThrough } from "node:stream";
|
||||
import { defineComponent, h, nextTick, shallowRef } from "vue";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { Box, Text, createApp, useCursor } from "@vue-tui/runtime";
|
||||
import ansiEscapes from "ansi-escapes";
|
||||
|
||||
const SHOW = "\x1b[?25h";
|
||||
|
||||
function makeTtyStdout(): { stream: NodeJS.WriteStream; writes: string[] } {
|
||||
const stream = new PassThrough() as unknown as NodeJS.WriteStream;
|
||||
// rows:10 so a 12-line frame is fullscreen (outputHeight >= viewportRows).
|
||||
Object.assign(stream, { isTTY: true, columns: 40, rows: 10 });
|
||||
const writes: string[] = [];
|
||||
const original = stream.write.bind(stream);
|
||||
stream.write = ((...args: unknown[]) => {
|
||||
writes.push(String(args[0]));
|
||||
return (original as (...a: unknown[]) => boolean)(...args);
|
||||
}) as NodeJS.WriteStream["write"];
|
||||
return { stream, writes };
|
||||
}
|
||||
|
||||
function makeTtyStdin(): NodeJS.ReadStream {
|
||||
const s = new PassThrough() as unknown as NodeJS.ReadStream;
|
||||
Object.assign(s, {
|
||||
isTTY: true,
|
||||
setRawMode(this: NodeJS.ReadStream) {
|
||||
return this;
|
||||
},
|
||||
setEncoding(this: NodeJS.ReadStream) {
|
||||
return this;
|
||||
},
|
||||
});
|
||||
(s as unknown as { ref: () => void }).ref = () => {};
|
||||
(s as unknown as { unref: () => void }).unref = () => {};
|
||||
return s;
|
||||
}
|
||||
|
||||
function makeTtyStderr(): NodeJS.WriteStream {
|
||||
const stream = new PassThrough() as unknown as NodeJS.WriteStream;
|
||||
Object.assign(stream, { isTTY: true, columns: 40, rows: 10 });
|
||||
return stream;
|
||||
}
|
||||
|
||||
// maxFps:0 makes commits immediate (no throttle) so each frame flushes
|
||||
// synchronously through log-update via waitUntilRenderFlush().
|
||||
function mountOpts(stdout: NodeJS.WriteStream) {
|
||||
return {
|
||||
stdout,
|
||||
stdin: makeTtyStdin(),
|
||||
stderr: makeTtyStderr(),
|
||||
interactive: true,
|
||||
exitOnCtrlC: false,
|
||||
maxFps: 0,
|
||||
patchConsole: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("leaving-fullscreen clear path with a declared cursor", () => {
|
||||
test("the shrink frame's caret lands on the LAST written row, not one above it", async () => {
|
||||
const { stream: stdout, writes } = makeTtyStdout();
|
||||
const lineCount = shallowRef(12); // start fullscreen (>= rows:10)
|
||||
|
||||
const App = defineComponent(() => {
|
||||
const { setCursorPosition } = useCursor();
|
||||
return () => {
|
||||
// Cursor at top-left so the row math is unambiguous: the suffix is a
|
||||
// pure cursorUp(<rows-to-top>) + cursorTo(0) + show.
|
||||
setCursorPosition({ x: 0, y: 0 });
|
||||
return h(Box, { flexDirection: "column" }, () =>
|
||||
Array.from({ length: lineCount.value }, (_unused, i) =>
|
||||
h(Text, { key: i }, () => `Line${i + 1}`),
|
||||
),
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const app = createApp(App);
|
||||
app.mount(mountOpts(stdout));
|
||||
await app.waitUntilRenderFlush();
|
||||
|
||||
// Now leave fullscreen: shrink 12 -> 3 lines (3 < rows:10).
|
||||
const before = writes.length;
|
||||
lineCount.value = 3;
|
||||
await nextTick();
|
||||
await app.waitUntilRenderFlush();
|
||||
const shrinkBytes = writes.slice(before).join("");
|
||||
|
||||
// The shrink frame is the clear path: clearTerminal + the 3-line output with
|
||||
// NO trailing newline. So after it the real caret sits on the LAST visible
|
||||
// row (row index 2 of 3). The declared cursor is {x:0,y:0}, so the suffix
|
||||
// must move UP exactly 2 rows: cursorUp(2). The bug recorded a trailing
|
||||
// newline, computing the caret basis as row 3 -> cursorUp(3) (one too many),
|
||||
// floating the caret one row above the top of the content.
|
||||
expect(shrinkBytes).toContain(ansiEscapes.clearTerminal);
|
||||
expect(shrinkBytes).toContain(SHOW);
|
||||
const upTwo = ansiEscapes.cursorUp(2); // "\x1b[2A"
|
||||
const upThree = ansiEscapes.cursorUp(3); // "\x1b[3A"
|
||||
expect(shrinkBytes).toContain(upTwo + ansiEscapes.cursorTo(0) + SHOW);
|
||||
expect(shrinkBytes).not.toContain(upThree + ansiEscapes.cursorTo(0) + SHOW);
|
||||
});
|
||||
|
||||
test("the NEXT frame's erase after leaving fullscreen is eraseLines(N), not (N+1)", async () => {
|
||||
const { stream: stdout, writes } = makeTtyStdout();
|
||||
const lineCount = shallowRef(12);
|
||||
const tag = shallowRef("A");
|
||||
|
||||
const App = defineComponent(() => {
|
||||
const { setCursorPosition } = useCursor();
|
||||
return () => {
|
||||
setCursorPosition({ x: 0, y: 0 });
|
||||
return h(Box, { flexDirection: "column" }, () =>
|
||||
Array.from({ length: lineCount.value }, (_unused, i) =>
|
||||
h(Text, { key: i }, () => `Line${i + 1}-${tag.value}`),
|
||||
),
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const app = createApp(App);
|
||||
app.mount(mountOpts(stdout));
|
||||
await app.waitUntilRenderFlush();
|
||||
|
||||
// Leave fullscreen: 12 -> 3 lines.
|
||||
lineCount.value = 3;
|
||||
await nextTick();
|
||||
await app.waitUntilRenderFlush();
|
||||
|
||||
// Now a plain content change at 3 lines (NOT a clear). log-update's diff
|
||||
// return-to-bottom must measure from the 3-line state actually on screen.
|
||||
// If sync recorded previousLineCount=4 (trailing-newline state) the diff
|
||||
// walks one row too many.
|
||||
const before = writes.length;
|
||||
tag.value = "B";
|
||||
await nextTick();
|
||||
await app.waitUntilRenderFlush();
|
||||
const updateBytes = writes.slice(before).join("");
|
||||
|
||||
// The 3-line frame has no trailing newline, so the caret rested on row 2 and
|
||||
// the return-to-bottom prefix on the next diff must move DOWN 0 rows from
|
||||
// {y:0}? No — it returns from previousCursorPosition.y(0) to bottom row
|
||||
// (previousLineCount-1). With the correct state previousLineCount=3 -> bottom
|
||||
// row 2 -> cursorDown(2). The buggy state previousLineCount=4 -> cursorDown(3).
|
||||
expect(updateBytes).toContain(ansiEscapes.cursorDown(2));
|
||||
expect(updateBytes).not.toContain(ansiEscapes.cursorDown(3));
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
});
|
||||
@@ -975,8 +975,24 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
||||
// string) so synchronization survives log-update's line diffing.
|
||||
if (synchronize) stdout.write(bsu);
|
||||
stdout.write(ansiEscapes.clearTerminal + frameState.fullStaticOutput + output);
|
||||
// Sync log-update state so next render computes correct erase
|
||||
writer.sync(outputToRender);
|
||||
// Sync log-update with the SAME bytes we just wrote (raw `output`), NOT
|
||||
// `outputToRender`. The direct write above emits raw `output` (no
|
||||
// appended "\n"), so sync()'s recorded state — line count AND the
|
||||
// hasTrailingNewline basis it feeds buildCursorSuffix — must reflect
|
||||
// `output`, not the "\n"-suffixed `outputToRender`. They DIVERGE only
|
||||
// when leaving fullscreen (a fullscreen frame shrinking below the
|
||||
// viewport): there `outputToRender = output + "\n"` while the screen has
|
||||
// raw `output`. Syncing `outputToRender` would (a) place the persistent
|
||||
// caret one row too HIGH (sync emits buildCursorSuffix with
|
||||
// hasTrailingNewline=true, basing the caret on row `visibleLineCount`
|
||||
// instead of the real `visibleLineCount - 1`), and (b) record
|
||||
// previousLineCount off by one so the next frame's return-to-bottom /
|
||||
// erase is eraseLines(N+1) (G46 residue). This is the
|
||||
// fullscreen→non-fullscreen sibling of #198. For the steady-state
|
||||
// fullscreen and screen-reader sub-cases `outputToRender === output`, so
|
||||
// this is byte-for-byte unchanged (G17: an empty SR frame still syncs ""
|
||||
// → zero lines, no spurious blank line).
|
||||
writer.sync(output);
|
||||
if (synchronize) stdout.write(esu);
|
||||
} else if (hasStaticOutput) {
|
||||
// Clear frame -> write static -> re-render frame via log-update
|
||||
|
||||
Reference in New Issue
Block a user