[web] do not send SemanticsAction.focus inside frame (#162554)

When a `SemanticsAction` fires while rendering a frame, delay it by a
zero-length timer.

## Explanation

A concrete situation where this happens is when a semantics update
causes DOM focus shift. DOM focus events are delivered synchronously
when induced programmatically. We _want_ to notify the framework about
the shift. Since it wasn't the framework that decided where the focus
moved, the framework may end up out-of-sync with the engine about which
widget is currently focused. However, if the framework is still in the
middle of rendering a frame, the notification may induce an illegal
`setState`. We have to wait until the framework is done before
delivering the notification.

## How

* Introduce `FrameService` and consolidate all frame scheduling logic
into it (this also makes it way more testable).
* Update all code that needs to schedule frames to use `FrameService`.
* Introduce `isRenderingFrame` boolean that can be used to tell if a
frame is being rendered.
* Change `invokeOnSemanticsAction` to use `isRenderingFrame` to decide
if the action can be delivered immediately, or delayed by a zero-length
timer.

Fixes https://github.com/flutter/flutter/issues/162472
This commit is contained in:
Yegor 2025-02-03 18:29:19 -08:00 committed by GitHub
parent 015cfa88d4
commit 3c43a9939b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 452 additions and 81 deletions

View File

@ -41215,6 +41215,7 @@ ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/font_fallback_data.dart + ../
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/font_fallbacks.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/fonts.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/frame_reference.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/frame_service.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/frame_timing_recorder.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/html/backdrop_filter.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/html/bitmap_canvas.dart + ../../../flutter/LICENSE
@ -44166,6 +44167,7 @@ FILE: ../../../flutter/lib/web_ui/lib/src/engine/font_fallback_data.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/font_fallbacks.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/fonts.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/frame_reference.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/frame_service.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/frame_timing_recorder.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/html/backdrop_filter.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/html/bitmap_canvas.dart

View File

@ -66,6 +66,7 @@ export 'engine/font_fallback_data.dart';
export 'engine/font_fallbacks.dart';
export 'engine/fonts.dart';
export 'engine/frame_reference.dart';
export 'engine/frame_service.dart';
export 'engine/frame_timing_recorder.dart';
export 'engine/html/backdrop_filter.dart';
export 'engine/html/bitmap_canvas.dart';

View File

@ -2,10 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/// A monotonically increasing frame number being rendered.
///
/// Used for debugging only.
int debugFrameNumber = 1;
// TODO(yjbanov): this file should be deleted as part of https://github.com/flutter/flutter/issues/145954
// because it's only used by the HTML renderer.
List<FrameReference<dynamic>> frameReferences = <FrameReference<dynamic>>[];

View File

@ -0,0 +1,195 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:js_interop';
import 'package:meta/meta.dart';
import 'package:ui/ui.dart' as ui;
import 'dom.dart';
import 'frame_timing_recorder.dart';
import 'platform_dispatcher.dart';
/// Provides frame scheduling functionality and frame lifecycle information to
/// all of the web engine.
///
/// 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 {
/// The singleton instance of the [FrameService] used to schedule frames.
///
/// This may be overridden in tests, for example, to pump fake frames, using
/// [debugOverrideFrameService].
static FrameService get instance => _instance ??= FrameService();
static FrameService? _instance;
/// Overrides the value returned by [instance].
///
/// If [mock] is null, resets the value of [instance] back to real
/// implementation.
///
/// This is intended for tests only.
@visibleForTesting
static void debugOverrideFrameService(FrameService? mock) {
_instance = mock;
}
/// A monotonically increasing frame number being rendered.
///
/// This is intended for tests only.
int get debugFrameNumber => _debugFrameNumber;
int _debugFrameNumber = 0;
/// Resets [debugFrameNumber] back to zero.
///
/// This is intended for tests only.
@visibleForTesting
void debugResetFrameNumber() {
_debugFrameNumber = 0;
}
/// Whether a frame has already been scheduled.
///
/// If this value is currently true, then calling [scheduleFrame] has no effect.
bool get isFrameScheduled => _isFrameScheduled;
bool _isFrameScheduled = false;
/// Whether the engine and framework are in the middle of rendering a frame.
///
/// Some DOM events can be triggered synchronously with DOM mutations, such as
/// the DOM "focus" event. Handlers of such events may wish to be aware of the
/// fact that the engine is actively rendering a frame. This is especially
/// true for DOM event handlers that send notifications to the framework. It
/// goes against the framework's design to receive events that lead to widget
/// state changes invalidating the current frame. That must be done in the
/// next frame.
///
/// DOM event handlers whose notifications to the framework result in state
/// changes may want to delay their notifications, e.g. by scheduling them in
/// a timer.
bool get isRenderingFrame => _isRenderingFrame;
bool _isRenderingFrame = false;
/// If not null, called immediately and synchronously after rendering a frame.
///
/// At the time this callback is called, the framework completed responding to
/// `onBeginFrame` and `onDrawFrame`, and [isRenderingFrame] is set to false.
///
/// Any microtasks scheduled while rendering the frame execute after this
/// callback.
ui.VoidCallback? onFinishedRenderingFrame;
void scheduleFrame() {
// A frame is already scheduled. Do nothing.
if (_isFrameScheduled) {
return;
}
_isFrameScheduled = true;
domWindow.requestAnimationFrame((JSNumber highResTime) {
// Reset immediately for two reasons:
//
// * While drawing a frame the framework may attempt to schedule a new
// frame, e.g. when there's a continuous animation.
// * If this value is stuck in `true` state, there will be no way to
// schedule new frames and the app will freeze. It is therefore the
// safest to reset this value before running any significant amount of
// functionality that may throw exceptions, or produce wasm traps.
_isFrameScheduled = false;
try {
_isRenderingFrame = true;
_debugFrameNumber += 1;
_renderFrame(highResTime.toDartDouble);
} finally {
_isRenderingFrame = false;
onFinishedRenderingFrame?.call();
}
});
}
/// The framework has special handling for the warm-up frame. It uses timers,
/// ensures that there's no regular frame scheduling happening before or
/// between timers. So this logic here trusts the the framework fulfills its
/// promises. For example, there's no check if _isFrameScheduled is already
/// true. The assumption that no prior frames were scheduled.
void scheduleWarmUpFrame({
required ui.VoidCallback beginFrame,
required ui.VoidCallback drawFrame,
}) {
_isFrameScheduled = true;
// A note from dkwingsmt:
//
// We use timers here to ensure that microtasks flush in between.
//
// TODO(dkwingsmt): This logic was moved from the framework and is different
// from how Web renders a regular frame, which doesn't flush microtasks
// between the callbacks at all (see `initializeEngineServices`). We might
// want to change this. See the to-do in `initializeEngineServices` and
// https://github.com/flutter/engine/pull/50570#discussion_r1496671676
Timer.run(() {
_isFrameScheduled = false;
_isRenderingFrame = true;
_debugFrameNumber += 1;
// TODO(yjbanov): it's funky that if beginFrame crashes, the drawFrame
// fires anyway. We should clean this up, or better explain
// what the expectations are for various situations. The
// "we did this before so let's continue doing it" excuse
// only works so far (referring to the discussion linked
// above).
beginFrame();
});
Timer.run(() {
try {
drawFrame();
} finally {
_isRenderingFrame = false;
onFinishedRenderingFrame?.call();
}
});
}
void _renderFrame(double highResTime) {
FrameTimingRecorder.recordCurrentFrameVsync();
// In Flutter terminology "building a frame" consists of "beginning
// frame" and "drawing frame".
//
// We do not call `recordBuildFinish` from here because
// part of the rasterization process, particularly in the HTML
// renderer, takes place in the `SceneBuilder.build()`.
FrameTimingRecorder.recordCurrentFrameBuildStart();
// We have to convert high-resolution time to `int` so we can construct
// a `Duration` out of it. However, high-res time is supplied in
// milliseconds as a double value, with sub-millisecond information
// hidden in the fraction. So we first multiply it by 1000 to uncover
// microsecond precision, and only then convert to `int`.
final int highResTimeMicroseconds = (1000 * highResTime).toInt();
if (EnginePlatformDispatcher.instance.onBeginFrame != null) {
EnginePlatformDispatcher.instance.invokeOnBeginFrame(
Duration(microseconds: highResTimeMicroseconds),
);
}
if (EnginePlatformDispatcher.instance.onDrawFrame != null) {
// On mobile Flutter flushes microtasks between onBeginFrame and
// onDrawFrame. The web doesn't because there's no way to hook into the
// event loop, which is controlled by the browser (mobile Flutter hooks
// into the event loop using C++ code behind-the-scenes). This hasn't
// been an issue yet. However, if in the future someone can find a way
// to implement it exactly like mobile does, that would be great.
//
// (Also see the to-do in
// `EnginePlatformDispatcher.scheduleWarmUpFrame`).
EnginePlatformDispatcher.instance.invokeOnDrawFrame();
}
}
}

View File

@ -7,6 +7,7 @@ import 'package:ui/ui.dart' as ui;
import '../dom.dart';
import '../frame_reference.dart';
import '../frame_service.dart';
import '../onscreen_logging.dart';
import '../semantics.dart';
import '../util.dart';
@ -73,7 +74,7 @@ void commitScene(PersistedScene scene) {
retainedSurfaces = <PersistedSurface>[];
}
if (debugExplainSurfaceStats) {
debugPrintSurfaceStats(scene, debugFrameNumber);
debugPrintSurfaceStats(scene, FrameService.instance.debugFrameNumber);
debugRepaintSurfaceStatsOverlay(scene);
}
@ -97,10 +98,6 @@ void commitScene(PersistedScene scene) {
if (debugExplainSurfaceStats) {
surfaceStats = <PersistedSurface, DebugSurfaceStats>{};
}
assert(() {
debugFrameNumber++;
return true;
}());
}
/// Signature of a function that receives a [PersistedSurface].

View File

@ -4,7 +4,6 @@
import 'dart:async';
import 'dart:developer' as developer;
import 'dart:js_interop';
import 'package:ui/src/engine.dart';
import 'package:ui/ui.dart' as ui;
@ -154,51 +153,6 @@ Future<void> initializeEngineServices({
Profiler.ensureInitialized();
}
bool waitingForAnimation = false;
scheduleFrameCallback = () {
// We're asked to schedule a frame and call `frameHandler` when the frame
// fires.
if (!waitingForAnimation) {
waitingForAnimation = true;
domWindow.requestAnimationFrame((JSNumber highResTime) {
FrameTimingRecorder.recordCurrentFrameVsync();
// In Flutter terminology "building a frame" consists of "beginning
// frame" and "drawing frame".
//
// We do not call `recordBuildFinish` from here because
// part of the rasterization process, particularly in the HTML
// renderer, takes place in the `SceneBuilder.build()`.
FrameTimingRecorder.recordCurrentFrameBuildStart();
// Reset immediately, because `frameHandler` can schedule more frames.
waitingForAnimation = false;
// We have to convert high-resolution time to `int` so we can construct
// a `Duration` out of it. However, high-res time is supplied in
// milliseconds as a double value, with sub-millisecond information
// hidden in the fraction. So we first multiply it by 1000 to uncover
// microsecond precision, and only then convert to `int`.
final int highResTimeMicroseconds = (1000 * highResTime.toDartDouble).toInt();
if (EnginePlatformDispatcher.instance.onBeginFrame != null) {
EnginePlatformDispatcher.instance.invokeOnBeginFrame(
Duration(microseconds: highResTimeMicroseconds),
);
}
if (EnginePlatformDispatcher.instance.onDrawFrame != null) {
// TODO(yjbanov): technically Flutter flushes microtasks between
// onBeginFrame and onDrawFrame. We don't, which hasn't
// been an issue yet, but eventually we'll have to
// implement it properly. (Also see the to-do in
// `EnginePlatformDispatcher.scheduleWarmUpFrame`).
EnginePlatformDispatcher.instance.invokeOnDrawFrame();
}
});
}
};
assetManager ??= ui_web.AssetManager(assetBase: configuration.assetBase);
_setAssetManager(assetManager);

View File

@ -13,11 +13,6 @@ import 'package:ui/ui_web/src/ui_web.dart' as ui_web;
import '../engine.dart';
/// Requests that the browser schedule a frame.
///
/// This may be overridden in tests, for example, to pump fake frames.
ui.VoidCallback? scheduleFrameCallback;
/// Signature of functions added as a listener to high contrast changes
typedef HighContrastListener = void Function(bool enabled);
typedef _KeyDataResponseCallback = void Function(bool handled);
@ -735,10 +730,7 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher {
/// scheduling of frames.
@override
void scheduleFrame() {
if (scheduleFrameCallback == null) {
throw Exception('scheduleFrameCallback must be initialized first.');
}
scheduleFrameCallback!();
FrameService.instance.scheduleFrame();
}
@override
@ -746,15 +738,7 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher {
required ui.VoidCallback beginFrame,
required ui.VoidCallback drawFrame,
}) {
Timer.run(beginFrame);
// We use timers here to ensure that microtasks flush in between.
//
// TODO(dkwingsmt): This logic was moved from the framework and is different
// from how Web renders a regular frame, which doesn't flush microtasks
// between the callbacks at all (see `initializeEngineServices`). We might
// want to change this. See the to-do in `initializeEngineServices` and
// https://github.com/flutter/engine/pull/50570#discussion_r1496671676
Timer.run(drawFrame);
FrameService.instance.scheduleWarmUpFrame(beginFrame: beginFrame, drawFrame: drawFrame);
}
/// Updates the application's rendering on the GPU with the newly provided
@ -1248,11 +1232,28 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher {
/// Engine code should use this method instead of the callback directly.
/// Otherwise zones won't work properly.
void invokeOnSemanticsAction(int viewId, int nodeId, ui.SemanticsAction action, ByteData? args) {
invoke1<ui.SemanticsActionEvent>(
_onSemanticsActionEvent,
_onSemanticsActionEventZone,
ui.SemanticsActionEvent(type: action, nodeId: nodeId, viewId: viewId, arguments: args),
);
void sendActionToFramework() {
invoke1<ui.SemanticsActionEvent>(
_onSemanticsActionEvent,
_onSemanticsActionEventZone,
ui.SemanticsActionEvent(type: action, nodeId: nodeId, viewId: viewId, arguments: args),
);
}
// Semantic actions should not be sent to the framework while the framework
// is rendering a frame, even if the action is induced as a result of
// rendering it. An example of when the framework might need to be notified
// about an action as a result of rendering a new frame is a semantics
// update which results in the screen reader shifting focus (DOM "focus"
// events are delivered synchronously). In this situation a
// `SemanticsAction.focus` might be induced, and while it should be
// delivered to the framework asap, it must be done after the frame is done
// rendering at the earliest.
if (FrameService.instance.isRenderingFrame) {
Timer.run(sendActionToFramework);
} else {
sendActionToFramework();
}
}
// TODO(dnfield): make this work on web.

View File

@ -15,7 +15,6 @@ void main() {
void testMain() {
test('services are initalized separately from UI', () async {
final JsFlutterConfiguration? config = await bootstrapAndExtractConfig();
expect(scheduleFrameCallback, isNull);
expect(findGlassPane(), isNull);
expect(RawKeyboard.instance, isNull);
@ -24,7 +23,6 @@ void testMain() {
// After initializing services the UI should remain intact.
await initializeEngineServices(jsConfiguration: config);
expect(scheduleFrameCallback, isNotNull);
expect(windowFlutterCanvasKit, isNotNull);
expect(findGlassPane(), isNull);

View File

@ -7,6 +7,7 @@ import 'dart:js_interop';
import 'package:test/test.dart';
import 'package:ui/src/engine.dart' as engine;
import 'package:ui/src/engine/frame_service.dart';
import 'package:ui/src/engine/initialization.dart';
import 'package:ui/ui.dart' as ui;
import 'package:ui/ui_web/src/ui_web.dart' as ui_web;
@ -41,7 +42,7 @@ void setUpUnitTests({
engine.EngineFlutterDisplay.instance.debugOverrideDevicePixelRatio(devicePixelRatio);
engine.EnginePlatformDispatcher.instance.implicitView?.debugPhysicalSizeOverride =
const ui.Size(800 * devicePixelRatio, 600 * devicePixelRatio);
engine.scheduleFrameCallback = () {};
FrameService.debugOverrideFrameService(FakeFrameService());
}
setUpRenderingForTests();
@ -90,3 +91,8 @@ void _disableImplicitView() {
);
}
}
class FakeFrameService extends FrameService {
@override
void scheduleFrame() {}
}

View File

@ -0,0 +1,152 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'package:test/bootstrap/browser.dart';
import 'package:test/test.dart';
import 'package:ui/src/engine.dart';
void main() {
internalBootstrapBrowserTest(() => testMain);
}
void testMain() {
group('FrameService', () {
setUp(() {
FrameService.debugOverrideFrameService(null);
expect(FrameService.instance.runtimeType, FrameService);
EnginePlatformDispatcher.instance.onBeginFrame = null;
EnginePlatformDispatcher.instance.onDrawFrame = null;
});
test('instance is valid and can be overridden', () {
final defaultInstance = FrameService.instance;
expect(defaultInstance.runtimeType, FrameService);
FrameService.debugOverrideFrameService(DummyFrameService());
expect(FrameService.instance.runtimeType, DummyFrameService);
FrameService.debugOverrideFrameService(null);
expect(FrameService.instance.runtimeType, FrameService);
});
test('counts frames', () async {
final instance = FrameService.instance;
instance.debugResetFrameNumber();
final frameCompleter = Completer<void>();
instance.onFinishedRenderingFrame = () {
frameCompleter.complete();
};
expect(instance.debugFrameNumber, 0);
instance.scheduleFrame();
await frameCompleter.future;
expect(instance.debugFrameNumber, 1);
});
test('isFrameScheduled is true iff the frame is scheduled', () async {
final instance = FrameService.instance;
instance.debugResetFrameNumber();
var frameCompleter = Completer<void>();
instance.onFinishedRenderingFrame = () {
frameCompleter.complete();
};
// Normal case: pump one frame
expect(instance.isFrameScheduled, isFalse);
instance.scheduleFrame();
expect(instance.isFrameScheduled, isTrue);
await frameCompleter.future;
expect(instance.isFrameScheduled, isFalse);
expect(instance.debugFrameNumber, 1);
// Test idempotency
instance.debugResetFrameNumber();
frameCompleter = Completer<void>();
instance.scheduleFrame();
instance.scheduleFrame();
instance.scheduleFrame();
instance.scheduleFrame();
expect(instance.isFrameScheduled, isTrue);
await frameCompleter.future;
expect(instance.isFrameScheduled, isFalse);
expect(instance.debugFrameNumber, 1);
});
test('onBeginFrame and onDrawFrame are called with isRenderingFrame set to true', () async {
final instance = FrameService.instance;
bool? isRenderingInOnBeginFrame;
EnginePlatformDispatcher.instance.onBeginFrame = (_) {
isRenderingInOnBeginFrame = instance.isRenderingFrame;
};
bool? isRenderingInOnDrawFrame;
EnginePlatformDispatcher.instance.onDrawFrame = () {
isRenderingInOnDrawFrame = instance.isRenderingFrame;
};
final frameCompleter = Completer<void>();
bool? valueInOnFinishedRenderingFrame;
instance.onFinishedRenderingFrame = () {
valueInOnFinishedRenderingFrame = instance.isRenderingFrame;
frameCompleter.complete();
};
expect(instance.isRenderingFrame, isFalse);
instance.scheduleFrame();
// IMPORTANT: scheduled, but not yet rendering
expect(instance.isRenderingFrame, isFalse);
await frameCompleter.future;
expect(instance.isFrameScheduled, isFalse);
expect(isRenderingInOnBeginFrame, isTrue);
expect(isRenderingInOnDrawFrame, isTrue);
expect(valueInOnFinishedRenderingFrame, isFalse);
});
test('scheduleWarmUpFrame', () async {
final instance = FrameService.instance;
final frameCompleter = Completer<void>();
bool? valueInOnFinishedRenderingFrame;
instance.onFinishedRenderingFrame = () {
valueInOnFinishedRenderingFrame = instance.isRenderingFrame;
frameCompleter.complete();
};
bool? isRenderingInOnBeginFrame;
bool? isRenderingInOnDrawFrame;
expect(instance.isRenderingFrame, isFalse);
expect(instance.isFrameScheduled, isFalse);
instance.scheduleWarmUpFrame(
beginFrame: () {
isRenderingInOnBeginFrame = instance.isRenderingFrame;
},
drawFrame: () {
isRenderingInOnDrawFrame = instance.isRenderingFrame;
},
);
// IMPORTANT: scheduled, but not yet rendering
expect(instance.isFrameScheduled, isTrue);
expect(instance.isRenderingFrame, isFalse);
await frameCompleter.future;
expect(instance.isFrameScheduled, isFalse);
expect(isRenderingInOnBeginFrame, isTrue);
expect(isRenderingInOnDrawFrame, isTrue);
expect(valueInOnFinishedRenderingFrame, isFalse);
});
});
}
class DummyFrameService extends FrameService {}

View File

@ -250,6 +250,73 @@ Future<void> testMain() async {
);
});
test('onSemanticsActionEvent delays action until after frame', () async {
final eventLog = <ui.SemanticsAction>[];
void callback(ui.SemanticsActionEvent event) {
eventLog.add(event.type);
}
ui.PlatformDispatcher.instance.onSemanticsActionEvent = callback;
// Outside frame: action must be sent immediately
EnginePlatformDispatcher.instance.invokeOnSemanticsAction(
myWindow.viewId,
0,
ui.SemanticsAction.focus,
null,
);
expect(eventLog, [ui.SemanticsAction.focus]);
eventLog.clear();
bool tapCalled = false;
EnginePlatformDispatcher.instance.onBeginFrame = (_) {
// Inside onBeginFrame: should be delayed
EnginePlatformDispatcher.instance.invokeOnSemanticsAction(
myWindow.viewId,
0,
ui.SemanticsAction.tap,
null,
);
tapCalled = true;
};
bool increaseCalled = false;
EnginePlatformDispatcher.instance.onDrawFrame = () {
// Inside onDrawFrame: should be delayed
EnginePlatformDispatcher.instance.invokeOnSemanticsAction(
myWindow.viewId,
0,
ui.SemanticsAction.increase,
null,
);
increaseCalled = true;
};
final frameCompleter = Completer<void>();
FrameService.instance.onFinishedRenderingFrame = () {
frameCompleter.complete();
};
FrameService.instance.scheduleFrame();
await frameCompleter.future;
// Even though invokeOnSemanticsAction was called for tap and increase
// actions the actions have not yet been delivered to the framework, because
// the actions happened inside onBeginFrame and onDrawFrame. The events are
// queues in zero-length timers.
expect(tapCalled, isTrue);
expect(increaseCalled, isTrue);
expect(eventLog, isEmpty);
// Flush the timers after the frame.
await Future<void>.delayed(Duration.zero);
// Now the events should be delivered.
expect(eventLog, [ui.SemanticsAction.tap, ui.SemanticsAction.increase]);
});
test('onAccessibilityFeaturesChanged preserves the zone', () {
final Zone innerZone = Zone.current.fork();