Repository navigation
text_selection: Stop writing selection state on every frame - #3407
Merged
Merged
Conversation
Selectable text, the text selection layer and inputs wrote entities and globals on every frame they drew, even when nothing about the selection or focus changed: each participant registered its geometry through an update of the window's selection state, `TextView` pushed itself onto the `GlobalState` text view stack through `global_mut`, the selection registry global was set again, `Root` re-synced focus, and the scroll and mouse-move handlers updated the state on every event. Under GPUI Fast's retained views, a write is a change: every view that read any of these was built again on the next frame. In a chat transcript this rebuilt every message row on every scrolled frame, and kept the list off its scroll layer. - Participants record their geometry without updating the state while there is no selection, gesture or touch selection for it to change. - The text view stack and the selection document order are interior state of `GlobalState`, read through `GlobalState::global`. - Painted runs and copy text are interior state of the participant. - Snapshots are published to a participant only when they differ; the registry, the active scope, focus sync and the mouse-move and scroll handlers write only when something changed. - Touch handles are recorded per participant and replaced when it paints them again, instead of expiring with frames the selection layer may not draw when its view is retained. A test checks that redrawing selectable text leaves a view reading the selection state retained under GPUI Fast. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
huacnlee
added a commit
to longbridge/gpui-fast
that referenced
this pull request
Oct 8, 2026
## Problem A profile of scrolling a long session in Allsum (about 50–60 % process CPU on the reporter's machine) put most of the time in `List::prepaint → StateInner::layout_items → layout_as_list_item → Taffy → InlineFlow → shape_line`, even though gpui-fast's retained views and list scroll layers were on. The transcript list's scroll layer was almost never composited: every scrolled frame laid out and shaped the rows it showed again. Reproduced with `gpui_perf` scenarios shaped like a chat view (`chat-scroll`, no dependency on the app), then checked against Allsum itself with temporary per-frame counters (not committed) until the list stayed composited. ## Causes and changes **1. Reads the layer took for content changes** (first commits) - `is_scrolled_to_end()` read while rendering (a "back to bottom" button) counted as reading the list's offset. It is now recorded as a read of its answer, which changes only when the list reaches or leaves its end. - `scroll_to_end()` every render bumped the list state even at its end. It no longer does. - What a view writes into a model as it renders (an input writing its options) counted as an outside change. - What a view reads of its list *after* building it (an outline marker reading the first row shown) no longer counts as shaping the rows; only what its `render` read before the list was built does. **2. One row changing repainted the whole layer** (last commit) - The layer now keeps what each row read (rendering, layout, prepaint, paint) and the views drawn in it, apart from what the rest of the list read. A change read by one row — its Markdown parsed after it first rendered, its view notified — renders that row again, alone, on a composited frame. - A row the list only measured, without prepainting it, is not the layer's; nothing it read is kept. **3. The view holding the list rendered again for something unrelated** - A fade animation of the back-to-bottom button, or a task notifying the view every two seconds, repainted and then demoted the layer. Now the list renders the rows it shows again and marks the other rows it holds to be rendered before they show. A list whose item count changed is still painted afresh. - A change during the demotion cooldown restarted the whole (doubling) cooldown, so a view notified every two seconds kept its list off its layer for good. It now asks for 60 more stable frames. A repaint that left every row as the layer held it does not count as a change. - Broad refreshes demote only when two come within 120 frames of each other. **4. Rows laid out from scratch** - A `list` lays out every row it shows before prepainting the first, so the first row took every row's layout keys and, when carried, claimed their Taffy nodes: the other rows got throwaway nodes each frame. Each row now keeps only its own keys. - A list row's nodes outlive it for 240 frames, so a row scrolled out and back keeps its measurements instead of shaping its text again. - A leaf measured through `request_measured_layout`, given a new closure every frame, is measured again before layout and left clean if it measures as before, instead of being dirtied with every node above it. Upstream files change only by hooks (`list.rs`, `taffy.rs`); `script/check-upstream` passes. `docs/scroll-layers.md` is updated. ## Results **Allsum** (macOS, Apple silicon, release; a long session scrolled by the wheel for 30 s; process CPU from `ps`, two runs each, against the build the report was measured on): | Scroll | before | after | |---|---|---| | slow (40 px events) | 35.3–36.4 % | **31.2–33.4 %** | | fast (120 px events) | 43.0–43.6 % | **37.0–37.7 %** | Main thread over 12 s of slow scrolling (samply): | | before | after | |---|---|---| | `List::prepaint` | 1374 ms | **451 ms** | | `StateInner::layout_items` | 1218 ms | **84 ms** | | `TaffyLayoutEngine::compute_layout` | 879 ms | **180 ms** | | main thread total | 3395 ms | **2699 ms** | | list layer decisions | mostly demoted (bypass) | **composited on ~99 % of list frames** | What remains on the main thread is laying out and painting rows as they enter the overscan, and presenting the frame. **gpui_perf `chat-scroll`** (headless, 1200 frames; the scenario now also parses message bodies the frame after they first render, notifies its view every two seconds and reads the outline offset after building the list): | | layers off | layers on | |---|---|---| | frame mean | 0.266 ms | **0.139 ms** | | instructions | 3.77M | **1.93M** | | composited | – | **93 %** | On `main`, the first version of this scenario (without the parsing, notifications and outline read) composited no frame: 0.262 ms with layers on, as with them off. ## Testing - `cargo test -p gpui --lib`: 617 passed. `cargo test -p gpui_perf`: passed. - New tests in `fast/tests/layers_lists.rs`, each checked to fail without its fix: - a change read by one row (a model its view reads, a model the row renderer reads, the row's view itself) renders that row alone, composited, drawing as without layers; - a view rendering again renders the rows shown and the rest as they show, drawing as without layers; - a view animating beside its list keeps the layer; a demoted list notified every few seconds gets its layer back; - each held row keeps only its own layout keys; scrolling back and forth lays no row out afresh; - plus the at-end / offset-read / own-write tests from the earlier commits. - New test in `fast/tests/layout.rs`: a rebuilt measured leaf measuring the same leaves its layout alone, and one measuring differently moves what follows, both matching a from-scratch frame. - `gpui_perf --headless --verify`: every scenario paints identical frames with layers on and off. - `cargo clippy -p gpui -p gpui_perf --lib --tests` clean. Companion gpui-kit change, needed for the Allsum numbers above (no per-frame entity/global writes from text selection, `Root` and inputs): longbridge/gpui-kit#3407. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
huacnlee
added a commit
that referenced
this pull request
Oct 8, 2026
## Description Follow-up to #3407. Two more places update an entity even though nothing in it changed. Under GPUI Fast, a view that read that entity is then counted as changed and built again, even when it is retained. - **`TextViewState`**: a markdown text that is parsed synchronously when it is set still receives an acknowledgement (`baseline_ack`) from the background parser. `commit_parsed_update` discarded it, but only after `weak_self.update`, which already marked the state as updated. The receive task now checks first, with `read_with` and the new `TextViewState::commits`, and skips results that commit nothing. `commit_parsed_update` uses the same check. - **`ResizablePanelGroup` / `ResizablePanel`**: both report their bounds in `on_prepaint` on every frame they are drawn, and updated `ResizableState` every time, even when the bounds were the same. Every view that read the state was rebuilt on every frame. They now return early when nothing would change: same group bounds, or `ResizableState::panel_size_changes` (crate-private) is false. Behaviour is unchanged: the skipped updates were no-ops. ## Before / After Measured by the new tests (`gpui-fast` feature), counting renders of a view that reads the state: | Case | Before | After | | --- | --- | --- | | Reader of a `TextViewState` after the parser acknowledges a synchronous parse | 2 renders | 1 render | | Reader of a `ResizableState` while its panels are redrawn at the same bounds (3 extra frames) | 11 renders | 5 renders | ## Tests - `text::state::tests::parse_ack::acknowledging_a_parse_leaves_its_readers_retained` - `resizable::tests::redrawing_panels_where_they_were_leaves_their_readers_retained` Both fail without the fix and pass with it. `cargo fmt --check` and `cargo clippy -p gpui-base -- --deny warnings` (with and without `gpui-fast`) are clean. `cargo test -p gpui-base --lib` passes, 1323 tests. With `--features gpui-fast` the same 14 tests fail on `main` as on this branch, so this change neither causes nor fixes them. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
trancong12102
added a commit
to aislopware/gpui-kit
that referenced
this pull request
Oct 9, 2026
… fork's own longbridge#3407 stops selectable text, the selection layer and TextView writing entities and globals on every frame. The fork's a5aaeef and the commits after it (7f9d593 among them) do the same and more: TextView's reveal and selection frame run inside the state's own view, and the selection's document order holds across frames that paint only some views. Both rewrote the same state, so the fork's commits are replayed over upstream with longbridge#3407's text side taken back; its Root and InputState changes stay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
trancong12102
added a commit
to aislopware/gpui-kit
that referenced
this pull request
Oct 9, 2026
…e one is made longbridge#3407 stops the pointer's moves and the wheel updating the window's selection state when no selection is being made, and the touch edit menu when no touch selection is active: every update counted as a change for the views reading the state. The fork took back longbridge#3407's text selection side for its own (fa37e9d), which lacked these two guards. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
trancong12102
added a commit
to aislopware/gpui-kit
that referenced
this pull request
Oct 9, 2026
…e selection state Every participant registers its geometry on every frame it paints, and the fork's register did so through an update of the window's selection state. Under GPUI Fast's retained views an update is a change, so every view that read the state was built again on the next frame however idle the text: a reader beside selectable text rendered 7 times where 3 suffice. Port longbridge#3407's register_quietly to the fork's own selection state: with no selection, gesture or touch selection active, and none held by the participant, its registration goes into the participant ledger without an update. The ledger (participants and the automatic document order) moves into RefCells so recording it, pruning dead participants and numbering the automatic order work from a read; register_participant takes the same path after its selection checks. The participant's painted runs and copy text are interior too, as in longbridge#3407, so update_runs and set_hit_test_runs no longer update it per frame. finish_painted_frame already wrote nothing on an idle frame. Selection behaviour is unchanged: an active selection still registers through register_participant, and finish_painted_frame delivers any snapshot that changed. Tests: redrawing_selectable_text_leaves_its_readers_retained (from longbridge#3407, unconditional on the fork, which always retains), and redrawing_selectable_text_with_a_selection_keeps_it. longbridge#3407's test fix to a_flow_is_laid_out_again_at_another_width (notify on a width change) is taken, since the per-frame writes no longer rebuild that view. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
linruohan
pushed a commit
to linruohan/gpui-component
that referenced
this pull request
Oct 9, 2026
…ge#3407) ## Problem Scrolling a long chat session in Allsum used 35–45 % process CPU here (50–60 % in the original report), most of it laying out and shaping message rows that had only moved. Part of the cause was in gpui-kit: selectable text, the text selection layer, `Root` and inputs **wrote** entities and globals on every frame they drew, even when nothing about the selection or focus changed. Under GPUI Fast's retained views a write is a change, so every view that read any of these (every message row with a `TextView`) was built again on the next frame, and the transcript list could not stay on its scroll layer. Per-frame writes found: | Where | Write every frame | |---|---| | `TextSelectionHandle::register` | `WindowSelectionState` updated with the participant's geometry | | `TextView` prepaint / paint | `GlobalState::global_mut` to push/pop the text view stack, `begin_selection_frame` | | `update_runs` / `set_hit_test_runs` | participant entity updated with its painted runs | | `TextSelectionLayer` | registry global set again; touch frame shifted; finish-frame flag updated | | selection publishing | `set_snapshot` on every participant, changed or not | | scroll / mouse-move handlers | state updated on every event, selecting or not | | `Root` / `InputState` | focus registry re-synced | ## Changes - With no selection, gesture or touch selection active, a participant records its geometry without updating the state (`register_quietly`, `finish_frame_quietly`). - The text view stack and selection document order are interior state of `GlobalState` (`RefCell`/`Cell`), read through `GlobalState::global`. Painted runs and copy text are interior state of the participant. - Snapshots are published only to participants whose snapshot differs. The registry, active scope, focus sync and the mouse-move and scroll handlers write only when something changed. - Touch handles are recorded per participant and replaced when that participant paints them again. Before, they expired with frames the selection layer drew; once the layer's view is retained it may not draw, so stale handle bounds made a tap on the text ignored. - Test fix: `a_flow_is_laid_out_again_at_another_width` changed the view's width without `cx.notify()`. It passed only because the per-frame writes rebuilt the view anyway. ## Before / after Allsum release build (macOS, Apple silicon), long session scrolled by the wheel for 30 s, process CPU, together with longbridge/gpui-fast#44: | Scroll | before | after | |---|---|---| | slow | 35.3–36.4 % | **31.2–33.4 %** | | fast | 43.0–43.6 % | **37.0–37.7 %** | Main thread over 12 s: `List::prepaint` 1374 → 451 ms, `StateInner::layout_items` 1218 → 84 ms, `compute_layout` 879 → 180 ms. With only the gpui-fast change, the list still fell off its layer, because these writes kept rebuilding the rows. ## Testing - New test `redrawing_selectable_text_leaves_its_readers_retained` (GPUI Fast): a view reading the selection state beside selectable text is not rendered again when the text redraws. It fails without this change (13 renders instead of 5). - `cargo test -p gpui-base --lib`: 1317 passed. `cargo test -p gpui-component --lib`: 649 passed. - `cargo test -p gpui-base --lib --features gpui-fast`: the same 14 tests fail as on `main`, and no new ones. Before the touch-handle change, `long_press_release_keeps_handles_which_drag_the_selection` failed here. - `cargo clippy -p gpui-base -p gpui-component --tests -- --deny warnings` (with and without `gpui-fast`) and `cargo fmt --check` are clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
linruohan
pushed a commit
to linruohan/gpui-component
that referenced
this pull request
Oct 9, 2026
…#3418) ## Description Follow-up to longbridge#3407. Two more places update an entity even though nothing in it changed. Under GPUI Fast, a view that read that entity is then counted as changed and built again, even when it is retained. - **`TextViewState`**: a markdown text that is parsed synchronously when it is set still receives an acknowledgement (`baseline_ack`) from the background parser. `commit_parsed_update` discarded it, but only after `weak_self.update`, which already marked the state as updated. The receive task now checks first, with `read_with` and the new `TextViewState::commits`, and skips results that commit nothing. `commit_parsed_update` uses the same check. - **`ResizablePanelGroup` / `ResizablePanel`**: both report their bounds in `on_prepaint` on every frame they are drawn, and updated `ResizableState` every time, even when the bounds were the same. Every view that read the state was rebuilt on every frame. They now return early when nothing would change: same group bounds, or `ResizableState::panel_size_changes` (crate-private) is false. Behaviour is unchanged: the skipped updates were no-ops. ## Before / After Measured by the new tests (`gpui-fast` feature), counting renders of a view that reads the state: | Case | Before | After | | --- | --- | --- | | Reader of a `TextViewState` after the parser acknowledges a synchronous parse | 2 renders | 1 render | | Reader of a `ResizableState` while its panels are redrawn at the same bounds (3 extra frames) | 11 renders | 5 renders | ## Tests - `text::state::tests::parse_ack::acknowledging_a_parse_leaves_its_readers_retained` - `resizable::tests::redrawing_panels_where_they_were_leaves_their_readers_retained` Both fail without the fix and pass with it. `cargo fmt --check` and `cargo clippy -p gpui-base -- --deny warnings` (with and without `gpui-fast`) are clean. `cargo test -p gpui-base --lib` passes, 1323 tests. With `--features gpui-fast` the same 14 tests fail on `main` as on this branch, so this change neither causes nor fixes them. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Scrolling a long chat session in Allsum used 35–45 % process CPU here (50–60 % in the original report), most of it laying out and shaping message rows that had only moved. Part of the cause was in gpui-kit: selectable text, the text selection layer,
Rootand inputs wrote entities and globals on every frame they drew, even when nothing about the selection or focus changed.Under GPUI Fast's retained views a write is a change, so every view that read any of these (every message row with a
TextView) was built again on the next frame, and the transcript list could not stay on its scroll layer.Per-frame writes found:
TextSelectionHandle::registerWindowSelectionStateupdated with the participant's geometryTextViewprepaint / paintGlobalState::global_mutto push/pop the text view stack,begin_selection_frameupdate_runs/set_hit_test_runsTextSelectionLayerset_snapshoton every participant, changed or notRoot/InputStateChanges
register_quietly,finish_frame_quietly).GlobalState(RefCell/Cell), read throughGlobalState::global. Painted runs and copy text are interior state of the participant.a_flow_is_laid_out_again_at_another_widthchanged the view's width withoutcx.notify(). It passed only because the per-frame writes rebuilt the view anyway.Before / after
Allsum release build (macOS, Apple silicon), long session scrolled by the wheel for 30 s, process CPU, together with longbridge/gpui-fast#44:
Main thread over 12 s:
List::prepaint1374 → 451 ms,StateInner::layout_items1218 → 84 ms,compute_layout879 → 180 ms. With only the gpui-fast change, the list still fell off its layer, because these writes kept rebuilding the rows.Testing
redrawing_selectable_text_leaves_its_readers_retained(GPUI Fast): a view reading the selection state beside selectable text is not rendered again when the text redraws. It fails without this change (13 renders instead of 5).cargo test -p gpui-base --lib: 1317 passed.cargo test -p gpui-component --lib: 649 passed.cargo test -p gpui-base --lib --features gpui-fast: the same 14 tests fail as onmain, and no new ones. Before the touch-handle change,long_press_release_keeps_handles_which_drag_the_selectionfailed here.cargo clippy -p gpui-base -p gpui-component --tests -- --deny warnings(with and withoutgpui-fast) andcargo fmt --checkare clean.🤖 Generated with Claude Code