Add error messages to `_debugCanPerformMutations` (#105638)

diff --git a/packages/flutter/lib/src/rendering/object.dart b/packages/flutter/lib/src/rendering/object.dart
index 3eb9b2b..fd32cbf 100644
--- a/packages/flutter/lib/src/rendering/object.dart
+++ b/packages/flutter/lib/src/rendering/object.dart
@@ -1579,34 +1579,115 @@
     late bool result;
     assert(() {
       if (_debugDisposed) {
-        result = false;
-        return true;
+        throw FlutterError.fromParts(<DiagnosticsNode>[
+          ErrorSummary('A disposed RenderObject was mutated.'),
+          DiagnosticsProperty<RenderObject>(
+            'The disposed RenderObject was',
+            this,
+            style: DiagnosticsTreeStyle.errorProperty,
+          ),
+        ]);
       }
-      if (owner != null && !owner!.debugDoingLayout) {
+
+      final PipelineOwner? owner = this.owner;
+      // Detached nodes are allowed to mutate and the "can perform mutations"
+      // check will be performed when they re-attach. This assert is only useful
+      // during layout.
+      if (owner == null || !owner.debugDoingLayout) {
         result = true;
         return true;
       }
-      RenderObject node = this;
-      while (true) {
-        if (node._doingThisLayoutWithCallback) {
+
+      RenderObject? activeLayoutRoot = this;
+      while (activeLayoutRoot != null) {
+        final bool mutationsToDirtySubtreesAllowed = activeLayoutRoot.owner?._debugAllowMutationsToDirtySubtrees ?? false;
+        final bool doingLayoutWithCallback = activeLayoutRoot._doingThisLayoutWithCallback;
+        // Mutations on this subtree is allowed when:
+        // - the subtree is being mutated in a layout callback.
+        // - a different part of the render tree is doing a layout callback,
+        //   and this subtree is being reparented to that subtree, as a result
+        //   of global key reparenting.
+        if (doingLayoutWithCallback || mutationsToDirtySubtreesAllowed && activeLayoutRoot._needsLayout) {
           result = true;
+          return true;
+        }
+
+        if (!activeLayoutRoot._debugMutationsLocked) {
+          final AbstractNode? p = activeLayoutRoot.parent;
+          activeLayoutRoot = p is RenderObject ? p : null;
+        } else {
+          // activeLayoutRoot found.
           break;
         }
-        if (owner != null && owner!._debugAllowMutationsToDirtySubtrees && node._needsLayout) {
-          result = true;
-          break;
-        }
-        if (node._debugMutationsLocked) {
-          result = false;
-          break;
-        }
-        if (node.parent is! RenderObject) {
-          result = true;
-          break;
-        }
-        node = node.parent! as RenderObject;
       }
-      return true;
+
+      final RenderObject debugActiveLayout = RenderObject.debugActiveLayout!;
+      final String culpritMethodName = debugActiveLayout.debugDoingThisLayout ? 'performLayout' : 'performResize';
+      final String culpritFullMethodName = '${debugActiveLayout.runtimeType}.$culpritMethodName';
+      result = false;
+
+      if (activeLayoutRoot == null) {
+        throw FlutterError.fromParts(<DiagnosticsNode>[
+          ErrorSummary('A $runtimeType was mutated in $culpritFullMethodName.'),
+          ErrorDescription(
+            'The RenderObject was mutated when none of its ancestors is actively performing layout.',
+          ),
+          DiagnosticsProperty<RenderObject>(
+            'The RenderObject being mutated was',
+            this,
+            style: DiagnosticsTreeStyle.errorProperty,
+          ),
+          DiagnosticsProperty<RenderObject>(
+            'The RenderObject that was mutating the said $runtimeType was',
+            debugActiveLayout,
+            style: DiagnosticsTreeStyle.errorProperty,
+          ),
+        ]);
+      }
+
+      if (activeLayoutRoot == this) {
+        throw FlutterError.fromParts(<DiagnosticsNode>[
+          ErrorSummary('A $runtimeType was mutated in its own $culpritMethodName implementation.'),
+          ErrorDescription('A RenderObject must not re-dirty itself while still being laid out.'),
+          DiagnosticsProperty<RenderObject>(
+            'The RenderObject being mutated was',
+            this,
+            style: DiagnosticsTreeStyle.errorProperty,
+          ),
+          ErrorHint('Consider using the LayoutBuilder widget to dynamically change a subtree during layout.'),
+        ]);
+      }
+
+      final ErrorSummary summary = ErrorSummary('A $runtimeType was mutated in $culpritFullMethodName.');
+      final bool isMutatedByAncestor = activeLayoutRoot == debugActiveLayout;
+      final String description = isMutatedByAncestor
+        ? 'A RenderObject must not mutate its descendants in its $culpritMethodName method.'
+        : 'A RenderObject must not mutate another RenderObject from a different render subtree '
+          'in its $culpritMethodName method.';
+
+      throw FlutterError.fromParts(<DiagnosticsNode>[
+        summary,
+        ErrorDescription(description),
+        DiagnosticsProperty<RenderObject>(
+          'The RenderObject being mutated was',
+          this,
+          style: DiagnosticsTreeStyle.errorProperty,
+        ),
+        DiagnosticsProperty<RenderObject>(
+          'The ${isMutatedByAncestor ? 'ancestor ' : ''}RenderObject that was mutating the said $runtimeType was',
+          debugActiveLayout,
+          style: DiagnosticsTreeStyle.errorProperty,
+        ),
+        if (!isMutatedByAncestor) DiagnosticsProperty<RenderObject>(
+          'Their common ancestor was',
+          activeLayoutRoot,
+          style: DiagnosticsTreeStyle.errorProperty,
+        ),
+        ErrorHint(
+          'Mutating the layout of another RenderObject may cause some RenderObjects in its subtree to be laid out more than once. '
+          'Consider using the LayoutBuilder widget to dynamically mutate a subtree during layout.'
+        ),
+      ]);
     }());
     return result;
   }
@@ -1799,6 +1880,7 @@
   /// Only call this if [parent] is not null.
   @protected
   void markParentNeedsLayout() {
+    assert(_debugCanPerformMutations);
     _needsLayout = true;
     assert(this.parent != null);
     final RenderObject parent = this.parent! as RenderObject;
diff --git a/packages/flutter/test/rendering/mutations_test.dart b/packages/flutter/test/rendering/mutations_test.dart
index 3e987b6..7f04768 100644
--- a/packages/flutter/test/rendering/mutations_test.dart
+++ b/packages/flutter/test/rendering/mutations_test.dart
@@ -8,9 +8,12 @@
 import 'rendering_tester.dart';
 
 class RenderLayoutTestBox extends RenderProxyBox {
-  RenderLayoutTestBox(this.onLayout);
+  RenderLayoutTestBox(this.onLayout, {
+    this.onPerformLayout,
+  });
 
   final VoidCallback onLayout;
+  final VoidCallback? onPerformLayout;
 
   @override
   void layout(Constraints constraints, { bool parentUsesSize = false }) {
@@ -27,7 +30,10 @@
   bool get sizedByParent => true;
 
   @override
-  void performLayout() { }
+  void performLayout() {
+    child?.layout(constraints, parentUsesSize: true);
+    onPerformLayout?.call();
+  }
 }
 
 void main() {
@@ -72,4 +78,130 @@
     expect(movedChild1, isFalse);
     expect(movedChild2, isFalse);
   });
+
+  group('Throws when illegal mutations are attempted: ', () {
+    FlutterError catchLayoutError(RenderBox box) {
+      Object? error;
+      layout(box, onErrors: () {
+        error = TestRenderingFlutterBinding.instance.takeFlutterErrorDetails()!.exception;
+      });
+      expect(error, isFlutterError);
+      return error! as FlutterError;
+    }
+
+    test('on disposed render objects', () {
+      final RenderBox box = RenderLayoutTestBox(() {});
+      box.dispose();
+
+      Object? error;
+      try {
+        box.markNeedsLayout();
+      } catch (e) {
+        error = e;
+      }
+
+      expect(error, isFlutterError);
+      expect(
+        (error! as FlutterError).message,
+        equalsIgnoringWhitespace(
+          'A disposed RenderObject was mutated.\n'
+          'The disposed RenderObject was:\n'
+          '${box.toStringShort()}'
+        )
+      );
+    });
+
+    test('marking itself dirty in performLayout', () {
+      late RenderBox child1;
+      final RenderFlex block = RenderFlex(textDirection: TextDirection.ltr);
+      block.add(child1 = RenderLayoutTestBox(() {}, onPerformLayout: () { child1.markNeedsLayout(); }));
+
+      expect(
+        catchLayoutError(block).message,
+        equalsIgnoringWhitespace(
+          'A RenderLayoutTestBox was mutated in its own performLayout implementation.\n'
+          'A RenderObject must not re-dirty itself while still being laid out.\n'
+          'The RenderObject being mutated was:\n'
+          '${child1.toStringShort()}\n'
+          'Consider using the LayoutBuilder widget to dynamically change a subtree during layout.'
+        )
+      );
+    });
+
+    test('marking a sibling dirty in performLayout', () {
+      late RenderBox child1, child2;
+      final RenderFlex block = RenderFlex(textDirection: TextDirection.ltr);
+      block.add(child1 = RenderLayoutTestBox(() {}));
+      block.add(child2 = RenderLayoutTestBox(() {}, onPerformLayout: () { child1.markNeedsLayout(); }));
+
+      expect(
+        catchLayoutError(block).message,
+        equalsIgnoringWhitespace(
+          'A RenderLayoutTestBox was mutated in RenderLayoutTestBox.performLayout.\n'
+          'A RenderObject must not mutate another RenderObject from a different render subtree in its performLayout method.\n'
+          'The RenderObject being mutated was:\n'
+          '${child1.toStringShort()}\n'
+          'The RenderObject that was mutating the said RenderLayoutTestBox was:\n'
+          '${child2.toStringShort()}\n'
+          'Their common ancestor was:\n'
+          '${block.toStringShort()}\n'
+          'Mutating the layout of another RenderObject may cause some RenderObjects in its subtree to be laid out more than once. Consider using the LayoutBuilder widget to dynamically mutate a subtree during layout.'
+        )
+      );
+    });
+
+    test('marking a descendant dirty in performLayout', () {
+      late RenderBox child1;
+      final RenderFlex block = RenderFlex(textDirection: TextDirection.ltr);
+      block.add(child1 = RenderLayoutTestBox(() {}));
+      block.add(RenderLayoutTestBox(child1.markNeedsLayout));
+
+      expect(
+        catchLayoutError(block).message,
+        equalsIgnoringWhitespace(
+          'A RenderLayoutTestBox was mutated in RenderFlex.performLayout.\n'
+          'A RenderObject must not mutate its descendants in its performLayout method.\n'
+          'The RenderObject being mutated was:\n'
+          '${child1.toStringShort()}\n'
+          'The ancestor RenderObject that was mutating the said RenderLayoutTestBox was:\n'
+          '${block.toStringShort()}\n'
+          'Mutating the layout of another RenderObject may cause some RenderObjects in its subtree to be laid out more than once. Consider using the LayoutBuilder widget to dynamically mutate a subtree during layout.'
+        ),
+      );
+    });
+
+    test('marking an out-of-band mutation in performLayout', () {
+      late RenderProxyBox child1, child11, child2, child21;
+      final RenderFlex block = RenderFlex(textDirection: TextDirection.ltr);
+      block.add(child1 = RenderLayoutTestBox(() {}));
+      block.add(child2 = RenderLayoutTestBox(() {}));
+      child1.child = child11 = RenderLayoutTestBox(() {});
+      layout(block);
+
+      expect(block.debugNeedsLayout, false);
+      expect(child1.debugNeedsLayout, false);
+      expect(child11.debugNeedsLayout, false);
+      expect(child2.debugNeedsLayout, false);
+
+      // Add a new child to child2 which is a relayout boundary.
+      child2.child = child21 = RenderLayoutTestBox(() {}, onPerformLayout: child11.markNeedsLayout);
+
+      FlutterError? error;
+      pumpFrame(onErrors: () {
+        error = TestRenderingFlutterBinding.instance.takeFlutterErrorDetails()!.exception as FlutterError;
+      });
+
+      expect(
+        error?.message,
+        equalsIgnoringWhitespace(
+          'A RenderLayoutTestBox was mutated in RenderLayoutTestBox.performLayout.\n'
+          'The RenderObject was mutated when none of its ancestors is actively performing layout.\n'
+          'The RenderObject being mutated was:\n'
+          '${child11.toStringShort()}\n'
+          'The RenderObject that was mutating the said RenderLayoutTestBox was:\n'
+          '${child21.toStringShort()}'
+        ),
+      );
+    });
+  });
 }