diff --git a/.agents/docs/parity-ledger.md b/.agents/docs/parity-ledger.md index 9afc460..d7ce934 100644 --- a/.agents/docs/parity-ledger.md +++ b/.agents/docs/parity-ledger.md @@ -86,7 +86,7 @@ Non-obvious calls made while fixing gaps, recorded for review in the final repor | G43 | render-lifecycle-reconciler | Non-interactive unmount SKIPS the trailing newline when the final frame is empty — sweep-5 LOW | P3 | todo | — | — | | G44 | static-newline-spacer | `` layout props (flexDirection/padding/justify/align/gap/margin/width) dropped from isolated paint — only column-default + visual border/color survive (MEDIUM, sweep-6) | P1 | todo | — | — | | G45 | focus | Programmatic focusNext()/focusPrevious() are no-ops while focus is disabled; Ink keeps them live (guard belongs only in Tab listener) (MEDIUM, sweep-6) | P1 | pr-open | `fix/parity-programmatic-focus-while-disabled` | #57 | -| G46 | render-lifecycle-reconciler | Non-empty multi-line screen-reader frames append a spurious trailing `\n` + off-by-one erase that Ink does not (G17 only handled the empty case) (MEDIUM, sweep-6) | P1 | todo | — | — | +| G46 | render-lifecycle-reconciler | Non-empty multi-line screen-reader frames append a spurious trailing `\n` + off-by-one erase that Ink does not (G17 only handled the empty case) (MEDIUM, sweep-6) | P1 | pr-open | `fix/parity-nonempty-sr-frame-newline` | #58 | | G47 | box-layout-border | Per-edge border toggles (borderTop/Bottom/Left/Right) reserve yoga border space even with no borderStyle set (only on reactive patch) — sweep-6 LOW | P3 | todo | — | — | | G48 | text-wrap-transform | Measure path omits sanitizeAnsi that Ink applies in squashTextNodes (diverges only for private-param/intermediate-byte CSI) — sweep-6 LOW | P3 | todo | — | — | | G49 | stdout-stderr-stdin-size-cursor | restoreLastOutput() uses `??` (nullish) instead of Ink's ` | | ` (falsy) fallback, dropping the restored trailing newline before the first frame — sweep-6 LOW | P3 | todo | — | — | diff --git a/packages/runtime-tests/integration/accessibility/screen-reader-live.test.tsx b/packages/runtime-tests/integration/accessibility/screen-reader-live.test.tsx index 5a61e96..184c5a7 100644 --- a/packages/runtime-tests/integration/accessibility/screen-reader-live.test.tsx +++ b/packages/runtime-tests/integration/accessibility/screen-reader-live.test.tsx @@ -1,5 +1,6 @@ -import { defineComponent, nextTick } from "vue"; +import { defineComponent, nextTick, shallowRef } from "vue"; import { expect, test } from "vite-plus/test"; +import ansiEscapes from "ansi-escapes"; import { Box, createApp, Static, Text } from "@vue-tui/runtime"; import { makeFakeStdin, @@ -180,3 +181,67 @@ test.sequential("empty SR frame does not write a spurious blank trailing line", app.unmount(); }); + +// G46 (Ink parity): a NON-empty multi-line SR frame must be written verbatim +// with NO appended trailing newline, matching Ink's SR branch (ink.tsx:617-621): +// stdout.write(erase + wrappedOutput); lastOutputToRender = wrappedOutput (no +// "\n"); lastOutputHeight = wrappedOutput.split("\n").length. G17 only handled +// the empty case, so a 2-line frame "Line one\nLine two" got a spurious "\n" +// appended — parking the cursor on a blank line below content AND making every +// subsequent multi-line SR frame erase N+1 lines instead of N (off-by-one). +test.sequential("non-empty multi-line SR frame appends no trailing newline and erases exactly N lines", async () => { + const labels = shallowRef<[string, string]>(["Line one", "Line two"]); + const App = defineComponent(() => { + return () => ( + + {labels.value[0]} + {labels.value[1]} + + ); + }); + + 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, + isScreenReaderEnabled: true, + }); + + await nextTick(); + await nextTick(); + + const firstContent = getContentWrites(writes).join(""); + + // (a) The first SR frame must end exactly at the last content line — no + // spurious trailing newline below "Line two". + expect(firstContent).toContain("Line one\nLine two"); + expect(firstContent.endsWith("Line two")).toBe(true); + expect(firstContent.endsWith("Line two\n")).toBe(false); + + // Reactive update to a second multi-line frame. + writes.length = 0; + labels.value = ["Line three", "Line four"]; + await nextTick(); + await nextTick(); + + const secondRaw = writes.join(""); + + // (b) The erase for the previous 2-line frame must be exactly eraseLines(2), + // NOT eraseLines(3) (the off-by-one caused by the spurious trailing newline). + expect(secondRaw).toContain(ansiEscapes.eraseLines(2)); + expect(secondRaw).not.toContain(ansiEscapes.eraseLines(3)); + + // And the second frame is itself written verbatim with no trailing newline. + const secondContent = getContentWrites(writes).join(""); + expect(secondContent.endsWith("Line four")).toBe(true); + expect(secondContent.endsWith("Line four\n")).toBe(false); + + app.unmount(); +}); diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index 429f0f9..8ef736f 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -639,12 +639,16 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp // Fullscreen: output fills or exceeds terminal height — no trailing newline. // Only apply when writing to a real TTY — piped output always gets trailing newlines. const isFullscreen = isTty && outputHeight >= viewportRows; - // SR parity (G17 edge b): Ink's screen-reader path writes the wrapped - // output directly with NO appended newline (ink.tsx:617-621), so an empty - // SR frame emits zero lines instead of a spurious blank line. We scope - // this to EMPTY SR output to avoid touching non-SR or non-empty SR frames. - const isEmptyScreenReaderFrame = isScreenReaderEnabled && output === ""; - const outputToRender = isFullscreen || isEmptyScreenReaderFrame ? output : output + "\n"; + // SR parity (G17 + G46): Ink's screen-reader branch (ink.tsx:617-621) + // writes the wrapped output verbatim — `stdout.write(erase + wrappedOutput)` + // with `lastOutputToRender = wrappedOutput` (NO appended "\n" in ANY case) + // and `lastOutputHeight = wrappedOutput === "" ? 0 : split("\n").length`. + // So EVERY SR frame, empty or not, must skip the trailing newline: an empty + // frame emits zero lines instead of a spurious blank line (G17), and a + // non-empty multi-line frame keeps its true line count so the next-frame + // erase is eraseLines(N), not eraseLines(N+1) (G46 off-by-one). Non-SR + // interactive frames are untouched — they still append "\n" as before. + const outputToRender = isFullscreen || isScreenReaderEnabled ? output : output + "\n"; const shouldClear = shouldClearTerminalForFrame({ isTty,