Skip to content

Commit 8c383e0

Browse files
committed
fix(tugboat): dedupe hidden/paused into single app_backgrounded
1 parent 04bc823 commit 8c383e0

2 files changed

Lines changed: 55 additions & 1 deletion

File tree

sdks/flutter/packages/tugboat/lib/src/controller.dart

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1090,6 +1090,8 @@ class TugboatReplayController extends ChangeNotifier {
10901090
bool _skipCapture = false;
10911091
bool _captureLifecycleActive = true;
10921092
int _captureLifecycleEpoch = 0;
1093+
AppLifecycleState? _lastLifecycleState;
1094+
String? _lastLifecycleEventType;
10931095
int _routeEpoch = 0;
10941096
final Map<String, _RouteCaptureWork> _activeRouteCaptures =
10951097
<String, _RouteCaptureWork>{};
@@ -1655,6 +1657,8 @@ class TugboatReplayController extends ChangeNotifier {
16551657
_clearReleasedInteractions(reason: InteractionRejectionReason.sessionEnd);
16561658
_captureLifecycleActive = true;
16571659
_captureLifecycleEpoch++;
1660+
_lastLifecycleState = null;
1661+
_lastLifecycleEventType = null;
16581662
_endSessionFuture = null;
16591663
_clock
16601664
..reset()
@@ -5151,6 +5155,17 @@ class TugboatReplayController extends ChangeNotifier {
51515155
}
51525156

51535157
void recordAppLifecycleState(AppLifecycleState state) {
5158+
final eventType = _appLifecycleEventType(state);
5159+
// Flutter emits hidden + paused back-to-back on every background
5160+
// transition (inactive -> hidden -> paused). Both map to
5161+
// app_backgrounded, so without dedup each background produces two
5162+
// identical events at the same atMs (see pmkit.raw_events). Drop exact
5163+
// repeats and consecutive same-type events; a new event is only emitted
5164+
// on an effective transition (e.g. backgrounded -> foregrounded ->
5165+
// backgrounded still emits twice, correctly).
5166+
final isDuplicate =
5167+
state == _lastLifecycleState ||
5168+
eventType == _lastLifecycleEventType;
51545169
switch (state) {
51555170
case AppLifecycleState.paused:
51565171
case AppLifecycleState.hidden:
@@ -5186,11 +5201,17 @@ class TugboatReplayController extends ChangeNotifier {
51865201
case AppLifecycleState.inactive:
51875202
break;
51885203
}
5204+
_lastLifecycleState = state;
5205+
if (isDuplicate) {
5206+
_lastLifecycleEventType = eventType;
5207+
return;
5208+
}
5209+
_lastLifecycleEventType = eventType;
51895210
_addEvent(
51905211
TugboatEvent(
51915212
id: _nextId('event'),
51925213
atMs: atMs,
5193-
type: _appLifecycleEventType(state),
5214+
type: eventType,
51945215
data: {'state': state.name},
51955216
),
51965217
);

sdks/flutter/packages/tugboat/test/tugboat_replay_test.dart

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,39 @@ void main() {
183183
);
184184
});
185185

186+
testWidgets('hidden plus paused emits a single app_backgrounded', (
187+
tester,
188+
) async {
189+
await tester.pumpWidget(
190+
MaterialApp(
191+
builder: (context, child) =>
192+
TugboatReplay.wrapApp(config: _testConfig, child: child!),
193+
home: const SizedBox.expand(),
194+
),
195+
);
196+
await tester.pump();
197+
198+
final session = TugboatReplay.controller!.session!;
199+
final baseline = session.events
200+
.where((event) => event.type == 'app_backgrounded')
201+
.length;
202+
// Flutter delivers hidden + paused back-to-back on every background
203+
// transition; both map to app_backgrounded and must coalesce.
204+
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden);
205+
await tester.pump();
206+
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
207+
await tester.pump();
208+
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
209+
await tester.pump();
210+
211+
expect(
212+
session.events.where((event) => event.type == 'app_backgrounded'),
213+
hasLength(baseline + 1),
214+
);
215+
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
216+
await tester.pump();
217+
});
218+
186219
testWidgets('captures initial screenshot and tap interaction anchors', (
187220
tester,
188221
) async {

0 commit comments

Comments
 (0)