[flutter_releases] Cherrypick web engine accessibility and canvas fixes (#32867)

* [web] do not allocate canvases just for text (#30804)

* [web:a11y] fix traversal and hit-test orders (#32712)

* [web:a11y] implement traversal and hit-test orders
* remove unused intersectionIndicesNew
* canvaskit: fix platform view offset and scene host initialization
* remove "we" in a bunch of comments

Co-authored-by: Yegor <yjbanov@google.com>
diff --git a/lib/web_ui/lib/src/engine/canvas_pool.dart b/lib/web_ui/lib/src/engine/canvas_pool.dart
index 6528fae..7bf1b38 100644
--- a/lib/web_ui/lib/src/engine/canvas_pool.dart
+++ b/lib/web_ui/lib/src/engine/canvas_pool.dart
@@ -76,13 +76,6 @@
     translate(transform.dx, transform.dy);
   }
 
-  /// Returns true if no canvas has been allocated yet.
-  bool get isEmpty => _canvas == null;
-
-  /// Returns true if a canvas has been allocated for use.
-  bool get isNotEmpty => _canvas != null;
-
-
   /// Returns [CanvasRenderingContext2D] api to draw into this canvas.
   html.CanvasRenderingContext2D get context {
     html.CanvasRenderingContext2D? ctx = _context;
@@ -106,12 +99,28 @@
     return _contextHandle!;
   }
 
-  /// Prevents active canvas to be used for rendering and prepares a new
-  /// canvas allocation on next drawing request that will require one.
+  /// Returns true if a canvas is currently available for drawing.
   ///
-  /// Saves current canvas so we can dispose
-  /// and replay the clip/transform stack on top of new canvas.
-  void closeCurrentCanvas() {
+  /// Calling [contextHandle] or, transitively, any of the `draw*` methods while
+  /// this returns true will reuse the existing canvas. Otherwise, a new canvas
+  /// will be allocated.
+  ///
+  /// Previously allocated and closed canvases (see [closeCanvas]) are not
+  /// considered by this getter.
+  bool get hasCanvas => _canvas != null;
+
+  /// Stops the currently available canvas from receiving any further drawing
+  /// commands.
+  ///
+  /// After calling this method, a subsequent call to [contextHandle] or,
+  /// transitively, any of the `draw*` methods will cause a new canvas to be
+  /// allocated.
+  ///
+  /// The closed canvas becomes an "active" canvas, that is a canvas that's used
+  /// to render picture content in the current frame. Active canvases may be
+  /// reused in other pictures if their contents are no longer needed for this
+  /// picture.
+  void closeCanvas() {
     assert(_rootElement != null);
     // Place clean copy of current canvas with context stack restored and paint
     // reset into pool.
diff --git a/lib/web_ui/lib/src/engine/canvaskit/embedded_views.dart b/lib/web_ui/lib/src/engine/canvaskit/embedded_views.dart
index acbd5e8..aa307d4 100644
--- a/lib/web_ui/lib/src/engine/canvaskit/embedded_views.dart
+++ b/lib/web_ui/lib/src/engine/canvaskit/embedded_views.dart
@@ -242,7 +242,7 @@
     }
 
     // Apply mutators to the slot
-    _applyMutators(params.mutators, slot, viewId);
+    _applyMutators(params, slot, viewId);
   }
 
   int _countClips(MutatorsStack mutators) {
@@ -309,9 +309,12 @@
   }
 
   void _applyMutators(
-      MutatorsStack mutators, html.Element embeddedView, int viewId) {
+      EmbeddedViewParams params, html.Element embeddedView, int viewId) {
+    final MutatorsStack mutators = params.mutators;
     html.Element head = embeddedView;
-    Matrix4 headTransform = Matrix4.identity();
+    Matrix4 headTransform = params.offset == ui.Offset.zero
+      ? Matrix4.identity()
+      : Matrix4.translationValues(params.offset.dx, params.offset.dy, 0);
     double embeddedOpacity = 1.0;
     _resetAnchor(head);
     _cleanUpClipDefs(viewId);
diff --git a/lib/web_ui/lib/src/engine/canvaskit/surface.dart b/lib/web_ui/lib/src/engine/canvaskit/surface.dart
index 0bfc5df..1736001 100644
--- a/lib/web_ui/lib/src/engine/canvaskit/surface.dart
+++ b/lib/web_ui/lib/src/engine/canvaskit/surface.dart
@@ -282,6 +282,18 @@
       height: _pixelHeight,
     );
     this.htmlCanvas = htmlCanvas;
+
+    // The DOM elements used to render pictures are used purely to put pixels on
+    // the screen. They have no semantic information. If an assistive technology
+    // attempts to scan picture content it will look like garbage and confuse
+    // users. UI semantics are exported as a separate DOM tree rendered parallel
+    // to pictures.
+    //
+    // Why are layer and scene elements not hidden from ARIA? Because those
+    // elements may contain platform views, and platform views must be
+    // accessible.
+    htmlCanvas.setAttribute('aria-hidden', 'true');
+
     htmlCanvas.style.position = 'absolute';
     _updateLogicalHtmlCanvasSize();
 
diff --git a/lib/web_ui/lib/src/engine/embedder.dart b/lib/web_ui/lib/src/engine/embedder.dart
index 834af36..5b228e6 100644
--- a/lib/web_ui/lib/src/engine/embedder.dart
+++ b/lib/web_ui/lib/src/engine/embedder.dart
@@ -37,11 +37,15 @@
 /// - [semanticsHostElement], hosts the ARIA-annotated semantics tree.
 class FlutterViewEmbedder {
   FlutterViewEmbedder() {
-    reset();
     assert(() {
       _setupHotRestart();
       return true;
     }());
+    reset();
+    assert(() {
+      _registerHotRestartCleanUp();
+      return true;
+    }());
   }
 
   // The tag name for the root view of the flutter app (glass-pane)
@@ -83,7 +87,7 @@
   /// This element is created and inserted in the HTML DOM once. It is never
   /// removed or moved.
   ///
-  /// We render semantics inside the glasspane for proper focus and event
+  /// Render semantics inside the glasspane for proper focus and event
   /// handling. If semantics is behind the glasspane, the phone will disable
   /// focusing by touch, only by tabbing around the UI. If semantics is in
   /// front of glasspane, then DOM event won't bubble up to the glasspane so
@@ -99,11 +103,15 @@
   html.Element? _sceneElement;
 
   /// This is state persistent across hot restarts that indicates what
-  /// to clear.  We delay removal of old visible state to make the
+  /// to clear.  Delay removal of old visible state to make the
   /// transition appear smooth.
   static const String _staleHotRestartStore = '__flutter_state';
   List<html.Element?>? _staleHotRestartState;
 
+  /// Creates a container for DOM elements that need to be cleaned up between
+  /// hot restarts.
+  ///
+  /// If a contains already exists, reuses the existing one.
   void _setupHotRestart() {
     // This persists across hot restarts to clear stale DOM.
     _staleHotRestartState = getJsProperty<List<html.Element?>?>(html.window, _staleHotRestartStore);
@@ -112,7 +120,12 @@
       setJsProperty(
           html.window, _staleHotRestartStore, _staleHotRestartState);
     }
+  }
 
+  /// Registers DOM elements that need to be cleaned up before hot restarting.
+  ///
+  /// [_setupHotRestart] must have been called prior to calling this method.
+  void _registerHotRestartCleanUp() {
     registerHotRestartListener(() {
       _resizeSubscription?.cancel();
       _localeSubscription?.cancel();
@@ -133,11 +146,11 @@
     }
   }
 
-  /// We don't want to unnecessarily move DOM nodes around. If a DOM node is
+  /// Don't unnecessarily move DOM nodes around. If a DOM node is
   /// already in the right place, skip DOM mutation. This is both faster and
   /// more correct, because moving DOM nodes loses internal state, such as
   /// text selection.
-  void renderScene(html.Element? sceneElement) {
+  void addSceneToSceneHost(html.Element? sceneElement) {
     if (sceneElement != _sceneElement) {
       _sceneElement?.remove();
       _sceneElement = sceneElement;
@@ -203,7 +216,7 @@
     setElementStyle(bodyElement, 'padding', '0');
     setElementStyle(bodyElement, 'margin', '0');
 
-    // TODO(yjbanov): fix this when we support KVM I/O. Currently we scroll
+    // TODO(yjbanov): fix this when KVM I/O support is added. Currently scroll
     //                using drag, and text selection interferes.
     setElementStyle(bodyElement, 'user-select', 'none');
     setElementStyle(bodyElement, '-webkit-user-select', 'none');
@@ -211,7 +224,7 @@
     setElementStyle(bodyElement, '-moz-user-select', 'none');
 
     // This is required to prevent the browser from doing any native touch
-    // handling. If we don't do this, the browser doesn't report 'pointermove'
+    // handling. If this is not done, the browser doesn't report 'pointermove'
     // events properly.
     setElementStyle(bodyElement, 'touch-action', 'none');
 
@@ -227,7 +240,7 @@
     for (final html.Element viewportMeta
         in html.document.head!.querySelectorAll('meta[name="viewport"]')) {
       if (assertionsEnabled) {
-        // Filter out the meta tag that we ourselves placed on the page. This is
+        // Filter out the meta tag that the engine placed on the page. This is
         // to avoid UI flicker during hot restart. Hot restart will clean up the
         // old meta tag synchronously with the first post-restart frame.
         if (!viewportMeta.hasAttribute('flt-viewport')) {
@@ -265,7 +278,8 @@
       ..bottom = '0'
       ..left = '0';
 
-    // This must be appended to the body, so we can create a host node properly.
+    // This must be appended to the body, so the engine can create a host node
+    // properly.
     bodyElement.append(glassPaneElement);
 
     // Create a [HostNode] under the glass pane element, and attach everything
@@ -277,6 +291,14 @@
     _sceneHostElement = html.document.createElement('flt-scene-host')
       ..style.pointerEvents = 'none';
 
+    /// CanvasKit uses a static scene element that never gets replaced, so it's
+    /// added eagerly during initialization here and never touched, unless the
+    /// system is reset due to hot restart or in a test.
+    if (useCanvasKit) {
+      skiaSceneHost = html.Element.tag('flt-scene');
+      addSceneToSceneHost(skiaSceneHost);
+    }
+
     final html.Element semanticsHostElement =
         html.document.createElement('flt-semantics-host');
     semanticsHostElement.style
@@ -290,13 +312,24 @@
         .prepareAccessibilityPlaceholder();
 
     glassPaneElementHostNode.nodes.addAll(<html.Node>[
-      semanticsHostElement,
       _accessibilityPlaceholder,
       _sceneHostElement!,
+
+      // The semantic host goes last because hit-test order-wise it must be
+      // first. If semantics goes under the scene host, platform views will
+      // obscure semantic elements.
+      //
+      // You may be wondering: wouldn't semantics obscure platform views and
+      // make then not accessible? At least with some careful planning, that
+      // should not be the case. The semantics tree makes all of its non-leaf
+      // elements transparent. This way, if a platform view appears among other
+      // interactive Flutter widgets, as long as those widgets do not intersect
+      // with the platform view, the platform view will be reachable.
+      semanticsHostElement,
     ]);
 
     // When debugging semantics, make the scene semi-transparent so that the
-    // semantics tree is visible.
+    // semantics tree is more prominent.
     if (configuration.debugShowSemanticsNodes) {
       _sceneHostElement!.style.opacity = '0.3';
     }
@@ -304,11 +337,6 @@
     PointerBinding.initInstance(glassPaneElement);
     KeyboardBinding.initInstance(glassPaneElement);
 
-    // Hide the DOM nodes used to render the scene from accessibility, because
-    // the accessibility tree is built from the SemanticsNode tree as a parallel
-    // DOM tree.
-    _sceneHostElement!.setAttribute('aria-hidden', 'true');
-
     if (html.window.visualViewport == null && isWebKit) {
       // Older Safari versions sometimes give us bogus innerWidth/innerHeight
       // values when the page loads. When it changes the values to correct ones
@@ -321,10 +349,11 @@
       //
       // VisualViewport API is not enabled in Firefox as well. On the other hand
       // Firefox returns correct values for innerHeight, innerWidth.
-      // Firefox also triggers html.window.onResize therefore we don't need this
-      // timer to be set up for Firefox.
+      // Firefox also triggers html.window.onResize therefore this timer does
+      // not need to be set up for Firefox.
       final int initialInnerWidth = html.window.innerWidth!;
-      // Counts how many times we checked screen size. We check up to 5 times.
+      // Counts how many times screen size was checked. It is checked up to 5
+      // times.
       int checkCount = 0;
       Timer.periodic(const Duration(milliseconds: 100), (Timer t) {
         checkCount += 1;
@@ -361,7 +390,7 @@
   }
 
   /// The framework specifies semantics in physical pixels, but CSS uses
-  /// logical pixels. To compensate, we inject an inverse scale at the root
+  /// logical pixels. To compensate, an inverse scale is injected at the root
   /// level.
   void updateSemanticsScreenProperties() {
     _semanticsHostElement!.style.transform =
diff --git a/lib/web_ui/lib/src/engine/html/bitmap_canvas.dart b/lib/web_ui/lib/src/engine/html/bitmap_canvas.dart
index 9b7bb4a..8b9677d 100644
--- a/lib/web_ui/lib/src/engine/html/bitmap_canvas.dart
+++ b/lib/web_ui/lib/src/engine/html/bitmap_canvas.dart
@@ -370,7 +370,7 @@
       _renderStrategy.isInsideSvgFilterTree ||
       (_preserveImageData == false && _contains3dTransform) ||
       (_childOverdraw &&
-          _canvasPool.isEmpty &&
+          !_canvasPool.hasCanvas &&
           paint.maskFilter == null &&
           paint.shader == null &&
           paint.style != ui.PaintingStyle.stroke);
@@ -384,7 +384,7 @@
       ((_childOverdraw ||
               _renderStrategy.hasImageElements ||
               _renderStrategy.hasParagraphs) &&
-          _canvasPool.isEmpty &&
+          !_canvasPool.hasCanvas &&
           paint.maskFilter == null &&
           paint.shader == null);
 
@@ -469,7 +469,7 @@
       element.style.mixBlendMode = blendModeToCssMixBlendMode(blendMode) ?? '';
     }
     // Switch to preferring DOM from now on, and close the current canvas.
-    _closeCurrentCanvas();
+    _closeCanvas();
   }
 
   @override
@@ -626,7 +626,7 @@
       _applyTargetSize(
           imageElement, image.width.toDouble(), image.height.toDouble());
     }
-    _closeCurrentCanvas();
+    _closeCanvas();
   }
 
   html.ImageElement _reuseOrCreateImage(HtmlImage htmlImage) {
@@ -770,7 +770,7 @@
         restore();
       }
     }
-    _closeCurrentCanvas();
+    _closeCanvas();
   }
 
   void _applyTargetSize(
@@ -882,8 +882,8 @@
   //   |--- <img>
   // Any drawing operations after these tags should allocate a new canvas,
   // instead of drawing into earlier canvas.
-  void _closeCurrentCanvas() {
-    _canvasPool.closeCurrentCanvas();
+  void _closeCanvas() {
+    _canvasPool.closeCanvas();
     _childOverdraw = true;
     _cachedLastCssFont = null;
   }
@@ -939,16 +939,24 @@
   void drawParagraph(CanvasParagraph paragraph, ui.Offset offset) {
     assert(paragraph.isLaidOut);
 
-    /// - paragraph.drawOnCanvas checks that the text styling doesn't include
-    /// features that prevent text from being rendered correctly using canvas.
-    /// - _childOverdraw check prevents sandwitching multiple canvas elements
-    /// when we have alternating paragraphs and other drawing commands that are
-    /// suitable for canvas.
-    /// - To make sure an svg filter is applied correctly to paragraph we
-    /// check isInsideSvgFilterTree to make sure dom node doesn't have any
-    /// parents that apply one.
-    if (paragraph.drawOnCanvas && _childOverdraw == false &&
-        !_renderStrategy.isInsideSvgFilterTree) {
+    // Normally, text is composited as a plain HTML <p> tag. However, if a
+    // bitmap canvas was used for a preceding drawing command, then it's more
+    // efficient to continue compositing into the existing canvas, if possible.
+    // Whether it's possible to composite a paragraph into a 2D canvas depends
+    // on the following:
+    final bool canCompositeIntoBitmapCanvas =
+        // Cannot composite if the paragraph cannot be drawn into bitmap canvas
+        // in the first place.
+        paragraph.canDrawOnCanvas &&
+        // Cannot composite if there's no bitmap canvas to composite into.
+        // Creating a new bitmap canvas just to draw text doesn't make sense.
+        _canvasPool.hasCanvas &&
+        !_childOverdraw &&
+        // Bitmap canvas introduces correctness issues in the presence of SVG
+        // filters, so prefer plain HTML in this case.
+        !_renderStrategy.isInsideSvgFilterTree;
+
+    if (canCompositeIntoBitmapCanvas) {
       paragraph.paint(this, offset);
       return;
     }
@@ -977,7 +985,7 @@
     paragraphElement.style
       ..left = '0px'
       ..top = '0px';
-    _closeCurrentCanvas();
+    _closeCanvas();
   }
 
   /// Draws vertices on a gl context.
diff --git a/lib/web_ui/lib/src/engine/html/picture.dart b/lib/web_ui/lib/src/engine/html/picture.dart
index b105c99..0cecee8 100644
--- a/lib/web_ui/lib/src/engine/html/picture.dart
+++ b/lib/web_ui/lib/src/engine/html/picture.dart
@@ -122,7 +122,20 @@
 
   @override
   html.Element createElement() {
-    return defaultCreateElement('flt-picture');
+    final html.Element element = defaultCreateElement('flt-picture');
+
+    // The DOM elements used to render pictures are used purely to put pixels on
+    // the screen. They have no semantic information. If an assistive technology
+    // attempts to scan picture content it will look like garbage and confuse
+    // users. UI semantics are exported as a separate DOM tree rendered parallel
+    // to pictures.
+    //
+    // Why are layer and scene elements not hidden from ARIA? Because those
+    // elements may contain platform views, and platform views must be
+    // accessible.
+    element.setAttribute('aria-hidden', 'true');
+
+    return element;
   }
 
   @override
diff --git a/lib/web_ui/lib/src/engine/initialization.dart b/lib/web_ui/lib/src/engine/initialization.dart
index 829ada7..873dbc6 100644
--- a/lib/web_ui/lib/src/engine/initialization.dart
+++ b/lib/web_ui/lib/src/engine/initialization.dart
@@ -256,12 +256,6 @@
   Keyboard.initialize(onMacOs: operatingSystem == OperatingSystem.macOs);
   MouseCursor.initialize();
   ensureFlutterViewEmbedderInitialized();
-
-  if (useCanvasKit) {
-    /// Add a Skia scene host.
-    skiaSceneHost = html.Element.tag('flt-scene');
-    flutterViewEmbedder.renderScene(skiaSceneHost);
-  }
   _initializationState = DebugEngineInitializationState.initialized;
 }
 
diff --git a/lib/web_ui/lib/src/engine/platform_dispatcher.dart b/lib/web_ui/lib/src/engine/platform_dispatcher.dart
index 5ad7a4d..76a6da3 100644
--- a/lib/web_ui/lib/src/engine/platform_dispatcher.dart
+++ b/lib/web_ui/lib/src/engine/platform_dispatcher.dart
@@ -607,7 +607,7 @@
       rasterizer!.draw(layerScene.layerTree);
     } else {
       final SurfaceScene surfaceScene = scene as SurfaceScene;
-      flutterViewEmbedder.renderScene(surfaceScene.webOnlyRootElement);
+      flutterViewEmbedder.addSceneToSceneHost(surfaceScene.webOnlyRootElement);
     }
     frameTimingsOnRasterFinish();
   }
diff --git a/lib/web_ui/lib/src/engine/semantics/semantics.dart b/lib/web_ui/lib/src/engine/semantics/semantics.dart
index 5ae9832..01feefb 100644
--- a/lib/web_ui/lib/src/engine/semantics/semantics.dart
+++ b/lib/web_ui/lib/src/engine/semantics/semantics.dart
@@ -274,14 +274,15 @@
     // DOM nodes created for semantics objects are positioned absolutely using
     // transforms.
     element.style.position = 'absolute';
+    element.setAttribute('id', 'flt-semantic-node-$id');
 
     // The root node has some properties that other nodes do not.
     if (id == 0 && !configuration.debugShowSemanticsNodes) {
-      // Make all semantics transparent. We use `filter` instead of `opacity`
+      // Make all semantics transparent. Use `filter` instead of `opacity`
       // attribute because `filter` is stronger. `opacity` does not apply to
       // some elements, particularly on iOS, such as the slider thumb and track.
       //
-      // We use transparency instead of "visibility:hidden" or "display:none"
+      // Use transparency instead of "visibility:hidden" or "display:none"
       // so that a screen reader does not ignore these elements.
       element.style.filter = 'opacity(0%)';
 
@@ -291,7 +292,7 @@
     }
 
     // Make semantic elements visible for debugging by outlining them using a
-    // green border. We do not use `border` attribute because it affects layout
+    // green border. Do not use `border` attribute because it affects layout
     // (`outline` does not).
     if (configuration.debugShowSemanticsNodes) {
       element.style.outline = '1px solid green';
@@ -601,6 +602,19 @@
     _dirtyFields |= _tooltipIndex;
   }
 
+  /// See [ui.SemanticsUpdateBuilder.updateNode].
+  int get platformViewId => _platformViewId;
+  int _platformViewId = -1;
+
+  /// Whether this object represents a platform view.
+  bool get isPlatformView => _platformViewId != -1;
+
+  static const int _platformViewIdIndex = 1 << 23;
+
+  void _markPlatformViewIdDirty() {
+    _dirtyFields |= _platformViewIdIndex;
+  }
+
   /// A unique permanent identifier of the semantics node in the tree.
   final int id;
 
@@ -632,7 +646,11 @@
   html.Element? getOrCreateChildContainer() {
     if (_childContainerElement == null) {
       _childContainerElement = html.Element.tag('flt-semantics-container');
-      _childContainerElement!.style.position = 'absolute';
+      _childContainerElement!.style
+        ..position = 'absolute'
+        // Ignore pointer events on child container so that platform views
+        // behind it can be reached.
+        ..pointerEvents = 'none';
       element.append(_childContainerElement!);
     }
     return _childContainerElement;
@@ -702,9 +720,9 @@
 
   /// Updates this object from data received from a semantics [update].
   ///
-  /// This method creates [SemanticsObject]s for the direct children of this
-  /// object. However, it does not recursively populate them.
-  void updateWith(SemanticsNodeUpdate update) {
+  /// Does not update children. Children are updated in a separate pass because
+  /// at this point children's self information is not ready yet.
+  void updateSelf(SemanticsNodeUpdate update) {
     // Update all field values and their corresponding dirty flags before
     // applying the updates to the DOM.
     assert(update.flags != null); // ignore: unnecessary_null_comparison
@@ -838,9 +856,13 @@
       _markAdditionalActionsDirty();
     }
 
+    if (_platformViewId != update.platformViewId) {
+      _platformViewId = update.platformViewId;
+      _markPlatformViewIdDirty();
+    }
+
     // Apply updates to the DOM.
     _updateRoles();
-    _updateChildrenInTraversalOrder();
 
     // All properties that affect positioning and sizing are checked together
     // any one of them triggers position and size recomputation.
@@ -848,9 +870,193 @@
       recomputePositionAndSize();
     }
 
-    // Make sure we create a child container only when there are children.
-    assert(_childContainerElement == null || hasChildren);
-    _dirtyFields = 0;
+    // Ignore pointer events on all container nodes and all platform view nodes.
+    // This is so that the platform views are not obscured by semantic elements
+    // and can be reached by inspecting the web page.
+    if (!hasChildren && !isPlatformView) {
+      element.style.pointerEvents = 'all';
+    } else {
+      element.style.pointerEvents = 'none';
+    }
+  }
+
+  /// The order children are currently rendered in.
+  List<SemanticsObject>? _currentChildrenInRenderOrder;
+
+  /// Updates direct children of this node, if any.
+  ///
+  /// Specifies two orders of direct children:
+  ///
+  /// * Traversal order: the logical order of child nodes that establishes the
+  ///   next and previous relationship between UI widgets. When the user
+  ///   traverses the UI using next/previous gestures the accessibility focus
+  ///   follows the traversal order.
+  /// * Hit-test order: determines the top/bottom relationship between widgets.
+  ///   When the user is inspecting the UI using the drag gesture, the widgets
+  ///   that appear "on top" hit-test order wise take the focus. This order is
+  ///   communicated in the DOM using the inverse paint order, specified by the
+  ///   z-index CSS style attribute.
+  void updateChildren() {
+    // Trivial case: remove all children.
+    if (_childrenInHitTestOrder == null ||
+        _childrenInHitTestOrder!.isEmpty) {
+      if (_currentChildrenInRenderOrder == null ||
+          _currentChildrenInRenderOrder!.isEmpty) {
+        // A container element must not have been created when child list is empty.
+        assert(_childContainerElement == null);
+        _currentChildrenInRenderOrder = null;
+        return;
+      }
+
+      // A container element must have been created when child list is not empty.
+      assert(_childContainerElement != null);
+
+      // Remove all children from this semantics object.
+      final int len = _currentChildrenInRenderOrder!.length;
+      for (int i = 0; i < len; i++) {
+        owner._detachObject(_currentChildrenInRenderOrder![i].id);
+      }
+      _childContainerElement!.remove();
+      _childContainerElement = null;
+      _currentChildrenInRenderOrder = null;
+      return;
+    }
+
+    // At this point it is guaranteed to have at least one child.
+    final Int32List childrenInTraversalOrder = _childrenInTraversalOrder!;
+    final Int32List childrenInHitTestOrder = _childrenInHitTestOrder!;
+    final int childCount = childrenInHitTestOrder.length;
+    final html.Element? containerElement = getOrCreateChildContainer();
+
+    assert(childrenInTraversalOrder.length == childrenInHitTestOrder.length);
+
+    // Always render in traversal order, because the accessibility traversal
+    // is determined by the DOM order of elements.
+    final List<SemanticsObject> childrenInRenderOrder = <SemanticsObject>[];
+    for (int i = 0; i < childCount; i++) {
+      childrenInRenderOrder.add(owner._semanticsTree[childrenInTraversalOrder[i]]!);
+    }
+
+    // The z-index determines hit testing. Technically, it also affects paint
+    // order. However, this does not matter because our ARIA tree is invisible.
+    // On top of that, it is a bad UI practice when hit test order does not match
+    // paint order, because human eye must be able to predict hit test order
+    // simply by looking at the UI (if a dialog is painted on top of a dismiss
+    // barrier, then tapping on anything inside the dialog should not land on
+    // the barrier).
+    final bool zIndexMatters = childCount > 1;
+    if (zIndexMatters) {
+      for (int i = 0; i < childCount; i++) {
+        final SemanticsObject child = owner._semanticsTree[childrenInHitTestOrder[i]]!;
+
+        // Invert the z-index because hit-test order is inverted with respect to
+        // paint order.
+        child.element.style.zIndex = '${childCount - i}';
+      }
+    }
+
+    // Trivial case: previous list was empty => just populate the container.
+    if (_currentChildrenInRenderOrder == null ||
+        _currentChildrenInRenderOrder!.isEmpty) {
+      for (final SemanticsObject child in childrenInRenderOrder) {
+        containerElement!.append(child.element);
+        owner._attachObject(parent: this, child: child);
+      }
+      _currentChildrenInRenderOrder = childrenInRenderOrder;
+      return;
+    }
+
+    // At this point it is guaranteed to have had a non-empty previous child list.
+    final List<SemanticsObject> previousChildrenInRenderOrder = _currentChildrenInRenderOrder!;
+    final int previousCount = previousChildrenInRenderOrder.length;
+
+    // Both non-empty case.
+
+    // Problem: child nodes have been added, removed, and/or reordered. On the
+    //          web, many assistive technologies cannot track DOM elements
+    //          moving around, losing focus. The best approach is to try to keep
+    //          child elements as stable as possible.
+    // Solution: find all common elements in both lists and record their indices
+    //           in the old list (in the `intersectionIndicesOld` variable). The
+    //           longest increases subsequence provides the longest chain of
+    //           semantics nodes that didn't move relative to each other. Those
+    //           nodes (represented by the `stationaryIds` variable) are kept
+    //           stationary, while all others are moved/inserted/deleted around
+    //           them. This gives the maximum node stability, and covers most
+    //           use-cases, including scrolling in any direction, insertions,
+    //           deletions, drag'n'drop, etc.
+
+    // Indices into the old child list pointing at children that also exist in
+    // the new child list.
+    final List<int> intersectionIndicesOld = <int>[];
+
+    int newIndex = 0;
+
+    // The smallest of the two child list lengths.
+    final int minLength = math.min(previousCount, childCount);
+
+    // Scan forward until first discrepancy.
+    while (newIndex < minLength &&
+        previousChildrenInRenderOrder[newIndex] ==
+            childrenInRenderOrder[newIndex]) {
+      intersectionIndicesOld.add(newIndex);
+      newIndex += 1;
+    }
+
+    // Trivial case: child lists are identical both in length and order => do nothing.
+    if (previousCount == childrenInRenderOrder.length && newIndex == childCount) {
+      return;
+    }
+
+    // If child lists are not identical, continue computing the intersection
+    // between the two lists.
+    while (newIndex < childCount) {
+      for (int oldIndex = 0; oldIndex < previousCount; oldIndex += 1) {
+        if (previousChildrenInRenderOrder[oldIndex] ==
+            childrenInRenderOrder[newIndex]) {
+          intersectionIndicesOld.add(oldIndex);
+          break;
+        }
+      }
+      newIndex += 1;
+    }
+
+    // The longest sub-sequence in the old list maximizes the number of children
+    // that do not need to be moved.
+    final List<int?> longestSequence = longestIncreasingSubsequence(intersectionIndicesOld);
+    final List<int> stationaryIds = <int>[];
+    for (int i = 0; i < longestSequence.length; i += 1) {
+      stationaryIds.add(
+        previousChildrenInRenderOrder[intersectionIndicesOld[longestSequence[i]!]].id
+      );
+    }
+
+    // Remove children that are no longer in the list.
+    for (int i = 0; i < previousCount; i++) {
+      if (!intersectionIndicesOld.contains(i)) {
+        // Child not in the intersection. Must be removed.
+        final int childId = previousChildrenInRenderOrder[i].id;
+        owner._detachObject(childId);
+      }
+    }
+
+    html.Element? refNode;
+    for (int i = childCount - 1; i >= 0; i -= 1) {
+      final SemanticsObject child = childrenInRenderOrder[i];
+      if (!stationaryIds.contains(child.id)) {
+        if (refNode == null) {
+          containerElement!.append(child.element);
+        } else {
+          containerElement!.insertBefore(child.element, refNode);
+        }
+        owner._attachObject(parent: this, child: child);
+      } else {
+        assert(child._parent == this);
+      }
+      refNode = child.element;
+    }
+
+    _currentChildrenInRenderOrder = childrenInRenderOrder;
   }
 
   /// Populates the HTML "role" attribute based on a [condition].
@@ -859,10 +1065,10 @@
   ///
   /// If [condition] is false, removes the HTML "role" attribute from [element]
   /// if the current role is set to [ariaRoleName]. Otherwise, leaves the value
-  /// unchanged. This is done so we gracefully handle multiple competing roles.
+  /// unchanged. This is done to gracefully handle multiple competing roles.
   /// For example, if the role changes from "button" to "img" and tappable role
   /// manager attempts to clean up after the image role manager applied the new
-  /// role, we do not want it to erase the new role.
+  /// role, semantics avoids erasing the new role.
   void setAriaRole(String ariaRoleName, bool condition) {
     if (condition) {
       element.setAttribute('role', ariaRoleName);
@@ -893,8 +1099,8 @@
 
     final bool shouldUseTappableRole =
       (hasAction(ui.SemanticsAction.tap) || hasFlag(ui.SemanticsFlag.isButton)) &&
-      // Text fields manage their own focus/tap interactions. We don't need the
-      // tappable role manager. It only confuses AT.
+      // Text fields manage their own focus/tap interactions. Tappable role
+      // manager is not needed. It only confuses AT.
       !isTextField;
 
     _updateRole(Role.tappable, shouldUseTappableRole);
@@ -921,8 +1127,8 @@
       manager.dispose();
       _roleManagers.remove(role);
     }
-    // Nothing to do in the "else case" as it means that we want to disable a
-    // role that we don't currently have in the first place.
+    // Nothing to do in the "else case". There's no existing role manager to
+    // disable.
   }
 
   /// Whether the object represents an UI element with "increase" or "decrease"
@@ -1040,144 +1246,6 @@
     }
   }
 
-  Int32List? _previousChildrenInTraversalOrder;
-
-  /// Updates the traversal child list of [object] from the given [update].
-  ///
-  /// This method does not recursively update child elements' properties or
-  /// their grandchildren. This is handled by [updateSemantics] method walking
-  /// all the update nodes.
-  void _updateChildrenInTraversalOrder() {
-    // Remove all children case.
-    if (_childrenInTraversalOrder == null ||
-        _childrenInTraversalOrder!.isEmpty) {
-      if (_previousChildrenInTraversalOrder == null ||
-          _previousChildrenInTraversalOrder!.isEmpty) {
-        // We must not have created a container element when child list is empty.
-        assert(_childContainerElement == null);
-        _previousChildrenInTraversalOrder = _childrenInTraversalOrder;
-        return;
-      }
-
-      // We must have created a container element when child list is not empty.
-      assert(_childContainerElement != null);
-
-      // Remove all children from this semantics object.
-      final int len = _previousChildrenInTraversalOrder!.length;
-      for (int i = 0; i < len; i++) {
-        owner._detachObject(_previousChildrenInTraversalOrder![i]);
-      }
-      _previousChildrenInTraversalOrder = null;
-      _childContainerElement!.remove();
-      _childContainerElement = null;
-      _previousChildrenInTraversalOrder = _childrenInTraversalOrder;
-      return;
-    }
-
-    final html.Element? containerElement = getOrCreateChildContainer();
-
-    // Empty case.
-    if (_previousChildrenInTraversalOrder == null ||
-        _previousChildrenInTraversalOrder!.isEmpty) {
-      _previousChildrenInTraversalOrder = _childrenInTraversalOrder;
-      for (final int id in _previousChildrenInTraversalOrder!) {
-        final SemanticsObject child = owner.getOrCreateObject(id);
-        containerElement!.append(child.element);
-        owner._attachObject(parent: this, child: child);
-      }
-      _previousChildrenInTraversalOrder = _childrenInTraversalOrder;
-      return;
-    }
-
-    // Both non-empty case.
-
-    // Indices into the new child list pointing at children that also exist in
-    // the old child list.
-    final List<int> intersectionIndicesNew = <int>[];
-
-    // Indices into the old child list pointing at children that also exist in
-    // the new child list.
-    final List<int> intersectionIndicesOld = <int>[];
-
-    int newIndex = 0;
-
-    // The smallest of the two child list lengths.
-    final int minLength = math.min(
-      _previousChildrenInTraversalOrder!.length,
-      _childrenInTraversalOrder!.length,
-    );
-
-    // Scan forward until first discrepancy.
-    while (newIndex < minLength &&
-        _previousChildrenInTraversalOrder![newIndex] ==
-            _childrenInTraversalOrder![newIndex]) {
-      intersectionIndicesNew.add(newIndex);
-      intersectionIndicesOld.add(newIndex);
-      newIndex += 1;
-    }
-
-    // If child lists are identical, do nothing.
-    if (_previousChildrenInTraversalOrder!.length ==
-            _childrenInTraversalOrder!.length &&
-        newIndex == _childrenInTraversalOrder!.length) {
-      return;
-    }
-
-    // If child lists are not identical, continue computing the intersection
-    // between the two lists.
-    while (newIndex < _childrenInTraversalOrder!.length) {
-      for (int oldIndex = 0;
-          oldIndex < _previousChildrenInTraversalOrder!.length;
-          oldIndex += 1) {
-        if (_previousChildrenInTraversalOrder![oldIndex] ==
-            _childrenInTraversalOrder![newIndex]) {
-          intersectionIndicesNew.add(newIndex);
-          intersectionIndicesOld.add(oldIndex);
-          break;
-        }
-      }
-      newIndex += 1;
-    }
-
-    // The longest sub-sequence in the old list maximizes the number of children
-    // that do not need to be moved.
-    final List<int?> longestSequence =
-        longestIncreasingSubsequence(intersectionIndicesOld);
-    final List<int> stationaryIds = <int>[];
-    for (int i = 0; i < longestSequence.length; i += 1) {
-      stationaryIds.add(_previousChildrenInTraversalOrder![
-          intersectionIndicesOld[longestSequence[i]!]]);
-    }
-
-    // Remove children that are no longer in the list.
-    for (int i = 0; i < _previousChildrenInTraversalOrder!.length; i++) {
-      if (!intersectionIndicesOld.contains(i)) {
-        // Child not in the intersection. Must be removed.
-        final int childId = _previousChildrenInTraversalOrder![i];
-        owner._detachObject(childId);
-      }
-    }
-
-    html.Element? refNode;
-    for (int i = _childrenInTraversalOrder!.length - 1; i >= 0; i -= 1) {
-      final int childId = _childrenInTraversalOrder![i];
-      final SemanticsObject child = owner.getOrCreateObject(childId);
-      if (!stationaryIds.contains(childId)) {
-        if (refNode == null) {
-          containerElement!.append(child.element);
-        } else {
-          containerElement!.insertBefore(child.element, refNode);
-        }
-        owner._attachObject(parent: this, child: child);
-      } else {
-        assert(child._parent == this);
-      }
-      refNode = child.element;
-    }
-
-    _previousChildrenInTraversalOrder = _childrenInTraversalOrder;
-  }
-
   @override
   String toString() {
     if (assertionsEnabled) {
@@ -1195,15 +1263,15 @@
 /// Controls how pointer events and browser-detected gestures are treated by
 /// the Web Engine.
 enum AccessibilityMode {
-  /// We are not told whether the assistive technology is enabled or not.
+  /// Flutter is not told whether the assistive technology is enabled or not.
   ///
   /// This is the default mode.
   ///
-  /// In this mode we use a gesture recognition system that deduplicates
+  /// In this mode a gesture recognition system is used that deduplicates
   /// gestures detected by Flutter with gestures detected by the browser.
   unknown,
 
-  /// We are told whether the assistive technology is enabled.
+  /// Flutter is told whether the assistive technology is enabled.
   known,
 }
 
@@ -1368,7 +1436,7 @@
     _semanticsEnabled = value;
 
     if (!_semanticsEnabled) {
-      // We do not process browser events at all when semantics is explicitly
+      // Do not process browser events at all when semantics is explicitly
       // disabled. All gestures are handled by the framework-level gesture
       // recognizers from pointer events.
       if (_gestureMode != GestureMode.pointerEvents) {
@@ -1426,8 +1494,7 @@
     return _gestureModeClock;
   }
 
-  /// Disables browser gestures temporarily because we have detected pointer
-  /// events.
+  /// Disables browser gestures temporarily because pointer events were detected.
   ///
   /// This is used to deduplicate gestures detected by Flutter and gestures
   /// detected by the browser. Flutter-detected gestures have higher precedence.
@@ -1447,29 +1514,29 @@
   /// The browser sends us both raw pointer events and gestures from
   /// [SemanticsObject.element]s. There could be three possibilities:
   ///
-  /// 1. Assistive technology is enabled and we know that it is.
-  /// 2. Assistive technology is disabled and we know that it isn't.
-  /// 3. We do not know whether an assistive technology is enabled.
+  /// 1. Assistive technology is enabled and Flutter knows that it is.
+  /// 2. Assistive technology is disabled and Flutter knows that it isn't.
+  /// 3. Flutter does not know whether an assistive technology is enabled.
   ///
   /// If [autoEnableOnTap] was called, this will automatically enable semantics
   /// if the user requests it.
   ///
-  /// In the first case we can ignore raw pointer events and only interpret
+  /// In the first case ignore raw pointer events and only interpret
   /// high-level gestures, e.g. "click".
   ///
-  /// In the second case we can ignore high-level gestures and interpret the raw
+  /// In the second case ignore high-level gestures and interpret the raw
   /// pointer events directly.
   ///
-  /// Finally, in a mode when we do not know if an assistive technology is
-  /// enabled or not we do a best-effort estimate which to respond to, raw
-  /// pointer or high-level gestures. We avoid doing both because that will
+  /// Finally, in a mode when Flutter does not know if an assistive technology
+  /// is enabled or not do a best-effort estimate which to respond to, raw
+  /// pointer or high-level gestures. Avoid doing both because that will
   /// result in double-firing of event listeners, such as `onTap` on a button.
-  /// An approach we use is to measure the distance between the last pointer
+  /// The approach is to measure the distance between the last pointer
   /// event and a gesture event. If a gesture is receive "soon" after the last
   /// received pointer event (determined by a heuristic), it is debounced as it
   /// is likely that the gesture detected from the pointer even will do the
-  /// right thing. However, if we receive a standalone gesture we will map it
-  /// onto a [ui.SemanticsAction] to be processed by the framework.
+  /// right thing. However, if a standalone gesture is received, map it onto a
+  /// [ui.SemanticsAction] to be processed by the framework.
   bool receiveGlobalEvent(html.Event event) {
     // For pointer event reference see:
     //
@@ -1536,7 +1603,7 @@
   /// [semanticsEnabled] is `false`.
   ///
   /// If [mode] is [AccessibilityMode.unknown] the gesture is accepted if it is
-  /// not accompanied by pointer events. In the presence of pointer events we
+  /// not accompanied by pointer events. In the presence of pointer events,
   /// delegate to Flutter's gesture detection system to produce gestures.
   bool shouldAcceptBrowserGesture(String eventType) {
     if (_mode == AccessibilityMode.known) {
@@ -1586,9 +1653,21 @@
     }
 
     final SemanticsUpdate update = uiUpdate as SemanticsUpdate;
+
+    // First, update each object's information about itself. This information is
+    // later used to fix the parent-child and sibling relationships between
+    // objects.
     for (final SemanticsNodeUpdate nodeUpdate in update._nodeUpdates!) {
       final SemanticsObject object = getOrCreateObject(nodeUpdate.id);
-      object.updateWith(nodeUpdate);
+      object.updateSelf(nodeUpdate);
+    }
+
+    // Second, fix the tree structure. This is moved out into its own loop,
+    // because each object's own information must be updated first.
+    for (final SemanticsNodeUpdate nodeUpdate in update._nodeUpdates!) {
+      final SemanticsObject object = _semanticsTree[nodeUpdate.id]!;
+      object.updateChildren();
+      object._dirtyFields = 0;
     }
 
     if (_rootSemanticsElement == null) {
@@ -1602,11 +1681,18 @@
     assert(_semanticsTree.containsKey(0)); // must contain root node
     assert(() {
       // Validate tree
-      _semanticsTree.forEach((int? id, SemanticsObject? object) {
-        assert(id == object!.id);
+      _semanticsTree.forEach((int? id, SemanticsObject object) {
+        assert(id == object.id);
+
+        // Dirty fields should be cleared after the tree has been finalized.
+        assert(object._dirtyFields == 0);
+
+        // Make sure a child container is created only when there are children.
+        assert(object._childContainerElement == null || object.hasChildren);
+
         // Ensure child ID list is consistent with the parent-child
         // relationship of the semantics tree.
-        if (object!._childrenInTraversalOrder != null) {
+        if (object._childrenInTraversalOrder != null) {
           for (final int childId in object._childrenInTraversalOrder!) {
             final SemanticsObject? child = _semanticsTree[childId];
             if (child == null) {
@@ -1673,8 +1759,7 @@
       mins[expansionIndex] = i;
     }
     if (expansionIndex > longest) {
-      // If we found a subsequence longer than any we've
-      // found yet, update `longest`
+      // Record the longest subsequence found so far.
       longest = expansionIndex;
     }
   }
diff --git a/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart b/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart
index 64c7bf3..77f8f4c 100644
--- a/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart
+++ b/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart
@@ -31,7 +31,7 @@
     required this.paragraphStyle,
     required this.plainText,
     required this.placeholderCount,
-    required this.drawOnCanvas,
+    required this.canDrawOnCanvas,
   });
 
   /// The flat list of spans that make up this paragraph.
@@ -47,7 +47,10 @@
   final int placeholderCount;
 
   /// Whether this paragraph can be drawn on a bitmap canvas.
-  final bool drawOnCanvas;
+  ///
+  /// Some text features cannot be rendered into a 2D canvas and must use HTML,
+  /// such as font features and text decorations.
+  final bool canDrawOnCanvas;
 
   @override
   double get width => _layoutService.width;
@@ -623,7 +626,7 @@
     }
   }
 
-  bool _drawOnCanvas = true;
+  bool _canDrawOnCanvas = true;
 
   @override
   void addText(String text) {
@@ -632,24 +635,24 @@
     _plainTextBuffer.write(text);
     final int end = _plainTextBuffer.length;
 
-    if (_drawOnCanvas) {
+    if (_canDrawOnCanvas) {
       final ui.TextDecoration? decoration = style.decoration;
       if (decoration != null && decoration != ui.TextDecoration.none) {
-        _drawOnCanvas = false;
+        _canDrawOnCanvas = false;
       }
     }
 
-    if (_drawOnCanvas) {
+    if (_canDrawOnCanvas) {
       final List<ui.FontFeature>? fontFeatures = style.fontFeatures;
       if (fontFeatures != null && fontFeatures.isNotEmpty) {
-        _drawOnCanvas = false;
+        _canDrawOnCanvas = false;
       }
     }
 
-    if (_drawOnCanvas) {
+    if (_canDrawOnCanvas) {
       final List<ui.FontVariation>? fontVariations = style.fontVariations;
       if (fontVariations != null && fontVariations.isNotEmpty) {
-        _drawOnCanvas = false;
+        _canDrawOnCanvas = false;
       }
     }
 
@@ -663,7 +666,7 @@
       paragraphStyle: _paragraphStyle,
       plainText: _plainTextBuffer.toString(),
       placeholderCount: _placeholderCount,
-      drawOnCanvas: _drawOnCanvas,
+      canDrawOnCanvas: _canDrawOnCanvas,
     );
   }
 }
diff --git a/lib/web_ui/test/canvaskit/embedded_views_test.dart b/lib/web_ui/test/canvaskit/embedded_views_test.dart
index c619f5e..b6ada66 100644
--- a/lib/web_ui/test/canvaskit/embedded_views_test.dart
+++ b/lib/web_ui/test/canvaskit/embedded_views_test.dart
@@ -130,6 +130,34 @@
       );
     });
 
+    test('correctly offsets platform views', () async {
+      ui.platformViewRegistry.registerViewFactory(
+        'test-platform-view',
+        (int viewId) => html.DivElement()..id = 'view-0',
+      );
+      await createPlatformView(0, 'test-platform-view');
+
+      final EnginePlatformDispatcher dispatcher =
+          ui.window.platformDispatcher as EnginePlatformDispatcher;
+      final LayerSceneBuilder sb = LayerSceneBuilder();
+      sb.addPlatformView(0, offset: const ui.Offset(3, 4), width: 5, height: 6);
+      dispatcher.rasterizer!.draw(sb.build().layerTree);
+
+      final html.Element slotHost =
+          flutterViewEmbedder.sceneElement!.querySelector('flt-platform-view-slot')!;
+      final html.CssStyleDeclaration style = slotHost.style;
+
+      expect(style.transform, 'matrix(1, 0, 0, 1, 3, 4)');
+      expect(style.width, '5px');
+      expect(style.height, '6px');
+
+      final html.Rectangle<num> slotRect = slotHost.getBoundingClientRect();
+      expect(slotRect.left, 3);
+      expect(slotRect.top, 4);
+      expect(slotRect.right, 8);
+      expect(slotRect.bottom, 10);
+    });
+
     // Returns the list of CSS transforms applied to the ancestor chain of
     // elements starting from `viewHost`, up until and excluding <flt-scene>.
     List<String> getTransformChain(html.Element viewHost) {
diff --git a/lib/web_ui/test/canvaskit/semantics_test.dart b/lib/web_ui/test/canvaskit/semantics_test.dart
new file mode 100644
index 0000000..36969fb
--- /dev/null
+++ b/lib/web_ui/test/canvaskit/semantics_test.dart
@@ -0,0 +1,29 @@
+// 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.
+
+@TestOn('chrome || safari || firefox')
+
+import 'dart:async';
+
+import 'package:test/bootstrap/browser.dart';
+import 'package:test/test.dart';
+
+import '../engine/semantics/semantics_test.dart';
+import 'common.dart';
+
+void main() {
+  internalBootstrapBrowserTest(() => testMain);
+}
+
+// Run the same semantics tests in CanvasKit mode because as of today we do not
+// yet share platform view logic with the HTML renderer, which affects
+// semantics.
+Future<void> testMain() async {
+  group('CanvasKit semantics', () {
+    setUpCanvasKitTest();
+
+    runSemanticsTests();
+    // TODO(hterkelsen): https://github.com/flutter/flutter/issues/60040
+  }, skip: isIosSafari);
+}
diff --git a/lib/web_ui/test/engine/semantics/semantics_test.dart b/lib/web_ui/test/engine/semantics/semantics_test.dart
index e6bdd4f..d7ec3fa 100644
--- a/lib/web_ui/test/engine/semantics/semantics_test.dart
+++ b/lib/web_ui/test/engine/semantics/semantics_test.dart
@@ -12,12 +12,10 @@
 import 'package:test/bootstrap/browser.dart';
 import 'package:test/test.dart';
 
-import 'package:ui/src/engine.dart' show flutterViewEmbedder;
-import 'package:ui/src/engine/browser_detection.dart';
-import 'package:ui/src/engine/semantics.dart';
-import 'package:ui/src/engine/vector_math.dart';
+import 'package:ui/src/engine.dart';
 import 'package:ui/ui.dart' as ui;
 
+import '../../common.dart';
 import 'semantics_tester.dart';
 
 DateTime _testTime = DateTime(2018, 12, 17);
@@ -25,12 +23,17 @@
 EngineSemanticsOwner semantics() => EngineSemanticsOwner.instance;
 
 void main() {
-  internalBootstrapBrowserTest(() => testMain);
+  internalBootstrapBrowserTest(() {
+    return testMain;
+  });
 }
 
 Future<void> testMain() async {
   await ui.webOnlyInitializePlatform();
+  runSemanticsTests();
+}
 
+void runSemanticsTests() {
   setUp(() {
     EngineSemanticsOwner.debugResetSemantics();
   });
@@ -71,6 +74,9 @@
   group('live region', () {
     _testLiveRegion();
   });
+  group('platform view', () {
+    _testPlatformView();
+  });
 }
 
 void _testEngineSemanticsOwner() {
@@ -422,13 +428,17 @@
     updateNode(
       builder,
       id: 0,
-      actions: 0,
-      flags: 0,
       transform: Matrix4.identity().toFloat64(),
       rect: zeroOffsetRect,
       childrenInHitTestOrder: Int32List.fromList(<int>[1]),
       childrenInTraversalOrder: Int32List.fromList(<int>[1]),
     );
+    updateNode(
+      builder,
+      id: 1,
+      transform: Matrix4.identity().toFloat64(),
+      rect: zeroOffsetRect,
+    );
 
     semantics().updateSemantics(builder.build());
     expectSemanticsTree('''
@@ -477,6 +487,12 @@
       childrenInHitTestOrder: Int32List.fromList(<int>[1]),
       childrenInTraversalOrder: Int32List.fromList(<int>[1]),
     );
+    updateNode(
+      builder,
+      id: 1,
+      transform: Matrix4.identity().toFloat64(),
+      rect: const ui.Rect.fromLTRB(10, 10, 20, 20),
+    );
 
     semantics().updateSemantics(builder.build());
     expectSemanticsTree('''
@@ -514,23 +530,20 @@
       childrenInHitTestOrder: Int32List.fromList(<int>[1]),
       childrenInTraversalOrder: Int32List.fromList(<int>[1]),
     );
+    updateNode(
+      builder,
+      id: 1,
+      transform: Matrix4.identity().toFloat64(),
+      rect: const ui.Rect.fromLTRB(10, 10, 20, 20),
+    );
 
     semantics().updateSemantics(builder.build());
-    if (browserEngine == BrowserEngine.edge) {
-      expectSemanticsTree('''
-<sem style="color: rgba(0, 0, 0, 0); filter: opacity(0%)">
-  <sem-c>
-    <sem></sem>
-  </sem-c>
-</sem>''');
-    } else {
-      expectSemanticsTree('''
+    expectSemanticsTree('''
 <sem style="$rootSemanticStyle">
   <sem-c>
     <sem></sem>
   </sem-c>
 </sem>''');
-    }
 
     final html.Element parentElement =
         appHostNode.querySelector('flt-semantics')!;
@@ -555,6 +568,134 @@
 
     semantics().semanticsEnabled = false;
   });
+
+  test('renders in traversal order, hit-tests in reverse z-index order', () async {
+    semantics()
+      ..debugOverrideTimestampFunction(() => _testTime)
+      ..semanticsEnabled = true;
+
+    // State 1: render initial tree with middle elements swapped hit-test wise
+    {
+      final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
+      updateNode(
+        builder,
+        childrenInTraversalOrder: Int32List.fromList(<int>[1, 2, 3, 4]),
+        childrenInHitTestOrder: Int32List.fromList(<int>[1, 3, 2, 4]),
+      );
+
+      for (int id = 1; id <= 4; id++) {
+        updateNode(builder, id: id);
+      }
+
+      semantics().updateSemantics(builder.build());
+      expectSemanticsTree('''
+<sem style="$rootSemanticStyle">
+  <sem-c>
+    <sem style="z-index: 4"></sem>
+    <sem style="z-index: 2"></sem>
+    <sem style="z-index: 3"></sem>
+    <sem style="z-index: 1"></sem>
+  </sem-c>
+</sem>''');
+    }
+
+    // State 2: update z-index
+    {
+      final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
+      updateNode(
+        builder,
+        childrenInTraversalOrder: Int32List.fromList(<int>[1, 2, 3, 4]),
+        childrenInHitTestOrder: Int32List.fromList(<int>[1, 2, 3, 4]),
+      );
+      semantics().updateSemantics(builder.build());
+      expectSemanticsTree('''
+<sem style="$rootSemanticStyle">
+  <sem-c>
+    <sem style="z-index: 4"></sem>
+    <sem style="z-index: 3"></sem>
+    <sem style="z-index: 2"></sem>
+    <sem style="z-index: 1"></sem>
+  </sem-c>
+</sem>''');
+    }
+
+    // State 3: update traversal order
+    {
+      final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
+      updateNode(
+        builder,
+        childrenInTraversalOrder: Int32List.fromList(<int>[4, 2, 3, 1]),
+        childrenInHitTestOrder: Int32List.fromList(<int>[1, 2, 3, 4]),
+      );
+      semantics().updateSemantics(builder.build());
+      expectSemanticsTree('''
+<sem style="$rootSemanticStyle">
+  <sem-c>
+    <sem style="z-index: 1"></sem>
+    <sem style="z-index: 3"></sem>
+    <sem style="z-index: 2"></sem>
+    <sem style="z-index: 4"></sem>
+  </sem-c>
+</sem>''');
+    }
+
+    // State 3: update both orders
+    {
+      final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
+      updateNode(
+        builder,
+        childrenInTraversalOrder: Int32List.fromList(<int>[1, 3, 2, 4]),
+        childrenInHitTestOrder: Int32List.fromList(<int>[3, 4, 1, 2]),
+      );
+      semantics().updateSemantics(builder.build());
+      expectSemanticsTree('''
+<sem style="$rootSemanticStyle">
+  <sem-c>
+    <sem style="z-index: 2"></sem>
+    <sem style="z-index: 4"></sem>
+    <sem style="z-index: 1"></sem>
+    <sem style="z-index: 3"></sem>
+  </sem-c>
+</sem>''');
+    }
+
+    semantics().semanticsEnabled = false;
+  });
+
+  test('container nodes are transparent and leaf children are opaque hit-test wise', () async {
+    semantics()
+      ..debugOverrideTimestampFunction(() => _testTime)
+      ..semanticsEnabled = true;
+
+    final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
+    updateNode(
+      builder,
+      childrenInTraversalOrder: Int32List.fromList(<int>[1, 2]),
+      childrenInHitTestOrder: Int32List.fromList(<int>[1, 2]),
+    );
+    updateNode(builder, id: 1);
+    updateNode(builder, id: 2);
+
+    semantics().updateSemantics(builder.build());
+    expectSemanticsTree('''
+<sem style="$rootSemanticStyle">
+  <sem-c>
+    <sem style="z-index: 2"></sem>
+    <sem style="z-index: 1"></sem>
+  </sem-c>
+</sem>''');
+
+    final html.Element root = appHostNode.querySelector('#flt-semantic-node-0')!;
+    expect(root.style.pointerEvents, 'none');
+
+    final html.Element child1 = appHostNode.querySelector('#flt-semantic-node-1')!;
+    expect(child1.style.pointerEvents, 'all');
+
+    final html.Element child2 = appHostNode.querySelector('#flt-semantic-node-2')!;
+    expect(child2.style.pointerEvents, 'all');
+
+    semantics().semanticsEnabled = false;
+  });
 }
 
 void _testVerticalScrolling() {
@@ -597,6 +738,12 @@
       childrenInHitTestOrder: Int32List.fromList(<int>[1]),
       childrenInTraversalOrder: Int32List.fromList(<int>[1]),
     );
+    updateNode(
+      builder,
+      id: 1,
+      transform: Matrix4.identity().toFloat64(),
+      rect: const ui.Rect.fromLTRB(10, 10, 20, 20),
+    );
 
     semantics().updateSemantics(builder.build());
     expectSemanticsTree('''
@@ -670,9 +817,9 @@
     expectSemanticsTree('''
 <sem style="$rootSemanticStyle; touch-action: none; overflow-y: scroll">
   <sem-c>
-    <sem></sem>
-    <sem></sem>
-    <sem></sem>
+    <sem style="z-index: 3"></sem>
+    <sem style="z-index: 2"></sem>
+    <sem style="z-index: 1"></sem>
   </sem-c>
 </sem>''');
 
@@ -749,6 +896,12 @@
       childrenInHitTestOrder: Int32List.fromList(<int>[1]),
       childrenInTraversalOrder: Int32List.fromList(<int>[1]),
     );
+    updateNode(
+      builder,
+      id: 1,
+      transform: Matrix4.identity().toFloat64(),
+      rect: const ui.Rect.fromLTRB(10, 10, 20, 20),
+    );
 
     semantics().updateSemantics(builder.build());
     expectSemanticsTree('''
@@ -803,9 +956,9 @@
     expectSemanticsTree('''
 <sem style="$rootSemanticStyle; touch-action: none; overflow-x: scroll">
   <sem-c>
-    <sem></sem>
-    <sem></sem>
-    <sem></sem>
+    <sem style="z-index: 3"></sem>
+    <sem style="z-index: 2"></sem>
+    <sem style="z-index: 1"></sem>
   </sem-c>
 </sem>''');
 
@@ -933,9 +1086,7 @@
     expect(await logger.actionLog.first, ui.SemanticsAction.decrease);
 
     semantics().semanticsEnabled = false;
-  },
-      // TODO(yjbanov): https://github.com/flutter/flutter/issues/50754
-      skip: browserEngine == BrowserEngine.edge);
+  });
 
   test('renders a node that can both increment and decrement', () async {
     semantics()
@@ -964,9 +1115,7 @@
 </sem>''');
 
     semantics().semanticsEnabled = false;
-  },
-      // TODO(yjbanov): https://github.com/flutter/flutter/issues/50754
-      skip: browserEngine == BrowserEngine.edge);
+  });
 }
 
 void _testTextField() {
@@ -993,9 +1142,7 @@
 </sem>''');
 
     semantics().semanticsEnabled = false;
-  },
-      // TODO(yjbanov): https://github.com/flutter/flutter/issues/50754
-      skip: browserEngine == BrowserEngine.edge);
+  });
 
   // TODO(yjbanov): this test will need to be adjusted for Safari when we add
   //                Safari testing.
@@ -1062,9 +1209,7 @@
 ''');
 
     semantics().semanticsEnabled = false;
-  },
-      // TODO(yjbanov): https://github.com/flutter/flutter/issues/50754
-      skip: browserEngine == BrowserEngine.edge);
+  });
 
   test('renders a switched on disabled switch element', () async {
     semantics()
@@ -1090,9 +1235,7 @@
 ''');
 
     semantics().semanticsEnabled = false;
-  },
-      // TODO(yjbanov): https://github.com/flutter/flutter/issues/50754
-      skip: browserEngine == BrowserEngine.edge);
+  });
 
   test('renders a switched off switch element', () async {
     semantics()
@@ -1118,9 +1261,7 @@
 ''');
 
     semantics().semanticsEnabled = false;
-  },
-      // TODO(yjbanov): https://github.com/flutter/flutter/issues/50754
-      skip: browserEngine == BrowserEngine.edge);
+  });
 
   test('renders a checked checkbox', () async {
     semantics()
@@ -1147,9 +1288,7 @@
 ''');
 
     semantics().semanticsEnabled = false;
-  },
-      // TODO(yjbanov): https://github.com/flutter/flutter/issues/50754
-      skip: browserEngine == BrowserEngine.edge);
+  });
 
   test('renders a checked disabled checkbox', () async {
     semantics()
@@ -1175,9 +1314,7 @@
 ''');
 
     semantics().semanticsEnabled = false;
-  },
-      // TODO(yjbanov): https://github.com/flutter/flutter/issues/50754
-      skip: browserEngine == BrowserEngine.edge);
+  });
 
   test('renders an unchecked checkbox', () async {
     semantics()
@@ -1203,9 +1340,7 @@
 ''');
 
     semantics().semanticsEnabled = false;
-  },
-      // TODO(yjbanov): https://github.com/flutter/flutter/issues/50754
-      skip: browserEngine == BrowserEngine.edge);
+  });
 
   test('renders a checked radio button', () async {
     semantics()
@@ -1233,9 +1368,7 @@
 ''');
 
     semantics().semanticsEnabled = false;
-  },
-      // TODO(yjbanov): https://github.com/flutter/flutter/issues/50754
-      skip: browserEngine == BrowserEngine.edge);
+  });
 
   test('renders a checked disabled radio button', () async {
     semantics()
@@ -1262,9 +1395,7 @@
 ''');
 
     semantics().semanticsEnabled = false;
-  },
-      // TODO(yjbanov): https://github.com/flutter/flutter/issues/50754
-      skip: browserEngine == BrowserEngine.edge);
+  });
 
   test('renders an unchecked checkbox', () async {
     semantics()
@@ -1291,9 +1422,7 @@
 ''');
 
     semantics().semanticsEnabled = false;
-  },
-      // TODO(yjbanov): https://github.com/flutter/flutter/issues/50754
-      skip: browserEngine == BrowserEngine.edge);
+  });
 }
 
 void _testTappable() {
@@ -1320,9 +1449,7 @@
     expect(tester.getSemanticsObject(0).element.tabIndex, 0);
 
     semantics().semanticsEnabled = false;
-  },
-      // TODO(yjbanov): https://github.com/flutter/flutter/issues/50754
-      skip: browserEngine == BrowserEngine.edge);
+  });
 
   test('renders a disabled tappable widget', () async {
     semantics()
@@ -1347,9 +1474,7 @@
 ''');
 
     semantics().semanticsEnabled = false;
-  },
-      // TODO(yjbanov): https://github.com/flutter/flutter/issues/50754
-      skip: browserEngine == BrowserEngine.edge);
+  });
 }
 
 void _testImage() {
@@ -1375,9 +1500,7 @@
 ''');
 
     semantics().semanticsEnabled = false;
-  },
-      // TODO(yjbanov): https://github.com/flutter/flutter/issues/50754
-      skip: browserEngine == BrowserEngine.edge);
+  });
 
   test('renders an image with a child node and with a label', () async {
     semantics()
@@ -1396,6 +1519,12 @@
       childrenInHitTestOrder: Int32List.fromList(<int>[1]),
       childrenInTraversalOrder: Int32List.fromList(<int>[1]),
     );
+    updateNode(
+      builder,
+      id: 1,
+      transform: Matrix4.identity().toFloat64(),
+      rect: const ui.Rect.fromLTRB(10, 10, 20, 20),
+    );
 
     semantics().updateSemantics(builder.build());
     expectSemanticsTree('''
@@ -1408,9 +1537,7 @@
 </sem>''');
 
     semantics().semanticsEnabled = false;
-  },
-      // TODO(yjbanov): https://github.com/flutter/flutter/issues/50754
-      skip: browserEngine == BrowserEngine.edge);
+  });
 
   test('renders an image with no child nodes without a label', () async {
     semantics()
@@ -1432,9 +1559,7 @@
         '''<sem role="img" style="$rootSemanticStyle"></sem>''');
 
     semantics().semanticsEnabled = false;
-  },
-      // TODO(yjbanov): https://github.com/flutter/flutter/issues/50754
-      skip: browserEngine == BrowserEngine.edge);
+  });
 
   test('renders an image with a child node and without a label', () async {
     semantics()
@@ -1452,6 +1577,12 @@
       childrenInHitTestOrder: Int32List.fromList(<int>[1]),
       childrenInTraversalOrder: Int32List.fromList(<int>[1]),
     );
+    updateNode(
+      builder,
+      id: 1,
+      transform: Matrix4.identity().toFloat64(),
+      rect: const ui.Rect.fromLTRB(10, 10, 20, 20),
+    );
 
     semantics().updateSemantics(builder.build());
     expectSemanticsTree('''
@@ -1464,9 +1595,7 @@
 </sem>''');
 
     semantics().semanticsEnabled = false;
-  },
-      // TODO(yjbanov): https://github.com/flutter/flutter/issues/50754
-      skip: browserEngine == BrowserEngine.edge);
+  });
 }
 
 void _testLiveRegion() {
@@ -1492,9 +1621,7 @@
 ''');
 
     semantics().semanticsEnabled = false;
-  },
-      // TODO(yjbanov): https://github.com/flutter/flutter/issues/50754
-      skip: browserEngine == BrowserEngine.edge);
+  });
 
   test('does not render a live region if there is no label', () async {
     semantics()
@@ -1520,6 +1647,184 @@
   });
 }
 
+void _testPlatformView() {
+  test('is transparent w.r.t. hit testing', () async {
+    semantics()
+      ..debugOverrideTimestampFunction(() => _testTime)
+      ..semanticsEnabled = true;
+
+    final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
+    updateNode(
+      builder,
+      platformViewId: 5,
+      rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
+    );
+    semantics().updateSemantics(builder.build());
+
+    expectSemanticsTree('<sem style="$rootSemanticStyle"></sem>');
+    final html.Element element = appHostNode.querySelector('flt-semantics')!;
+    expect(element.style.pointerEvents, 'none');
+
+    semantics().semanticsEnabled = false;
+  });
+
+  // This test simulates the scenario of three child semantic nodes contained by
+  // a common parent. The first and the last nodes are plain leaf nodes. The
+  // middle node is a platform view node. Nodes overlap. The test hit tests
+  // various points and verifies that the correct DOM element receives the
+  // event. The test does this using `documentOrShadow.elementFromPoint`, which,
+  // if browsers are to be trusted, should do the same thing as if a pointer
+  // event landed at the given location.
+  //
+  // 0px   -------------
+  //       |           |
+  //       |           | <- plain semantic node
+  //       |     1     |
+  // 15px  | -------------
+  //       | |           |
+  // 25px  --|           |
+  //         |     2     |  <- platform view
+  //         |           |
+  // 35px    | -------------
+  //         | |           |
+  // 45px    --|           |
+  //           |     3     |  <- plain semantic node
+  //           |           |
+  //           |           |
+  // 60px      -------------
+  test('is reachable via a hit test', () async {
+    semantics()
+      ..debugOverrideTimestampFunction(() => _testTime)
+      ..semanticsEnabled = true;
+
+    ui.platformViewRegistry.registerViewFactory(
+      'test-platform-view',
+      (int viewId) => html.DivElement()
+        ..id = 'view-0'
+        ..style.width = '100%'
+        ..style.height = '100%',
+    );
+    await createPlatformView(0, 'test-platform-view');
+
+    final ui.SceneBuilder sceneBuilder = ui.SceneBuilder();
+    sceneBuilder.addPlatformView(
+      0,
+      offset: const ui.Offset(0, 15),
+      width: 20,
+      height: 30,
+    );
+    ui.window.render(sceneBuilder.build());
+
+    final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
+    updateNode(
+      builder,
+      rect: const ui.Rect.fromLTRB(0, 0, 20, 60),
+      childrenInTraversalOrder: Int32List.fromList(<int>[1, 2, 3]),
+      childrenInHitTestOrder: Int32List.fromList(<int>[1, 2, 3]),
+    );
+    updateNode(
+      builder,
+      id: 1,
+      rect: const ui.Rect.fromLTRB(0, 0, 20, 25),
+    );
+    updateNode(
+      builder,
+      id: 2,
+      // This has to match the values passed to `addPlatformView` above.
+      rect: const ui.Rect.fromLTRB(0, 15, 20, 45),
+      platformViewId: 0,
+    );
+    updateNode(
+      builder,
+      id: 3,
+      rect: const ui.Rect.fromLTRB(0, 35, 20, 60),
+    );
+
+    semantics().updateSemantics(builder.build());
+    expectSemanticsTree('''
+<sem style="$rootSemanticStyle">
+  <sem-c>
+    <sem style="z-index: 3"></sem>
+    <sem style="z-index: 2"></sem>
+    <sem style="z-index: 1"></sem>
+  </sem-c>
+</sem>''');
+
+    final html.Element root = appHostNode.querySelector('#flt-semantic-node-0')!;
+    expect(root.style.pointerEvents, 'none');
+
+    final html.Element child1 = appHostNode.querySelector('#flt-semantic-node-1')!;
+    expect(child1.style.pointerEvents, 'all');
+    final html.Rectangle<num> child1Rect = child1.getBoundingClientRect();
+    expect(child1Rect.left, 0);
+    expect(child1Rect.top, 0);
+    expect(child1Rect.right, 20);
+    expect(child1Rect.bottom, 25);
+
+    final html.Element child2 = appHostNode.querySelector('#flt-semantic-node-2')!;
+    expect(child2.style.pointerEvents, 'none');
+    final html.Rectangle<num> child2Rect = child2.getBoundingClientRect();
+    expect(child2Rect.left, 0);
+    expect(child2Rect.top, 15);
+    expect(child2Rect.right, 20);
+    expect(child2Rect.bottom, 45);
+
+    final html.Element child3 = appHostNode.querySelector('#flt-semantic-node-3')!;
+    expect(child3.style.pointerEvents, 'all');
+    final html.Rectangle<num> child3Rect = child3.getBoundingClientRect();
+    expect(child3Rect.left, 0);
+    expect(child3Rect.top, 35);
+    expect(child3Rect.right, 20);
+    expect(child3Rect.bottom, 60);
+
+    final html.Element platformViewElement = flutterViewEmbedder.glassPaneElement!.querySelector('#view-0')!;
+    final html.Rectangle<num> platformViewRect = platformViewElement.getBoundingClientRect();
+    expect(platformViewRect.left, 0);
+    expect(platformViewRect.top, 15);
+    expect(platformViewRect.right, 20);
+    expect(platformViewRect.bottom, 45);
+
+    // This test is only relevant for shadow DOM because we only really support
+    // proper platform view embedding in browsers that support shadow DOM.
+    final html.ShadowRoot shadowRoot = appHostNode.node as html.ShadowRoot;
+
+    // Hit test child 1
+    expect(shadowRoot.elementFromPoint(10, 10)!, child1);
+
+    // Hit test overlap between child 1 and 2
+    // TODO(yjbanov): this is a known limitation, see https://github.com/flutter/flutter/issues/101439
+    expect(shadowRoot.elementFromPoint(10, 20)!, child1);
+
+    // Hit test child 2
+    // Clicking at the location of the middle semantics node should allow the
+    // event to go through the semantic tree and hit the platform view. Since
+    // platform views are projected into the shadow DOM from outside the shadow
+    // root, it would be reachable both from the shadow root (by hitting the
+    // corresponding <slot> tag) and from the document (by hitting the platform
+    // view element itself).
+
+    // Browsers disagree about which element should be returned when hit testing
+    // a shadow root. However, they do agree when hit testing `document`.
+    //
+    // See:
+    //   * https://github.com/w3c/csswg-drafts/issues/556
+    //   * https://bugzilla.mozilla.org/show_bug.cgi?id=1502369
+    expect(html.document.elementFromPoint(10, 30)!, platformViewElement);
+
+    // Hit test overlap between child 2 and 3
+    expect(shadowRoot.elementFromPoint(10, 40)!, child3);
+
+    // Hit test child 3
+    expect(shadowRoot.elementFromPoint(10, 50)!, child3);
+
+    semantics().semanticsEnabled = false;
+    // TODO(yjbanov): unable to debug this test on iOS Safari as hacking on a
+    //                Linux machine. iOS Safari returns getBoundingClientRect
+    //                values that are half of desktop browsers, possibly due to
+    //                devicePixelRatio but need to confirm.
+  }, skip: isIosSafari);
+}
+
 /// A facade in front of [ui.SemanticsUpdateBuilder.updateNode] that
 /// supplies default values for semantics attributes.
 // TODO(yjbanov): move this to TestSemanticsBuilder
@@ -1532,7 +1837,7 @@
   int currentValueLength = 0,
   int textSelectionBase = 0,
   int textSelectionExtent = 0,
-  int platformViewId = 0,
+  int platformViewId = -1, // -1 means not a platform view
   int scrollChildren = 0,
   int scrollIndex = 0,
   double scrollPosition = 0.0,
@@ -1597,3 +1902,33 @@
     additionalActions: additionalActions,
   );
 }
+
+const MethodCodec codec = StandardMethodCodec();
+
+/// Sends a platform message to create a Platform View with the given id and viewType.
+Future<void> createPlatformView(int id, String viewType) {
+  final Completer<void> completer = Completer<void>();
+  ui.window.sendPlatformMessage(
+    'flutter/platform_views',
+    codec.encodeMethodCall(MethodCall(
+      'create',
+      <String, dynamic>{
+        'id': id,
+        'viewType': viewType,
+      },
+    )),
+    (dynamic _) => completer.complete(),
+  );
+  return completer.future;
+}
+
+/// Disposes of the platform view with the given [id].
+Future<void> disposePlatformView(int id) {
+  final Completer<void> completer = Completer<void>();
+  window.sendPlatformMessage(
+    'flutter/platform_views',
+    codec.encodeMethodCall(MethodCall('dispose', id)),
+    (dynamic _) => completer.complete(),
+  );
+  return completer.future;
+}
diff --git a/lib/web_ui/test/engine/semantics/semantics_tester.dart b/lib/web_ui/test/engine/semantics/semantics_tester.dart
index 2c5f9d4..3181043 100644
--- a/lib/web_ui/test/engine/semantics/semantics_tester.dart
+++ b/lib/web_ui/test/engine/semantics/semantics_tester.dart
@@ -360,8 +360,9 @@
 
 /// Verifies the HTML structure of the current semantics tree.
 void expectSemanticsTree(String semanticsHtml) {
+  const List<String> ignoredAttributes = <String>['pointer-events'];
   expect(
-    canonicalizeHtml(appHostNode.querySelector('flt-semantics')!.outerHtml!),
+    canonicalizeHtml(appHostNode.querySelector('flt-semantics')!.outerHtml!, ignoredAttributes: ignoredAttributes),
     canonicalizeHtml(semanticsHtml),
   );
 }
diff --git a/lib/web_ui/test/html/bitmap_canvas_golden_test.dart b/lib/web_ui/test/html/bitmap_canvas_golden_test.dart
index 2d40b4b..c6da523 100644
--- a/lib/web_ui/test/html/bitmap_canvas_golden_test.dart
+++ b/lib/web_ui/test/html/bitmap_canvas_golden_test.dart
@@ -270,4 +270,26 @@
       pixelComparison: PixelComparison.precise,
     );
   });
+
+  // Regression test for https://github.com/flutter/flutter/issues/96498. When
+  // a picture is made of just text that can be rendered using plain HTML,
+  // BitmapCanvas should not create any <canvas> elements as they are expensive.
+  test('does not allocate bitmap canvas just for text', () async {
+    canvas = BitmapCanvas(const Rect.fromLTWH(0, 0, 50, 50), RenderStrategy());
+
+    final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle(fontFamily: 'Roboto'));
+    builder.addText('Hello');
+    final CanvasParagraph paragraph = builder.build() as CanvasParagraph;
+    paragraph.layout(const ParagraphConstraints(width: 1000));
+
+    canvas.drawParagraph(paragraph, const Offset(8.5, 8.5));
+    expect(
+      canvas.rootElement.querySelectorAll('canvas'),
+      isEmpty,
+    );
+    expect(
+      canvas.rootElement.querySelectorAll('flt-paragraph').single.innerText,
+      'Hello',
+    );
+  });
 }
diff --git a/lib/web_ui/test/html/compositing/compositing_golden_test.dart b/lib/web_ui/test/html/compositing/compositing_golden_test.dart
index b6e3601..e4b0a6b 100644
--- a/lib/web_ui/test/html/compositing/compositing_golden_test.dart
+++ b/lib/web_ui/test/html/compositing/compositing_golden_test.dart
@@ -847,7 +847,7 @@
         final RecordingCanvas canvas = recorder.beginRecording(outerClip);
         canvas.drawParagraph(paragraph, const ui.Offset(8.5, 8.5));
         final ui.Picture picture = recorder.endRecording();
-        expect(paragraph.drawOnCanvas, isFalse);
+        expect(paragraph.canDrawOnCanvas, isFalse);
 
         builder.addPicture(
           ui.Offset.zero,
@@ -861,7 +861,7 @@
         final RecordingCanvas canvas = recorder.beginRecording(innerClip);
         canvas.drawParagraph(paragraph, ui.Offset(8.5, 8.5 + innerClip.top));
         final ui.Picture picture = recorder.endRecording();
-        expect(paragraph.drawOnCanvas, isFalse);
+        expect(paragraph.canDrawOnCanvas, isFalse);
 
         builder.addPicture(
           ui.Offset.zero,
diff --git a/lib/web_ui/test/matchers.dart b/lib/web_ui/test/matchers.dart
index 0d3d210..f47a30f 100644
--- a/lib/web_ui/test/matchers.dart
+++ b/lib/web_ui/test/matchers.dart
@@ -225,9 +225,12 @@
 /// [throwOnUnusedAttributes] to `true` to check that expected HTML strings do
 /// not contain irrelevant attributes. It is ok for actual HTML to contain all
 /// kinds of attributes. They only need to be filtered out before testing.
-String canonicalizeHtml(String htmlContent,
-    {HtmlComparisonMode mode = HtmlComparisonMode.nonLayoutOnly,
-    bool throwOnUnusedAttributes = false}) {
+String canonicalizeHtml(
+  String htmlContent, {
+  HtmlComparisonMode mode = HtmlComparisonMode.nonLayoutOnly,
+  bool throwOnUnusedAttributes = false,
+  List<String>? ignoredAttributes,
+}) {
   if (htmlContent.trim().isEmpty) {
     return '';
   }
@@ -331,6 +334,11 @@
                 final List<String> parts = attr.split(':');
                 if (parts.length == 2) {
                   final String name = parts.first;
+
+                  if (ignoredAttributes != null && ignoredAttributes.contains(name)) {
+                    return null;
+                  }
+
                   // Whether the attribute is one that's set to the same value and
                   // never changes. Such attributes are usually not interesting to
                   // test.