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

Eliminate repeated strings.Split allocs by caching raw content lines on tabShared #6

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

Summary

In edit mode, two separate functions each independently call strings.Split(t.content, "\n") on every keypress and every render frame. syncEditOffset splits the full document to calculate scroll position, and renderEditView splits it again to build the visible line output.

Because both run in the same frame on every keystroke, a single keypress in a 1000-line file produces two full document splits (roughly 2MB of short-lived slice allocations that immediately become garbage collected). This constant GC pressure accumulates over a typing session and contributes to the subtle lag users experience in insert mode, particularly in larger files. It also might explain some of the astronomical memory use I saw when profiling the WIP v2 BubbleTea migration branches code earlier.

To make matters worse, paneGutterWidth, called from both, iterates all open tabs in the pane on every render to recompute the line-number column width, even though the digit count of a line number almost never changes during normal editing.

Proposed Fix/Enhancement

Maintain a rawLines []string field on tabShared that is kept in sync with content via a setContent() helper. Consumers that previously called strings.Split locally just reference t.rawLines instead. Gutter width is similarly cached on the pane struct and only recomputed when the tab set or line count actually changes.

Conditions of Satisfaction

  • Add rawLines []string to the tabShared struct in viewer.go and introduce a setContent(s string) helper method on tabShared
    • The helper sets both ts.content = s and ts.rawLines = strings.Split(s, "\n") atomically, making it impossible for the two fields to fall out of sync
    • rawLines lives on tabShared rather than viewerTab because content is shared across all pane views of the same file
  • Replace all direct t.content = ... assignments with t.setContent(...) at every call site
    • Sites to update include: handleInsertKeys and handleReplaceKeys in keys.go, reloadTextarea and any other direct assignments in edit.go, the undo and redo apply paths, and the file-load path wherever tabShared.content is first populated
    • A grep for \.content = scoped to tabShared receivers will surface all locations that need updating
  • Update syncEditOffset and renderEditView in render.go to use t.rawLines instead of calling strings.Split
    • The logic of both functions is otherwise unchanged; only the source of contentLines changes from a fresh allocation to the cached field
    • With both functions using the cache, zero strings.Split allocations occur per keystroke during editing
  • Add a gutterWidth int field to the pane struct and introduce an updatePaneGutterWidth(paneIdx int) method on viewer.Model
    • The method iterates tabs in the pane and computes the max digit count needed for line numbers, storing the result on pane.gutterWidth
    • paneGutterWidth() becomes a simple field read instead of an iteration, eliminating the per-frame N-tab scan
    • updatePaneGutterWidth is called when a tab is opened or closed in a pane, and after buildDisplayLines updates totalLines in renderTab
    • Because line-number digit counts only change when a file crosses a power-of-10 line boundary, recomputes are extremely rare in practice

External Resources

N/A

## Summary In edit mode, two separate functions each independently call `strings.Split(t.content, "\n")` on every keypress and every render frame. `syncEditOffset` splits the full document to calculate scroll position, and `renderEditView` splits it again to build the visible line output. Because both run in the same frame on every keystroke, a single keypress in a 1000-line file produces two full document splits (roughly 2MB of short-lived slice allocations that immediately become garbage collected). This constant GC pressure accumulates over a typing session and contributes to the subtle lag users experience in insert mode, particularly in larger files. It also might explain some of the astronomical memory use I saw when profiling the WIP v2 BubbleTea migration branches code earlier. To make matters worse, `paneGutterWidth`, called from both, iterates all open tabs in the pane on every render to recompute the line-number column width, even though the digit count of a line number almost never changes during normal editing. ## Proposed Fix/Enhancement Maintain a `rawLines []string` field on `tabShared` that is kept in sync with `content` via a `setContent()` helper. Consumers that previously called `strings.Split` locally just reference `t.rawLines` instead. Gutter width is similarly cached on the `pane` struct and only recomputed when the tab set or line count actually changes. ## Conditions of Satisfaction - [ ] Add `rawLines []string` to the `tabShared` struct in `viewer.go` and introduce a `setContent(s string)` helper method on `tabShared` - The helper sets both `ts.content = s` and `ts.rawLines = strings.Split(s, "\n")` atomically, making it impossible for the two fields to fall out of sync - `rawLines` lives on `tabShared` rather than `viewerTab` because `content` is shared across all pane views of the same file - [ ] Replace all direct `t.content = ...` assignments with `t.setContent(...)` at every call site - Sites to update include: `handleInsertKeys` and `handleReplaceKeys` in `keys.go`, `reloadTextarea` and any other direct assignments in `edit.go`, the undo and redo apply paths, and the file-load path wherever `tabShared.content` is first populated - A grep for `\.content =` scoped to `tabShared` receivers will surface all locations that need updating - [ ] Update `syncEditOffset` and `renderEditView` in `render.go` to use `t.rawLines` instead of calling `strings.Split` - The logic of both functions is otherwise unchanged; only the source of `contentLines` changes from a fresh allocation to the cached field - With both functions using the cache, zero `strings.Split` allocations occur per keystroke during editing - [ ] Add a `gutterWidth int` field to the `pane` struct and introduce an `updatePaneGutterWidth(paneIdx int)` method on `viewer.Model` - The method iterates tabs in the pane and computes the max digit count needed for line numbers, storing the result on `pane.gutterWidth` - `paneGutterWidth()` becomes a simple field read instead of an iteration, eliminating the per-frame N-tab scan - `updatePaneGutterWidth` is called when a tab is opened or closed in a pane, and after `buildDisplayLines` updates `totalLines` in `renderTab` - Because line-number digit counts only change when a file crosses a power-of-10 line boundary, recomputes are extremely rare in practice ## External Resources N/A
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#6
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?