Adds API in semanticsconfiguration to decide how to merge child semanticsConfigurations (#110730)

* Adds semantics merger API and fix input decorator

* addressing comments

* abstractnode to object

* feature complete

* addressing comments

* fix comments

* conditionally add sort order

* fix bool

* fix test

* more fix

* fix tests
diff --git a/packages/flutter/lib/src/material/input_decorator.dart b/packages/flutter/lib/src/material/input_decorator.dart
index 193c3b1..1f32086 100644
--- a/packages/flutter/lib/src/material/input_decorator.dart
+++ b/packages/flutter/lib/src/material/input_decorator.dart
@@ -1326,6 +1326,35 @@
     return Size.zero;
   }
 
+  ChildSemanticsConfigurationsResult _childSemanticsConfigurationDelegate(List<SemanticsConfiguration> childConfigs) {
+    final ChildSemanticsConfigurationsResultBuilder builder = ChildSemanticsConfigurationsResultBuilder();
+    List<SemanticsConfiguration>? prefixMergeGroup;
+    List<SemanticsConfiguration>? suffixMergeGroup;
+    for (final SemanticsConfiguration childConfig in childConfigs) {
+      if (childConfig.tagsChildrenWith(_InputDecoratorState._kPrefixSemanticsTag)) {
+        prefixMergeGroup ??= <SemanticsConfiguration>[];
+        prefixMergeGroup.add(childConfig);
+      } else if (childConfig.tagsChildrenWith(_InputDecoratorState._kSuffixSemanticsTag)) {
+        suffixMergeGroup ??= <SemanticsConfiguration>[];
+        suffixMergeGroup.add(childConfig);
+      } else {
+        builder.markAsMergeUp(childConfig);
+      }
+    }
+    if (prefixMergeGroup != null) {
+      builder.markAsSiblingMergeGroup(prefixMergeGroup);
+    }
+    if (suffixMergeGroup != null) {
+      builder.markAsSiblingMergeGroup(suffixMergeGroup);
+    }
+    return builder.build();
+  }
+
+  @override
+  void describeSemanticsConfiguration(SemanticsConfiguration config) {
+    config.childConfigurationsDelegate = _childSemanticsConfigurationDelegate;
+  }
+
   @override
   void performLayout() {
     final BoxConstraints constraints = this.constraints;
@@ -1713,12 +1742,16 @@
     this.text,
     this.style,
     this.child,
+    this.semanticsSortKey,
+    required this.semanticsTag,
   });
 
   final bool labelIsFloating;
   final String? text;
   final TextStyle? style;
   final Widget? child;
+  final SemanticsSortKey? semanticsSortKey;
+  final SemanticsTag semanticsTag;
 
   @override
   Widget build(BuildContext context) {
@@ -1728,7 +1761,11 @@
         duration: _kTransitionDuration,
         curve: _kTransitionCurve,
         opacity: labelIsFloating ? 1.0 : 0.0,
-        child: child ?? (text == null ? null : Text(text!, style: style)),
+        child: Semantics(
+          sortKey: semanticsSortKey,
+          tagForChildren: semanticsTag,
+          child: child ?? (text == null ? null : Text(text!, style: style)),
+        ),
       ),
     );
   }
@@ -1899,6 +1936,11 @@
   late AnimationController _floatingLabelController;
   late AnimationController _shakingLabelController;
   final _InputBorderGap _borderGap = _InputBorderGap();
+  static const OrdinalSortKey _kPrefixSemanticsSortOrder = OrdinalSortKey(0);
+  static const OrdinalSortKey _kInputSemanticsSortOrder = OrdinalSortKey(1);
+  static const OrdinalSortKey _kSuffixSemanticsSortOrder = OrdinalSortKey(2);
+  static const SemanticsTag _kPrefixSemanticsTag = SemanticsTag('_InputDecoratorState.prefix');
+  static const SemanticsTag _kSuffixSemanticsTag = SemanticsTag('_InputDecoratorState.suffix');
 
   @override
   void initState() {
@@ -2218,22 +2260,42 @@
       ),
     );
 
-    final Widget? prefix = decoration.prefix == null && decoration.prefixText == null ? null :
-      _AffixText(
-        labelIsFloating: widget._labelShouldWithdraw,
-        text: decoration.prefixText,
-        style: MaterialStateProperty.resolveAs(decoration.prefixStyle, materialState) ?? hintStyle,
-        child: decoration.prefix,
-      );
+    final bool hasPrefix = decoration.prefix != null || decoration.prefixText != null;
+    final bool hasSuffix = decoration.suffix != null || decoration.suffixText != null;
 
-    final Widget? suffix = decoration.suffix == null && decoration.suffixText == null ? null :
-      _AffixText(
-        labelIsFloating: widget._labelShouldWithdraw,
-        text: decoration.suffixText,
-        style: MaterialStateProperty.resolveAs(decoration.suffixStyle, materialState) ?? hintStyle,
-        child: decoration.suffix,
-      );
+    Widget? input = widget.child;
+    // If at least two out of the three are visible, it needs semantics sort
+    // order.
+    final bool needsSemanticsSortOrder = widget._labelShouldWithdraw && (input != null ? (hasPrefix || hasSuffix) : (hasPrefix && hasSuffix));
 
+    final Widget? prefix = hasPrefix
+      ? _AffixText(
+          labelIsFloating: widget._labelShouldWithdraw,
+          text: decoration.prefixText,
+          style: MaterialStateProperty.resolveAs(decoration.prefixStyle, materialState) ?? hintStyle,
+          semanticsSortKey: needsSemanticsSortOrder ? _kPrefixSemanticsSortOrder : null,
+          semanticsTag: _kPrefixSemanticsTag,
+          child: decoration.prefix,
+        )
+      : null;
+
+    final Widget? suffix = hasSuffix
+      ? _AffixText(
+          labelIsFloating: widget._labelShouldWithdraw,
+          text: decoration.suffixText,
+          style: MaterialStateProperty.resolveAs(decoration.suffixStyle, materialState) ?? hintStyle,
+          semanticsSortKey: needsSemanticsSortOrder ? _kSuffixSemanticsSortOrder : null,
+          semanticsTag: _kSuffixSemanticsTag,
+          child: decoration.suffix,
+        )
+      : null;
+
+    if (input != null && needsSemanticsSortOrder) {
+      input = Semantics(
+        sortKey: _kInputSemanticsSortOrder,
+        child: input,
+      );
+    }
 
     final bool decorationIsDense = decoration.isDense ?? false;
     final double iconSize = decorationIsDense ? 18.0 : 24.0;
@@ -2272,7 +2334,9 @@
                 color: _getPrefixIconColor(themeData, defaults),
                 size: iconSize,
               ),
-              child: decoration.prefixIcon!,
+              child: Semantics(
+                child: decoration.prefixIcon,
+              ),
             ),
           ),
         ),
@@ -2297,7 +2361,9 @@
                 color: _getSuffixIconColor(themeData, defaults),
                 size: iconSize,
               ),
-              child: decoration.suffixIcon!,
+              child: Semantics(
+                child: decoration.suffixIcon,
+              ),
             ),
           ),
         ),
@@ -2374,7 +2440,7 @@
         isDense: decoration.isDense,
         visualDensity: themeData.visualDensity,
         icon: icon,
-        input: widget.child,
+        input: input,
         label: label,
         hint: hint,
         prefix: prefix,
diff --git a/packages/flutter/lib/src/rendering/object.dart b/packages/flutter/lib/src/rendering/object.dart
index c3aa820..7449016 100644
--- a/packages/flutter/lib/src/rendering/object.dart
+++ b/packages/flutter/lib/src/rendering/object.dart
@@ -3100,6 +3100,10 @@
     if (_cachedSemanticsConfiguration == null) {
       _cachedSemanticsConfiguration = SemanticsConfiguration();
       describeSemanticsConfiguration(_cachedSemanticsConfiguration!);
+      assert(
+        !_cachedSemanticsConfiguration!.explicitChildNodes || _cachedSemanticsConfiguration!.childConfigurationsDelegate == null,
+        'A SemanticsConfiguration with explicitChildNode set to true cannot have a non-null childConfigsDelegate.',
+      );
     }
     return _cachedSemanticsConfiguration!;
   }
@@ -3161,7 +3165,13 @@
 
     final bool wasSemanticsBoundary = _semantics != null && (_cachedSemanticsConfiguration?.isSemanticBoundary ?? false);
     _cachedSemanticsConfiguration = null;
-    bool isEffectiveSemanticsBoundary = _semanticsConfiguration.isSemanticBoundary && wasSemanticsBoundary;
+    // The childConfigurationsDelegate may produce sibling nodes to be attached
+    // to the parent of this semantics node, thus it can't be a semantics
+    // boundary.
+    bool isEffectiveSemanticsBoundary =
+      _semanticsConfiguration.childConfigurationsDelegate == null &&
+      _semanticsConfiguration.isSemanticBoundary &&
+      wasSemanticsBoundary;
     RenderObject node = this;
 
     while (!isEffectiveSemanticsBoundary && node.parent is RenderObject) {
@@ -3213,11 +3223,13 @@
     assert(fragment is _InterestingSemanticsFragment);
     final _InterestingSemanticsFragment interestingFragment = fragment as _InterestingSemanticsFragment;
     final List<SemanticsNode> result = <SemanticsNode>[];
+    final List<SemanticsNode> siblingNodes = <SemanticsNode>[];
     interestingFragment.compileChildren(
       parentSemanticsClipRect: _semantics?.parentSemanticsClipRect,
       parentPaintClipRect: _semantics?.parentPaintClipRect,
       elevationAdjustment: _semantics?.elevationAdjustment ?? 0.0,
       result: result,
+      siblingNodes: siblingNodes,
     );
     final SemanticsNode node = result.single;
     // Fragment only wants to add this node's SemanticsNode to the parent.
@@ -3235,70 +3247,94 @@
     bool dropSemanticsOfPreviousSiblings = config.isBlockingSemanticsOfPreviouslyPaintedNodes;
 
     final bool producesForkingFragment = !config.hasBeenAnnotated && !config.isSemanticBoundary;
-    final List<_InterestingSemanticsFragment> fragments = <_InterestingSemanticsFragment>[];
-    final Set<_InterestingSemanticsFragment> toBeMarkedExplicit = <_InterestingSemanticsFragment>{};
     final bool childrenMergeIntoParent = mergeIntoParent || config.isMergingSemanticsOfDescendants;
-
+    final List<SemanticsConfiguration> childConfigurations = <SemanticsConfiguration>[];
+    final bool explicitChildNode = config.explicitChildNodes || parent is! RenderObject;
+    final bool hasChildConfigurationsDelegate = config.childConfigurationsDelegate != null;
+    final Map<SemanticsConfiguration, _InterestingSemanticsFragment> configToFragment = <SemanticsConfiguration, _InterestingSemanticsFragment>{};
+    final List<_InterestingSemanticsFragment> mergeUpFragments = <_InterestingSemanticsFragment>[];
+    final List<List<_InterestingSemanticsFragment>> siblingMergeFragmentGroups = <List<_InterestingSemanticsFragment>>[];
     visitChildrenForSemantics((RenderObject renderChild) {
       assert(!_needsLayout);
       final _SemanticsFragment parentFragment = renderChild._getSemanticsForParent(
         mergeIntoParent: childrenMergeIntoParent,
       );
       if (parentFragment.dropsSemanticsOfPreviousSiblings) {
-        fragments.clear();
-        toBeMarkedExplicit.clear();
+        childConfigurations.clear();
+        mergeUpFragments.clear();
+        siblingMergeFragmentGroups.clear();
         if (!config.isSemanticBoundary) {
           dropSemanticsOfPreviousSiblings = true;
         }
       }
-      // Figure out which child fragments are to be made explicit.
-      for (final _InterestingSemanticsFragment fragment in parentFragment.interestingFragments) {
-        fragments.add(fragment);
+      for (final _InterestingSemanticsFragment fragment in parentFragment.mergeUpFragments) {
         fragment.addAncestor(this);
         fragment.addTags(config.tagsForChildren);
-        if (config.explicitChildNodes || parent is! RenderObject) {
-          fragment.markAsExplicit();
-          continue;
+        if (hasChildConfigurationsDelegate && fragment.config != null) {
+          // This fragment need to go through delegate to determine whether it
+          // merge up or not.
+          childConfigurations.add(fragment.config!);
+          configToFragment[fragment.config!] = fragment;
+        } else {
+          mergeUpFragments.add(fragment);
         }
-        if (!fragment.hasConfigForParent || producesForkingFragment) {
-          continue;
-        }
-        if (!config.isCompatibleWith(fragment.config)) {
-          toBeMarkedExplicit.add(fragment);
-        }
-        final int siblingLength = fragments.length - 1;
-        for (int i = 0; i < siblingLength; i += 1) {
-          final _InterestingSemanticsFragment siblingFragment = fragments[i];
-          if (!fragment.config!.isCompatibleWith(siblingFragment.config)) {
-            toBeMarkedExplicit.add(fragment);
-            toBeMarkedExplicit.add(siblingFragment);
+      }
+      if (parentFragment is _ContainerSemanticsFragment) {
+        // Container fragments needs to propagate sibling merge group to be
+        // compiled by _SwitchableSemanticsFragment.
+        for (final List<_InterestingSemanticsFragment> siblingMergeGroup in parentFragment.siblingMergeGroups) {
+          for (final _InterestingSemanticsFragment siblingMergingFragment in siblingMergeGroup) {
+            siblingMergingFragment.addAncestor(this);
+            siblingMergingFragment.addTags(config.tagsForChildren);
           }
+          siblingMergeFragmentGroups.add(siblingMergeGroup);
         }
       }
     });
 
-    for (final _InterestingSemanticsFragment fragment in toBeMarkedExplicit) {
-      fragment.markAsExplicit();
+    assert(hasChildConfigurationsDelegate || configToFragment.isEmpty);
+
+    if (explicitChildNode) {
+      for (final _InterestingSemanticsFragment fragment in mergeUpFragments) {
+        fragment.markAsExplicit();
+      }
+    } else if (hasChildConfigurationsDelegate && childConfigurations.isNotEmpty) {
+      final ChildSemanticsConfigurationsResult result = config.childConfigurationsDelegate!(childConfigurations);
+      mergeUpFragments.addAll(
+        result.mergeUp.map<_InterestingSemanticsFragment>((SemanticsConfiguration config) => configToFragment[config]!),
+      );
+      for (final Iterable<SemanticsConfiguration> group in result.siblingMergeGroups) {
+        siblingMergeFragmentGroups.add(
+          group.map<_InterestingSemanticsFragment>((SemanticsConfiguration config) => configToFragment[config]!).toList()
+        );
+      }
     }
 
     _needsSemanticsUpdate = false;
 
-    _SemanticsFragment result;
+    final _SemanticsFragment result;
     if (parent is! RenderObject) {
       assert(!config.hasBeenAnnotated);
       assert(!mergeIntoParent);
+      assert(siblingMergeFragmentGroups.isEmpty);
+      _marksExplicitInMergeGroup(mergeUpFragments, isMergeUp: true);
+      siblingMergeFragmentGroups.forEach(_marksExplicitInMergeGroup);
       result = _RootSemanticsFragment(
         owner: this,
         dropsSemanticsOfPreviousSiblings: dropSemanticsOfPreviousSiblings,
       );
     } else if (producesForkingFragment) {
       result = _ContainerSemanticsFragment(
+        siblingMergeGroups: siblingMergeFragmentGroups,
         dropsSemanticsOfPreviousSiblings: dropSemanticsOfPreviousSiblings,
       );
     } else {
+      _marksExplicitInMergeGroup(mergeUpFragments, isMergeUp: true);
+      siblingMergeFragmentGroups.forEach(_marksExplicitInMergeGroup);
       result = _SwitchableSemanticsFragment(
         config: config,
         mergeIntoParent: mergeIntoParent,
+        siblingMergeGroups: siblingMergeFragmentGroups,
         owner: this,
         dropsSemanticsOfPreviousSiblings: dropSemanticsOfPreviousSiblings,
       );
@@ -3307,12 +3343,34 @@
         fragment.markAsExplicit();
       }
     }
-
-    result.addAll(fragments);
-
+    result.addAll(mergeUpFragments);
     return result;
   }
 
+  void _marksExplicitInMergeGroup(List<_InterestingSemanticsFragment> mergeGroup, {bool isMergeUp = false}) {
+    final Set<_InterestingSemanticsFragment> toBeExplicit = <_InterestingSemanticsFragment>{};
+    for (int i = 0; i < mergeGroup.length; i += 1) {
+      final _InterestingSemanticsFragment fragment = mergeGroup[i];
+      if (!fragment.hasConfigForParent) {
+        continue;
+      }
+      if (isMergeUp && !_semanticsConfiguration.isCompatibleWith(fragment.config)) {
+        toBeExplicit.add(fragment);
+      }
+      final int siblingLength = i;
+      for (int j = 0; j < siblingLength; j += 1) {
+        final _InterestingSemanticsFragment siblingFragment = mergeGroup[j];
+        if (!fragment.config!.isCompatibleWith(siblingFragment.config)) {
+          toBeExplicit.add(fragment);
+          toBeExplicit.add(siblingFragment);
+        }
+      }
+    }
+    for (final _InterestingSemanticsFragment fragment in toBeExplicit) {
+      fragment.markAsExplicit();
+    }
+  }
+
   /// Called when collecting the semantics of this node.
   ///
   /// The implementation has to return the children in paint order skipping all
@@ -3985,8 +4043,9 @@
 ///  * [_ContainerSemanticsFragment]: a container class to transport the semantic
 ///    information of multiple [_InterestingSemanticsFragment] to a parent.
 abstract class _SemanticsFragment {
-  _SemanticsFragment({ required this.dropsSemanticsOfPreviousSiblings })
-    : assert (dropsSemanticsOfPreviousSiblings != null);
+  _SemanticsFragment({
+    required this.dropsSemanticsOfPreviousSiblings,
+  }) : assert (dropsSemanticsOfPreviousSiblings != null);
 
   /// Incorporate the fragments of children into this fragment.
   void addAll(Iterable<_InterestingSemanticsFragment> fragments);
@@ -4002,25 +4061,29 @@
 
   /// Returns [_InterestingSemanticsFragment] describing the actual semantic
   /// information that this fragment wants to add to the parent.
-  List<_InterestingSemanticsFragment> get interestingFragments;
+  List<_InterestingSemanticsFragment> get mergeUpFragments;
 }
 
 /// A container used when a [RenderObject] wants to add multiple independent
 /// [_InterestingSemanticsFragment] to its parent.
 ///
 /// The [_InterestingSemanticsFragment] to be added to the parent can be
-/// obtained via [interestingFragments].
+/// obtained via [mergeUpFragments].
 class _ContainerSemanticsFragment extends _SemanticsFragment {
+  _ContainerSemanticsFragment({
+    required super.dropsSemanticsOfPreviousSiblings,
+    required this.siblingMergeGroups,
+  });
 
-  _ContainerSemanticsFragment({ required super.dropsSemanticsOfPreviousSiblings });
+  final List<List<_InterestingSemanticsFragment>> siblingMergeGroups;
 
   @override
   void addAll(Iterable<_InterestingSemanticsFragment> fragments) {
-    interestingFragments.addAll(fragments);
+    mergeUpFragments.addAll(fragments);
   }
 
   @override
-  final List<_InterestingSemanticsFragment> interestingFragments = <_InterestingSemanticsFragment>[];
+  final List<_InterestingSemanticsFragment> mergeUpFragments = <_InterestingSemanticsFragment>[];
 }
 
 /// A [_SemanticsFragment] that describes which concrete semantic information
@@ -4057,6 +4120,7 @@
     required Rect? parentPaintClipRect,
     required double elevationAdjustment,
     required List<SemanticsNode> result,
+    required List<SemanticsNode> siblingNodes,
   });
 
   /// The [SemanticsConfiguration] the child wants to merge into the parent's
@@ -4086,7 +4150,7 @@
   bool get hasConfigForParent => config != null;
 
   @override
-  List<_InterestingSemanticsFragment> get interestingFragments => <_InterestingSemanticsFragment>[this];
+  List<_InterestingSemanticsFragment> get mergeUpFragments => <_InterestingSemanticsFragment>[this];
 
   Set<SemanticsTag>? _tagsForChildren;
 
@@ -4124,7 +4188,13 @@
   });
 
   @override
-  void compileChildren({ Rect? parentSemanticsClipRect, Rect? parentPaintClipRect, required double elevationAdjustment, required List<SemanticsNode> result }) {
+  void compileChildren({
+    Rect? parentSemanticsClipRect,
+    Rect? parentPaintClipRect,
+    required double elevationAdjustment,
+    required List<SemanticsNode> result,
+    required List<SemanticsNode> siblingNodes,
+  }) {
     assert(_tagsForChildren == null || _tagsForChildren!.isEmpty);
     assert(parentSemanticsClipRect == null);
     assert(parentPaintClipRect == null);
@@ -4150,8 +4220,11 @@
         parentPaintClipRect: parentPaintClipRect,
         elevationAdjustment: 0.0,
         result: children,
+        siblingNodes: siblingNodes,
       );
     }
+    // Root node does not have a parent and thus can't attach sibling nodes.
+    assert(siblingNodes.isEmpty);
     node.updateWith(config: null, childrenInInversePaintOrder: children);
 
     // The root node is the only semantics node allowed to be invisible. This
@@ -4201,9 +4274,11 @@
   _SwitchableSemanticsFragment({
     required bool mergeIntoParent,
     required SemanticsConfiguration config,
+    required List<List<_InterestingSemanticsFragment>> siblingMergeGroups,
     required super.owner,
     required super.dropsSemanticsOfPreviousSiblings,
-  }) : _mergeIntoParent = mergeIntoParent,
+  }) : _siblingMergeGroups = siblingMergeGroups,
+       _mergeIntoParent = mergeIntoParent,
        _config = config,
        assert(mergeIntoParent != null),
        assert(config != null);
@@ -4211,14 +4286,126 @@
   final bool _mergeIntoParent;
   SemanticsConfiguration _config;
   bool _isConfigWritable = false;
+  bool _mergesToSibling = false;
+
+  final List<List<_InterestingSemanticsFragment>> _siblingMergeGroups;
+
+  void _mergeSiblingGroup(Rect? parentSemanticsClipRect, Rect? parentPaintClipRect, List<SemanticsNode> result, Set<int> usedSemanticsIds) {
+    for (final List<_InterestingSemanticsFragment> group in _siblingMergeGroups) {
+      Rect? rect;
+      Rect? semanticsClipRect;
+      Rect? paintClipRect;
+      SemanticsConfiguration? configuration;
+      // Use empty set because the _tagsForChildren may not contains all of the
+      // tags if this fragment is not explicit. The _tagsForChildren are added
+      // to sibling nodes at the end of compileChildren if this fragment is
+      // explicit.
+      final Set<SemanticsTag> tags = <SemanticsTag>{};
+      SemanticsNode? node;
+      for (final _InterestingSemanticsFragment fragment in group) {
+        if (fragment.config != null) {
+          final _SwitchableSemanticsFragment switchableFragment = fragment as _SwitchableSemanticsFragment;
+          switchableFragment._mergesToSibling = true;
+          node ??= fragment.owner._semantics;
+          if (configuration == null) {
+            switchableFragment._ensureConfigIsWritable();
+            configuration = switchableFragment.config;
+          } else {
+            configuration.absorb(switchableFragment.config!);
+          }
+          // It is a child fragment of a _SwitchableFragment, it must have a
+          // geometry.
+          final _SemanticsGeometry geometry = switchableFragment._computeSemanticsGeometry(
+            parentSemanticsClipRect: parentSemanticsClipRect,
+            parentPaintClipRect: parentPaintClipRect,
+          )!;
+          final Rect fragmentRect = MatrixUtils.transformRect(geometry.transform, geometry.rect);
+          if (rect == null) {
+            rect = fragmentRect;
+          } else {
+            rect = rect.expandToInclude(fragmentRect);
+          }
+          if (geometry.semanticsClipRect != null) {
+            final Rect rect = MatrixUtils.transformRect(geometry.transform, geometry.semanticsClipRect!);
+            if (semanticsClipRect == null) {
+              semanticsClipRect = rect;
+            } else {
+              semanticsClipRect = semanticsClipRect.intersect(rect);
+            }
+          }
+          if (geometry.paintClipRect != null) {
+            final Rect rect = MatrixUtils.transformRect(geometry.transform, geometry.paintClipRect!);
+            if (paintClipRect == null) {
+              paintClipRect = rect;
+            } else {
+              paintClipRect = paintClipRect.intersect(rect);
+            }
+          }
+          if (switchableFragment._tagsForChildren != null) {
+            tags.addAll(switchableFragment._tagsForChildren!);
+          }
+        }
+      }
+      // Can be null if all fragments in group are marked as explicit.
+      if (configuration != null && !rect!.isEmpty) {
+        if (node == null || usedSemanticsIds.contains(node.id)) {
+          node = SemanticsNode(showOnScreen: owner.showOnScreen);
+        }
+        usedSemanticsIds.add(node.id);
+        node
+          ..tags = tags
+          ..rect = rect
+          ..transform = null // Will be set when compiling immediate parent node.
+          ..parentSemanticsClipRect = semanticsClipRect
+          ..parentPaintClipRect = paintClipRect;
+        for (final _InterestingSemanticsFragment fragment in group) {
+          if (fragment.config != null) {
+            fragment.owner._semantics = node;
+          }
+        }
+        node.updateWith(config: configuration);
+        result.add(node);
+      }
+    }
+  }
+
   final List<_InterestingSemanticsFragment> _children = <_InterestingSemanticsFragment>[];
 
   @override
-  void compileChildren({ Rect? parentSemanticsClipRect, Rect? parentPaintClipRect, required double elevationAdjustment, required List<SemanticsNode> result }) {
+  void compileChildren({
+    Rect? parentSemanticsClipRect,
+    Rect? parentPaintClipRect,
+    required double elevationAdjustment,
+    required List<SemanticsNode> result,
+    required List<SemanticsNode> siblingNodes,
+  }) {
+    final Set<int> usedSemanticsIds = <int>{};
+    Iterable<_InterestingSemanticsFragment> compilingFragments = _children;
+    for (final List<_InterestingSemanticsFragment> siblingGroup in _siblingMergeGroups) {
+      compilingFragments = compilingFragments.followedBy(siblingGroup);
+    }
     if (!_isExplicit) {
-      owner._semantics = null;
-      for (final _InterestingSemanticsFragment fragment in _children) {
+      if (!_mergesToSibling) {
+        owner._semantics = null;
+      }
+      _mergeSiblingGroup(
+        parentSemanticsClipRect,
+        parentPaintClipRect,
+        siblingNodes,
+        usedSemanticsIds,
+      );
+      for (final _InterestingSemanticsFragment fragment in compilingFragments) {
         assert(_ancestorChain.first == fragment._ancestorChain.last);
+        if (fragment is _SwitchableSemanticsFragment) {
+          // Cached semantics node may be part of sibling merging group prior
+          // to this update. In this case, the semantics node may continue to
+          // be reused in that sibling merging group.
+          if (fragment._isExplicit &&
+              fragment.owner._semantics != null &&
+              usedSemanticsIds.contains(fragment.owner._semantics!.id)) {
+            fragment.owner._semantics = null;
+          }
+        }
         fragment._ancestorChain.addAll(_ancestorChain.skip(1));
         fragment.compileChildren(
           parentSemanticsClipRect: parentSemanticsClipRect,
@@ -4228,14 +4415,16 @@
           // its children are placed at the elevation dictated by this config.
           elevationAdjustment: elevationAdjustment + _config.elevation,
           result: result,
+          siblingNodes: siblingNodes,
         );
       }
       return;
     }
 
-    final _SemanticsGeometry? geometry = _needsGeometryUpdate
-        ? _SemanticsGeometry(parentSemanticsClipRect: parentSemanticsClipRect, parentPaintClipRect: parentPaintClipRect, ancestors: _ancestorChain)
-        : null;
+    final _SemanticsGeometry? geometry = _computeSemanticsGeometry(
+      parentSemanticsClipRect: parentSemanticsClipRect,
+      parentPaintClipRect: parentPaintClipRect,
+    );
 
     if (!_mergeIntoParent && (geometry?.dropFromTree ?? false)) {
       return; // Drop the node, it's not going to be visible.
@@ -4264,22 +4453,66 @@
         _config.isHidden = true;
       }
     }
-
     final List<SemanticsNode> children = <SemanticsNode>[];
-    for (final _InterestingSemanticsFragment fragment in _children) {
+    _mergeSiblingGroup(
+      node.parentSemanticsClipRect,
+      node.parentPaintClipRect,
+      siblingNodes,
+      usedSemanticsIds,
+    );
+    for (final _InterestingSemanticsFragment fragment in compilingFragments) {
+      if (fragment is _SwitchableSemanticsFragment) {
+        // Cached semantics node may be part of sibling merging group prior
+        // to this update. In this case, the semantics node may continue to
+        // be reused in that sibling merging group.
+        if (fragment._isExplicit &&
+            fragment.owner._semantics != null &&
+            usedSemanticsIds.contains(fragment.owner._semantics!.id)) {
+          fragment.owner._semantics = null;
+        }
+      }
+      final List<SemanticsNode> childSiblingNodes = <SemanticsNode>[];
       fragment.compileChildren(
         parentSemanticsClipRect: node.parentSemanticsClipRect,
         parentPaintClipRect: node.parentPaintClipRect,
         elevationAdjustment: 0.0,
         result: children,
+        siblingNodes: childSiblingNodes,
       );
+      siblingNodes.addAll(childSiblingNodes);
     }
+
     if (_config.isSemanticBoundary) {
       owner.assembleSemanticsNode(node, _config, children);
     } else {
       node.updateWith(config: _config, childrenInInversePaintOrder: children);
     }
     result.add(node);
+    // Sibling node needs to attach to the parent of an explicit node.
+    for (final SemanticsNode siblingNode in siblingNodes) {
+      // sibling nodes are in the same coordinate of the immediate explicit node.
+      // They need to share the same transform if they are going to attach to the
+      // parent of the immediate explicit node.
+      assert(siblingNode.transform == null);
+      siblingNode
+        ..transform = node.transform
+        ..isMergedIntoParent = node.isMergedIntoParent;
+      if (_tagsForChildren != null) {
+        siblingNode.tags ??= <SemanticsTag>{};
+        siblingNode.tags!.addAll(_tagsForChildren!);
+      }
+    }
+    result.addAll(siblingNodes);
+    siblingNodes.clear();
+  }
+
+  _SemanticsGeometry? _computeSemanticsGeometry({
+    required Rect? parentSemanticsClipRect,
+    required Rect? parentPaintClipRect,
+  }) {
+    return _needsGeometryUpdate
+      ? _SemanticsGeometry(parentSemanticsClipRect: parentSemanticsClipRect, parentPaintClipRect: parentPaintClipRect, ancestors: _ancestorChain)
+      : null;
   }
 
   @override
diff --git a/packages/flutter/lib/src/semantics/semantics.dart b/packages/flutter/lib/src/semantics/semantics.dart
index bed9eea..041c70d 100644
--- a/packages/flutter/lib/src/semantics/semantics.dart
+++ b/packages/flutter/lib/src/semantics/semantics.dart
@@ -6,6 +6,7 @@
 import 'dart:ui' as ui;
 import 'dart:ui' show Offset, Rect, SemanticsAction, SemanticsFlag, StringAttribute, TextDirection;
 
+import 'package:collection/collection.dart';
 import 'package:flutter/foundation.dart';
 import 'package:flutter/painting.dart' show MatrixUtils, TransformProperty;
 import 'package:flutter/services.dart';
@@ -53,6 +54,20 @@
 /// Used by [SemanticsOwner.onSemanticsUpdate].
 typedef SemanticsUpdateCallback = void Function(ui.SemanticsUpdate update);
 
+/// Signature for the [SemanticsConfiguration.childConfigurationsDelegate].
+///
+/// The input list contains all [SemanticsConfiguration]s that rendering
+/// children want to merge upward. One can tag a render child with a
+/// [SemanticsTag] and look up its [SemanticsConfiguration]s through
+/// [SemanticsConfiguration.tagsChildrenWith].
+///
+/// The return value is the arrangement of these configs, including which
+/// configs continue to merge upward and which configs form sibling merge group.
+///
+/// Use [ChildSemanticsConfigurationsResultBuilder] to generate the return
+/// value.
+typedef ChildSemanticsConfigurationsDelegate = ChildSemanticsConfigurationsResult Function(List<SemanticsConfiguration>);
+
 /// A tag for a [SemanticsNode].
 ///
 /// Tags can be interpreted by the parent of a [SemanticsNode]
@@ -85,6 +100,89 @@
   String toString() => '${objectRuntimeType(this, 'SemanticsTag')}($name)';
 }
 
+/// The result that contains the arrangement for the child
+/// [SemanticsConfiguration]s.
+///
+/// When the [PipelineOwner] builds the semantics tree, it uses the returned
+/// [ChildSemanticsConfigurationsResult] from
+/// [SemanticsConfiguration.childConfigurationsDelegate] to decide how semantics nodes
+/// should form.
+///
+/// Use [ChildSemanticsConfigurationsResultBuilder] to build the result.
+class ChildSemanticsConfigurationsResult {
+  ChildSemanticsConfigurationsResult._(this.mergeUp, this.siblingMergeGroups);
+
+  /// Returns the [SemanticsConfiguration]s that are supposed to be merged into
+  /// the parent semantics node.
+  ///
+  /// [SemanticsConfiguration]s that are either semantics boundaries or are
+  /// conflicting with other [SemanticsConfiguration]s will form explicit
+  /// semantics nodes. All others will be merged into the parent.
+  final List<SemanticsConfiguration> mergeUp;
+
+  /// The groups of child semantics configurations that want to merge together
+  /// and form a sibling [SemanticsNode].
+  ///
+  /// All the [SemanticsConfiguration]s in a given group that are either
+  /// semantics boundaries or are conflicting with other
+  /// [SemanticsConfiguration]s of the same group will be excluded from the
+  /// sibling merge group and form independent semantics nodes as usual.
+  ///
+  /// The result [SemanticsNode]s from the merges are attached as the sibling
+  /// nodes of the immediate parent semantics node. For example, a `RenderObjectA`
+  /// has a rendering child, `RenderObjectB`. If both of them form their own
+  /// semantics nodes, `SemanticsNodeA` and `SemanticsNodeB`, any semantics node
+  /// created from sibling merge groups of `RenderObjectB` will be attach to
+  /// `SemanticsNodeA` as a sibling of `SemanticsNodeB`.
+  final List<List<SemanticsConfiguration>> siblingMergeGroups;
+}
+
+/// The builder to build a [ChildSemanticsConfigurationsResult] based on its
+/// annotations.
+///
+/// To use this builder, one can use [markAsMergeUp] and
+/// [markAsSiblingMergeGroup] to annotate the arrangement of
+/// [SemanticsConfiguration]s. Once all the configs are annotated, use [build]
+/// to generate the [ChildSemanticsConfigurationsResult].
+class ChildSemanticsConfigurationsResultBuilder {
+  /// Creates a [ChildSemanticsConfigurationsResultBuilder].
+  ChildSemanticsConfigurationsResultBuilder();
+
+  final List<SemanticsConfiguration> _mergeUp = <SemanticsConfiguration>[];
+  final List<List<SemanticsConfiguration>> _siblingMergeGroups = <List<SemanticsConfiguration>>[];
+
+  /// Marks the [SemanticsConfiguration] to be merged into the parent semantics
+  /// node.
+  ///
+  /// The [SemanticsConfiguration] will be added to the
+  /// [ChildSemanticsConfigurationsResult.mergeUp] that this builder builds.
+  void markAsMergeUp(SemanticsConfiguration config) => _mergeUp.add(config);
+
+  /// Marks a group of [SemanticsConfiguration]s to merge together
+  /// and form a sibling [SemanticsNode].
+  ///
+  /// The group of [SemanticsConfiguration]s will be added to the
+  /// [ChildSemanticsConfigurationsResult.siblingMergeGroups] that this builder builds.
+  void markAsSiblingMergeGroup(List<SemanticsConfiguration> configs) => _siblingMergeGroups.add(configs);
+
+  /// Builds a [ChildSemanticsConfigurationsResult] contains the arrangement.
+  ChildSemanticsConfigurationsResult build() {
+    assert((){
+      final Set<SemanticsConfiguration> seenConfigs = <SemanticsConfiguration>{};
+      for (final SemanticsConfiguration config in <SemanticsConfiguration>[..._mergeUp, ..._siblingMergeGroups.flattened]) {
+        assert(
+          seenConfigs.add(config),
+          'Duplicated SemanticsConfigurations. This can happen if the same '
+          'SemanticsConfiguration was marked twice in markAsMergeUp and/or '
+          'markAsSiblingMergeGroup'
+        );
+      }
+      return true;
+    }());
+    return ChildSemanticsConfigurationsResult._(_mergeUp, _siblingMergeGroups);
+  }
+}
+
 /// An identifier of a custom semantics action.
 ///
 /// Custom semantics actions can be provided to make complex user
@@ -3724,6 +3822,25 @@
     _onDidLoseAccessibilityFocus = value;
   }
 
+  /// A delegate that decides how to handle [SemanticsConfiguration]s produced
+  /// in the widget subtree.
+  ///
+  /// The [SemanticsConfiguration]s are produced by rendering objects in the
+  /// subtree and want to merge up to their parent. This delegate can decide
+  /// which of these should be merged together to form sibling SemanticsNodes and
+  /// which of them should be merged upwards into the parent SemanticsNode.
+  ///
+  /// The input list of [SemanticsConfiguration]s can be empty if the rendering
+  /// object of this semantics configuration is a leaf node.
+  ChildSemanticsConfigurationsDelegate? get childConfigurationsDelegate => _childConfigurationsDelegate;
+  ChildSemanticsConfigurationsDelegate? _childConfigurationsDelegate;
+  set childConfigurationsDelegate(ChildSemanticsConfigurationsDelegate? value) {
+    assert(value != null);
+    _childConfigurationsDelegate = value;
+    // Setting the childConfigsDelegate does not annotate any meaningful
+    // semantics information of the config.
+  }
+
   /// Returns the action handler registered for [action] or null if none was
   /// registered.
   SemanticsActionHandler? getActionHandler(SemanticsAction action) => _actions[action];
@@ -4448,6 +4565,11 @@
   ///  * [addTagForChildren] to add a tag and for more information about their
   ///    usage.
   Iterable<SemanticsTag>? get tagsForChildren => _tagsForChildren;
+
+  /// Whether this configuration will tag the child semantics nodes with a
+  /// given [SemanticsTag].
+  bool tagsChildrenWith(SemanticsTag tag) => _tagsForChildren?.contains(tag) ?? false;
+
   Set<SemanticsTag>? _tagsForChildren;
 
   /// Specifies a [SemanticsTag] that this configuration wants to apply to all
diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart
index 944e444..1cf7a24 100644
--- a/packages/flutter/test/material/text_field_test.dart
+++ b/packages/flutter/test/material/text_field_test.dart
@@ -4375,6 +4375,47 @@
     expect(prefixText.style, prefixStyle);
   });
 
+  testWidgets('TextField prefix and suffix create a sibling node', (WidgetTester tester) async {
+    final SemanticsTester semantics = SemanticsTester(tester);
+    await tester.pumpWidget(
+      overlay(
+        child: TextField(
+          controller: TextEditingController(text: 'some text'),
+          decoration: const InputDecoration(
+            prefixText: 'Prefix',
+            suffixText: 'Suffix',
+          ),
+        ),
+      ),
+    );
+
+    expect(semantics, hasSemantics(TestSemantics.root(
+      children: <TestSemantics>[
+        TestSemantics.rootChild(
+          id: 2,
+          textDirection: TextDirection.ltr,
+          label: 'Prefix',
+        ),
+        TestSemantics.rootChild(
+          id: 1,
+          textDirection: TextDirection.ltr,
+          value: 'some text',
+          actions: <SemanticsAction>[
+            SemanticsAction.tap,
+          ],
+          flags: <SemanticsFlag>[
+            SemanticsFlag.isTextField,
+          ],
+        ),
+        TestSemantics.rootChild(
+          id: 3,
+          textDirection: TextDirection.ltr,
+          label: 'Suffix',
+        ),
+      ],
+    ), ignoreTransform: true, ignoreRect: true));
+  });
+
   testWidgets('TextField with specified suffixStyle', (WidgetTester tester) async {
     final TextStyle suffixStyle = TextStyle(
       color: Colors.pink[500],
diff --git a/packages/flutter/test/widgets/scrollable_test.dart b/packages/flutter/test/widgets/scrollable_test.dart
index 12b1839..4b5e379 100644
--- a/packages/flutter/test/widgets/scrollable_test.dart
+++ b/packages/flutter/test/widgets/scrollable_test.dart
@@ -1429,6 +1429,51 @@
     handle.dispose();
   });
 
+  testWidgets('Two panel semantics is added to the sibling nodes of direct children', (WidgetTester tester) async {
+    final SemanticsHandle handle = tester.ensureSemantics();
+    final UniqueKey key = UniqueKey();
+    await tester.pumpWidget(MaterialApp(
+      home: Scaffold(
+        body: ListView(
+          key: key,
+          children: const <Widget>[
+            TextField(
+              autofocus: true,
+              decoration: InputDecoration(
+                prefixText: 'prefix',
+              ),
+            ),
+          ],
+        ),
+      ),
+    ));
+    // Wait for focus.
+    await tester.pumpAndSettle();
+
+    final SemanticsNode scrollableNode = tester.getSemantics(find.byKey(key));
+    SemanticsNode? intermediateNode;
+    scrollableNode.visitChildren((SemanticsNode node) {
+      intermediateNode = node;
+      return true;
+    });
+    SemanticsNode? syntheticScrollableNode;
+    intermediateNode!.visitChildren((SemanticsNode node) {
+      syntheticScrollableNode = node;
+      return true;
+    });
+    expect(syntheticScrollableNode!.hasFlag(ui.SemanticsFlag.hasImplicitScrolling), isTrue);
+
+    int numberOfChild = 0;
+    syntheticScrollableNode!.visitChildren((SemanticsNode node) {
+      expect(node.isTagged(RenderViewport.useTwoPaneSemantics), isTrue);
+      numberOfChild += 1;
+      return true;
+    });
+    expect(numberOfChild, 2);
+
+    handle.dispose();
+  });
+
   testWidgets('Scroll inertia cancel event', (WidgetTester tester) async {
     await pumpTest(tester, null);
     await tester.fling(find.byType(Scrollable), const Offset(0.0, -dragOffset), 1000.0);
diff --git a/packages/flutter/test/widgets/semantics_child_configs_delegate_test.dart b/packages/flutter/test/widgets/semantics_child_configs_delegate_test.dart
new file mode 100644
index 0000000..b09c4cd
--- /dev/null
+++ b/packages/flutter/test/widgets/semantics_child_configs_delegate_test.dart
@@ -0,0 +1,259 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/rendering.dart';
+import 'package:flutter/widgets.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import 'semantics_tester.dart';
+
+void main() {
+  testWidgets('Semantics can merge sibling group', (WidgetTester tester) async {
+    final SemanticsTester semantics = SemanticsTester(tester);
+    const SemanticsTag first = SemanticsTag('1');
+    const SemanticsTag second = SemanticsTag('2');
+    const SemanticsTag third = SemanticsTag('3');
+    ChildSemanticsConfigurationsResult delegate(List<SemanticsConfiguration> configs) {
+      expect(configs.length, 3);
+      final ChildSemanticsConfigurationsResultBuilder builder = ChildSemanticsConfigurationsResultBuilder();
+      final List<SemanticsConfiguration> sibling = <SemanticsConfiguration>[];
+      // Merge first and third
+      for (final SemanticsConfiguration config in configs) {
+        if (config.tagsChildrenWith(first) || config.tagsChildrenWith(third)) {
+          sibling.add(config);
+        } else {
+          builder.markAsMergeUp(config);
+        }
+      }
+      builder.markAsSiblingMergeGroup(sibling);
+      return builder.build();
+    }
+    await tester.pumpWidget(
+      Directionality(
+        textDirection: TextDirection.ltr,
+        child: Semantics(
+          label: 'parent',
+          child: TestConfigDelegate(
+            delegate: delegate,
+            child: Column(
+              children: <Widget>[
+                Semantics(
+                  label: '1',
+                  tagForChildren: first,
+                  child: const SizedBox(width: 100, height: 100),
+                  // this tests that empty nodes disappear
+                ),
+                Semantics(
+                  label: '2',
+                  tagForChildren: second,
+                  child: const SizedBox(width: 100, height: 100),
+                ),
+                Semantics(
+                  label: '3',
+                  tagForChildren: third,
+                  child: const SizedBox(width: 100, height: 100),
+                ),
+              ],
+            ),
+          ),
+        ),
+      ),
+    );
+
+    expect(semantics, hasSemantics(TestSemantics.root(
+      children: <TestSemantics>[
+        TestSemantics.rootChild(
+          label: 'parent\n2',
+        ),
+        TestSemantics.rootChild(
+          label: '1\n3',
+        ),
+      ],
+    ), ignoreId: true, ignoreRect: true, ignoreTransform: true));
+  });
+
+  testWidgets('Semantics can drop semantics config', (WidgetTester tester) async {
+    final SemanticsTester semantics = SemanticsTester(tester);
+    const SemanticsTag first = SemanticsTag('1');
+    const SemanticsTag second = SemanticsTag('2');
+    const SemanticsTag third = SemanticsTag('3');
+    ChildSemanticsConfigurationsResult delegate(List<SemanticsConfiguration> configs) {
+      final ChildSemanticsConfigurationsResultBuilder builder = ChildSemanticsConfigurationsResultBuilder();
+      // Merge first and third
+      for (final SemanticsConfiguration config in configs) {
+        if (config.tagsChildrenWith(first) || config.tagsChildrenWith(third)) {
+          continue;
+        }
+        builder.markAsMergeUp(config);
+      }
+      return builder.build();
+    }
+    await tester.pumpWidget(
+      Directionality(
+        textDirection: TextDirection.ltr,
+        child: Semantics(
+          label: 'parent',
+          child: TestConfigDelegate(
+            delegate: delegate,
+            child: Column(
+              children: <Widget>[
+                Semantics(
+                  label: '1',
+                  tagForChildren: first,
+                  child: const SizedBox(width: 100, height: 100),
+                  // this tests that empty nodes disappear
+                ),
+                Semantics(
+                  label: '2',
+                  tagForChildren: second,
+                  child: const SizedBox(width: 100, height: 100),
+                ),
+                Semantics(
+                  label: '3',
+                  tagForChildren: third,
+                  child: const SizedBox(width: 100, height: 100),
+                ),
+              ],
+            ),
+          ),
+        ),
+      ),
+    );
+
+    expect(semantics, hasSemantics(TestSemantics.root(
+      children: <TestSemantics>[
+        TestSemantics.rootChild(
+          label: 'parent\n2',
+        ),
+      ],
+    ), ignoreId: true, ignoreRect: true, ignoreTransform: true));
+  });
+
+  testWidgets('Semantics throws when mark the same config twice case 1', (WidgetTester tester) async {
+    const SemanticsTag first = SemanticsTag('1');
+    const SemanticsTag second = SemanticsTag('2');
+    const SemanticsTag third = SemanticsTag('3');
+    ChildSemanticsConfigurationsResult delegate(List<SemanticsConfiguration> configs) {
+      final ChildSemanticsConfigurationsResultBuilder builder = ChildSemanticsConfigurationsResultBuilder();
+      // Marks the same one twice.
+      builder.markAsMergeUp(configs.first);
+      builder.markAsMergeUp(configs.first);
+      return builder.build();
+    }
+    await tester.pumpWidget(
+      Directionality(
+        textDirection: TextDirection.ltr,
+        child: Semantics(
+          label: 'parent',
+          child: TestConfigDelegate(
+            delegate: delegate,
+            child: Column(
+              children: <Widget>[
+                Semantics(
+                  label: '1',
+                  tagForChildren: first,
+                  child: const SizedBox(width: 100, height: 100),
+                  // this tests that empty nodes disappear
+                ),
+                Semantics(
+                  label: '2',
+                  tagForChildren: second,
+                  child: const SizedBox(width: 100, height: 100),
+                ),
+                Semantics(
+                  label: '3',
+                  tagForChildren: third,
+                  child: const SizedBox(width: 100, height: 100),
+                ),
+              ],
+            ),
+          ),
+        ),
+      ),
+    );
+
+    expect(tester.takeException(), isAssertionError);
+  });
+
+  testWidgets('Semantics throws when mark the same config twice case 2', (WidgetTester tester) async {
+    const SemanticsTag first = SemanticsTag('1');
+    const SemanticsTag second = SemanticsTag('2');
+    const SemanticsTag third = SemanticsTag('3');
+    ChildSemanticsConfigurationsResult delegate(List<SemanticsConfiguration> configs) {
+      final ChildSemanticsConfigurationsResultBuilder builder = ChildSemanticsConfigurationsResultBuilder();
+      // Marks the same one twice.
+      builder.markAsMergeUp(configs.first);
+      builder.markAsSiblingMergeGroup(<SemanticsConfiguration>[configs.first]);
+      return builder.build();
+    }
+    await tester.pumpWidget(
+      Directionality(
+        textDirection: TextDirection.ltr,
+        child: Semantics(
+          label: 'parent',
+          child: TestConfigDelegate(
+            delegate: delegate,
+            child: Column(
+              children: <Widget>[
+                Semantics(
+                  label: '1',
+                  tagForChildren: first,
+                  child: const SizedBox(width: 100, height: 100),
+                  // this tests that empty nodes disappear
+                ),
+                Semantics(
+                  label: '2',
+                  tagForChildren: second,
+                  child: const SizedBox(width: 100, height: 100),
+                ),
+                Semantics(
+                  label: '3',
+                  tagForChildren: third,
+                  child: const SizedBox(width: 100, height: 100),
+                ),
+              ],
+            ),
+          ),
+        ),
+      ),
+    );
+
+    expect(tester.takeException(), isAssertionError);
+  });
+}
+
+class TestConfigDelegate extends SingleChildRenderObjectWidget {
+  const TestConfigDelegate({super.key, required this.delegate, super.child});
+  final ChildSemanticsConfigurationsDelegate delegate;
+
+  @override
+  RenderTestConfigDelegate createRenderObject(BuildContext context) => RenderTestConfigDelegate(
+    delegate: delegate,
+  );
+
+  @override
+  void updateRenderObject(BuildContext context, RenderTestConfigDelegate renderObject) {
+    renderObject.delegate = delegate;
+  }
+}
+
+class RenderTestConfigDelegate extends RenderProxyBox {
+  RenderTestConfigDelegate({
+    ChildSemanticsConfigurationsDelegate? delegate,
+  }) : _delegate = delegate;
+
+  ChildSemanticsConfigurationsDelegate? get delegate => _delegate;
+  ChildSemanticsConfigurationsDelegate? _delegate;
+  set delegate(ChildSemanticsConfigurationsDelegate? value) {
+    if (value != _delegate) {
+      markNeedsSemanticsUpdate();
+    }
+    _delegate = value;
+  }
+
+  @override
+  void describeSemanticsConfiguration(SemanticsConfiguration config) {
+    config.childConfigurationsDelegate = _delegate;
+  }
+}