Skip to content

perf: add RepaintBoundary around chat messages#2933

Open
9clg6 wants to merge 1 commit intomainfrom
perf/repaint-boundary-messages
Open

perf: add RepaintBoundary around chat messages#2933
9clg6 wants to merge 1 commit intomainfrom
perf/repaint-boundary-messages

Conversation

@9clg6
Copy link
Collaborator

@9clg6 9clg6 commented Mar 17, 2026

Summary

Wrap each Message widget in a RepaintBoundary to isolate GPU repaints per message.

Problem

Each message goes through ~12 levels of widget nesting (AutoScrollTagMessageVisibilityDetectorColumnSwipeableMessage → ...) without any repaint isolation. When a single message changes state (hover, reaction update, selection), the rendering engine could repaint the entire visible chat area.

Solution

Add RepaintBoundary(child: Message(...)) inside AutoScrollTag in ChatEventListItem. Each message now gets its own compositing layer, so state changes only trigger a repaint of that specific message.

Memory cost

Each RepaintBoundary creates a separate layer in the render tree. However, SliverChildBuilderDelegate only builds the ~10-15 messages currently visible on screen, so the overhead is bounded and negligible.

Note on measurement

Metrics were collected via Chrome DevTools performance trace with CPU 4x throttling (simulating a low-end device). The raw flame chart JSON exceeded 350,000 lines — analysis was automated using the Chrome DevTools MCP on Claude Code.

RepaintBoundary reduces GPU overdraw, which is not directly visible in main-thread trace metrics. The improvement shows up as reduced composite/paint time in the rendering pipeline and smoother visual interactions (hover, reactions), but is difficult to quantify precisely via DevTools trace events alone. This is a standard Flutter best practice for complex list items.

Files changed

File Change
lib/pages/chat/chat_event_list_item.dart Wrap Message in RepaintBoundary

Summary by CodeRabbit

  • Performance & Memory Management
    • Optimized message rendering for smoother chat interactions.
    • Improved timeline management to maintain consistent memory usage during extended chat sessions.
    • Enhanced resource cleanup to prevent memory accumulation.

@coderabbitai
Copy link

coderabbitai bot commented Mar 17, 2026

Walkthrough

The changes optimize chat message rendering and timeline memory management. A RepaintBoundary wrapper is added around message widgets in the event list item for rendering optimization. The chat page introduces timeline event capping at 500 events with a _trimTimelineIfNeeded() method that removes oldest events when the limit is exceeded. Timeline lifecycle handling is strengthened by explicitly canceling subscriptions before nulling references in multiple navigation methods. The dispose method now clears the Flutter image cache to improve memory cleanup.

Suggested reviewers

  • dab246
  • hoangdat
  • nqhhdev
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Description check ❓ Inconclusive The PR description covers the summary, problem, solution, and memory cost considerations, but is missing several template sections including Root cause, Test recommendations, Pre-merge checklist, and Resolved (screenshots/videos). Add the missing template sections: explicitly state if this is a bug or feature, provide test recommendations, confirm pre-merge readiness, and include before/after screenshots as requested in comments.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely summarizes the main change: adding a RepaintBoundary around chat messages for performance optimization.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/repaint-boundary-messages
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Contributor

This PR has been deployed to https://linagora.github.io/twake-on-matrix/2933

@9clg6 9clg6 force-pushed the perf/repaint-boundary-messages branch from 3d86348 to 01ae931 Compare March 17, 2026 17:20
Copy link
Collaborator

@tddang-linagora tddang-linagora left a comment

Choose a reason for hiding this comment

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

  • Add before after screenshots of the improvement

@9clg6
Copy link
Collaborator Author

9clg6 commented Mar 19, 2026

  • Add before after screenshots of the improvement

RepaintBoundary reduces GPU overdraw, which is not directly visible in main-thread trace metrics. The improvement shows up as reduced composite/paint time in the rendering pipeline and smoother visual interactions (hover, reactions), but is difficult to quantify precisely via DevTools trace events alone. This is a standard Flutter best practice for complex list items.

@9clg6 9clg6 changed the base branch from refacto/performance to main March 20, 2026 11:07
@9clg6 9clg6 force-pushed the perf/repaint-boundary-messages branch from 01ae931 to cbe83f2 Compare March 20, 2026 11:08
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
lib/pages/chat/chat_event_list_item.dart (1)

63-79: Avoid calling listHorizontalActionMenuBuilder twice in one build.

Line 63 and Line 75 invoke the same builder for the same event. Cache once and reuse to reduce build-time overhead and keep both menu representations consistent.

♻️ Proposed refactor
   `@override`
   Widget build(BuildContext context) {
     if (!event.isVisibleInGui) return const SizedBox();
+    final horizontalActions = controller.listHorizontalActionMenuBuilder(event);

     return AutoScrollTag(
       key: ValueKey(event.eventId),
@@
-          listHorizontalActionMenu: controller.listHorizontalActionMenuBuilder(
-            event,
-          ),
+          listHorizontalActionMenu: horizontalActions,
@@
-          listAction: controller.listHorizontalActionMenuBuilder(event).map((
+          listAction: horizontalActions.map((
             action,
           ) {
             return ContextMenuAction(name: action.action.name);
           }).toList(),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/pages/chat/chat_event_list_item.dart` around lines 63 - 79, The code
calls controller.listHorizontalActionMenuBuilder(event) twice; cache its result
once in a local variable (e.g., final actions =
controller.listHorizontalActionMenuBuilder(event)) and then reuse that variable
for both listHorizontalActionMenu and listAction to avoid duplicated work and
keep the menu representations consistent; update references to
listHorizontalActionMenu and the listAction mapping (which creates
ContextMenuAction(name: action.action.name)) to use the cached actions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@lib/pages/chat/chat_event_list_item.dart`:
- Around line 63-79: The code calls
controller.listHorizontalActionMenuBuilder(event) twice; cache its result once
in a local variable (e.g., final actions =
controller.listHorizontalActionMenuBuilder(event)) and then reuse that variable
for both listHorizontalActionMenu and listAction to avoid duplicated work and
keep the menu representations consistent; update references to
listHorizontalActionMenu and the listAction mapping (which creates
ContextMenuAction(name: action.action.name)) to use the cached actions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9ac61e44-215f-4a14-bbc9-816e9b086946

📥 Commits

Reviewing files that changed from the base of the PR and between 844b35b and cbe83f2.

📒 Files selected for processing (2)
  • lib/pages/chat/chat_event_list_item.dart
  • lib/utils/matrix_sdk_extensions/hive_collections_database.dart
💤 Files with no reviewable changes (1)
  • lib/utils/matrix_sdk_extensions/hive_collections_database.dart

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/pages/chat/chat.dart`:
- Around line 3729-3735: The current code calls
PaintingBinding.instance.imageCache.clear() and clearLiveImages(), which evicts
app-wide images; remove those two calls from chat.dart (e.g., from
ChatPage/ChatState cleanup) and instead either 1) do nothing (let Flutter's LRU
handle eviction) or 2) set a lower global cap once via
PaintingBinding.instance.imageCache.maximumSize (e.g., in app init) or 3)
perform targeted eviction by calling ImageProvider.evict() for room-specific MXC
image URLs during chat cleanup (use the same place you removed the clears, e.g.,
dispose/close handler). Ensure you reference
PaintingBinding.instance.imageCache, imageCache.maximumSize, and
ImageProvider.evict when making the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ab8bba5c-13f6-48b5-84ef-41cd3187fb1b

📥 Commits

Reviewing files that changed from the base of the PR and between cbe83f2 and f44f4b4.

📒 Files selected for processing (2)
  • lib/pages/chat/chat.dart
  • lib/pages/chat/chat_event_list_item.dart
🚧 Files skipped from review as they are similar to previous changes (1)
  • lib/pages/chat/chat_event_list_item.dart

Comment on lines +3729 to +3735

// Evict decoded images from Flutter's image cache to free GPU/raster
// memory. This is important because decoded bitmaps can consume
// significantly more memory than their compressed form.
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Clearing the global image cache affects the entire app, not just this chat.

PaintingBinding.instance.imageCache.clear() and clearLiveImages() evict ALL cached images app-wide, including:

  • Avatars in the chat list and contact list
  • Images from other chat rooms the user has open
  • Any UI images loaded from assets

This will cause:

  • Visible flickering when navigating back to screens that had cached images
  • Additional network requests to re-fetch previously cached images
  • Poor UX, especially on slower connections

Flutter's image cache already uses LRU eviction to manage memory automatically. If memory is a concern for this specific chat, consider:

  1. Letting the default LRU behavior handle eviction naturally
  2. Only evicting MXC images specific to this room's messages (if a mechanism exists)
  3. Reducing imageCache.maximumSize globally instead of clearing on every chat close
🔧 Suggested fix: Remove aggressive cache clearing
   timeline?.cancelSubscriptions();
   timeline = null;
-
-  // Evict decoded images from Flutter's image cache to free GPU/raster
-  // memory. This is important because decoded bitmaps can consume
-  // significantly more memory than their compressed form.
-  PaintingBinding.instance.imageCache.clear();
-  PaintingBinding.instance.imageCache.clearLiveImages();
-
+  // Flutter's LRU image cache handles eviction automatically
   inputFocus.dispose();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/pages/chat/chat.dart` around lines 3729 - 3735, The current code calls
PaintingBinding.instance.imageCache.clear() and clearLiveImages(), which evicts
app-wide images; remove those two calls from chat.dart (e.g., from
ChatPage/ChatState cleanup) and instead either 1) do nothing (let Flutter's LRU
handle eviction) or 2) set a lower global cap once via
PaintingBinding.instance.imageCache.maximumSize (e.g., in app init) or 3)
perform targeted eviction by calling ImageProvider.evict() for room-specific MXC
image URLs during chat cleanup (use the same place you removed the clears, e.g.,
dispose/close handler). Ensure you reference
PaintingBinding.instance.imageCache, imageCache.maximumSize, and
ImageProvider.evict when making the change.

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.

2 participants