Skip to content

Address console rendering and scrollback performance#13201

Merged
softwarenerd merged 17 commits into
mainfrom
9852/console-perf
Apr 28, 2026
Merged

Address console rendering and scrollback performance#13201
softwarenerd merged 17 commits into
mainfrom
9852/console-perf

Conversation

@softwarenerd
Copy link
Copy Markdown
Contributor

@softwarenerd softwarenerd commented Apr 24, 2026

Description

Addresses #9852.

Console performance improvements: memoized rendering + scrollback trimming

The console had two related performance problems. First, every item re-rendered on every append, so as the console grew, each new line got more expensive to show. Second, the existing scrollback cap wasn't actually freeing memory or work: the old code flipped a hidden flag on items that fell off the edge but left them in the list, so every render still walked the full history and every streamed output item kept its full ANSI state alive. The cap could also only act at the whole-item level, so a single long-running streamed activity (which accumulates many thousands of output lines into one item) was untouched regardless of its size.

This branch memoizes every runtime item the console can render. For static items that means a default memo comparison is enough; for mutable ones (notably the activity item that accumulates streamed output) a monotonic version counter is bumped whenever its content actually changes, and React uses that to decide whether to re-render. Items that didn't change are skipped entirely.

On top of that, every runtime item now participates in a unified scrollback trim. When the configured cap is exceeded the console walks its items from newest to oldest, letting each one report how much scrollback it takes and whether it trimmed itself in place, and splices off anything that fell out of scope. The trim runs on every append and also when the user lowers the scrollback setting.

Because rendering is now a lot cheaper, the default console.scrollbackSize has been raised to 10,000 items (with a new range of 1,000–20,000). A one-time-per-profile migration clears any stale user override so everyone picks up the new default, unless they explicitly set their own value from here on.

New unit tests cover the trim walk, the version-bump semantics for activity-item mutations, and the new ANSI-output helper used when trimming streamed output.

Release Notes

New Features

  • N/A

Bug Fixes

QA Notes

Tests:
@:console

I've added additional tests in this PR.
I tested this on Windows and macOS.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 24, 2026

E2E Tests 🚀
This PR will run tests tagged with: @:critical @:console

readme  valid tags

@softwarenerd softwarenerd requested review from jmcphers and removed request for timtmok April 24, 2026 22:44
Copy link
Copy Markdown
Contributor

@juliasilge juliasilge left a comment

Choose a reason for hiding this comment

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

I built this and tested it out with the approach I outlined in #9852 (comment), and this is just massively, massively improved. Amazing! 🏆

This is a pretty sensitive area of the product, so Brian and I chatted about running more tests. They are kicked off here:

https://github.com/posit-dev/positron/actions/runs/24916997294

Other questions I have:

  • Shouldn't the new unit test file be switched over to use Vitest? Related to #13033
  • Should we add an additional test on PositronConsoleInstance.trimScrollback about cleanup logic? IIUC from reading the code, it is at least a little tricky (deleting from _runtimeItemActivities, clearing _runtimeItemPendingInput when it gets spliced) and might be possible to break moving forward

@juliasilge
Copy link
Copy Markdown
Contributor

If this work was branched from main before #13033 landed, we should definitely merge from main and will need to migrate that test file.

@softwarenerd
Copy link
Copy Markdown
Contributor Author

If this work was branched from main before #13033 landed, we should definitely merge from main and will need to migrate that test file.

I rebased this branch on main a little while before creating the PR, so I think we're all set in this regard.

@juliasilge
Copy link
Copy Markdown
Contributor

I'm not sure about that; it looks like it is written as a Mocha test instead of with Vitest. Can you check out what we now say at:

https://github.com/posit-dev/positron/blob/main/.claude/rules/core-tests.md

jmcphers
jmcphers previously approved these changes Apr 25, 2026
Copy link
Copy Markdown
Collaborator

@jmcphers jmcphers left a comment

Choose a reason for hiding this comment

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

This is working great for me. It's SUCH a big improvement and was so fast even when I pushed it heavily.

The only real perf issue I found is that with the new cap for console items, the rendering can get expensive on resize. Here's a repo ... run this R statement in your console:

library(crayon); for(i in 1:10000) cat(i, sapply(runif(3), function(x) do.call(crayon::make_style(sample(colors(),1)), list(formatC(x, digits=4, format="f")))), "\n")
Image

Then drag the vertical separator between the console and the console tab list back and forth. Even on my M4 without any throttling it's very sluggish. We might consider debouncing the redraw during resize events so that resizing is smooth.

That's a pathological case, though, that we could address on followup. Epic PR!

StorageTarget.MACHINE
);
}, err => {
this._logService.warn(`Failed to reset ${scrollbackSizeSettingId} user override: ${err}`);
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.

Do you think we should set the migration reset key here so we don't keep trying over and over on subsequent runs? In my experience automated settings JSON edits fail for a lot of reasons (e.g. user has a pre-existing JSON error in their file, etc.).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I considered this for a while. I also asked Claude what it thought about this idea. It's answer was:

Q: Perhaps we should set the migration reset key in the error path so we don't keep trying over and over on subsequent runs when there's a failure. Do you agree?

A: I'd push back on this, actually. The current behavior is intentional and documented in the JSDoc above the
method — and I think it's right:

Setting the flag on failure means abandoning the migration. If the user keeps their (now-obsolete) override forever because of a transient write failure on first startup, that's a worse outcome than logging a warning a few times. The flag is a "we did the work" marker, not a "we tried" marker — flipping that semantic loses information.

Most plausible failure modes are transient:

  • Settings file lock / write contention with another process
  • Brief I/O hiccup
  • Race during profile bootstrapping

For these, retrying is the recovery path.

For genuinely permanent failures (e.g. malformed settings.json, read-only filesystem), the warning is the user-visible signal that something needs attention. Suppressing it by stashing the flag would hide a real problem.

The cost of the current behavior is one async no-op + one log line per startup until the underlying issue is fixed. That seems cheap compared to silently abandoning a migration.

If you want to bound the retries, the right shape would be a counter (e.g. give up after 5 attempts), not flipping the success flag. But honestly I think the current behavior is fine as-is.

I think it might be fine as is.

@softwarenerd
Copy link
Copy Markdown
Contributor Author

This is working great for me. It's SUCH a big improvement and was so fast even when I pushed it heavily.

The only real perf issue I found is that with the new cap for console items, the rendering can get expensive on resize. Here's a repo ... run this R statement in your console:

library(crayon); for(i in 1:10000) cat(i, sapply(runif(3), function(x) do.call(crayon::make_style(sample(colors(),1)), list(formatC(x, digits=4, format="f")))), "\n")
Image Then drag the vertical separator between the console and the console tab list back and forth. Even on my M4 without any throttling it's very sluggish. We might consider debouncing the redraw during resize events so that resizing is smooth.

That's a pathological case, though, that we could address on followup. Epic PR!

I've investigated this issue. The lagginess only happens when the ConsoleTabList is being rendered alongside the console. I don't have a solution yet. This is quite frustrating.

@softwarenerd
Copy link
Copy Markdown
Contributor Author

softwarenerd commented Apr 28, 2026

This is working great for me. It's SUCH a big improvement and was so fast even when I pushed it heavily.
The only real perf issue I found is that with the new cap for console items, the rendering can get expensive on resize. Here's a repo ... run this R statement in your console:

library(crayon); for(i in 1:10000) cat(i, sapply(runif(3), function(x) do.call(crayon::make_style(sample(colors(),1)), list(formatC(x, digits=4, format="f")))), "\n")
Image Then drag the vertical separator between the console and the console tab list back and forth. Even on my M4 without any throttling it's very sluggish. We might consider debouncing the redraw during resize events so that resizing is smooth. That's a pathological case, though, that we could address on followup. Epic PR!

I've investigated this issue. The lagginess only happens when the ConsoleTabList is being rendered alongside the console. I don't have a solution yet. This is quite frustrating.

OK, I managed to trace this problem down. It turned out that it was being caused by setting the stylesheet cursor unnecessarily. So:

			// Set the cursor.
			if (cursor) {
				styleSheet.textContent = `* { cursor: ${cursor} !important; }`;
			}

Was invalidating the entire DOM every time the pointerMoveHandler was called! The fix was to cache the current cursor and only update the stylesheet when necessary:

			// Set the cursor only when it changes. Writing to the stylesheet invalidates
			// style on every element matching `*` (the whole document), so this must not
			// run every pointermove.
			if (cursor && cursor !== currentCursor) {
				currentCursor = cursor;
				styleSheet.textContent = `* { cursor: ${cursor} !important; }`;
			}

Screen recording:

Screen.Recording.2026-04-27.at.6.18.02.PM.mov

Copy link
Copy Markdown
Contributor

@juliasilge juliasilge left a comment

Choose a reason for hiding this comment

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

Such a huge improvement!

@softwarenerd softwarenerd merged commit 824679b into main Apr 28, 2026
75 of 77 checks passed
@softwarenerd softwarenerd deleted the 9852/console-perf branch April 28, 2026 04:54
@github-actions github-actions Bot locked and limited conversation to collaborators Apr 28, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants