Skip to content

fix: preserve last <Static> line erased after a full-clear frame - #974

Merged
sindresorhus merged 5 commits into
vadimdemedes:masterfrom
costajohnt:fix/973-static-taller-than-viewport
Jul 16, 2026
Merged

fix: preserve last <Static> line erased after a full-clear frame#974
sindresorhus merged 5 commits into
vadimdemedes:masterfrom
costajohnt:fix/973-static-taller-than-viewport

Conversation

@costajohnt

@costajohnt costajohnt commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

What

Related to #973 (does not close it). This fixes a full clear accounting bug found while investigating that report. The last line of a <Static> block taller than the viewport was erased (and not repainted) when the live region updated after a frame routed through the full clear path. The incremental hasStaticOutput path implicated in the original capture is unchanged, so #973 stays open pending a recapture with a patched build.

Root cause

In the full-clear path of renderInteractiveFrame (src/ink.tsx), Ink wrote the live region as output (no trailing newline in non-fullscreen mode) but recorded its height via this.log.sync(outputToRender), where outputToRender is output + '\n'. The recorded line count was one row greater than what was actually on screen, so the next incremental frame's eraseLines(previousLineCount) reached one row too far and erased the last committed <Static> line. The full-clear path was the only one of the three render paths writing bare output; the other two already write outputToRender.

Fix

Write outputToRender in the full-clear path so the physical output matches the height it records (and the value already assigned to lastOutputToRender). In fullscreen mode outputToRender === output, so that path is byte-identical; in non-fullscreen mode the trailing newline rests the cursor exactly where the steady-state path already leaves it.

Test

Added a regression test that drives a <Static> block taller than a 4-row viewport through the frame sequence that triggers the bug. The erased line still exists in the raw output stream, so a small helper reconstructs the visible terminal buffer (replaying only the handful of escapes Ink emits — no new dependency) and asserts the last static line is still on screen. The test fails before this change and passes after. Full suite passes (1030 tests).


Note on the style: and chore: commits: CI installs with npm install (no committed lockfile), so it resolves prettier 3.9.x, while ^3.8.1 still accepts an existing 3.8.x install, which formats these files differently and fails XO. The chore: commit raises the floor to ^3.9.4 so any install satisfying the range lints the way CI does, and the style: commit contains the corresponding reformat.

…imdemedes#973)

In the full-clear render path, Ink wrote the live region as `output`
(no trailing newline in non-fullscreen mode) but recorded its height via
`log.sync(outputToRender)`, where `outputToRender` includes the trailing
newline. The recorded line count was therefore one row greater than what
was on screen, so the next incremental frame's `eraseLines` reached one
row too far and erased the last committed `<Static>` line.

Write `outputToRender` in the full-clear path so the physical output
matches the height it records (and the value already assigned to
`lastOutputToRender`), consistent with the other two render paths.
CI installs dependencies with `npm install` (no committed lockfile), so
`prettier: ^3.8.1` now resolves to 3.9.x, which formats these short union
types on a single line. The committed code predates that change, so the
lint step failed on files unrelated to this PR's fix. Reformat the four
affected declarations to match the prettier version CI resolves.
@sindresorhus

Copy link
Copy Markdown
Collaborator
  1. The style only commit is brittle. package.json allows Prettier 3.8.1, and with 3.8.1 the changed files fail XO on the collapsed union types and the loop spacing. CI happens to install a newer Prettier because there is no lockfile, but anyone with 3.8.1 gets a failing lint run. Increase the dependency there to latest.

  2. The new regression fixture is timer driven. The comments already say the three phases must render as separate frames, but the test only spaces them out with setTimeout. If the event loop is delayed and those updates batch together, the positive control can still pass while the bug was never exercised. Since useApp exposes waitUntilRenderFlush, the fixture should await a flushed frame between inflate, shrink, and nudge.

One more thing: I’m not fully convinced this closes #973 as originally reported. It fixes a real nearby bug in the full clear path, but the issue thread’s captured failure points at the hasStaticOutput incremental path after committing a tall Static block, and that branch is unchanged. So I’d either retitle this as a full clear accounting fix and not close #973 yet, or add evidence from the reporter’s replay that this patch fixes the original byte stream.

@costajohnt

Copy link
Copy Markdown
Contributor Author

Addressed all three points. The prettier floor is now ^3.9.4 so a 3.8.x install can no longer satisfy the range and fail lint. The fixture now awaits a flushed frame between the phases as suggested. One thing came up while doing it. In the default legacy mode a freshly scheduled update has not committed yet when waitUntilRenderFlush runs, so the awaits alone let all three updates batch into one frame and the rewritten test passed even with the fix reverted. Each phase now yields a macrotask before the flush so React commits first, and the test additionally asserts the frames were distinct so coalescing fails loudly instead of passing silently. I verified the test fails with the fix reverted and passes with it.

On the closing question, you are right. I edited the body so it no longer closes #973, since the incremental path from the original capture is untouched here. The reporter offered to capture again with a patched build, which would settle whether this full clear bug was the mechanism in the wild.

import React, {useEffect, useState} from 'react';
import {Static, Box, Text, render, useApp} from '../../src/index.js';

// Reproduces vadimdemedes/ink#973: the last line of a <Static> block taller

@sindresorhus sindresorhus Jul 4, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use a multi-line code comment for this whole thing. No hard-wrapping.

@sindresorhus

Copy link
Copy Markdown
Collaborator

In, test/fixtures/issue-973-static-taller-than-viewport.tsx and test/render.tsx:365, still names/comments this as reproducing #973, but the PR discussion and updated PR body correctly say this fixes a nearby full-clear accounting bug and does not cover the original incremental <Static> path from #973. I’d rename the fixture/test/comment to “full-clear static accounting” or “related to #973”.

@sindresorhus
sindresorhus merged commit e51dfdd into vadimdemedes:master Jul 16, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants