Skip to content

[bug] Terminal resize causes all overlay layers to disappear permanently until next explicit write #1911

Description

@ionfwsrijan

Summary

When the terminal is resized, LayerManager.resize() sets dirtyRegion = null on every overlay layer. The next render frame calls App.requestRender() which composites layers on top of the freshly-cleared screen buffer, but composite() skips any layer whose dirtyRegion is null. This means all overlay content (modals, dropdown menus, toasts, tooltips) visibly vanishes after every terminal resize and never reappears until the overlay widget performs an explicit write to its layer.

Affected Code

packages/core/src/terminal/LayerManager.tsresize() method (lines 254–264)

resize(cols: number, rows: number): void {
    this._cols = cols;
    this._rows = rows;

    for (const layer of this._layers.values()) {
        layer.cells = this._createGrid();
        layer.dirtyRegion = null;   // ← BUG: marks layer as "nothing to composite"
    }

    this._allocateHitGrids();
}

packages/core/src/terminal/LayerManager.tscomposite() method (lines 222–249)

composite(screen: Screen): void {
    const sorted = this.getSortedLayers();

    for (const layer of sorted) {
        if (!layer.dirtyRegion) continue;  // ← Skip layer — nothing rendered
        // ... composite logic ...
    }
}

packages/core/src/app/App.tsrequestRender() (lines 374–461)

// Composite overlay layers on top of the base rendering.
// Runs even when only layers are dirty (root widget is clean).
this.layers.composite(this.screen);

This call happens every frame, but after resize it finds all layers with null dirtyRegion and composites nothing.

packages/core/src/terminal/Screen.tsresize() (lines 437–443)

Additionally, Screen.resize() fails to reset several state variables that become stale after resize:

resize(cols: number, rows: number): void {
    this._cols = cols;
    this._rows = rows;
    this.front = this._createGrid(cols, rows);
    this.back = this._createGrid(cols, rows);
    this._previousLines = [];
    // BUG: _previousStyleLines NOT reset — stale style fingerprints
    // BUG: _clipStack NOT reset — clip regions with wrong dimensions
    // BUG: _translateY / _translateYStack NOT reset — wrong scroll offset
    // BUG: _backdropFilters NOT reset — stale dim regions
    // BUG: _ansiQueue NOT reset — stale ANSI sequences
    // BUG: _flushEpoch NOT reset — could cause flush skip
    // BUG: _swapping NOT reset — could deadlock swap
}

Root Cause

The LayerManager.resize() sets dirtyRegion = null on all layers. This is the same value used to initialize a freshly constructed layer. Unlike the base Screen, which uses screen.invalidate() after resize to force a full redraw, layers have no equivalent "invalidate" signal. The composite() method interprets dirtyRegion === null as "no changes since last composite" and skips the layer entirely.

Additionally, Screen.resize() does not clear ancillary state (_clipStack, _translateYStack, _backdropFilters, _ansiQueue, _previousStyleLines, _flushEpoch, _swapping), which can cause correctness issues independent of the layer problem:

  • _previousStyleLines having stale entries for rows that no longer exist causes getPreviousStyleLine() to return garbage.
  • A stale _clipStack could cause setCell writes to be incorrectly clipped after resize.
  • Stale _backdropFilters could cause flushBackdropFilters() to apply dimming based on pre-resize coordinates.
  • Stale _flushEpoch + _swapping could cause the renderer's double-swap guard to malfunction.

Reproduction Steps

  1. Create an app that displays a modal overlay (using App.addOverlay('modal', 100) and writing cells via App.layers.setCell('modal', ...)).
  2. Start the app and open the modal (observe it renders correctly over the main content).
  3. Resize the terminal window (drag the corner, or send a SIGWINCH).
  4. Observe that the modal disappears and never comes back, even though the underlying page content re-renders correctly.
  5. Pressing any key that triggers a layer write (e.g., dismissing and re-opening the modal) restores the overlay — demonstrating the root cause.

Expected Behavior

After a terminal resize, all overlay layers should be fully recomposited on the next frame, maintaining visual parity with the pre-resize state. Screen.resize() should also fully reset all internal state to safe defaults.

Proposed Fix

The fix requires coordinated changes across multiple files:

1. LayerManager.resize() — Invalidate all layers on resize

Change dirtyRegion = null to dirtyRegion = { x: 0, y: 0, width: cols, height: rows } so every layer is fully recomposited after resize.

2. Screen.resize() — Reset all state, not just grids

Reset _previousStyleLines (to []), _clipStack (to []), _translateYStack (to []), _translateY (to 0), _backdropFilters (to []), _ansiQueue (to []), _flushEpoch (to -1), and _swapping (to false). Some of these may already be covered by clear() calls in requestRender(), but resize() can be called independently (e.g., from the terminal resize handler) and must leave the screen in a fully consistent state.

3. Optionally, App.requestRender() — Ensure layers are composited

The compositing step already runs unconditionally, so no change is needed there — but adding an explicit this.layers.invalidate() call after resize in App.ts's resize handler would be a belt-and-suspenders approach.

4. Test coverage

  • Add LayerManager.test.ts test: resize and verify composite() writes layer content to the screen after resize.
  • Add Screen.test.ts test: resize and verify all ancillary state is reset.

Related

The Screen.invalidate() method already exists (line 449 of Screen.ts) and forces a full redraw by clearing the front buffer. A similar LayerManager.invalidateAll() method should be created and called from App.ts's terminal resize handler.

Metadata

Metadata

Assignees

Labels

assignedIssue claimed by a contributor.type:bug+10 pts. Bug fix.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions