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.ts — resize() 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.ts — composite() 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.ts — requestRender() (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.ts — resize() (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
- Create an app that displays a modal overlay (using
App.addOverlay('modal', 100) and writing cells via App.layers.setCell('modal', ...)).
- Start the app and open the modal (observe it renders correctly over the main content).
- Resize the terminal window (drag the corner, or send a
SIGWINCH).
- Observe that the modal disappears and never comes back, even though the underlying page content re-renders correctly.
- 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.
Summary
When the terminal is resized,
LayerManager.resize()setsdirtyRegion = nullon every overlay layer. The next render frame callsApp.requestRender()which composites layers on top of the freshly-cleared screen buffer, butcomposite()skips any layer whosedirtyRegionis 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.ts—resize()method (lines 254–264)packages/core/src/terminal/LayerManager.ts—composite()method (lines 222–249)packages/core/src/app/App.ts—requestRender()(lines 374–461)This call happens every frame, but after resize it finds all layers with null dirtyRegion and composites nothing.
packages/core/src/terminal/Screen.ts—resize()(lines 437–443)Additionally,
Screen.resize()fails to reset several state variables that become stale after resize:Root Cause
The
LayerManager.resize()setsdirtyRegion = nullon all layers. This is the same value used to initialize a freshly constructed layer. Unlike the baseScreen, which usesscreen.invalidate()after resize to force a full redraw, layers have no equivalent "invalidate" signal. Thecomposite()method interpretsdirtyRegion === nullas "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:_previousStyleLineshaving stale entries for rows that no longer exist causesgetPreviousStyleLine()to return garbage._clipStackcould cause setCell writes to be incorrectly clipped after resize._backdropFilterscould causeflushBackdropFilters()to apply dimming based on pre-resize coordinates._flushEpoch+_swappingcould cause the renderer's double-swap guard to malfunction.Reproduction Steps
App.addOverlay('modal', 100)and writing cells viaApp.layers.setCell('modal', ...)).SIGWINCH).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 resizeChange
dirtyRegion = nulltodirtyRegion = { x: 0, y: 0, width: cols, height: rows }so every layer is fully recomposited after resize.2.
Screen.resize()— Reset all state, not just gridsReset
_previousStyleLines(to[]),_clipStack(to[]),_translateYStack(to[]),_translateY(to0),_backdropFilters(to[]),_ansiQueue(to[]),_flushEpoch(to-1), and_swapping(tofalse). Some of these may already be covered byclear()calls inrequestRender(), butresize()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 compositedThe compositing step already runs unconditionally, so no change is needed there — but adding an explicit
this.layers.invalidate()call after resize inApp.ts's resize handler would be a belt-and-suspenders approach.4. Test coverage
LayerManager.test.tstest: resize and verifycomposite()writes layer content to the screen after resize.Screen.test.tstest: 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 similarLayerManager.invalidateAll()method should be created and called fromApp.ts's terminal resize handler.