Skip to content

Keep desktop frames within the 120 Hz budget - #2070

Merged
boyan01 merged 101 commits into
mainfrom
perf/desktop-frame-time
Oct 19, 2026
Merged

boyan01 merged 101 commits into
mainfrom
perf/desktop-frame-time

Conversation

@YeungKC

@YeungKC YeungKC commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

This makes the desktop app keep frames within the 120 Hz budget (8.33 ms) in most interactions on Linux. Before, opening a chat, switching unread chats, and opening shared media took frames of 85–194 ms.

Results

Measured on a Linux desktop (i7-8850H, 1920x1080), profile build, Flutter 3.47.7, Impeller (OpenGL ES), with an offline copy of a real account database. The same 66 interaction steps ran on main (ea42b5f9) and on this branch, and the worst UI and raster frame of each step came from the timeline.

Steps with a frame over 8.33 ms Worst frame
main 45 of 66 194 ms (open shared media)
This branch 14 of 66 UI 13.2 ms, raster 14.5 ms

Some examples (worst UI frame of the step):

Step main This branch
Open a chat 85–91 ms 7.9–9.0 ms
Switch between unread chats 92–106 ms 9.8–13.2 ms
Open shared media 188–194 ms 4.9–5.3 ms
Scroll shared media while it loads 167 ms 8.4 ms
Scroll the chat history fast 29–95 ms 9.2–9.5 ms

Images, the side menu, and the input stay within the budget on this branch. One run varies by about 1–2 ms, so steps near 8.33 ms pass in some runs and not in others.

The steps that still go over the budget:

  • The first open of a large group: raster 14.5 ms.
  • Switching unread chats and fast scrolling: 9.2–13.2 ms, from the build of the visible rows and garbage collection.
  • Jumping to the latest messages: up to 11.3 ms, because the old window unmounts about 54 rows in one frame.

Changes

Text and fonts on Linux:

  • Build semantics only while an assistive technology is on, from the AT-SPI status. Before, every new row flushed semantics.
  • Reach emoji and CJK fonts through the theme's font fallback, look up the Linux font families before the first frame, and skip fontconfig searches for missing families.
  • Show \r\n as \n in message text, which skips two font fallback lookups (about 0.4 ms each) for each line break.

Chat timeline:

  • Rebuild only the rows whose content or neighbours changed, and reuse matched text spans.
  • Build new rows within a per-frame time budget that the lists above and below the center share. A post of a new window builds a frame after its rows.
  • Parse posts and highlight their code blocks in the load balancer before their rows build.
  • Start directional restores from the center, measure rows against the viewport, and remove redundant layers from message rows.

Conversation list and search:

  • Rebuild only the rows that changed, keep the shortcut trees stable when the focus or the selection mode changes, and keep the list ready while searching.
  • Vendor scrollable_positioned_list 0.3.8 in third_party/ and add findChildIndexCallback, so rows keep their elements when a conversation moves. The package is no longer maintained.
  • Spread the rows of a new list over frames, and load search messages in smaller pages.

Images and media:

  • Decode message and shared media images at the size they show, not at the source size.
  • Decode blur hash placeholders off the UI thread, and spread the shared media page over frames.

Other:

  • Draw the emoji panel from emoji images, and open it without a long frame.
  • Find message links in the background on macOS, and load the link detector's language data after startup.
  • Cache date formats, the local day of message times, id color hashes, decoded memberships, and SVG icon rasters.
  • Keep page animations from laying out and repainting the home page.

Panels:

  • The category rail keeps its expanded layout while it collapses, and its clip shows the part that fits; it changes to its collapsed layout when the width reaches it. Before, it changed layout in the first frame and showed an empty area for the rest of the animation. The pages beside the rail keep the widest width they have in the animation (SlackWidthBox), so they lay out once and not in every frame.
  • The side page keeps its animation, and the chat narrows and widens with it as before. The page now builds at zero width a frame before its width animation starts, so the first frame does not build the page and lay out the chat at once.
  • After a scroll through the history, the slowest frame of these animations is 6.1 ms (UI) and 6.6 ms (raster), and the other frames of the side page take about 4 ms. Before, opening the group info dropped 17 frames at 9–25 ms each.

Fixes:

  • A delivery receipt no longer moves a message that failed to decrypt, or an unknown message, to a later status. A message from another device of the account that failed to decrypt became read when its read receipt arrived, so the row showed the encrypted data as text, and the message was never requested again (the resend request looks only for failed messages). markMessageRead already skipped these statuses.
  • The bottom bar animates its height only while it switches between the input and the selection bar. Before, a new line in the input grew the input before the bar, and the bar's clip covered the input's padding for 300 ms.

Dependencies:

Profiling tools (no effect on normal builds):

  • --dart-define=MIXIN_FRAME_LOG=true logs frame timings, and --dart-define=MIXIN_PROFILE_WIDGETS=true adds widget build and paint events to the timeline. Both are compile-time constants, so other builds leave the code out.
  • integration_test/chat_timeline_perf_test.dart and integration_test/text_layout_bench_test.dart are profile benchmarks, and test_driver/ has drivers that write timelines and CPU profiles.

Risks

  • Text: the CRLF change affects only how message text shows. The copy action copies the original content. Text that a user selects and copies from the row has \n line endings. Emoji and CJK now come from the theme fallback list on Linux.
  • Images: the decoder gets the smallest size that covers the shown box and keeps the aspect ratio of the image. The size comes from the decoder after the EXIF rotation. Each box side goes up in steps of 64 physical pixels, so a window resize decodes again only now and then. An image that is smaller than its box decodes at full size.
  • Panels: the rail now hides its labels behind its moving edge instead of switching to icons first. While the rail moves, the chat text wraps at one width and changes once, at the start or the end. The side page starts its animation one frame (about 8 ms) later.
  • Chat rows build over a few frames when a window opens. Rows outside the visible region can appear one or two frames later; the visible rows always build in the first frames.
  • macOS: the tests and analysis pass, and the app runs, but frame times were not measured on macOS.

Testing

  • dart format, build_runner (no changes), dart analyze --fatal-infos: pass.
  • flutter test: 327 tests pass and 9 are skipped. New tests cover row rebuilds, the frame budget, the menu focus rules, CRLF display, image decode sizes, SlackWidthBox, receipt status updates, and the bottom bar height. The vendored scrollable_positioned_list has its own tests, which pass.
  • flutter build linux --release: pass.
  • The 66-step regression above, on Linux, against main.
  • Frame captures of the rail collapse and expand and the side page open and close on Linux, compared with main.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Six unresolved findings include critical deferred-mount starvation, two panel lifecycle defects, image-cache retention, resize playback, and mention-highlighting regressions.

4 open findings
What changed in this PR

Reduces desktop rendering costs toward the 120 Hz frame budget, focusing on chat navigation, scrolling, shared media, and panel animations.

Changes:

  • Reuses rows, budgets mounting across frames, and caches text processing.
  • Optimizes image decoding, font handling, SVG rendering, and panel layout.
  • Vendors positioned-list support and updates dependencies.
  • Adds profiling tools, regression tests, and receipt-status safeguards.
File Description
third_party/​scrollable_positioned_list/​test/​find_child_index_test.dart Tests keyed row preservation.
third_party/​scrollable_positioned_list/​README.md Documents vendored package usage.
third_party/​scrollable_positioned_list/​pubspec.yaml Defines vendored dependencies.
third_party/​scrollable_positioned_list/​LICENSE Preserves upstream licensing.
third_party/​scrollable_positioned_list/​lib/​src/​scroll_view.dart Provides unbounded scroll views.
third_party/​scrollable_positioned_list/​lib/​src/​scroll_offset_notifier.dart Publishes offset notifications.
third_party/​scrollable_positioned_list/​lib/​src/​scroll_offset_listener.dart Exposes offset listeners.
third_party/​scrollable_positioned_list/​lib/​src/​post_mount_callback.dart Provides mount callbacks.
third_party/​scrollable_positioned_list/​lib/​src/​item_positions_notifier.dart Publishes item positions.
third_party/​scrollable_positioned_list/​lib/​src/​item_positions_listener.dart Defines position-listening APIs.
third_party/​scrollable_positioned_list/​lib/​src/​element_registry.dart Tracks mounted elements.
third_party/​scrollable_positioned_list/​lib/​scrollable_positioned_list.dart Exports vendored APIs.
third_party/​scrollable_positioned_list/​CHANGELOG.md Records upstream and local changes.
test/​widgets/​without_crlf_test.dart Tests CRLF display normalization.
test/​widgets/​sticker_page/​emoji_page_test.dart Tests emoji rendering and insertion.
test/​widgets/​slack_width_box_test.dart Tests stable widths and clipping.
test/​widgets/​media_image_pipeline_test.dart Tests decode sizing and GIF gating.
test/​widgets/​markdown_test.dart Tests Markdown theme reuse.
test/​widgets/​interactive_decorated_box_test.dart Tests reduced interaction rebuilds.
test/​widgets/​image_test.dart Tests asynchronous placeholder decoding.
test/​widgets/​high_light_text_test.dart Tests span reuse and link preparation.
test/​widgets/​deferred_mount_test.dart Tests frame-budgeted mounting.
test/​utils/​emoji_test.dart Tests selective emoji handling.
test/​utils/​datetime_format_utils_test.dart Tests date-format caching.
test/​ui/​home/​conversation/​conversation_hotkey_test.dart Tests stable shortcuts and focus behavior.
test/​ui/​home/​chat/​chat_scroll_coordinator_test.dart Removes obsolete date-geometry coverage.
test/​blaze/​message_status_test.dart Tests receipt-status safeguards.
test_driver/​profile_driver.dart Writes CPU profiles.
test_driver/​perf_driver.dart Writes performance reports.
pubspec.yaml Updates dependencies and profiling support.
lib/​widgets/​user/​phone_number_input.dart Rasterizes arrow SVG.
lib/​widgets/​user_selector/​conversation_selector.dart Rasterizes selector SVGs.
lib/​widgets/​toast.dart Rasterizes result icons.
lib/​widgets/​sticker_page/​sticker_page.dart Rasterizes sticker-panel icons.
lib/​widgets/​status.dart Rasterizes status icons.
lib/​widgets/​slack_width_box.dart Adds stable-width clipped layout.
lib/​widgets/​search_text_field.dart Rasterizes search icon.
lib/​widgets/​radio.dart Rasterizes selection icon.
lib/​widgets/​payment/​multisigs_payment_dialog.dart Rasterizes payment icons.
lib/​widgets/​more_extended_text.dart Inherits fonts during measurement.
lib/​widgets/​mixin_image.dart Exposes gapless playback.
lib/​widgets/​message/​message.dart Reduces message-row overhead.
lib/​widgets/​message/​message_rows.dart Adds rendering-based row equality.
lib/​widgets/​message/​message_day_time.dart Keeps traversal callbacks current.
lib/​widgets/​message/​message_datetime_and_status.dart Reuses formats and rasterizes icons.
lib/​widgets/​message/​item/​video/​video_message.dart Rasterizes playback icon.
lib/​widgets/​message/​item/​transfer/​safe_transfer_message.dart Rasterizes snapshot decoration.
lib/​widgets/​message/​item/​transfer/​inscription_message/​inscription_message.dart Rasterizes collection placeholder.
lib/​widgets/​message/​item/​transfer/​inscription_message/​inscription_content.dart Rasterizes inscription placeholders.
lib/​widgets/​message/​item/​transcript_message.dart Rasterizes transcript icon.
lib/​widgets/​message/​item/​text/​text_message.dart Filters highlight updates and defers selection setup.
lib/​widgets/​message/​item/​recall_message.dart Rasterizes recall icon.
lib/​widgets/​message/​item/​quote_message.dart Rasterizes quote icons.
lib/​widgets/​message/​item/​post_message.dart Rasterizes post-detail icon.
lib/​widgets/​message/​item/​location/​location_message_widget.dart Rasterizes location marker.
lib/​widgets/​message/​item/​image/​image_preview_page.dart Rasterizes navigation icons.
lib/​widgets/​message/​item/​image/​image_message.dart Uses display-sized image decoding.
lib/​widgets/​message/​item/​action/​action_message.dart Rasterizes action icons.
lib/​widgets/​message_status_icon.dart Rasterizes receipt icons.
lib/​widgets/​menu.dart Rasterizes menu icons.
lib/​widgets/​media_image_pipeline.dart Adds bounded decoding and playback controls.
lib/​widgets/​markdown.dart Prepares posts and caches themes.
lib/​widgets/​interactive_decorated_box.dart Avoids redundant rebuilds and animations.
lib/​widgets/​image.dart Decodes and caches blur-hash images asynchronously.
lib/​widgets/​deferred_mount.dart Budgets mounting across frames.
lib/​widgets/​conversation/​badges_widget.dart Rasterizes badge icons.
lib/​widgets/​cell.dart Rasterizes cell arrow.
lib/​widgets/​auth.dart Rasterizes lock icon.
lib/​widgets/​actions/​command_palette_action.dart Rasterizes empty-state icon.
lib/​widgets/​action_button.dart Rasterizes action-button SVGs.
lib/​utils/​web_view/​web_view_mobile.dart Rasterizes web-view icons.
lib/​utils/​system/​system_fonts.dart Resolves and warms Linux fonts.
lib/​utils/​system/​linux_accessibility.dart Gates Linux semantics through AT-SPI.
lib/​utils/​frame_log.dart Adds optional frame instrumentation.
lib/​utils/​emoji.dart Limits explicit emoji-font spans.
lib/​utils/​device_transfer/​device_transfer_widget.dart Rasterizes transfer-status icons.
lib/​utils/​device_transfer/​device_transfer_dialog.dart Rasterizes transfer illustrations.
lib/​utils/​datetime_format_utils.dart Caches formats and local days.
lib/​utils/​color_utils.dart Caches identifier hashes.
lib/​ui/​setting/​setting_page.dart Rasterizes settings icons.
lib/​ui/​setting/​proxy_page.dart Rasterizes selection icon.
lib/​ui/​setting/​backup_page.dart Rasterizes backup illustration.
lib/​ui/​setting/​account_delete_page.dart Rasterizes deletion illustration.
lib/​ui/​landing/​landing_qrcode.dart Rasterizes retry icon.
lib/​ui/​home/​slide_page.dart Rasterizes category-rail icons.
lib/​ui/​home/​notifier/​search_message_controller.dart Defines smaller search pages.
lib/​ui/​home/​notifier/​message_controller.dart Prepares content before publishing windows.
lib/​ui/​home/​notifier/​conversation_list_controller.dart Warms snapshots and deduplicates visibility updates.
lib/​ui/​home/​home.dart Stabilizes rail-adjacent layout and painting.
lib/​ui/​home/​conversation/​unseen_conversation_list.dart Reuses keyed unread-conversation rows.
lib/​ui/​home/​conversation/​search_list.dart Bounds preview work and search pages.
lib/​ui/​home/​conversation/​network_status.dart Isolates indicator repainting.
lib/​ui/​home/​conversation/​conversation_page.dart Keeps conversations mounted during search.
lib/​ui/​home/​conversation/​conversation_hotkey.dart Keeps shortcut structure stable.
lib/​ui/​home/​conversation/​audio_player_bar.dart Rasterizes audio icon.
lib/​ui/​home/​chat/​selection_bottom_bar.dart Rasterizes selection-action icons.
lib/​ui/​home/​chat/​input_container.dart Warms emoji images and rasterizes icons.
lib/​ui/​home/​chat/​files_preview.dart Rasterizes file-preview icons.
lib/​ui/​home/​chat/​chat_side_router.dart Coordinates panel animation and width slack.
lib/​ui/​home/​chat/​chat_scroll_coordinator.dart Simplifies geometry and restoration handling.
lib/​ui/​home/​chat/​chat_page.dart Stabilizes chat width and shortcuts.
lib/​ui/​home/​chat/​chat_jump_buttons.dart Rasterizes jump icon.
lib/​ui/​home/​chat/​chat_content_overlays.dart Rasterizes overlay icons.
lib/​ui/​home/​chat/​chat_bar.dart Rasterizes disappearing-message indicator.
lib/​ui/​home/​chat_slide_page/​shared_media_page.dart Lazily mounts media tabs.
lib/​ui/​home/​chat_slide_page/​share_media/​shared_media_list.dart Caches items and defers mounting.
lib/​ui/​home/​chat_slide_page/​share_media/​post_page.dart Prepares and shares parsed posts.
lib/​ui/​home/​chat_slide_page/​share_media/​media_page.dart Rasterizes video indicator.
lib/​ui/​home/​chat_slide_page/​search_message_page.dart Uses smaller search pages.
lib/​ui/​home/​chat_slide_page/​groups_in_common_page.dart Rasterizes empty-state icon.
lib/​ui/​home/​chat_slide_page/​group_invite/​group_invite_dialog.dart Rasterizes invitation icons.
lib/​ui/​home/​chat_slide_page/​disappear_message_page.dart Rasterizes duration-setting icons.
lib/​ui/​home/​chat_slide_page/​circle_manager_page.dart Rasterizes circle-management icons.
lib/​main.dart Initializes accessibility, fonts, and profiling.
lib/​db/​fts_database.g.dart Refreshes generated Drift mappings.
lib/​db/​dao/​user_dao.g.dart Qualifies generated table references.
lib/​db/​dao/​transcript_message_dao.g.dart Qualifies generated table references.
lib/​db/​dao/​token_dao.g.dart Qualifies generated table references.
lib/​db/​dao/​sticker_relationship_dao.g.dart Qualifies generated table references.
lib/​db/​dao/​sticker_dao.g.dart Qualifies generated table references.
lib/​db/​dao/​snapshot_dao.g.dart Qualifies generated table references.
lib/​db/​dao/​safe_snapshot_dao.g.dart Qualifies generated table references.
lib/​db/​dao/​pin_message_dao.g.dart Qualifies generated table references.
lib/​db/​dao/​participant_session_dao.g.dart Qualifies generated table references.
lib/​db/​dao/​participant_dao.g.dart Qualifies generated table references.
lib/​db/​dao/​message_dao.g.dart Qualifies generated table references.
lib/​db/​dao/​inscription_item_dao.g.dart Qualifies generated table references.
lib/​db/​dao/​flood_message_dao.g.dart Qualifies generated table references.
lib/​db/​dao/​favorite_app_dao.g.dart Qualifies generated table references.
lib/​db/​dao/​expired_message_dao.g.dart Qualifies generated table references.
lib/​db/​dao/​conversation_dao.g.dart Qualifies generated table references.
lib/​db/​dao/​circle_dao.g.dart Qualifies generated table references.
lib/​db/​dao/​circle_conversation_dao.g.dart Qualifies generated table references.
lib/​db/​dao/​asset_dao.g.dart Qualifies generated table references.
lib/​db/​custom_sqlite3_database.dart Delegates additional SQLite APIs.
lib/​db/​converter/​membership_converter.dart Caches decoded memberships.
lib/​crypto/​signal/​signal_database.g.dart Adds explicit generated mapping types.
lib/​blaze/​blaze.dart Preserves failed and unknown message statuses.
integration_test/​chat_timeline_perf_test.dart Adds timeline performance benchmarks.
Files not reviewed (22)
  • lib/crypto/signal/signal_database.g.dart: Generated file
  • lib/db/dao/asset_dao.g.dart: Generated file
  • lib/db/dao/circle_conversation_dao.g.dart: Generated file
  • lib/db/dao/circle_dao.g.dart: Generated file
  • lib/db/dao/conversation_dao.g.dart: Generated file
  • lib/db/dao/expired_message_dao.g.dart: Generated file
  • lib/db/dao/favorite_app_dao.g.dart: Generated file
  • lib/db/dao/flood_message_dao.g.dart: Generated file
  • lib/db/dao/inscription_item_dao.g.dart: Generated file
  • lib/db/dao/message_dao.g.dart: Generated file
  • lib/db/dao/participant_dao.g.dart: Generated file
  • lib/db/dao/participant_session_dao.g.dart: Generated file
  • lib/db/dao/pin_message_dao.g.dart: Generated file
  • lib/db/dao/safe_snapshot_dao.g.dart: Generated file
  • lib/db/dao/snapshot_dao.g.dart: Generated file
  • lib/db/dao/sticker_dao.g.dart: Generated file
  • lib/db/dao/sticker_relationship_dao.g.dart: Generated file
  • lib/db/dao/token_dao.g.dart: Generated file
  • lib/db/dao/transcript_message_dao.g.dart: Generated file
  • lib/db/dao/user_dao.g.dart: Generated file
  • lib/db/fts_database.g.dart: Generated file
  • lib/db/mixin_database.g.dart: Generated file

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread lib/widgets/deferred_mount.dart Outdated
Comment thread lib/ui/home/chat/chat_side_router.dart Outdated
Comment thread lib/widgets/image.dart Outdated
Comment thread lib/widgets/media_image_pipeline.dart
Renders the real chat timeline (viewport, message rows, scroll
coordinator) from a fake message window with 1200 mixed messages and
records frame timings for opening, fast wheel scrolling through
history, fling chains, incoming messages and window resizing.

Run with:
  flutter drive --profile -d macos --driver test_driver/perf_driver.dart \
    --target integration_test/chat_timeline_perf_test.dart
The first chat opened in a fresh process pays one-time costs that the
real app pays earlier, so the benchmark now opens a warm-up chat first.
TRACE=true records a timeline with one event per widget build.
The text matchers declared 'implements Equatable' without inheriting
==, and CustomText keyed its memo on the matcher list identity, so every
row build re-ran URL, mention, emoji and keyword matching. Matchers now
use Equatable as a mixin and the memo compares each matcher, the text
span and the theme. The link detector is created once instead of per
match (about 1 ms each on macOS).

Chat timeline benchmark (macOS, profile, two runs each):
- open: worst build 54 ms -> 27 ms, p99 40 ms -> 22 ms
- fast wheel scroll through history: p99 12-14 ms -> 8 ms, no frames
  over budget
- fling chain: p99 6.5-6.9 ms -> 3.9-4.3 ms
Every window update built a new MessageItemWidget for every built row, so
one incoming message rebuilt dozens of rows. MessageRowModel now compares
by the message and the neighbour flags that rows render from, and the
viewport returns the same row widget while those inputs are unchanged.

Chat timeline benchmark (macOS, profile, two runs each):
- incoming messages: p90 5.0 ms -> 1.4 ms, p99 7.9 ms -> 4.5 ms
- fast wheel scroll: p99 8.1 ms -> 5.7-7.5 ms
The open scenario now switches conversations on a mounted shell, as the
chat page does, instead of mounting a new app tree. Wheel events arrive
every 8 ms, and each scenario reports frames over the 8.3 ms budget.
A row can keep its GlobalKey after it leaves the cache extent, and
localToGlobal on its detached render object threw a null check error
during viewport anchor correction.
ChatScrollCoordinator.visibleDateTime had no listener; the date chip
finds its own date. Each wheel tick ends a scroll, so the computation
ran localToGlobal for every rendered row about 120 times per second
(about 5% of UI isolate time in the Linux CPU profile).
TextSpan.hashCode hashes the whole span subtree and its styles, and the
text cache hashed every span for every matcher pass. Text matching time
in the Linux CPU profile dropped from 1165 to 504 samples.
The Linux embedder always enables Flutter semantics, unlike other
platforms, and scrolled lists rebuild their semantics every frame. The
app now reads the AT-SPI status over D-Bus and excludes semantics while
no assistive technology uses it. The benchmark also stops enabling
semantics, which testWidgets does by default.
--dart-define=MIXIN_FRAME_LOG=true prints each frame's vsync wait, build
and raster times to stderr; MIXIN_PROFILE_WIDGETS=true adds one timeline
event per widget build and layout. Both are compile-time constants, so
other builds drop the code.
On Linux a span with its own font family, or text whose fonts lack a
character, falls back through fontconfig for every paragraph. Wrapping
each emoji in a span with the emoji font made a 40-character paragraph
take about 4.2 ms to lay out, and Chinese text with the default font
about 3.2 ms (benchmark on an i7-8850H, profile build).

The theme now lists a CJK family and the emoji family as fallbacks on
Linux and Windows, and only emoji that text fonts also draw (symbols in
the Basic Multilingual Plane) keep a span with the emoji font. The same
paragraphs now take 0.3 to 0.4 ms. Plain text also skips the emoji
regular expression.
Loading group avatars while scrolling, and switching conversations,
rebuilt every row of the list. Rows now watch their own avatar users and
selection, and the list returns the same row widget while a conversation
is unchanged. A snapshot is shown after the mention and avatar caches
are filled, so a row's preview is computed synchronously and is final in
its first frame.

Fast wheel scroll through the conversation list on an i7-8850H (Linux,
real data): build p90 46-64 ms -> 25-28 ms, worst 88-108 ms -> 44-67 ms.
ConversationHotKey rebuilt its FocusableActionDetector on every focus
change and gave it no shortcuts while a multiline text field had focus.
The detector then changed its structure and moved its keyed MouseRegion,
so the whole home page below it was re-parented, and every element with
dependencies rebuilt. Opening a conversation moves focus to the composer,
so each switch rebuilt all conversation rows twice.

Always provide the shortcuts, and disable the actions while a multiline
text field has focus. A disabled action does not handle the key, so the
text field still gets it.
currentConversationIdProvider is itself a select, and a select on it
notified every row on each change. Select on conversationProvider
directly.
The first layout of each font family and weight that is not installed
costs a fontconfig search of about 30 ms on Linux. The default theme
family 'Roboto' and the markdown code fonts ('Mono', 'SF Mono', 'Menlo'
and others) are usually missing, so the first open of a chat with a
post stalled for about 470 ms.

Use the system CJK family as the theme family. Latin text already
rendered with it, because 'Roboto' was missing. Use the family that
fontconfig matches for 'monospace' as the code font, with the body
fallback fonts.
A DateFormat parses its pattern when it is created, which costs about
30 times more than a format. Every chat row and conversation row
created one. Keep one format for each skeleton and locale.
The Impeller GLES backend draws the whole scene again on every frame. An
SvgPicture with a color filter draws its picture inside a saveLayer, and
each saveLayer is an offscreen render pass. Every message status icon
and list icon added one render pass to every frame; an idle frame had 48
of them and took about 15 ms to rasterize on a Radeon RX 560.

Use the raster strategy for all SVG icons. vector_graphics then draws a
cached raster at the target size with the color filter on the paint, and
an idle frame needs 5 render passes.
A widget that rebuilds below a LayoutBuilder makes that LayoutBuilder lay
out again, and the layout climbed to the scaffold because the navigator
had loose constraints. Each layout then repainted from the root repaint
boundary. The connecting indicator in the conversation list repainted
and laid out the whole home page on every frame.

Give the navigator tight constraints so its LayoutBuilder is a relayout
boundary, add repaint boundaries above the home and navigator layout
builders, and isolate the connecting indicator in its own repaint
boundary.
- Wrap a row in a VisibilityDetector only while it has an unread
  mention. A detector adds a layer and a visibility check to every
  paint of every row.
- Create the selection icon's animation controller on the first
  selection mode. Every row had a controller that most rows never use.
- InteractiveDecoratedBox: skip the animated decoration when there is
  no hover or pressed decoration, and rebuild on hover only when the
  hover state changes, not on every pointer move.
- Build a text message's selection region when the mouse first enters
  the message. The region gave every row a focus node, gesture
  recognizers, and actions.
- Remove the second quick reply detector inside the bubble. The row
  detector already gets every pointer event of the bubble.
Chat rows compare the local day of each message with its neighbours
whenever the message window changes. Each comparison converted both UTC
times to local time and read the time zone for each date field. Keep the
local day of each time instance.
The Linux embedder always enables semantics. Excluding the app from the
semantics tree still left every added render object marking semantics
dirty, and a frame that added many rows flushed semantics for several
milliseconds.

Use a Linux binding whose semanticsEnabled also requires the AT-SPI
status, so the pipeline creates no semantics owner until assistive
technology is on, and drop LinuxSemanticsGate.
The first layout of each font family and weight searches fontconfig for
about 10 ms. Lay out text once in each weight the app uses during
startup, so the first chat or post that uses a weight does not stall.
This takes about 45 ms before the first frame.
The gif gate wrapped its child in a FutureBuilder while it checked a
file, and returned the child directly once the file was checked. A new
decode size of a checked file removed the FutureBuilder, so the image
state and its last frame were lost, and the thumbnail showed again.
The gate now keeps a ready flag and builds the child at the same place
in both cases.
@YeungKC

YeungKC commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator Author

Re the review note "Resizing normalized files discards the retained image frame": fixed in this push. NormalizedGifImageGate no longer switches between a FutureBuilder and a direct child. It keeps a ready flag and builds the child at the same place, so a new decode size of a checked file keeps the image state and gapless playback keeps its frame. The test a new decode size of a checked file keeps the child state counts the mounts of the child across a resize; it fails on the old code.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Cross-cutting accessibility, rendering, and asynchronous scrolling changes need final human cross-platform validation.

0 open findings

🧠 Review effort: Balanced

The emoji image batch had a 2 ms budget on the UI thread, but each
image then decodes its color glyph on the raster thread. A batch took
about 20 emoji, and the next batch started at once, so the warm-up held
the raster thread for up to 30 ms in a macOS system trace. A batch now
takes at most 3 emoji, and the next batch starts when its images are
done. No frame went over 8.3 ms during the warm-up after this change.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Broad rendering, accessibility, and asynchronous UI changes require human validation alongside the unresolved input regression.

0 open findings

Previously missed (1)

In code that hasn't changed since last review

Medium severity First touch long press cannot select text on unhovered rows

lib/​widgets/​message/​item/​text/​text_message.dart:185

On a desktop touchscreen, the first long press on a row that has not been hovered or focused no longer selects text. This pointer-down handler only schedules a rebuild; SelectableRegion and its long-press recognizer do not exist when that pointer sequence starts. The existing region supports touch and stylus selection (lib/widgets/message/item/text/selectable.dart:314–325), but the newly mounted recognizer cannot receive the initial down event. Keep the selection recognizers mounted, or preserve the initial gesture through lazy activation, and add a regression test for the first touch long press.

🧠 Review effort: Balanced

The first open of the emoji panel opened a file for each group and
sticker album icon on the UI thread, because the engine reads assets
there, and filtered thousands of emoji into the group lists. The
warm-up now loads the icons into the SVG cache, and the group lists are
computed once for the process. On macOS, the build of the first open
went from 9.7 ms to 5.6-8.9 ms, and its raster from 8.3 ms to 4.5-6 ms.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The unresolved input regression and broad accessibility, scheduling, and rendering changes require correction and cross-platform human validation.

0 open findings

Previously missed (1)

In code that hasn't changed since last review

Medium severity Mount selection before initial touch to support long-press

lib/​widgets/​message/​item/​text/​text_message.dart:185

On a desktop touchscreen, a fresh row has no selection recognizer when the first pointer-down arrives. _activate() only schedules a rebuild, so the new SelectableRegion misses that event and the first long press cannot select text. Keep selection available before the gesture starts for non-hover input, while retaining lazy mounting where safe. Add a regression test that long-presses a fresh row without first hovering over it.

🧠 Review effort: Balanced

Impeller draws a nine patch image as nine separate image draws, so each
bubble added nine entities to every frame. The shadow is now one vertex
mesh with texture coordinates over the same image. Screenshots differ
by 2 pixels at most 1 of 255. In a macOS raster profile of a chat
scroll, the shadow went from 8.3% to 2.5% of raster samples.
A message builds its selection region on the first pointer down, so the
region missed the down of the first touch long press, and that press did
not select. The row now times a touch press itself and asks the new
region to select the word at the press, as its own long press would.
Mouse input is unchanged.
@YeungKC

YeungKC commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator Author

Re the review note "First touch long press cannot select text on unhovered rows": fixed in c78136b. The row now times a touch or stylus press itself. When the press lasts kLongPressTimeout without lifting or moving past kTouchSlop, the row asks the new selection region to select the word at the press, as its own long press would. Mouse input and the lazy mounting of the region are unchanged. Tests: the first touch long press selects a word of a fresh row (fails on the old code) and a short touch on a fresh row does not select.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The broad accessibility, rendering, and scheduling changes require platform-specific human validation alongside resolution of the retained interaction regression.

0 open findings

Previously missed (1)

In code that hasn't changed since last review

Medium severity Preserve DecoratedBox hit testing when decoration is null

lib/​widgets/​interactive_decorated_box.dart:201

Returning the bare child removes the empty DecoratedBox's hit-test surface. ChatBar passes HitTestBehavior.deferToChild for its title and member count, whose children are wrapped in IgnorePointer (lib/ui/home/chat/chat_bar.dart:104–120). Tapping those areas therefore no longer opens conversation info. Keep a non-animated DecoratedBox with an empty decoration when _decoration is null, and add a regression test combining IgnorePointer with deferToChild.

🧠 Review effort: Balanced

An InteractiveDecoratedBox without decorations returned its child, so
it lost the hit area of the empty DecoratedBox. The chat bar wraps its
title and member count in IgnorePointer under HitTestBehavior.deferToChild,
and a tap on them no longer opened the conversation info. The box keeps
an empty DecoratedBox, still without the animation controller.
@YeungKC

YeungKC commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator Author

Re the review note "Preserve DecoratedBox hit testing when decoration is null": fixed in 395297d. Without decorations, InteractiveDecoratedBox now keeps an empty DecoratedBox (still no TweenAnimationBuilder), so a child under IgnorePointer with HitTestBehavior.deferToChild takes taps again. Checked on macOS: a tap on the chat title opens the conversation info, which it did not before the fix. The test a child under IgnorePointer still takes taps fails on the old code.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Cross-cutting rendering, accessibility, asynchronous loading, and dependency changes require final cross-platform human validation.

0 open findings

🧠 Review effort: Balanced

The old bound used the long side of the box for the short side of the image. This decoded a non-square box 1.6 to 4.5 times too large. In a tall box, a tall screenshot did not get a bound, so it decoded at full size.

Now the decoder gets the smallest size with the image aspect ratio that covers the box in physical pixels. Each box side goes up in steps of 64 pixels. The image does not scale up. The decoder gives the size after the EXIF rotation, so the stored media size is not necessary.
@boyan01
boyan01 merged commit 594e4dd into main Oct 19, 2026
3 checks passed
@boyan01
boyan01 deleted the perf/desktop-frame-time branch October 19, 2026 04:21
YeungKC added a commit that referenced this pull request Oct 23, 2026
The CJK fonts have black and white glyphs for some emoji, for example
U+1F17F. With the CJK family first, these emoji lost their color, but
they were in color before #2070.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants