[web] Only create one <style> for SelectableRegion (#161682)

We used to create and insert a new `<style>` element for each
`SelectableRegion` widget. That's unnecessary. All we need is one
`<style>` element that contains the style sheets that we want to apply.

Most of this PR is re-working the tests to be able to check that the
issue is actually fixed.

Fixes https://github.com/flutter/flutter/issues/161519
This commit is contained in:
Mouad Debbar 2025-02-05 15:51:29 -05:00 committed by GitHub
parent 093485d91c
commit 996badc9cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 230 additions and 146 deletions

View File

@ -46,6 +46,12 @@ class PlatformSelectableRegionContextMenu extends StatelessWidget {
@visibleForTesting
static RegisterViewFactory? debugOverrideRegisterViewFactory;
/// Resets the view factory registration to its initial state.
@visibleForTesting
static void debugResetRegistry() {
throw UnimplementedError();
}
@override
Widget build(BuildContext context) => throw UnimplementedError();
}

View File

@ -75,6 +75,12 @@ class PlatformSelectableRegionContextMenu extends StatelessWidget {
@visibleForTesting
static RegisterViewFactory? debugOverrideRegisterViewFactory;
/// Resets the view factory registration to its initial state.
@visibleForTesting
static void debugResetRegistry() {
_registeredViewType = null;
}
// Registers the view factories for the interceptor widgets.
static void _register() {
assert(_registeredViewType == null);
@ -104,21 +110,21 @@ class PlatformSelectableRegionContextMenu extends StatelessWidget {
}
static String _registerWebSelectionCallback(_WebSelectionCallBack callback) {
_registerViewFactory(_viewType, (int viewId) {
// Create css style for _kClassName.
final web.HTMLStyleElement styleElement =
web.document.createElement('style') as web.HTMLStyleElement;
web.document.head!.append(styleElement as JSAny);
final web.CSSStyleSheet sheet = styleElement.sheet!;
sheet.insertRule(_kClassRule, 0);
sheet.insertRule(_kClassSelectionRule, 1);
_registerViewFactory(_viewType, (int viewId, {Object? params}) {
final web.HTMLElement htmlElement = web.document.createElement('div') as web.HTMLElement;
htmlElement
..style.width = '100%'
..style.height = '100%'
..classList.add(_kClassName);
// Create css style for _kClassName.
final web.HTMLStyleElement styleElement =
web.document.createElement('style') as web.HTMLStyleElement;
web.document.head!.append(styleElement as JSAny);
final web.CSSStyleSheet sheet = styleElement.sheet!;
sheet.insertRule(_kClassRule, 0);
sheet.insertRule(_kClassSelectionRule, 1);
htmlElement.addEventListener(
'mousedown',
(web.Event event) {

View File

@ -8,7 +8,6 @@ library;
import 'dart:async';
import 'dart:ui_web' as ui_web;
import 'package:collection/collection.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/src/widgets/_html_element_view_web.dart'
@ -17,6 +16,8 @@ import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:web/web.dart' as web;
import 'web_platform_view_registry_utils.dart';
final Object _mockHtmlElement = Object();
Object _mockViewFactory(int id, {Object? params}) {
return _mockHtmlElement;
@ -419,101 +420,3 @@ void main() {
});
});
}
typedef FakeViewFactory = ({String viewType, bool isVisible, Function viewFactory});
typedef FakePlatformView = ({int id, String viewType, Object? params, Object htmlElement});
class FakePlatformViewRegistry implements ui_web.PlatformViewRegistry {
FakePlatformViewRegistry() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform_views,
_onMethodCall,
);
}
Set<FakePlatformView> get views => Set<FakePlatformView>.unmodifiable(_views);
final Set<FakePlatformView> _views = <FakePlatformView>{};
final Set<FakeViewFactory> _registeredViewTypes = <FakeViewFactory>{};
@override
bool registerViewFactory(String viewType, Function viewFactory, {bool isVisible = true}) {
if (_findRegisteredViewFactory(viewType) != null) {
return false;
}
_registeredViewTypes.add((viewType: viewType, isVisible: isVisible, viewFactory: viewFactory));
return true;
}
@override
Object getViewById(int viewId) {
return _findViewById(viewId)!.htmlElement;
}
FakeViewFactory? _findRegisteredViewFactory(String viewType) {
return _registeredViewTypes.singleWhereOrNull(
(FakeViewFactory registered) => registered.viewType == viewType,
);
}
FakePlatformView? _findViewById(int viewId) {
return _views.singleWhereOrNull((FakePlatformView view) => view.id == viewId);
}
Future<dynamic> _onMethodCall(MethodCall call) {
return switch (call.method) {
'create' => _create(call),
'dispose' => _dispose(call),
_ => Future<dynamic>.sync(() => null),
};
}
Future<dynamic> _create(MethodCall call) async {
final Map<dynamic, dynamic> args = call.arguments as Map<dynamic, dynamic>;
final int id = args['id'] as int;
final String viewType = args['viewType'] as String;
final Object? params = args['params'];
if (_findViewById(id) != null) {
throw PlatformException(
code: 'error',
message: 'Trying to create an already created platform view, view id: $id',
);
}
final FakeViewFactory? registered = _findRegisteredViewFactory(viewType);
if (registered == null) {
throw PlatformException(
code: 'error',
message: 'Trying to create a platform view of unregistered type: $viewType',
);
}
final ui_web.ParameterizedPlatformViewFactory viewFactory =
registered.viewFactory as ui_web.ParameterizedPlatformViewFactory;
_views.add((
id: id,
viewType: viewType,
params: params,
htmlElement: viewFactory(id, params: params),
));
return null;
}
Future<dynamic> _dispose(MethodCall call) async {
final int id = call.arguments as int;
final FakePlatformView? view = _findViewById(id);
if (view == null) {
throw PlatformException(
code: 'error',
message: 'Trying to dispose a platform view with unknown id: $id',
);
}
_views.remove(view);
return null;
}
}

View File

@ -5,14 +5,15 @@
@TestOn('browser') // This file contains web-only library.
library;
import 'dart:js_interop';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:web/web.dart' as web;
import 'web_platform_view_registry_utils.dart';
extension on web.HTMLCollection {
Iterable<web.Element?> get iterable =>
Iterable<web.Element?>.generate(length, (int index) => item(index));
@ -24,47 +25,75 @@ extension on web.CSSRuleList {
}
void main() {
web.HTMLElement? element;
PlatformSelectableRegionContextMenu.debugOverrideRegisterViewFactory = (
String viewType,
Object Function(int viewId) fn, {
bool isVisible = true,
}) {
element = fn(0) as web.HTMLElement;
// The element needs to be attached to the document body to receive mouse
// events.
web.document.body!.append(element! as JSAny);
};
// This force register the dom element.
PlatformSelectableRegionContextMenu(child: const Placeholder());
PlatformSelectableRegionContextMenu.debugOverrideRegisterViewFactory = null;
late FakePlatformViewRegistry fakePlatformViewRegistry;
setUp(() {
removeAllStyleElements();
fakePlatformViewRegistry = FakePlatformViewRegistry();
PlatformSelectableRegionContextMenu.debugOverrideRegisterViewFactory =
fakePlatformViewRegistry.registerViewFactory;
});
tearDown(() {
PlatformSelectableRegionContextMenu.debugOverrideRegisterViewFactory = null;
PlatformSelectableRegionContextMenu.debugResetRegistry();
});
testWidgets('DOM element is set up correctly', (WidgetTester tester) async {
final int currentViewId = platformViewsRegistry.getNextPlatformViewId();
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
selectionControls: EmptyTextSelectionControls(),
child: const Placeholder(),
),
),
);
final web.HTMLElement element =
fakePlatformViewRegistry.getViewById(currentViewId + 1) as web.HTMLElement;
test('DOM element is set up correctly', () async {
expect(element, isNotNull);
expect(element!.style.width, '100%');
expect(element!.style.height, '100%');
expect(element!.classList.length, 1);
final String className = element!.className;
expect(element.style.width, '100%');
expect(element.style.height, '100%');
expect(element.classList.length, 1);
expect(web.document.head!.children.iterable, isNotEmpty);
bool foundStyle = false;
for (final web.Element? element in web.document.head!.children.iterable) {
expect(element, isNotNull);
if (element!.tagName != 'STYLE') {
continue;
}
final web.CSSRuleList? rules = (element as web.HTMLStyleElement).sheet?.rules;
if (rules != null) {
foundStyle = rules.iterable.any((web.CSSRule? rule) => rule!.cssText.contains(className));
}
if (foundStyle) {
break;
}
}
expect(foundStyle, isTrue);
final int numberOfStyleElements = getNumberOfStyleElements();
expect(numberOfStyleElements, 1);
});
testWidgets('only one <style> is inserted into the DOM', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: ListView(
children: <Widget>[
SelectableRegion(
selectionControls: EmptyTextSelectionControls(),
child: const Placeholder(),
),
SelectableRegion(
selectionControls: EmptyTextSelectionControls(),
child: const Placeholder(),
),
SelectableRegion(
selectionControls: EmptyTextSelectionControls(),
child: const Placeholder(),
),
],
),
),
);
await tester.pumpAndSettle();
await tester.pumpAndSettle();
final int numberOfStyleElements = getNumberOfStyleElements();
expect(numberOfStyleElements, 1);
});
testWidgets('right click can trigger select word', (WidgetTester tester) async {
final int currentViewId = platformViewsRegistry.getNextPlatformViewId();
final FocusNode focusNode = FocusNode();
addTearDown(focusNode.dispose);
final UniqueKey spy = UniqueKey();
@ -77,13 +106,16 @@ void main() {
),
),
);
final web.HTMLElement element =
fakePlatformViewRegistry.getViewById(currentViewId + 1) as web.HTMLElement;
expect(element, isNotNull);
focusNode.requestFocus();
await tester.pump();
// Dispatch right click.
element!.dispatchEvent(
element.dispatchEvent(
web.MouseEvent('mousedown', web.MouseEventInit(button: 2, clientX: 200, clientY: 300)),
);
final RenderSelectionSpy renderSelectionSpy = tester.renderObject<RenderSelectionSpy>(
@ -104,6 +136,36 @@ void main() {
});
}
void removeAllStyleElements() {
final List<web.Element?> styles = web.document.head!.children.iterable.toList();
for (final web.Element? element in styles) {
if (element!.tagName == 'STYLE') {
element.remove();
}
}
}
int getNumberOfStyleElements() {
expect(web.document.head!.children.iterable, isNotEmpty);
int count = 0;
for (final web.Element? element in web.document.head!.children.iterable) {
expect(element, isNotNull);
if (element!.tagName != 'STYLE') {
continue;
}
final web.CSSRuleList? rules = (element as web.HTMLStyleElement).sheet?.rules;
if (rules != null) {
if (rules.iterable.any(
(web.CSSRule? rule) => rule!.cssText.contains('web-selectable-region-context-menu'),
)) {
count++;
}
}
}
return count;
}
class SelectionSpy extends LeafRenderObjectWidget {
const SelectionSpy({super.key});

View File

@ -0,0 +1,107 @@
// Copyright 2014 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:ui_web' as ui_web;
import 'package:collection/collection.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
typedef FakeViewFactory = ({String viewType, bool isVisible, Function viewFactory});
typedef FakePlatformView = ({int id, String viewType, Object? params, Object htmlElement});
class FakePlatformViewRegistry implements ui_web.PlatformViewRegistry {
FakePlatformViewRegistry() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform_views,
_onMethodCall,
);
}
Set<FakePlatformView> get views => Set<FakePlatformView>.unmodifiable(_views);
final Set<FakePlatformView> _views = <FakePlatformView>{};
final Set<FakeViewFactory> _registeredViewTypes = <FakeViewFactory>{};
@override
bool registerViewFactory(String viewType, Function viewFactory, {bool isVisible = true}) {
if (_findRegisteredViewFactory(viewType) != null) {
return false;
}
_registeredViewTypes.add((viewType: viewType, isVisible: isVisible, viewFactory: viewFactory));
return true;
}
@override
Object getViewById(int viewId) {
return _findViewById(viewId)!.htmlElement;
}
FakeViewFactory? _findRegisteredViewFactory(String viewType) {
return _registeredViewTypes.singleWhereOrNull(
(FakeViewFactory registered) => registered.viewType == viewType,
);
}
FakePlatformView? _findViewById(int viewId) {
return _views.singleWhereOrNull((FakePlatformView view) => view.id == viewId);
}
Future<dynamic> _onMethodCall(MethodCall call) {
return switch (call.method) {
'create' => _create(call),
'dispose' => _dispose(call),
_ => Future<dynamic>.sync(() => null),
};
}
Future<dynamic> _create(MethodCall call) async {
final Map<dynamic, dynamic> args = call.arguments as Map<dynamic, dynamic>;
final int id = args['id'] as int;
final String viewType = args['viewType'] as String;
final Object? params = args['params'];
if (_findViewById(id) != null) {
throw PlatformException(
code: 'error',
message: 'Trying to create an already created platform view, view id: $id',
);
}
final FakeViewFactory? registered = _findRegisteredViewFactory(viewType);
if (registered == null) {
throw PlatformException(
code: 'error',
message: 'Trying to create a platform view of unregistered type: $viewType',
);
}
final ui_web.ParameterizedPlatformViewFactory viewFactory =
registered.viewFactory as ui_web.ParameterizedPlatformViewFactory;
_views.add((
id: id,
viewType: viewType,
params: params,
htmlElement: viewFactory(id, params: params),
));
return null;
}
Future<dynamic> _dispose(MethodCall call) async {
final int id = call.arguments as int;
final FakePlatformView? view = _findViewById(id);
if (view == null) {
throw PlatformException(
code: 'error',
message: 'Trying to dispose a platform view with unknown id: $id',
);
}
_views.remove(view);
return null;
}
}