[web] Fix assertion thrown when hot restarting during animation (#175856)

Fixes https://github.com/flutter/flutter/issues/140684
Fixes https://github.com/flutter/flutter/issues/175260
This commit is contained in:
Mouad Debbar 2025-09-23 21:59:26 -04:00 committed by GitHub
parent 35b5342f9c
commit 6c5df591ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 68 additions and 2 deletions

View File

@ -10,6 +10,7 @@ import 'package:ui/ui.dart' as ui;
import 'dom.dart';
import 'frame_timing_recorder.dart';
import 'initialization.dart';
import 'platform_dispatcher.dart';
/// Provides frame scheduling functionality and frame lifecycle information to
@ -18,6 +19,10 @@ import 'platform_dispatcher.dart';
/// If new frame-related functionality needs to be added to the web engine,
/// prefer to add it here instead of implementing it ad hoc.
class FrameService {
FrameService() {
registerHotRestartListener(_dispose);
}
/// The singleton instance of the [FrameService] used to schedule frames.
///
/// This may be overridden in tests, for example, to pump fake frames, using
@ -25,6 +30,8 @@ class FrameService {
static FrameService get instance => _instance ??= FrameService();
static FrameService? _instance;
bool _isDisposed = false;
/// Overrides the value returned by [instance].
///
/// If [mock] is null, resets the value of [instance] back to real
@ -98,6 +105,18 @@ class FrameService {
// functionality that may throw exceptions, or produce wasm traps.
_isFrameScheduled = false;
if (_isDisposed) {
// Skip this animation frame because the instance has been disposed, meaning there was a
// hot restart performed. During a hot restart, Dart automatically cancels timers and
// microtasks, but animation frames are requested directly from the browser which isn't
// aware of hot restarts, and that leads to problems.
//
// See:
// - https://github.com/flutter/flutter/issues/175260
// - https://github.com/flutter/flutter/issues/140684#issuecomment-3251179364
return;
}
try {
_isRenderingFrame = true;
_frameData = ui.FrameData(frameNumber: _frameData.frameNumber + 1);
@ -196,4 +215,11 @@ class FrameService {
EnginePlatformDispatcher.instance.invokeOnDrawFrame();
}
}
void _dispose() {
if (identical(this, _instance)) {
_instance = null;
}
_isDisposed = true;
}
}

View File

@ -14,12 +14,18 @@ void main() {
void testMain() {
group('FrameService', () {
setUp(() {
void resetFrameService() {
// Emulate a hot restart to clear listeners from previous tests.
debugEmulateHotRestart();
FrameService.debugOverrideFrameService(null);
expect(FrameService.instance.runtimeType, FrameService);
EnginePlatformDispatcher.instance.onBeginFrame = null;
EnginePlatformDispatcher.instance.onDrawFrame = null;
});
}
setUp(resetFrameService);
tearDownAll(resetFrameService);
test('instance is valid and can be overridden', () {
final defaultInstance = FrameService.instance;
@ -151,6 +157,40 @@ void testMain() {
expect(isRenderingInOnDrawFrame, isTrue);
expect(valueInOnFinishedRenderingFrame, isFalse);
});
test('Frame is cancelled after a hot restart', () async {
final instance = FrameService.instance;
final frameCompleter = Completer<void>();
instance.onFinishedRenderingFrame = () {
frameCompleter.complete();
};
expect(instance.isFrameScheduled, isFalse);
instance.scheduleFrame();
expect(instance.isFrameScheduled, isTrue);
// Perform a hot restart immediately after scheduling the frame.
debugEmulateHotRestart();
// Wait for 1 second for the frame to be rendered.
bool timedOut = false;
await frameCompleter.future.timeout(
const Duration(seconds: 1),
onTimeout: () {
timedOut = true;
},
);
// No frame should've been rendered because the animation frame callback should've been
// cancelled on hot restart.
expect(timedOut, isTrue);
expect(instance.frameData.frameNumber, isZero);
expect(frameCompleter.isCompleted, isFalse);
// ... and no frame is scheduled.
expect(instance.isFrameScheduled, isFalse);
// To avoid leaving an uncompleted completer, let's complete it.
frameCompleter.complete();
});
});
}