[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);