[web] Repair RenderCanvas CSS size drift (#188797)
## What changed
This PR makes the stock `RenderCanvas` path repair stale inline canvas
CSS size even when the backing-store size and DPR have not changed.
Changes:
- Factor the expected logical canvas CSS size into a helper.
- Check actual `style.width` / `style.height` before the same-size early
return.
- Re-apply backing-store / DPR CSS size when the inline style is stale.
- Add regression coverage for `1206 x 2142 @ DPR 3`, repairing
`0.333333px` back to `402 x 714px`.
## Why
flutter/flutter#188723 tracks a Safari/WebKit Skwasm case where the
canvas backing store can be valid while the visible CSS/display rect
remains collapsed around `0.328125 x 0.328125px`.
The existing stock `RenderCanvas` code repairs CSS size on physical-size
changes and DPR changes, but not when only the inline CSS value is
stale. This PR makes that same-size path self-heal.
## Scope
This does not enable Skwasm by default, change WebKit policy, depend on
the multi-surface rasterizer, or by itself close all Safari/WebKit
default-policy gates.
## Validation
- `dart format`: passed.
- `git diff --check`: passed.
- Targeted `felt` browser test in the #188797 checkout: passed locally.
- Command: `DART_SUPPRESS_ANALYTICS=true HOME=/private/tmp
DART_SDK_DIR=/private/tmp/flutter-mobile-safari-skwasm-canvas-css-size-fix-upstream-worktree/engine/src/flutter/third_party/dart/tools/sdks/dart-sdk
./dev/felt test --gcs-prod --browser chrome --compiler dart2js
--renderer canvaskit --fail-early
test/engine/compositing/render_canvas_test.dart`
- Result: `chrome-dart2js-canvaskit-engine`, `3/3`, `All tests passed!`.
- Fresh `engine/src/out/wasm_release/flutter_web_sdk` build from the
#188797 worktree: passed locally.
- `dev/benchmarks/macrobenchmarks` built as web wasm/profile using the
local `wasm_release` SDK: passed locally.
- Guardrail: preserved SDK/app build logs and generated bundle were
checked for `FLUTTER_WEB_SKWASM_FORCE_MULTI_SURFACE_RASTERIZER`; the
string was absent.
- Simulator MobileSafari, Skwasm, `variant=none`, no forced CSS sizing,
`bench_card_infinite_scroll`: `probe-complete`, `errors: []`, loaded
`skwasm_heavy`, `main.dart.mjs`, and `main.dart.wasm`,
`crossOriginIsolated=true`, canvas backing `1206 x 2142`,
CSS/display/style `402 x 714` / `402px x 714px`.
- Physical iPhone MobileSafari, Skwasm, `variant=none`, no forced CSS
sizing, `bench_card_infinite_scroll`: `all-benchmarks-complete`,
`errors: []`, loaded `skwasm_heavy`, `main.dart.mjs`, and
`main.dart.wasm`, canvas backing `1206 x 2142`, CSS/display/style `402 x
714` / `402px x 714px`.
- Physical iPhone MobileSafari, Skwasm, `variant=none`, no forced CSS
sizing, `bench_simple_lazy_text_scroll`: `all-benchmarks-complete`,
`errors: []`, loaded `skwasm_heavy`, `main.dart.mjs`, and
`main.dart.wasm`, canvas backing `1206 x 2142`, CSS/display/style `402 x
714` / `402px x 714px`.
Physical iPhone page telemetry reported `crossOriginIsolated=false` even
though the harness served isolation headers. Skwasm still loaded through
the WebKit allowlist, so this is local physical
geometry/benchmark-completion evidence, not a cross-origin-isolation
pass.
Related issue: flutter/flutter#188723.
Broader Safari/WebKit Wasm/Skwasm tracking: flutter/flutter#178893.
---------
Co-authored-by: Harry Terkelsen <1961493+harryterkelsen@users.noreply.github.com>
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 73668ad..d1b91e7 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
@@ -52,18 +52,50 @@
late final DomCanvasRenderingContext2D renderContext2d = canvasElement.context2D;
+ static const double _logicalHtmlCanvasSizeEpsilon = 0.01;
+
double _currentDevicePixelRatio = -1;
/// Sets the CSS size of the canvas so that canvas pixels are 1:1 with device
/// pixels.
void _updateLogicalHtmlCanvasSize() {
+ final ({double devicePixelRatio, double height, double width}) logicalSize =
+ _computeLogicalHtmlCanvasSize();
+ final DomCSSStyleDeclaration style = canvasElement.style;
+ style.width = '${logicalSize.width}px';
+ style.height = '${logicalSize.height}px';
+ _currentDevicePixelRatio = logicalSize.devicePixelRatio;
+ }
+
+ ({double devicePixelRatio, double height, double width}) _computeLogicalHtmlCanvasSize() {
final double devicePixelRatio = EngineFlutterDisplay.instance.devicePixelRatio;
final double logicalWidth = _pixelWidth / devicePixelRatio;
final double logicalHeight = _pixelHeight / devicePixelRatio;
+ return (devicePixelRatio: devicePixelRatio, height: logicalHeight, width: logicalWidth);
+ }
+
+ bool _isLogicalHtmlCanvasSizeCurrent() {
+ final ({double devicePixelRatio, double height, double width}) logicalSize =
+ _computeLogicalHtmlCanvasSize();
+ if (logicalSize.devicePixelRatio != _currentDevicePixelRatio) {
+ return false;
+ }
final DomCSSStyleDeclaration style = canvasElement.style;
- style.width = '${logicalWidth}px';
- style.height = '${logicalHeight}px';
- _currentDevicePixelRatio = devicePixelRatio;
+ final double? actualWidth = _parseCssPixelLength(style.width);
+ final double? actualHeight = _parseCssPixelLength(style.height);
+ if (actualWidth == null || actualHeight == null) {
+ return false;
+ }
+ return (actualWidth - logicalSize.width).abs() < _logicalHtmlCanvasSizeEpsilon &&
+ (actualHeight - logicalSize.height).abs() < _logicalHtmlCanvasSizeEpsilon;
+ }
+
+ double? _parseCssPixelLength(String value) {
+ final String trimmed = value.trim();
+ if (!trimmed.endsWith('px')) {
+ return null;
+ }
+ return double.tryParse(trimmed.substring(0, trimmed.length - 2).trim());
}
/// Render the given [bitmap] with this [RenderCanvas].
@@ -99,9 +131,10 @@
// Check if the frame is the same size as before, and if so, we don't need
// to resize the canvas.
if (size.width == _pixelWidth && size.height == _pixelHeight) {
- // The existing canvas doesn't need to be resized (unless the device pixel
- // ratio changed).
- if (EngineFlutterDisplay.instance.devicePixelRatio != _currentDevicePixelRatio) {
+ // The existing canvas doesn't need to be resized, but its logical CSS
+ // size may still need to be repaired if the device pixel ratio changed
+ // or a stale inline style was left behind.
+ if (!_isLogicalHtmlCanvasSizeCurrent()) {
_updateLogicalHtmlCanvasSize();
}
return;
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 536ab9c..f5582c3 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,60 @@
expect(canvas.canvasElement.style.height, '32px');
});
+ test('repairs canvas logical size when inline CSS size drifts', () async {
+ final canvas = RenderCanvas();
+
+ final double originalDpr = EngineFlutterDisplay.instance.devicePixelRatio;
+ addTearDown(() {
+ EngineFlutterDisplay.instance.debugOverrideDevicePixelRatio(originalDpr);
+ });
+
+ EngineFlutterDisplay.instance.debugOverrideDevicePixelRatio(3.0);
+ canvas.render(await newBitmap(1206, 2142));
+
+ expect(canvas.canvasElement.width, 1206);
+ expect(canvas.canvasElement.height, 2142);
+ expect(canvas.canvasElement.style.width, '402px');
+ expect(canvas.canvasElement.style.height, '714px');
+
+ canvas.canvasElement.style
+ ..width = '0.333333px'
+ ..height = '0.333333px';
+
+ canvas.render(await newBitmap(1206, 2142));
+
+ expect(canvas.canvasElement.width, 1206);
+ expect(canvas.canvasElement.height, 2142);
+ expect(canvas.canvasElement.style.width, '402px');
+ expect(canvas.canvasElement.style.height, '714px');
+ });
+
+ test('preserves canvas logical size when inline CSS size is within tolerance', () async {
+ final canvas = RenderCanvas();
+
+ final double originalDpr = EngineFlutterDisplay.instance.devicePixelRatio;
+ addTearDown(() {
+ EngineFlutterDisplay.instance.debugOverrideDevicePixelRatio(originalDpr);
+ });
+
+ EngineFlutterDisplay.instance.debugOverrideDevicePixelRatio(3.0);
+ canvas.render(await newBitmap(1201, 2143));
+
+ expect(canvas.canvasElement.width, 1201);
+ expect(canvas.canvasElement.height, 2143);
+
+ canvas.canvasElement.style
+ ..width = '400.33px'
+ ..height = '714.34px';
+
+ canvas.render(await newBitmap(1201, 2143));
+
+ expect(canvas.canvasElement.width, 1201);
+ expect(canvas.canvasElement.height, 2143);
+ expect(canvas.canvasElement.style.width, '400.33px');
+ expect(canvas.canvasElement.style.height, '714.34px');
+ });
+
test('rounds physical size to nearest integer size', () async {
final EngineFlutterWindow implicitView = EnginePlatformDispatcher.instance.implicitView!;
implicitView.debugPhysicalSizeOverride = const ui.Size(199.999999, 200.000001);