perf(mobile): avoid redundant history-row work during navigation

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-17 16:51:44 -07:00
parent 6286a54321
commit 773020abae
8 changed files with 194 additions and 15 deletions

View File

@@ -5,6 +5,26 @@ import 'package:buzz/features/channels/date_formatters.dart';
int _ts(DateTime local) => local.millisecondsSinceEpoch ~/ 1000;
void main() {
test('timeline calendar grouping matches uncached local-day comparisons', () {
final days = TimelineCalendarDays();
final timestamps = [
DateTime(2025, 12, 31, 23, 59),
DateTime(2026, 1, 1),
DateTime(2026, 3, 8, 1, 30),
DateTime(2026, 3, 8, 3, 30),
DateTime(2026, 11, 1, 1, 30),
DateTime(2026, 11, 1, 3, 30),
DateTime(2026, 11, 2),
].map(_ts).toList();
for (var repeat = 0; repeat < 3; repeat++) {
for (final a in timestamps) {
for (final b in timestamps) {
expect(days.isSameDay(a, b), isSameDay(a, b));
}
}
}
});
group('formatDayHeading', () {
// Fix "now" so tests are deterministic.
final now = DateTime(2026, 4, 23, 14, 30); // Apr 23 2026, 2:30 PM local

View File

@@ -64,6 +64,43 @@ void main() {
isFalse,
);
});
testWidgets('loaded history has no send animation or row layout', (
tester,
) async {
await tester.pumpWidget(_testable(animate: false));
final history = find.byType(LocalMessageSendTransition);
expect(
find.descendant(of: history, matching: find.byType(AnimatedBuilder)),
findsNothing,
);
expect(find.byType(LocalSendRow), findsNothing);
expect(tester.getSize(history).height, 80);
expect(
tester.getSize(find.byKey(const ValueKey('avatar'))),
const Size(40, 40),
);
});
testWidgets('enabling motion does not replay a settled entrance', (
tester,
) async {
await tester.pumpWidget(_testable(animate: true, reduceMotion: true));
final avatar = tester.element(find.byKey(const ValueKey('avatar')));
await tester.pumpWidget(_testable(animate: true, reduceMotion: false));
expect(tester.element(find.byKey(const ValueKey('avatar'))), same(avatar));
expect(tester.getSize(find.byType(LocalMessageSendTransition)).height, 80);
expect(find.byType(LocalSendRow), findsNothing);
});
testWidgets('expiring a send claim retains message state', (tester) async {
await tester.pumpWidget(_testable());
await tester.pumpAndSettle();
final avatar = tester.element(find.byKey(const ValueKey('avatar')));
await tester.pumpWidget(_testable(animate: false));
expect(tester.element(find.byKey(const ValueKey('avatar'))), same(avatar));
expect(tester.getSize(find.byType(LocalMessageSendTransition)).height, 80);
});
testWidgets('history moves up while the avatar remains anchored', (
tester,
) async {
@@ -85,7 +122,7 @@ void main() {
),
);
await tester.pumpAndSettle();
expect(tester.getSize(find.byType(LocalSendRow)).height, 80);
expect(tester.getSize(find.byType(LocalMessageSendTransition)).height, 80);
expect(tester.getRect(find.byKey(const ValueKey('avatar'))), avatar);
expect(
tester.getTopLeft(find.byKey(const ValueKey('history'))).dy,
@@ -130,7 +167,10 @@ void main() {
await tester.pumpWidget(
_testable(animate: reduced, reduceMotion: reduced),
);
expect(tester.getSize(find.byType(LocalSendRow)).height, 80);
expect(
tester.getSize(find.byType(LocalMessageSendTransition)).height,
80,
);
expect(
tester
.widget<LocalSendProgress>(find.byType(LocalSendProgress))
@@ -138,7 +178,10 @@ void main() {
1,
);
await tester.pump(const Duration(milliseconds: 100));
expect(tester.getSize(find.byType(LocalSendRow)).height, 80);
expect(
tester.getSize(find.byType(LocalMessageSendTransition)).height,
80,
);
});
}
}

View File

@@ -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();
}
}