From 6c5df591ab4a9ee736bab75770f28cc95145bfac Mon Sep 17 00:00:00 2001 From: Mouad Debbar Date: Tue, 23 Sep 2025 21:59:26 -0400 Subject: [PATCH] [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 --- .../web_ui/lib/src/engine/frame_service.dart | 26 +++++++++++ .../test/engine/frame_service_test.dart | 44 ++++++++++++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/frame_service.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/frame_service.dart index 9e3639d6125..8fe9e9625e8 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/frame_service.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/frame_service.dart @@ -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; + } } diff --git a/engine/src/flutter/lib/web_ui/test/engine/frame_service_test.dart b/engine/src/flutter/lib/web_ui/test/engine/frame_service_test.dart index e99e0e212af..31358966609 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/frame_service_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/frame_service_test.dart @@ -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(); + 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(); + }); }); }