From 050d545264a88ed897884fa845d158dd59eeaf30 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 29 Oct 2026 14:41:53 -0700 Subject: [PATCH] perf(mobile): skip unchanged message footer layout (#8433) Rebuilding an unchanged message footer currently schedules another layout even when its overlap value is identical. Only invalidate footer layout when that value changes, preserving layout updates for actual geometry changes. ### Stable release recommendation Exclude from `mobile-release/conference-2026-10`; defer to the following release. The regression test demonstrates eliminated redundant layout, but the effect on navigation latency has not been quantified. ### Validation The added widget regression checks that unchanged footer rebuilds do not repeat layout and changed overlap still updates geometry. The independent footer build passes the full mobile suite, analysis, and native iOS Simulator build. Channel scrolling, opening an existing reaction list, and thread navigation were exercised on iOS 26.5 Simulator. The full local repository gate previously encountered an unrelated, unchanged desktop subprocess timing test failure; that test passed in isolation. All PR CI checks pass, including the native iOS suite. Signed-off-by: Tom Brow --- .../channels/message_footer_layout.dart | 14 +++-- .../channels/message_footer_layout_test.dart | 57 +++++++++++++++++++ 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/mobile/lib/features/channels/message_footer_layout.dart b/mobile/lib/features/channels/message_footer_layout.dart index a35409a9e2..6668b8a8f6 100644 --- a/mobile/lib/features/channels/message_footer_layout.dart +++ b/mobile/lib/features/channels/message_footer_layout.dart @@ -54,9 +54,7 @@ class _FooterLayout extends MultiChildRenderObjectWidget { BuildContext context, covariant RenderBox renderObject, ) { - (renderObject as _RenderMessageFooter) - ..overlap = overlap - ..markNeedsLayout(); + (renderObject as _RenderMessageFooter).overlap = overlap; } } @@ -70,9 +68,15 @@ class _RenderMessageFooter extends RenderBox RenderBox, ContainerBoxParentData > { - _RenderMessageFooter(this.overlap); + _RenderMessageFooter(this._overlap); - double overlap; + double _overlap; + double get overlap => _overlap; + set overlap(double value) { + if (_overlap == value) return; + _overlap = value; + markNeedsLayout(); + } @override void setupParentData(RenderBox child) { diff --git a/mobile/test/features/channels/message_footer_layout_test.dart b/mobile/test/features/channels/message_footer_layout_test.dart index 39b1cf3bf0..22f950dd82 100644 --- a/mobile/test/features/channels/message_footer_layout_test.dart +++ b/mobile/test/features/channels/message_footer_layout_test.dart @@ -1,8 +1,47 @@ import 'package:buzz/features/channels/message_footer_layout.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + testWidgets('unchanged footer geometry does not relayout its timeline row', ( + tester, + ) async { + var layouts = 0; + final overlap = ValueNotifier(8.0); + addTearDown(overlap.dispose); + await tester.pumpWidget( + MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: _LayoutCounter( + onLayout: () => layouts++, + child: ValueListenableBuilder( + valueListenable: overlap, + builder: (context, value, child) => MessageFooterLayout( + overlap: value, + footer: const SizedBox(width: 100, height: 32), + child: const SizedBox(width: 200, height: 60), + ), + ), + ), + ), + ), + ); + final initialLayouts = layouts; + tester.element(find.byType(MessageFooterLayout)).markNeedsBuild(); + await tester.pump(); + expect( + layouts, + initialLayouts, + reason: 'Rebuilding unchanged reactions must not dirty the row layout', + ); + overlap.value = 12; + await tester.pump(); + expect(layouts, greaterThan(initialLayouts)); + expect(tester.getSize(find.byType(MessageFooterLayout)).height, 80); + }); + testWidgets('footer global transforms and semantics include overlap offset', ( tester, ) async { @@ -118,3 +157,21 @@ void main() { }, ); } + +class _LayoutCounter extends SingleChildRenderObjectWidget { + const _LayoutCounter({required this.onLayout, required super.child}); + final VoidCallback onLayout; + @override + RenderObject createRenderObject(BuildContext context) => + _CountLayouts(onLayout); +} + +class _CountLayouts extends RenderProxyBox { + _CountLayouts(this.onLayout); + final VoidCallback onLayout; + @override + void performLayout() { + onLayout(); + super.performLayout(); + } +}