Skip to content

Fix cursorUp offset in incremental rendering for trailing newline#910

Merged
sindresorhus merged 2 commits into
vadimdemedes:masterfrom
ScriptBloom:fix/incremental-cursorup-off-by-one
Mar 23, 2026
Merged

Fix cursorUp offset in incremental rendering for trailing newline#910
sindresorhus merged 2 commits into
vadimdemedes:masterfrom
ScriptBloom:fix/incremental-cursorup-off-by-one

Conversation

@ScriptBloom

Copy link
Copy Markdown
Contributor

Summary

Fixes the cursor rewind offset in createIncremental (src/log-update.ts) that causes ghost/duplicate lines when the rendered output has a trailing newline.

Root cause: The "same or grow height" branch uses cursorUp(previousVisible - 1) to rewind to line 0. previousVisible is visibleLineCount() which excludes the trailing empty line from split('\n'). But after writing output ending with '\n', the cursor sits one row past the last visible line. So the rewind falls one row short — line 0 is never reached, all writes land one row too low, and the old line 0 content remains as a ghost line.

Fix: Replace previousVisible - 1 with previousLines.length - 1. This correctly accounts for the cursor position in both cases:

  • Trailing '\n' (non-fullscreen): previousLines.length = previousVisible + 1, so length - 1 = previousVisible → rewinds to row 0 ✅
  • No trailing '\n' (fullscreen): previousLines.length = previousVisible, so length - 1 = previousVisible - 1 → same as before ✅

The "shrink" branch is unaffected because it already handles trailing newlines via the extraSlot variable.

Changes

One line in src/log-update.ts:

  } else {
-   buffer.push(ansiEscapes.cursorUp(previousVisible - 1));
+   buffer.push(ansiEscapes.cursorUp(previousLines.length - 1));
  }

Test Plan

  • Verify with a non-fullscreen app using incrementalRendering: true that state changes update lines in place without ghost lines
  • Verify fullscreen apps are unaffected (no behavioral change when output has no trailing newline)
  • Verify the "shrink" path still works correctly when output height decreases
  • Existing test suite passes (test/log-update.tsx: 40 tests, test/cursor-helpers.tsx: 17 tests)

Related

Made with Cursor

@sindresorhus

Copy link
Copy Markdown
Collaborator

I think this needs a small regression test before merging. The fix itself looks right, but the current tests still don't pin down the case that broke here: a same-height incremental update after a render that ends with a trailing newline.

Right now the nearby tests only check that the update contains some cursor movement and the changed line text, so this exact off-by-one could come back later and still pass. I'd add one focused assertion around a flow like render('Line 1\nLine 2\nLine 3\n'); render('Line 1\nUpdated\nLine 3\n'); and verify the second write rewinds all the way to the top of the block before doing the surgical update.

@ScriptBloom

Copy link
Copy Markdown
Contributor Author

Thanks for the review! Added a focused regression test in 389a241 that asserts the exact cursorUp value for a same-height incremental update with trailing newline:

render('Line 1\nLine 2\nLine 3\n');
render('Line 1\nUpdated\nLine 3\n');
// Asserts secondCall starts with cursorUp(3), not cursorUp(2)
t.true(secondCall.startsWith(ansiEscapes.cursorUp(3)));

The test verifies the second write rewinds all the way to row 0 (cursorUp(3) = previousLines.length - 1). I confirmed it fails with the old code (cursorUp(2) = previousVisible - 1) and passes with the fix.

@sindresorhus sindresorhus merged commit c32da0b into vadimdemedes:master Mar 23, 2026
3 checks passed
@ScriptBloom ScriptBloom deleted the fix/incremental-cursorup-off-by-one branch March 23, 2026 06:54
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.

Incremental rendering: ghost/duplicate lines when output has trailing newline

2 participants