From 9fc122d2ec806186bd8c91186dcf22724414099f Mon Sep 17 00:00:00 2001 From: Boyin Chen Date: Sat, 17 Oct 2026 15:53:55 +0900 Subject: [PATCH] Render a blank renderer right away while paused While paused, RenderService defers refreshes until the IntersectionObserver reports the terminal back on screen, a frame after it is visible. A new renderer set while paused, or a sync redraw requested after its canvas was cleared, then showed the terminal blank for that frame. Both now render synchronously while paused. Fixes #6239 Co-Authored-By: Claude Opus 5.5 --- src/browser/services/RenderService.test.ts | 112 +++++++++++++++++++++ src/browser/services/RenderService.ts | 17 +++- 2 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 src/browser/services/RenderService.test.ts diff --git a/src/browser/services/RenderService.test.ts b/src/browser/services/RenderService.test.ts new file mode 100644 index 0000000000..be9b753276 --- /dev/null +++ b/src/browser/services/RenderService.test.ts @@ -0,0 +1,112 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import jsdom = require('jsdom'); +import { assert } from 'chai'; +import { RenderService } from './RenderService'; +import { MockCharSizeService, MockRenderer, MockThemeService } from '../TestUtils.test'; +import { MockBufferService, MockCoreService, MockDecorationService, MockLogService, MockOptionsService } from '../../common/TestUtils.test'; +import { ICoreBrowserService } from './Services'; +import { Emitter } from '../../common/Event'; +import { IRequestRedrawEvent } from '../renderer/shared/Types'; + +class TestRenderer extends MockRenderer { + public rendered: [number, number][] = []; + public resized: [number, number][] = []; + private readonly _onRequestRedrawEmitter = new Emitter(); + public onRequestRedraw = this._onRequestRedrawEmitter.event; + public requestRedraw(e: IRequestRedrawEvent): void { this._onRequestRedrawEmitter.fire(e); } + public renderRows(start: number, end: number): void { this.rendered.push([start, end]); } + public handleResize(cols: number, rows: number): void { this.resized.push([cols, rows]); } + public dispose(): void { } +} + +describe('RenderService', () => { + let window: jsdom.DOMWindow; + let renderService: RenderService; + let reportIntersection: (isIntersecting: boolean) => void; + let animationFrames: FrameRequestCallback[]; + + beforeEach(() => { + window = new jsdom.JSDOM('').window; + animationFrames = []; + (window as any).requestAnimationFrame = (callback: FrameRequestCallback) => animationFrames.push(callback); + (window as any).cancelAnimationFrame = () => {}; + (window as any).IntersectionObserver = class { + constructor(callback: (entries: Partial[]) => void) { + reportIntersection = isIntersecting => callback([{ isIntersecting, intersectionRatio: isIntersecting ? 1 : 0 }]); + } + public observe(): void { } + public disconnect(): void { } + }; + const coreBrowserService = { + serviceBrand: undefined, + onDprChange: new Emitter().event, + onWindowChange: new Emitter().event, + isFocused: true, + window: window as unknown as Window & typeof globalThis, + mainDocument: window.document, + dpr: 1 + } as ICoreBrowserService; + renderService = new RenderService( + 10, + window.document.createElement('div'), + new MockOptionsService(), + new MockLogService(), + new MockCharSizeService(5, 10), + new MockCoreService(), + new MockDecorationService(), + new MockBufferService(80, 10), + coreBrowserService, + new MockThemeService() + ); + }); + + afterEach(() => renderService.dispose()); + + describe('while paused', () => { + beforeEach(() => reportIntersection(false)); + + it('should render a new renderer right away', () => { + const renderer = new TestRenderer(); + renderService.setRenderer(renderer); + assert.deepEqual(renderer.rendered, [[0, 9]]); + }); + + it('should render a sync redraw right away, after the paused resize', () => { + const renderer = new TestRenderer(); + renderService.setRenderer(renderer); + renderer.rendered = []; + renderService.handleResize(100, 10); + assert.deepEqual(renderer.resized, []); + renderer.requestRedraw({ start: 0, end: 9, sync: true }); + assert.deepEqual(renderer.resized, [[100, 10]]); + assert.deepEqual(renderer.rendered, [[0, 9]]); + }); + + it('should not render other refreshes until resumed', () => { + const renderer = new TestRenderer(); + renderService.setRenderer(renderer); + renderer.rendered = []; + renderer.requestRedraw({ start: 0, end: 9 }); + renderService.refreshRows(2, 3); + assert.deepEqual(renderer.rendered, []); + assert.equal(animationFrames.length, 0); + reportIntersection(true); + animationFrames.splice(0).forEach(callback => callback(0)); + assert.deepEqual(renderer.rendered, [[0, 9]]); + }); + }); + + describe('while not paused', () => { + it('should render a new renderer on the next frame', () => { + const renderer = new TestRenderer(); + renderService.setRenderer(renderer); + assert.deepEqual(renderer.rendered, []); + animationFrames.splice(0).forEach(callback => callback(0)); + assert.deepEqual(renderer.rendered, [[0, 9]]); + }); + }); +}); diff --git a/src/browser/services/RenderService.ts b/src/browser/services/RenderService.ts index 75cd55418b..875349c72d 100644 --- a/src/browser/services/RenderService.ts +++ b/src/browser/services/RenderService.ts @@ -156,7 +156,13 @@ export class RenderService extends Disposable implements IRenderService { public refreshRows(start: number, end: number, sync: boolean = false, isRedrawOnly: boolean = false): void { if (this._isPaused) { this._needsFullRefresh = true; - return; + // A sync refresh means the renderer has nothing on screen (eg. its canvas was just cleared + // by a resize). The intersection observer reports a frame late, so the terminal may + // already be visible again: render now instead of showing it blank until then. + if (!sync) { + return; + } + this._pausedResizeTask.flush(); } if (this._coreService.decPrivateModes.synchronizedOutput) { @@ -250,9 +256,14 @@ export class RenderService extends Disposable implements IRenderService { if (this._renderer.value) { this._renderer.value.onRequestRedraw(e => this.refreshRows(e.start, e.end, e.sync, true)); - // Force a refresh + // Force a refresh. A new renderer starts out blank, so while paused render it right away, + // see refreshRows. this._needsSelectionRefresh = true; - this._fullRefresh(); + if (this._isPaused) { + this.refreshRows(0, this._rowCount - 1, true); + } else { + this._fullRefresh(); + } } }