From f81d6fddbc2a82f85d676fb36f6784e565822366 Mon Sep 17 00:00:00 2001 From: Guy Kogus Date: Sun, 10 May 2026 22:09:25 +0200 Subject: [PATCH 1/2] Reduce CanvasKit memory use on Safari --- engine/src/flutter/lib/web_ui/README.md | 4 ++ .../web_ui/docs/SAFARI_CANVASKIT_MEMORY.md | 25 ++++++++ .../lib/src/engine/canvaskit/renderer.dart | 22 +++++-- .../lib/src/engine/canvaskit/surface.dart | 2 + .../compositing/display_canvas_factory.dart | 1 + .../src/engine/compositing/rasterizer.dart | 3 + .../src/engine/compositing/render_canvas.dart | 18 +++++- .../lib/src/engine/compositing/surface.dart | 3 + .../web_ui/lib/src/engine/configuration.dart | 36 ++++++++++-- .../platform_views/content_manager.dart | 1 + .../test/canvaskit/rasterizer_test.dart | 43 ++++++++++---- .../web_ui/test/canvaskit/renderer_test.dart | 43 +++++++++++++- .../display_canvas_factory_test.dart | 18 ++++++ .../compositing/render_canvas_test.dart | 20 +++++++ .../test/engine/configuration_test.dart | 57 +++++++++++++++++++ .../platform_views/content_manager_test.dart | 15 +++++ .../src/widgets/_html_element_view_web.dart | 25 +++++++- .../test/widgets/html_element_view_test.dart | 45 +++++++++++++++ 18 files changed, 357 insertions(+), 24 deletions(-) create mode 100644 engine/src/flutter/lib/web_ui/docs/SAFARI_CANVASKIT_MEMORY.md diff --git a/engine/src/flutter/lib/web_ui/README.md b/engine/src/flutter/lib/web_ui/README.md index 13f54aca2c93d..d594c1588fcec 100644 --- a/engine/src/flutter/lib/web_ui/README.md +++ b/engine/src/flutter/lib/web_ui/README.md @@ -277,6 +277,9 @@ The Flutter Web engine's sources are in `localhost:` > `lib` > `_engine` > `engine`. You can set breakpoints in Dart source files and use the Chrome debugger to inspect variables' values. +For Safari-specific CanvasKit memory behavior, see +[Safari CanvasKit memory][10]. + ## Building CanvasKit and Skwasm To build CanvasKit and/or Skwasm locally, you must first set up your gclient config to @@ -329,3 +332,4 @@ Note: The provided Dart SDK is used to run the `felt` tool itself, and to compil [7]: https://developer.chrome.com/docs/devtools [8]: https://developer.chrome.com/docs/devtools/sources [9]: https://github.com/flutter/flutter/blob/main/docs/engine/contributing/Setting-up-the-Engine-development-environment.md#additional-steps-for-web-engine +[10]: docs/SAFARI_CANVASKIT_MEMORY.md diff --git a/engine/src/flutter/lib/web_ui/docs/SAFARI_CANVASKIT_MEMORY.md b/engine/src/flutter/lib/web_ui/docs/SAFARI_CANVASKIT_MEMORY.md new file mode 100644 index 0000000000000..e482d3ece78d7 --- /dev/null +++ b/engine/src/flutter/lib/web_ui/docs/SAFARI_CANVASKIT_MEMORY.md @@ -0,0 +1,25 @@ +# Safari CanvasKit memory + +Flutter Web uses CanvasKit to render Flutter pictures into one or more canvas +surfaces. Safari's WebGL and canvas memory can remain resident longer than in +Chromium-based browsers, so a CanvasKit app that is stable in Chrome can have a +much higher physical footprint in Safari. + +The web engine applies Safari-specific CanvasKit defaults to reduce this +footprint: + +* `canvasKitForceCpuOnly` defaults to `true` in Safari. An app can still opt out + by setting `canvasKitForceCpuOnly` in `window.flutterConfiguration` or by + compiling with `FLUTTER_WEB_CANVASKIT_FORCE_CPU_ONLY=false`. +* `canvasKitMaximumSurfaces` defaults to `2` in Safari. A single surface can + merge Flutter overlays with platform-view underlays, which can make platform + views such as Google Maps disappear behind Flutter content. Two surfaces keep + the underlay and overlay paths separate while reducing retained canvas state. +* The Safari CanvasKit rasterizer caps Skia's resource cache so GPU-backed + resources are not allowed to grow to the same size as other browsers. + +When changing these defaults, validate both memory and platform-view +composition. A useful regression test is a release-mode CanvasKit app that +places Flutter content above a platform view, idles for at least 60 seconds, and +checks that the platform view remains visible while Safari WebContent memory +stays below the expected limit. diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/renderer.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/renderer.dart index def2ed376b10d..76aea734b230e 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/renderer.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/renderer.dart @@ -7,6 +7,7 @@ import 'dart:js_interop'; import 'dart:math' as math; import 'dart:typed_data'; +import 'package:meta/meta.dart'; import 'package:ui/src/engine.dart'; import 'package:ui/ui.dart' as ui; import 'package:ui/ui_web/src/ui_web.dart' as ui_web; @@ -18,6 +19,9 @@ class CanvasKitRenderer extends Renderer { static CanvasKitRenderer get instance => _instance; static late CanvasKitRenderer _instance; + @visibleForTesting + static const int safariResourceCacheMaxBytes = 24 * 1024 * 1024; + Future? _initialized; @override @@ -34,14 +38,22 @@ class CanvasKitRenderer extends Renderer { FlutterFontCollection get fontCollection => _fontCollection; static Rasterizer _createRasterizer() { - if (configuration.canvasKitForceMultiSurfaceRasterizer || isSafari || isFirefox) { - return MultiSurfaceRasterizer( + final Rasterizer rasterizer; + final bool useMultiSurfaceRasterizer = + configuration.canvasKitForceMultiSurfaceRasterizer || isFirefox || isSafari; + if (useMultiSurfaceRasterizer) { + rasterizer = MultiSurfaceRasterizer( (OnscreenCanvasProvider canvasProvider) => CkOnscreenSurface(canvasProvider), ); + } else { + rasterizer = OffscreenCanvasRasterizer( + (OffscreenCanvasProvider canvasProvider) => CkOffscreenSurface(canvasProvider), + ); + } + if (isSafari) { + rasterizer.setResourceCacheMaxBytes(safariResourceCacheMaxBytes); } - return OffscreenCanvasRasterizer( - (OffscreenCanvasProvider canvasProvider) => CkOffscreenSurface(canvasProvider), - ); + return rasterizer; } @override diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/surface.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/surface.dart index 9301eed17a419..04acaf78d559d 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/surface.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/surface.dart @@ -226,6 +226,8 @@ abstract class CkSurface extends Surface { _skSurface = null; } + void release() {} + @override void setSkiaResourceCacheMaxBytes(int bytes) { _grContext?.setResourceCacheLimitBytes(bytes.toDouble()); diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/compositing/display_canvas_factory.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/compositing/display_canvas_factory.dart index b7615da4ef3e1..6c788b0083bbe 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/compositing/display_canvas_factory.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/compositing/display_canvas_factory.dart @@ -97,6 +97,7 @@ class DisplayCanvasFactory { 'was not created by this factory', ); canvas.hostElement.remove(); + canvas.release(); _liveCanvases.remove(canvas); _cache.add(canvas); } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/compositing/rasterizer.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/compositing/rasterizer.dart index 8ee65ca65b7bc..395d18d6174c5 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/compositing/rasterizer.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/compositing/rasterizer.dart @@ -164,6 +164,9 @@ abstract class DisplayCanvas { /// Initialize the overlay. void initialize(); + /// Releases transient resources while keeping this canvas reusable. + void release() {} + /// Disposes this overlay. void dispose(); } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/compositing/render_canvas.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/compositing/render_canvas.dart index 73668ad29862a..528e7c2365c54 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/compositing/render_canvas.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/compositing/render_canvas.dart @@ -47,6 +47,7 @@ class RenderCanvas extends DisplayCanvas { final DomHTMLCanvasElement canvasElement = createDomCanvasElement(); int _pixelWidth = 0; int _pixelHeight = 0; + bool _hasTransferredBitmap = false; late final DomImageBitmapRenderingContext renderContext = canvasElement.contextBitmapRenderer; @@ -73,6 +74,7 @@ class RenderCanvas extends DisplayCanvas { void render(DomImageBitmap bitmap) { _ensureSize(BitmapSize(bitmap.width, bitmap.height)); renderContext.transferFromImageBitmap(bitmap); + _hasTransferredBitmap = true; } void renderWithNoBitmapSupport( @@ -126,8 +128,22 @@ class RenderCanvas extends DisplayCanvas { // No extra initialization needed. } + @override + void release() { + if (!_hasTransferredBitmap) { + return; + } + renderContext.transferFromImageBitmap(null); + _hasTransferredBitmap = false; + } + @override void dispose() { - // No extra cleanup needed. + release(); + _pixelWidth = 0; + _pixelHeight = 0; + canvasElement.width = 0; + canvasElement.height = 0; + _updateLogicalHtmlCanvasSize(); } } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/compositing/surface.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/compositing/surface.dart index d7a6392a5c12a..1b86e0afba55e 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/compositing/surface.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/compositing/surface.dart @@ -43,6 +43,9 @@ abstract class SurfaceProvider { int? _resourceCacheMaxBytes; + @visibleForTesting + int? get debugResourceCacheMaxBytes => _resourceCacheMaxBytes; + void setSkiaResourceCacheMaxBytes(int bytes) { _resourceCacheMaxBytes = bytes; for (final C surface in _createdSurfaces) { diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/configuration.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/configuration.dart index cb350d848aadb..0d51bb3443551 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/configuration.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/configuration.dart @@ -47,6 +47,7 @@ library configuration; import 'dart:js_interop'; import 'package:meta/meta.dart'; +import 'browser_detection.dart'; import 'dom.dart'; enum CanvasKitVariant { @@ -288,10 +289,25 @@ class FlutterConfiguration { /// /// This is mainly used for testing or for apps that want to ensure they /// run on devices which don't support WebGL. - bool get canvasKitForceCpuOnly => - _configuration?.canvasKitForceCpuOnly ?? _defaultCanvasKitForceCpuOnly; + bool get canvasKitForceCpuOnly { + final bool? configured = _configuration?.canvasKitForceCpuOnly; + if (configured != null) { + return configured; + } + if (_hasDefaultCanvasKitForceCpuOnly) { + return _defaultCanvasKitForceCpuOnly; + } + return isSafari; + } + + static const String _canvasKitForceCpuOnlyEnvironment = 'FLUTTER_WEB_CANVASKIT_FORCE_CPU_ONLY'; + + static const bool _hasDefaultCanvasKitForceCpuOnly = bool.hasEnvironment( + _canvasKitForceCpuOnlyEnvironment, + ); + static const bool _defaultCanvasKitForceCpuOnly = bool.fromEnvironment( - 'FLUTTER_WEB_CANVASKIT_FORCE_CPU_ONLY', + _canvasKitForceCpuOnlyEnvironment, ); bool get canvasKitForceMultiSurfaceRasterizer => @@ -301,11 +317,23 @@ class FlutterConfiguration { 'FLUTTER_WEB_CANVASKIT_FORCE_MULTI_SURFACE_RASTERIZER', ); + /// The default maximum number of canvases to use when rendering in CanvasKit. + @visibleForTesting + static const int defaultCanvasKitMaximumSurfaces = 8; + + /// Safari needs at least two surfaces to keep platform-view underlays and + /// Flutter overlays separate, but more surfaces retain additional GPU state. + @visibleForTesting + static const int safariDefaultCanvasKitMaximumSurfaces = 2; + /// The maximum number of canvases to use when rendering in CanvasKit. /// /// Limits the amount of overlays that can be created. int get canvasKitMaximumSurfaces { - final int maxSurfaces = _configuration?.canvasKitMaximumSurfaces?.toInt() ?? 8; + final int defaultMaxSurfaces = isSafari + ? safariDefaultCanvasKitMaximumSurfaces + : defaultCanvasKitMaximumSurfaces; + final int maxSurfaces = _configuration?.canvasKitMaximumSurfaces?.toInt() ?? defaultMaxSurfaces; if (maxSurfaces < 1) { return 1; } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/platform_views/content_manager.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/platform_views/content_manager.dart index f0c7e172f098d..724def7a42cbd 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/platform_views/content_manager.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/platform_views/content_manager.dart @@ -176,6 +176,7 @@ class PlatformViewManager { /// never been rendered before. void clearPlatformView(int viewId) { // Remove from our cache, and then from the DOM... + _viewIdToType.remove(viewId); _contents.remove(viewId)?.remove(); } diff --git a/engine/src/flutter/lib/web_ui/test/canvaskit/rasterizer_test.dart b/engine/src/flutter/lib/web_ui/test/canvaskit/rasterizer_test.dart index 9722fb36d686a..617d1cabc0576 100644 --- a/engine/src/flutter/lib/web_ui/test/canvaskit/rasterizer_test.dart +++ b/engine/src/flutter/lib/web_ui/test/canvaskit/rasterizer_test.dart @@ -7,6 +7,7 @@ import 'dart:js_interop'; import 'package:test/bootstrap/browser.dart'; import 'package:test/test.dart'; import 'package:ui/src/engine.dart'; +import 'package:ui/ui_web/src/ui_web.dart' as ui_web; import 'common.dart'; @@ -16,16 +17,38 @@ void main() { void testMain() { setUpCanvasKitTest(withImplicitView: true); - test( - 'defaults to OffscreenCanvasRasterizer on Chrome and MultiSurfaceRasterizer on Firefox and Safari', - () { - if (isChromium) { - expect(CanvasKitRenderer.instance.rasterizer, isA()); - } else { - expect(CanvasKitRenderer.instance.rasterizer, isA()); - } - }, - ); + + tearDown(() { + ui_web.browser.debugBrowserEngineOverride = null; + ui_web.browser.debugOperatingSystemOverride = null; + CanvasKitRenderer.instance.debugResetRasterizer(); + }); + + test('defaults to OffscreenCanvasRasterizer on Chrome and MultiSurfaceRasterizer on Firefox', () { + if (isChromium) { + expect(CanvasKitRenderer.instance.rasterizer, isA()); + } else { + expect(CanvasKitRenderer.instance.rasterizer, isA()); + } + }); + + test('defaults to MultiSurfaceRasterizer on desktop Safari', () { + ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.webkit; + ui_web.browser.debugOperatingSystemOverride = ui_web.OperatingSystem.macOs; + + CanvasKitRenderer.instance.debugResetRasterizer(); + + expect(CanvasKitRenderer.instance.rasterizer, isA()); + }); + + test('defaults to MultiSurfaceRasterizer on iOS Safari', () { + ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.webkit; + ui_web.browser.debugOperatingSystemOverride = ui_web.OperatingSystem.iOs; + + CanvasKitRenderer.instance.debugResetRasterizer(); + + expect(CanvasKitRenderer.instance.rasterizer, isA()); + }); test('can be configured to always use MultiSurfaceRasterizer', () { debugOverrideJsConfiguration( diff --git a/engine/src/flutter/lib/web_ui/test/canvaskit/renderer_test.dart b/engine/src/flutter/lib/web_ui/test/canvaskit/renderer_test.dart index d0ddb1034393c..5897c22dbcf40 100644 --- a/engine/src/flutter/lib/web_ui/test/canvaskit/renderer_test.dart +++ b/engine/src/flutter/lib/web_ui/test/canvaskit/renderer_test.dart @@ -9,6 +9,7 @@ import 'package:test/test.dart'; import 'package:ui/src/engine.dart'; import 'package:ui/ui.dart' as ui; +import 'package:ui/ui_web/src/ui_web.dart' as ui_web; import 'common.dart'; @@ -83,6 +84,8 @@ void testMain() { setUpCanvasKitTest(); tearDown(() { + ui_web.browser.debugBrowserEngineOverride = null; + ui_web.browser.debugOperatingSystemOverride = null; CanvasKitRenderer.instance.debugResetRasterizer(); }); @@ -190,7 +193,7 @@ void testMain() { }); test( - 'defaults to OffscreenCanvasRasterizer on Chrome and MultiSurfaceRasterizer on Firefox and Safari', + 'defaults to OffscreenCanvasRasterizer on Chrome and MultiSurfaceRasterizer on Firefox', () { if (isChromium) { expect(CanvasKitRenderer.instance.rasterizer, isA()); @@ -200,6 +203,44 @@ void testMain() { }, ); + test('uses MultiSurfaceRasterizer and caps Skia resource cache on desktop Safari', () { + ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.webkit; + ui_web.browser.debugOperatingSystemOverride = ui_web.OperatingSystem.macOs; + + CanvasKitRenderer.instance.debugResetRasterizer(); + + expect(CanvasKitRenderer.instance.rasterizer, isA()); + expect( + CanvasKitRenderer.instance.rasterizer.surfaceProvider.debugResourceCacheMaxBytes, + CanvasKitRenderer.safariResourceCacheMaxBytes, + ); + }); + + test('uses MultiSurfaceRasterizer and caps Skia resource cache on iOS Safari', () { + ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.webkit; + ui_web.browser.debugOperatingSystemOverride = ui_web.OperatingSystem.iOs; + + CanvasKitRenderer.instance.debugResetRasterizer(); + + expect(CanvasKitRenderer.instance.rasterizer, isA()); + expect( + CanvasKitRenderer.instance.rasterizer.surfaceProvider.debugResourceCacheMaxBytes, + CanvasKitRenderer.safariResourceCacheMaxBytes, + ); + }); + + test('does not cap Skia resource cache on Chromium', () { + ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.blink; + + CanvasKitRenderer.instance.debugResetRasterizer(); + + expect(CanvasKitRenderer.instance.rasterizer, isA()); + expect( + CanvasKitRenderer.instance.rasterizer.surfaceProvider.debugResourceCacheMaxBytes, + isNull, + ); + }); + test('can be configured to always use MultiSurfaceRasterizer', () { debugOverrideJsConfiguration( {'canvasKitForceMultiSurfaceRasterizer': true}.jsify() diff --git a/engine/src/flutter/lib/web_ui/test/engine/compositing/display_canvas_factory_test.dart b/engine/src/flutter/lib/web_ui/test/engine/compositing/display_canvas_factory_test.dart index cd58d2358b18b..9815b1d78397e 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/compositing/display_canvas_factory_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/compositing/display_canvas_factory_test.dart @@ -13,6 +13,8 @@ void main() { } class DummyDisplayCanvas extends DisplayCanvas { + int releaseCount = 0; + @override void dispose() {} @@ -24,6 +26,11 @@ class DummyDisplayCanvas extends DisplayCanvas { @override void initialize() {} + @override + void release() { + releaseCount += 1; + } + @override bool get isConnected => throw UnimplementedError(); } @@ -64,6 +71,17 @@ void testMain() { expect(newCanvas, equals(canvas)); }); + test('releaseCanvas releases transient resources before caching the canvas', () { + final factory = DisplayCanvasFactory( + createCanvas: () => DummyDisplayCanvas(), + ); + + final DummyDisplayCanvas canvas = factory.getCanvas(); + factory.releaseCanvas(canvas); + + expect(canvas.releaseCount, 1); + }); + test('isLive', () { final factory = DisplayCanvasFactory(createCanvas: () => DummyDisplayCanvas()); diff --git a/engine/src/flutter/lib/web_ui/test/engine/compositing/render_canvas_test.dart b/engine/src/flutter/lib/web_ui/test/engine/compositing/render_canvas_test.dart index 536ab9ca18c4a..5ceb54cefdf8c 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/compositing/render_canvas_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/compositing/render_canvas_test.dart @@ -59,6 +59,26 @@ void testMain() { expect(canvas.canvasElement.style.height, '32px'); }); + test('releases transferred bitmap resources while remaining reusable', () async { + final canvas = RenderCanvas(); + canvas.render(await newBitmap(10, 16)); + + expect(canvas.canvasElement.width, 10); + expect(canvas.canvasElement.height, 16); + + canvas.release(); + expect(canvas.canvasElement.width, 10); + expect(canvas.canvasElement.height, 16); + + canvas.render(await newBitmap(20, 24)); + expect(canvas.canvasElement.width, 20); + expect(canvas.canvasElement.height, 24); + + canvas.dispose(); + expect(canvas.canvasElement.width, 0); + expect(canvas.canvasElement.height, 0); + }); + test('rounds physical size to nearest integer size', () async { final EngineFlutterWindow implicitView = EnginePlatformDispatcher.instance.implicitView!; implicitView.debugPhysicalSizeOverride = const ui.Size(199.999999, 200.000001); diff --git a/engine/src/flutter/lib/web_ui/test/engine/configuration_test.dart b/engine/src/flutter/lib/web_ui/test/engine/configuration_test.dart index 984127b36d27b..d686e869e36c4 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/configuration_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/configuration_test.dart @@ -10,6 +10,7 @@ import 'dart:js_interop'; import 'package:test/bootstrap/browser.dart'; import 'package:test/test.dart'; import 'package:ui/src/engine.dart'; +import 'package:ui/ui_web/src/ui_web.dart' as ui_web; import '../common/matchers.dart'; @@ -78,10 +79,44 @@ void testMain() { defaultConfig.setUserConfiguration({}.jsify()! as JsFlutterConfiguration); }); + tearDown(() { + ui_web.browser.debugBrowserEngineOverride = null; + }); + test('canvasKitVariant', () { expect(defaultConfig.canvasKitVariant, CanvasKitVariant.auto); }); + test('canvasKitMaximumSurfaces defaults to 8 outside Safari', () { + ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.blink; + + expect( + defaultConfig.canvasKitMaximumSurfaces, + FlutterConfiguration.defaultCanvasKitMaximumSurfaces, + ); + }); + + test('canvasKitMaximumSurfaces defaults to 2 on Safari', () { + ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.webkit; + + expect( + defaultConfig.canvasKitMaximumSurfaces, + FlutterConfiguration.safariDefaultCanvasKitMaximumSurfaces, + ); + }); + + test('canvasKitForceCpuOnly defaults to false outside Safari', () { + ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.blink; + + expect(defaultConfig.canvasKitForceCpuOnly, isFalse); + }); + + test('canvasKitForceCpuOnly defaults to true on Safari', () { + ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.webkit; + + expect(defaultConfig.canvasKitForceCpuOnly, isTrue); + }); + test('multiViewEnabled', () { expect(defaultConfig.multiViewEnabled, isFalse); }); @@ -131,5 +166,27 @@ void testMain() { ); expect(config.multiViewEnabled, isTrue); }); + + test('canvasKitMaximumSurfaces override is preserved on Safari', () { + ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.webkit; + + final config = FlutterConfiguration(); + config.setUserConfiguration( + {'canvasKitMaximumSurfaces': 4}.jsify()! as JsFlutterConfiguration, + ); + + expect(config.canvasKitMaximumSurfaces, 4); + }); + + test('canvasKitForceCpuOnly override is preserved on Safari', () { + ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.webkit; + + final config = FlutterConfiguration(); + config.setUserConfiguration( + {'canvasKitForceCpuOnly': false}.jsify()! as JsFlutterConfiguration, + ); + + expect(config.canvasKitForceCpuOnly, isFalse); + }); }); } diff --git a/engine/src/flutter/lib/web_ui/test/engine/platform_views/content_manager_test.dart b/engine/src/flutter/lib/web_ui/test/engine/platform_views/content_manager_test.dart index e9e9b31f6e904..8d0031284aa0b 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/platform_views/content_manager_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/platform_views/content_manager_test.dart @@ -60,6 +60,21 @@ void testMain() { expect(contentManager.knowsViewId(viewId), isFalse); expect(view.parentNode, isNull); }); + + test('forgets visibility metadata after clearing viewIds', () { + contentManager.renderContent( + ui_web.PlatformViewRegistry.defaultInvisibleViewType, + viewId, + {'tagName': 'script'}, + ); + + expect(contentManager.isInvisible(viewId), isTrue); + + contentManager.clearPlatformView(viewId); + + expect(contentManager.knowsViewId(viewId), isFalse); + expect(contentManager.isInvisible(viewId), isFalse); + }); }); group('registerFactory', () { diff --git a/packages/flutter/lib/src/widgets/_html_element_view_web.dart b/packages/flutter/lib/src/widgets/_html_element_view_web.dart index 1573d215f5823..09962e38b7e4b 100644 --- a/packages/flutter/lib/src/widgets/_html_element_view_web.dart +++ b/packages/flutter/lib/src/widgets/_html_element_view_web.dart @@ -58,7 +58,10 @@ extension HtmlElementViewImpl on HtmlElementView { /// Creates the controller and kicks off its initialization. _HtmlElementViewController _createController(PlatformViewCreationParams params) { final controller = _HtmlElementViewController(params.id, viewType, creationParams); - controller._initialize().then((_) { + controller._initialize().then((bool shouldNotifyCreated) { + if (!shouldNotifyCreated) { + return; + } params.onPlatformViewCreated(params.id); onPlatformViewCreated?.call(params.id); }); @@ -91,11 +94,18 @@ class _HtmlElementViewController extends PlatformViewController { final dynamic creationParams; bool _initialized = false; + bool _disposed = false; + bool _disposeSent = false; - Future _initialize() async { + Future _initialize() async { final args = {'id': viewId, 'viewType': viewType, 'params': creationParams}; await SystemChannels.platform_views.invokeMethod('create', args); _initialized = true; + if (_disposed) { + await _disposePlatformView(); + return false; + } + return true; } @override @@ -112,8 +122,17 @@ class _HtmlElementViewController extends PlatformViewController { @override Future dispose() async { + _disposed = true; if (_initialized) { - await SystemChannels.platform_views.invokeMethod('dispose', viewId); + await _disposePlatformView(); + } + } + + Future _disposePlatformView() async { + if (_disposeSent) { + return; } + _disposeSent = true; + await SystemChannels.platform_views.invokeMethod('dispose', viewId); } } diff --git a/packages/flutter/test/widgets/html_element_view_test.dart b/packages/flutter/test/widgets/html_element_view_test.dart index 36ad5b00d342b..071d23f4adfe9 100644 --- a/packages/flutter/test/widgets/html_element_view_test.dart +++ b/packages/flutter/test/widgets/html_element_view_test.dart @@ -208,6 +208,51 @@ void main() { expect(fakePlatformViewRegistry.views, isEmpty); }); + testWidgets('Dispose HTML view when create completes after widget disposal', ( + WidgetTester tester, + ) async { + final createCompleter = Completer(); + final methodCalls = []; + var didCallOnPlatformViewCreated = false; + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform_views, + (MethodCall call) { + methodCalls.add(call.method); + return switch (call.method) { + 'create' => createCompleter.future, + 'dispose' => Future.value(), + _ => Future.value(), + }; + }, + ); + + await tester.pumpWidget( + Center( + child: SizedBox( + width: 200.0, + height: 100.0, + child: HtmlElementView( + viewType: 'webview', + onPlatformViewCreated: (_) { + didCallOnPlatformViewCreated = true; + }, + ), + ), + ), + ); + expect(methodCalls, ['create']); + + await tester.pumpWidget(const Center(child: SizedBox(width: 200.0, height: 100.0))); + expect(methodCalls, ['create']); + + createCompleter.complete(); + await tester.pump(); + + expect(methodCalls, ['create', 'dispose']); + expect(didCallOnPlatformViewCreated, isFalse); + }); + testWidgets('HTML view survives widget tree change', (WidgetTester tester) async { final int currentViewId = platformViewsRegistry.getNextPlatformViewId(); fakePlatformViewRegistry.registerViewFactory('webview', _mockViewFactory); From 3d644d42ac695ae522d86009a56ad8137f5b21b3 Mon Sep 17 00:00:00 2001 From: Guy Kogus Date: Mon, 11 May 2026 12:05:58 +0200 Subject: [PATCH 2/2] Address Flutter style guide feedback --- .../web_ui/docs/SAFARI_CANVASKIT_MEMORY.md | 4 +- .../lib/src/engine/canvaskit/renderer.dart | 6 +- .../lib/src/engine/compositing/surface.dart | 2 +- .../web_ui/lib/src/engine/configuration.dart | 10 +-- .../test/canvaskit/rasterizer_test.dart | 34 +++++---- .../web_ui/test/canvaskit/renderer_test.dart | 62 +++++++++------- .../test/engine/configuration_test.dart | 74 +++++++++++-------- .../platform_views/content_manager_test.dart | 2 +- 8 files changed, 111 insertions(+), 83 deletions(-) diff --git a/engine/src/flutter/lib/web_ui/docs/SAFARI_CANVASKIT_MEMORY.md b/engine/src/flutter/lib/web_ui/docs/SAFARI_CANVASKIT_MEMORY.md index e482d3ece78d7..6f68be0d0fb8d 100644 --- a/engine/src/flutter/lib/web_ui/docs/SAFARI_CANVASKIT_MEMORY.md +++ b/engine/src/flutter/lib/web_ui/docs/SAFARI_CANVASKIT_MEMORY.md @@ -20,6 +20,6 @@ footprint: When changing these defaults, validate both memory and platform-view composition. A useful regression test is a release-mode CanvasKit app that -places Flutter content above a platform view, idles for at least 60 seconds, and -checks that the platform view remains visible while Safari WebContent memory +places Flutter content above a platform view, waits for Safari WebContent memory +to stabilize, and checks that the platform view remains visible while memory stays below the expected limit. diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/renderer.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/renderer.dart index 76aea734b230e..11f45a71543b2 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/renderer.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/renderer.dart @@ -7,7 +7,6 @@ import 'dart:js_interop'; import 'dart:math' as math; import 'dart:typed_data'; -import 'package:meta/meta.dart'; import 'package:ui/src/engine.dart'; import 'package:ui/ui.dart' as ui; import 'package:ui/ui_web/src/ui_web.dart' as ui_web; @@ -19,8 +18,7 @@ class CanvasKitRenderer extends Renderer { static CanvasKitRenderer get instance => _instance; static late CanvasKitRenderer _instance; - @visibleForTesting - static const int safariResourceCacheMaxBytes = 24 * 1024 * 1024; + static const int _safariResourceCacheMaxBytes = 24 * 1024 * 1024; Future? _initialized; @@ -51,7 +49,7 @@ class CanvasKitRenderer extends Renderer { ); } if (isSafari) { - rasterizer.setResourceCacheMaxBytes(safariResourceCacheMaxBytes); + rasterizer.setResourceCacheMaxBytes(_safariResourceCacheMaxBytes); } return rasterizer; } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/compositing/surface.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/compositing/surface.dart index 1b86e0afba55e..9b35c66f98bbe 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/compositing/surface.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/compositing/surface.dart @@ -43,7 +43,7 @@ abstract class SurfaceProvider { int? _resourceCacheMaxBytes; - @visibleForTesting + /// The configured Skia resource cache limit, if one has been set. int? get debugResourceCacheMaxBytes => _resourceCacheMaxBytes; void setSkiaResourceCacheMaxBytes(int bytes) { diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/configuration.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/configuration.dart index 0d51bb3443551..6c8d3a05ec24a 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/configuration.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/configuration.dart @@ -318,21 +318,19 @@ class FlutterConfiguration { ); /// The default maximum number of canvases to use when rendering in CanvasKit. - @visibleForTesting - static const int defaultCanvasKitMaximumSurfaces = 8; + static const int _defaultCanvasKitMaximumSurfaces = 8; /// Safari needs at least two surfaces to keep platform-view underlays and /// Flutter overlays separate, but more surfaces retain additional GPU state. - @visibleForTesting - static const int safariDefaultCanvasKitMaximumSurfaces = 2; + static const int _safariDefaultCanvasKitMaximumSurfaces = 2; /// The maximum number of canvases to use when rendering in CanvasKit. /// /// Limits the amount of overlays that can be created. int get canvasKitMaximumSurfaces { final int defaultMaxSurfaces = isSafari - ? safariDefaultCanvasKitMaximumSurfaces - : defaultCanvasKitMaximumSurfaces; + ? _safariDefaultCanvasKitMaximumSurfaces + : _defaultCanvasKitMaximumSurfaces; final int maxSurfaces = _configuration?.canvasKitMaximumSurfaces?.toInt() ?? defaultMaxSurfaces; if (maxSurfaces < 1) { return 1; diff --git a/engine/src/flutter/lib/web_ui/test/canvaskit/rasterizer_test.dart b/engine/src/flutter/lib/web_ui/test/canvaskit/rasterizer_test.dart index 617d1cabc0576..641e2a21c6fdd 100644 --- a/engine/src/flutter/lib/web_ui/test/canvaskit/rasterizer_test.dart +++ b/engine/src/flutter/lib/web_ui/test/canvaskit/rasterizer_test.dart @@ -18,12 +18,6 @@ void main() { void testMain() { setUpCanvasKitTest(withImplicitView: true); - tearDown(() { - ui_web.browser.debugBrowserEngineOverride = null; - ui_web.browser.debugOperatingSystemOverride = null; - CanvasKitRenderer.instance.debugResetRasterizer(); - }); - test('defaults to OffscreenCanvasRasterizer on Chrome and MultiSurfaceRasterizer on Firefox', () { if (isChromium) { expect(CanvasKitRenderer.instance.rasterizer, isA()); @@ -33,21 +27,33 @@ void testMain() { }); test('defaults to MultiSurfaceRasterizer on desktop Safari', () { - ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.webkit; - ui_web.browser.debugOperatingSystemOverride = ui_web.OperatingSystem.macOs; + try { + ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.webkit; + ui_web.browser.debugOperatingSystemOverride = ui_web.OperatingSystem.macOs; - CanvasKitRenderer.instance.debugResetRasterizer(); + CanvasKitRenderer.instance.debugResetRasterizer(); - expect(CanvasKitRenderer.instance.rasterizer, isA()); + expect(CanvasKitRenderer.instance.rasterizer, isA()); + } finally { + ui_web.browser.debugBrowserEngineOverride = null; + ui_web.browser.debugOperatingSystemOverride = null; + CanvasKitRenderer.instance.debugResetRasterizer(); + } }); test('defaults to MultiSurfaceRasterizer on iOS Safari', () { - ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.webkit; - ui_web.browser.debugOperatingSystemOverride = ui_web.OperatingSystem.iOs; + try { + ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.webkit; + ui_web.browser.debugOperatingSystemOverride = ui_web.OperatingSystem.iOs; - CanvasKitRenderer.instance.debugResetRasterizer(); + CanvasKitRenderer.instance.debugResetRasterizer(); - expect(CanvasKitRenderer.instance.rasterizer, isA()); + expect(CanvasKitRenderer.instance.rasterizer, isA()); + } finally { + ui_web.browser.debugBrowserEngineOverride = null; + ui_web.browser.debugOperatingSystemOverride = null; + CanvasKitRenderer.instance.debugResetRasterizer(); + } }); test('can be configured to always use MultiSurfaceRasterizer', () { diff --git a/engine/src/flutter/lib/web_ui/test/canvaskit/renderer_test.dart b/engine/src/flutter/lib/web_ui/test/canvaskit/renderer_test.dart index 5897c22dbcf40..14d901ec6ee11 100644 --- a/engine/src/flutter/lib/web_ui/test/canvaskit/renderer_test.dart +++ b/engine/src/flutter/lib/web_ui/test/canvaskit/renderer_test.dart @@ -84,8 +84,6 @@ void testMain() { setUpCanvasKitTest(); tearDown(() { - ui_web.browser.debugBrowserEngineOverride = null; - ui_web.browser.debugOperatingSystemOverride = null; CanvasKitRenderer.instance.debugResetRasterizer(); }); @@ -204,41 +202,55 @@ void testMain() { ); test('uses MultiSurfaceRasterizer and caps Skia resource cache on desktop Safari', () { - ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.webkit; - ui_web.browser.debugOperatingSystemOverride = ui_web.OperatingSystem.macOs; + try { + ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.webkit; + ui_web.browser.debugOperatingSystemOverride = ui_web.OperatingSystem.macOs; - CanvasKitRenderer.instance.debugResetRasterizer(); + CanvasKitRenderer.instance.debugResetRasterizer(); - expect(CanvasKitRenderer.instance.rasterizer, isA()); - expect( - CanvasKitRenderer.instance.rasterizer.surfaceProvider.debugResourceCacheMaxBytes, - CanvasKitRenderer.safariResourceCacheMaxBytes, - ); + expect(CanvasKitRenderer.instance.rasterizer, isA()); + expect( + CanvasKitRenderer.instance.rasterizer.surfaceProvider.debugResourceCacheMaxBytes, + 24 * 1024 * 1024, + ); + } finally { + ui_web.browser.debugBrowserEngineOverride = null; + ui_web.browser.debugOperatingSystemOverride = null; + } }); test('uses MultiSurfaceRasterizer and caps Skia resource cache on iOS Safari', () { - ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.webkit; - ui_web.browser.debugOperatingSystemOverride = ui_web.OperatingSystem.iOs; + try { + ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.webkit; + ui_web.browser.debugOperatingSystemOverride = ui_web.OperatingSystem.iOs; - CanvasKitRenderer.instance.debugResetRasterizer(); + CanvasKitRenderer.instance.debugResetRasterizer(); - expect(CanvasKitRenderer.instance.rasterizer, isA()); - expect( - CanvasKitRenderer.instance.rasterizer.surfaceProvider.debugResourceCacheMaxBytes, - CanvasKitRenderer.safariResourceCacheMaxBytes, - ); + expect(CanvasKitRenderer.instance.rasterizer, isA()); + expect( + CanvasKitRenderer.instance.rasterizer.surfaceProvider.debugResourceCacheMaxBytes, + 24 * 1024 * 1024, + ); + } finally { + ui_web.browser.debugBrowserEngineOverride = null; + ui_web.browser.debugOperatingSystemOverride = null; + } }); test('does not cap Skia resource cache on Chromium', () { - ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.blink; + try { + ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.blink; - CanvasKitRenderer.instance.debugResetRasterizer(); + CanvasKitRenderer.instance.debugResetRasterizer(); - expect(CanvasKitRenderer.instance.rasterizer, isA()); - expect( - CanvasKitRenderer.instance.rasterizer.surfaceProvider.debugResourceCacheMaxBytes, - isNull, - ); + expect(CanvasKitRenderer.instance.rasterizer, isA()); + expect( + CanvasKitRenderer.instance.rasterizer.surfaceProvider.debugResourceCacheMaxBytes, + isNull, + ); + } finally { + ui_web.browser.debugBrowserEngineOverride = null; + } }); test('can be configured to always use MultiSurfaceRasterizer', () { diff --git a/engine/src/flutter/lib/web_ui/test/engine/configuration_test.dart b/engine/src/flutter/lib/web_ui/test/engine/configuration_test.dart index d686e869e36c4..4fba03d954bca 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/configuration_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/configuration_test.dart @@ -79,42 +79,48 @@ void testMain() { defaultConfig.setUserConfiguration({}.jsify()! as JsFlutterConfiguration); }); - tearDown(() { - ui_web.browser.debugBrowserEngineOverride = null; - }); - test('canvasKitVariant', () { expect(defaultConfig.canvasKitVariant, CanvasKitVariant.auto); }); test('canvasKitMaximumSurfaces defaults to 8 outside Safari', () { - ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.blink; + try { + ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.blink; - expect( - defaultConfig.canvasKitMaximumSurfaces, - FlutterConfiguration.defaultCanvasKitMaximumSurfaces, - ); + expect(defaultConfig.canvasKitMaximumSurfaces, 8); + } finally { + ui_web.browser.debugBrowserEngineOverride = null; + } }); test('canvasKitMaximumSurfaces defaults to 2 on Safari', () { - ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.webkit; + try { + ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.webkit; - expect( - defaultConfig.canvasKitMaximumSurfaces, - FlutterConfiguration.safariDefaultCanvasKitMaximumSurfaces, - ); + expect(defaultConfig.canvasKitMaximumSurfaces, 2); + } finally { + ui_web.browser.debugBrowserEngineOverride = null; + } }); test('canvasKitForceCpuOnly defaults to false outside Safari', () { - ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.blink; + try { + ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.blink; - expect(defaultConfig.canvasKitForceCpuOnly, isFalse); + expect(defaultConfig.canvasKitForceCpuOnly, isFalse); + } finally { + ui_web.browser.debugBrowserEngineOverride = null; + } }); test('canvasKitForceCpuOnly defaults to true on Safari', () { - ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.webkit; + try { + ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.webkit; - expect(defaultConfig.canvasKitForceCpuOnly, isTrue); + expect(defaultConfig.canvasKitForceCpuOnly, isTrue); + } finally { + ui_web.browser.debugBrowserEngineOverride = null; + } }); test('multiViewEnabled', () { @@ -168,25 +174,33 @@ void testMain() { }); test('canvasKitMaximumSurfaces override is preserved on Safari', () { - ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.webkit; + try { + ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.webkit; - final config = FlutterConfiguration(); - config.setUserConfiguration( - {'canvasKitMaximumSurfaces': 4}.jsify()! as JsFlutterConfiguration, - ); + final config = FlutterConfiguration(); + config.setUserConfiguration( + {'canvasKitMaximumSurfaces': 4}.jsify()! as JsFlutterConfiguration, + ); - expect(config.canvasKitMaximumSurfaces, 4); + expect(config.canvasKitMaximumSurfaces, 4); + } finally { + ui_web.browser.debugBrowserEngineOverride = null; + } }); test('canvasKitForceCpuOnly override is preserved on Safari', () { - ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.webkit; + try { + ui_web.browser.debugBrowserEngineOverride = ui_web.BrowserEngine.webkit; - final config = FlutterConfiguration(); - config.setUserConfiguration( - {'canvasKitForceCpuOnly': false}.jsify()! as JsFlutterConfiguration, - ); + final config = FlutterConfiguration(); + config.setUserConfiguration( + {'canvasKitForceCpuOnly': false}.jsify()! as JsFlutterConfiguration, + ); - expect(config.canvasKitForceCpuOnly, isFalse); + expect(config.canvasKitForceCpuOnly, isFalse); + } finally { + ui_web.browser.debugBrowserEngineOverride = null; + } }); }); } diff --git a/engine/src/flutter/lib/web_ui/test/engine/platform_views/content_manager_test.dart b/engine/src/flutter/lib/web_ui/test/engine/platform_views/content_manager_test.dart index 8d0031284aa0b..bca087da69067 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/platform_views/content_manager_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/platform_views/content_manager_test.dart @@ -65,7 +65,7 @@ void testMain() { contentManager.renderContent( ui_web.PlatformViewRegistry.defaultInvisibleViewType, viewId, - {'tagName': 'script'}, + {'tagName': 'script'}, ); expect(contentManager.isInvisible(viewId), isTrue);