You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Keep desktop frames within the 120 Hz budget - #2070
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.
--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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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
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.
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.mainSome examples (worst UI frame of the step):
mainImages, 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:
Changes
Text and fonts on Linux:
\r\nas\nin message text, which skips two font fallback lookups (about 0.4 ms each) for each line break.Chat timeline:
Conversation list and search:
scrollable_positioned_list0.3.8 inthird_party/and addfindChildIndexCallback, so rows keep their elements when a conversation moves. The package is no longer maintained.Images and media:
Other:
Panels:
SlackWidthBox), so they lay out once and not in every frame.Fixes:
markMessageReadalready skipped these statuses.Dependencies:
mixin_markdown_widget0.3.3 (mixin_markdown_widget: measure text less and highlight code before the first frame flutter-plugins#525) andmixin_bot_sdk_dart1.5.1 (Cache enum conversions mixin_bot_sdk_dart#153), which contain the text measurement, code highlight, and enum conversion changes.Profiling tools (no effect on normal builds):
--dart-define=MIXIN_FRAME_LOG=truelogs frame timings, and--dart-define=MIXIN_PROFILE_WIDGETS=trueadds 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.dartandintegration_test/text_layout_bench_test.dartare profile benchmarks, andtest_driver/has drivers that write timelines and CPU profiles.Risks
\nline endings. Emoji and CJK now come from the theme fallback list on Linux.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 vendoredscrollable_positioned_listhas its own tests, which pass.flutter build linux --release: pass.main.main.