InodeLabs/fern
1
14
Fork
You've already forked fern
2

Glamour Re-Render on Resize made Lazy #7

Closed
opened 2026年03月26日 01:25:04 +01:00 by bugwhisperer · 0 comments

Summary

When the terminal is resized or a pane split is created or closed, fern currently re-renders every open tab through the full glamour pipeline synchronously before drawing the next frame. Each tab re-render involves creating a new glamour.TermRenderer, running the full markdown-to-ANSI render of the document, post-processing links and tags, and recomputing fold regions. With 10+ tabs open this produces a noticeable stutter on any resize event.

A second compounding issue exists in split-pane layouts: the glamour renderer is cached in a single slot keyed by width. When two panes have different widths (e.g. 60 and 120 columns), every call to renderTab thrashes the cache -- pane A creates a renderer at width 60, pane B creates one at width 120, pane A creates again at 60. Each tab render in a split layout triggers an unnecessary renderer rebuild.

The fix has two parts:

  1. Replace the single-slot renderer cache with a small width-keyed map so split panes share renderers without thrashing.
  2. Introduce lazy re-rendering so only the active tab is glamour-rendered immediately on resize while background tabs are deferred until first focused.

Conditions of Satisfaction

  • Replace the single renderer / renderWidth fields on viewer.Model with a renderers map[int]*glamour.TermRenderer cache keyed by render width
    • All calls to getRenderer(width) look up the map first and only construct a new glamour.TermRenderer on a cache miss
    • The map is capped at roughly 5 entries; when the cap is exceeded the oldest or least-recently-used(LRU) entry is evicted to prevent unbounded memory growth
    • On theme change the entire renderers map is cleared, matching the current behavior of nulling the single renderer field
  • Add renderWidth int and renderedContent string fields to tabShared to track the state of the cached glamour output
    • renderWidth records the pane width that was used for the current rendered / lines cache
    • renderedContent records the content string that was glamour-rendered, so a content change at a stable width still triggers a re-render
    • renderTab sets both fields at the end of each successful render pass
  • Change the resize handler to re-render only the active tab immediately and defer all background tabs
    • The call site that iterates all tabs and calls renderTab on each one should be narrowed to just the active tab
    • Background tabs have their renderWidth left stale (or explicitly set to -1) so the lazy guard triggers when they are next focused
  • Add a lazy re-render guard at the top of the view-mode render path (renderViewContent or renderSinglePane)
    • Before consuming t.lines, check whether t.renderWidth != expectedRenderWidth || t.renderedContent != t.content
    • If either condition is true, call renderTab inline before proceeding, ensuring the tab is always rendered with current dimensions and content when it becomes visible
  • Verify that content-change re-renders (external file change, save-and-reload) still fire correctly under the new invalidation logic
    • renderedContent guard must not suppress re-renders triggered by content edits, only those where neither width nor content has changed

External Resources

  • Goldmark is the markdown parser underlying glamour. Relevant to understanding rendering costs!
## Summary When the terminal is resized or a pane split is created or closed, fern currently re-renders every open tab through the full glamour pipeline synchronously before drawing the next frame. Each tab re-render involves creating a new `glamour.TermRenderer`, running the full markdown-to-ANSI render of the document, post-processing links and tags, and recomputing fold regions. With 10+ tabs open this produces a noticeable stutter on any resize event. A second compounding issue exists in split-pane layouts: the glamour renderer is cached in a single slot keyed by width. When two panes have different widths (e.g. 60 and 120 columns), every call to `renderTab` thrashes the cache -- pane A creates a renderer at width 60, pane B creates one at width 120, pane A creates again at 60. Each tab render in a split layout triggers an unnecessary renderer rebuild. The fix has two parts: 1. Replace the single-slot renderer cache with a small width-keyed map so split panes share renderers without thrashing. 2. Introduce lazy re-rendering so only the active tab is glamour-rendered immediately on resize while background tabs are deferred until first focused. ## Conditions of Satisfaction - [ ] Replace the single `renderer / renderWidth` fields on `viewer.Model` with a `renderers map[int]*glamour.TermRenderer` cache keyed by render width - All calls to `getRenderer(width)` look up the map first and only construct a new `glamour.TermRenderer` on a cache miss - The map is capped at roughly 5 entries; when the cap is exceeded the oldest or least-recently-used(LRU) entry is evicted to prevent unbounded memory growth - On theme change the entire `renderers` map is cleared, matching the current behavior of nulling the single renderer field - [ ] Add `renderWidth int` and `renderedContent string` fields to `tabShared` to track the state of the cached glamour output - `renderWidth` records the pane width that was used for the current `rendered` / `lines` cache - `renderedContent` records the content string that was glamour-rendered, so a content change at a stable width still triggers a re-render - `renderTab` sets both fields at the end of each successful render pass - [ ] Change the resize handler to re-render only the active tab immediately and defer all background tabs - The call site that iterates all tabs and calls `renderTab` on each one should be narrowed to just the active tab - Background tabs have their `renderWidth` left stale (or explicitly set to -1) so the lazy guard triggers when they are next focused - [ ] Add a lazy re-render guard at the top of the view-mode render path (`renderViewContent` or `renderSinglePane`) - Before consuming `t.lines`, check whether `t.renderWidth != expectedRenderWidth || t.renderedContent != t.content` - If either condition is true, call `renderTab` inline before proceeding, ensuring the tab is always rendered with current dimensions and content when it becomes visible - [ ] Verify that content-change re-renders (external file change, save-and-reload) still fire correctly under the new invalidation logic - `renderedContent` guard must not suppress re-renders triggered by content edits, only those where neither width nor content has changed ## External Resources - [Goldmark](https://github.com/yuin/goldmark) is the markdown parser underlying glamour. Relevant to understanding rendering costs!
Sign in to join this conversation.
No Branch/Tag specified
main
v1.5.6
v1.5.5
v1.5.4
v1.5.3
v1.5.2
v1.5.1
v1.5.0
v1.4.1
v1.4.0
v1.3.9
v1.3.8
v1.3.7
v1.3.6
v1.3.5
v1.3.4
v1.3.3
v1.3.2
v1.3.1
v1.3.0
v1.2.1
v1.2.0
v1.1.0-beta
v1.0.3-beta
v1.0.2-beta
v1.0.1-beta
v1.0.0-beta
v0.9.3
v0.9.2
v0.9.1
v0.9.0
v0.8.3
v0.8.2
v0.8.1
v0.8.0
Milestone
Clear milestone
No items
No milestone
Projects
Clear projects
No items
No project
Assignees
Clear assignees
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
InodeLabs/fern#7
Reference in a new issue
InodeLabs/fern
No description provided.
Delete branch "%!s()"

Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?