Relax `OverlayPortal` asserts (#129053)

Fixes https://github.com/flutter/flutter/issues/129025

Also 
- simplifies OverlayPortal code a bit and adds an assert.
-  `Tooltip` shouldn't rebuild when hiding/showing the tooltip
diff --git a/packages/flutter/lib/src/material/tooltip.dart b/packages/flutter/lib/src/material/tooltip.dart
index ff73b26..045d97d 100644
--- a/packages/flutter/lib/src/material/tooltip.dart
+++ b/packages/flutter/lib/src/material/tooltip.dart
@@ -425,30 +425,27 @@
   // _handleMouseExit. The set is cleared in _handleTapToDismiss, typically when
   // a PointerDown event interacts with some other UI component.
   final Set<int> _activeHoveringPointerDevices = <int>{};
+
+  static bool _isTooltipVisible(AnimationStatus status) {
+    return switch (status) {
+      AnimationStatus.completed || AnimationStatus.forward || AnimationStatus.reverse => true,
+      AnimationStatus.dismissed                                                       => false,
+    };
+  }
+
   AnimationStatus _animationStatus = AnimationStatus.dismissed;
   void _handleStatusChanged(AnimationStatus status) {
     assert(mounted);
-    final bool entryNeedsUpdating;
-    switch (status) {
-      case AnimationStatus.dismissed:
-        entryNeedsUpdating = _animationStatus != AnimationStatus.dismissed;
-        if (entryNeedsUpdating) {
-          Tooltip._openedTooltips.remove(this);
-          _overlayController.hide();
-        }
-      case AnimationStatus.completed:
-      case AnimationStatus.forward:
-      case AnimationStatus.reverse:
-        entryNeedsUpdating = _animationStatus == AnimationStatus.dismissed;
-        if (entryNeedsUpdating) {
-          _overlayController.show();
-          Tooltip._openedTooltips.add(this);
-          SemanticsService.tooltip(_tooltipMessage);
-        }
-    }
-
-    if (entryNeedsUpdating) {
-      setState(() { /* Rebuild to update the OverlayEntry */ });
+    switch ((_isTooltipVisible(_animationStatus), _isTooltipVisible(status))) {
+      case (true, false):
+        Tooltip._openedTooltips.remove(this);
+        _overlayController.hide();
+      case (false, true):
+        _overlayController.show();
+        Tooltip._openedTooltips.add(this);
+        SemanticsService.tooltip(_tooltipMessage);
+      case (true, true) || (false, false):
+        break;
     }
     _animationStatus = status;
   }
@@ -753,10 +750,7 @@
       decoration: widget.decoration ?? tooltipTheme.decoration ?? defaultDecoration,
       textStyle: widget.textStyle ?? tooltipTheme.textStyle ?? defaultTextStyle,
       textAlign: widget.textAlign ?? tooltipTheme.textAlign ?? _defaultTextAlign,
-      animation: CurvedAnimation(
-        parent: _controller,
-        curve: Curves.fastOutSlowIn,
-      ),
+      animation: CurvedAnimation(parent: _controller, curve: Curves.fastOutSlowIn),
       target: target,
       verticalOffset: widget.verticalOffset ?? tooltipTheme.verticalOffset ?? _defaultVerticalOffset,
       preferBelow: widget.preferBelow ?? tooltipTheme.preferBelow ?? _defaultPreferBelow,
diff --git a/packages/flutter/lib/src/painting/geometry.dart b/packages/flutter/lib/src/painting/geometry.dart
index a5f1487..e57d3cf 100644
--- a/packages/flutter/lib/src/painting/geometry.dart
+++ b/packages/flutter/lib/src/painting/geometry.dart
@@ -48,7 +48,7 @@
   // VERTICAL DIRECTION
   final bool fitsBelow = target.dy + verticalOffset + childSize.height <= size.height - margin;
   final bool fitsAbove = target.dy - verticalOffset - childSize.height >= margin;
-  final bool tooltipBelow = preferBelow ? fitsBelow || !fitsAbove : !(fitsAbove || !fitsBelow);
+  final bool tooltipBelow = fitsAbove == fitsBelow ? preferBelow : fitsBelow;
   final double y;
   if (tooltipBelow) {
     y = math.min(target.dy + verticalOffset, size.height - margin);
@@ -56,19 +56,11 @@
     y = math.max(target.dy - verticalOffset - childSize.height, margin);
   }
   // HORIZONTAL DIRECTION
-  final double x;
-  if (size.width - margin * 2.0 < childSize.width) {
-    x = (size.width - childSize.width) / 2.0;
-  } else {
-    final double normalizedTargetX = clampDouble(target.dx, margin, size.width - margin);
-    final double edge = margin + childSize.width / 2.0;
-    if (normalizedTargetX < edge) {
-      x = margin;
-    } else if (normalizedTargetX > size.width - edge) {
-      x = size.width - margin - childSize.width;
-    } else {
-      x = normalizedTargetX - childSize.width / 2.0;
-    }
-  }
+  final double flexibleSpace = size.width - childSize.width;
+  final double x = flexibleSpace <= 2 * margin
+    // If there's not enough horizontal space for margin + child, center the
+    // child.
+    ? flexibleSpace / 2.0
+    : clampDouble(target.dx - childSize.width / 2, margin, flexibleSpace - margin);
   return Offset(x, y);
 }
diff --git a/packages/flutter/lib/src/widgets/overlay.dart b/packages/flutter/lib/src/widgets/overlay.dart
index 8a432ad..c3e71df 100644
--- a/packages/flutter/lib/src/widgets/overlay.dart
+++ b/packages/flutter/lib/src/widgets/overlay.dart
@@ -1500,47 +1500,35 @@
   // used as the slot of the overlay child widget.
   //
   // The developer must call `show` to reveal the overlay so we can get a unique
-  // timestamp of the user interaction for sorting.
+  // timestamp of the user interaction for determining the z-index of the
+  // overlay child in the overlay.
   //
   // Avoid invalidating the cache if possible, since the framework uses `==` to
   // compare slots, and _OverlayEntryLocation can't override that operator since
-  // it's mutable.
+  // it's mutable. Changing slots can be relatively slow.
   bool _childModelMayHaveChanged = true;
   _OverlayEntryLocation? _locationCache;
+  static bool _isTheSameLocation(_OverlayEntryLocation locationCache, _RenderTheaterMarker marker) {
+    return locationCache._childModel == marker.overlayEntryWidgetState
+        && locationCache._theater == marker.theater;
+  }
+
   _OverlayEntryLocation _getLocation(int zOrderIndex, bool targetRootOverlay) {
     final _OverlayEntryLocation? cachedLocation = _locationCache;
-    if (cachedLocation != null && !_childModelMayHaveChanged) {
+    late final _RenderTheaterMarker marker = _RenderTheaterMarker.of(context, targetRootOverlay: targetRootOverlay);
+    final bool isCacheValid = cachedLocation != null
+                           && (!_childModelMayHaveChanged || _isTheSameLocation(cachedLocation, marker));
+    _childModelMayHaveChanged = false;
+    if (isCacheValid) {
       assert(cachedLocation._zOrderIndex == zOrderIndex);
+      assert(cachedLocation._debugIsLocationValid());
       return cachedLocation;
     }
-    _childModelMayHaveChanged = false;
-    final _RenderTheaterMarker? marker = _RenderTheaterMarker.maybeOf(context, targetRootOverlay: targetRootOverlay);
-    if (marker == null) {
-      throw FlutterError.fromParts(<DiagnosticsNode>[
-        ErrorSummary('No Overlay widget found.'),
-        ErrorDescription(
-          '${widget.runtimeType} widgets require an Overlay widget ancestor.\n'
-          'An overlay lets widgets float on top of other widget children.',
-        ),
-        ErrorHint(
-          'To introduce an Overlay widget, you can either directly '
-          'include one, or use a widget that contains an Overlay itself, '
-          'such as a Navigator, WidgetApp, MaterialApp, or CupertinoApp.',
-        ),
-        ...context.describeMissingAncestor(expectedAncestorType: Overlay),
-      ]);
-    }
-    final _OverlayEntryLocation returnValue;
-    if (cachedLocation == null) {
-      returnValue = _OverlayEntryLocation(zOrderIndex, marker.overlayEntryWidgetState, marker.theater);
-    } else if (cachedLocation._childModel != marker.overlayEntryWidgetState || cachedLocation._theater != marker.theater) {
-      cachedLocation._dispose();
-      returnValue = _OverlayEntryLocation(zOrderIndex, marker.overlayEntryWidgetState, marker.theater);
-    } else {
-      returnValue = cachedLocation;
-    }
-    assert(returnValue._zOrderIndex == zOrderIndex);
-    return _locationCache = returnValue;
+    // Otherwise invalidate the cache and create a new location.
+    cachedLocation?._debugMarkLocationInvalid();
+    final _OverlayEntryLocation newLocation = _OverlayEntryLocation(zOrderIndex, marker.overlayEntryWidgetState, marker.theater);
+    assert(newLocation._zOrderIndex == zOrderIndex);
+    return _locationCache = newLocation;
   }
 
   @override
@@ -1582,7 +1570,7 @@
   @override
   void dispose() {
     widget.controller._attachTarget = null;
-    _locationCache?._dispose();
+    _locationCache?._debugMarkLocationInvalid();
     _locationCache = null;
     super.dispose();
   }
@@ -1593,14 +1581,14 @@
       '${widget.controller.runtimeType}.show() should not be called during build.'
     );
     setState(() { _zOrderIndex = zOrderIndex; });
-    _locationCache?._dispose();
+    _locationCache?._debugMarkLocationInvalid();
     _locationCache = null;
   }
 
   void hide() {
     assert(SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks);
     setState(() { _zOrderIndex = null; });
-    _locationCache?._dispose();
+    _locationCache?._debugMarkLocationInvalid();
     _locationCache = null;
   }
 
@@ -1681,7 +1669,7 @@
   }
 
   void _addChild(_RenderDeferredLayoutBox child) {
-    assert(_debugNotDisposed());
+    assert(_debugIsLocationValid());
     _addToChildModel(child);
     _theater._addDeferredChild(child);
     assert(child.parent == _theater);
@@ -1696,7 +1684,7 @@
 
   void _moveChild(_RenderDeferredLayoutBox child, _OverlayEntryLocation fromLocation) {
     assert(fromLocation != this);
-    assert(_debugNotDisposed());
+    assert(_debugIsLocationValid());
     final _RenderTheater fromTheater = fromLocation._theater;
     final _OverlayEntryWidgetState fromModel = fromLocation._childModel;
 
@@ -1712,34 +1700,54 @@
   }
 
   void _activate(_RenderDeferredLayoutBox child) {
-    assert(_debugNotDisposed());
+    // This call is allowed even when this location is invalidated.
+    // See _OverlayPortalElement.activate.
     assert(_overlayChildRenderBox == null, '$_overlayChildRenderBox');
     _theater._adoptDeferredLayoutBoxChild(child);
     _overlayChildRenderBox = child;
   }
 
   void _deactivate(_RenderDeferredLayoutBox child) {
-    assert(_debugNotDisposed());
+    // This call is allowed even when this location is invalidated.
     _theater._dropDeferredLayoutBoxChild(child);
     _overlayChildRenderBox = null;
   }
 
-  bool _debugNotDisposed() {
-    if (_debugDisposedStackTrace == null) {
+  // Throws a StateError if this location is already invalidated and shouldn't
+  // be used as an OverlayPortal slot. Must be used in asserts.
+  //
+  // Generally, `assert(_debugIsLocationValid())` should be used to prevent
+  // invalid accesses to an invalid `_OverlayEntryLocation` object. Exceptions
+  // to this rule are _removeChild, _deactive, which will be called when the
+  // OverlayPortal is being removed from the widget tree and may use the
+  // location information to perform cleanup tasks.
+  //
+  // Another exception is the _activate method which is called by
+  // _OverlayPortalElement.activate. See the comment in _OverlayPortalElement.activate.
+  bool _debugIsLocationValid() {
+    if (_debugMarkLocationInvalidStackTrace == null) {
       return true;
     }
-    throw StateError('$this is already disposed. Stack trace: $_debugDisposedStackTrace');
+    throw StateError('$this is already disposed. Stack trace: $_debugMarkLocationInvalidStackTrace');
   }
 
-  StackTrace? _debugDisposedStackTrace;
+  // The StackTrace of the first _debugMarkLocationInvalid call. It's only for
+  // debugging purposes and the StackTrace will only be captured in debug builds.
+  //
+  // The effect of this method is not reversible. Once marked invalid, this
+  // object can't be marked as valid again.
+  StackTrace? _debugMarkLocationInvalidStackTrace;
   @mustCallSuper
-  void _dispose() {
-    assert(_debugNotDisposed());
+  void _debugMarkLocationInvalid() {
+    assert(_debugIsLocationValid());
     assert(() {
-      _debugDisposedStackTrace = StackTrace.current;
+      _debugMarkLocationInvalidStackTrace = StackTrace.current;
       return true;
     }());
   }
+
+  @override
+  String toString() => '${objectRuntimeType(this, '_OverlayEntryLocation')}[${shortHash(this)}] ${_debugMarkLocationInvalidStackTrace != null ? "(INVALID)":""}';
 }
 
 class _RenderTheaterMarker extends InheritedWidget {
@@ -1758,13 +1766,31 @@
         || oldWidget.overlayEntryWidgetState != overlayEntryWidgetState;
   }
 
-  static _RenderTheaterMarker? maybeOf(BuildContext context, { bool targetRootOverlay = false }) {
+  static _RenderTheaterMarker of(BuildContext context, { bool targetRootOverlay = false }) {
+    final _RenderTheaterMarker? marker;
     if (targetRootOverlay) {
       final InheritedElement? ancestor = _rootRenderTheaterMarkerOf(context.getElementForInheritedWidgetOfExactType<_RenderTheaterMarker>());
       assert(ancestor == null || ancestor.widget is _RenderTheaterMarker);
-      return ancestor != null ? context.dependOnInheritedElement(ancestor) as _RenderTheaterMarker? : null;
+      marker = ancestor != null ? context.dependOnInheritedElement(ancestor) as _RenderTheaterMarker? : null;
+    } else {
+      marker = context.dependOnInheritedWidgetOfExactType<_RenderTheaterMarker>();
     }
-    return context.dependOnInheritedWidgetOfExactType<_RenderTheaterMarker>();
+    if (marker != null) {
+      return marker;
+    }
+    throw FlutterError.fromParts(<DiagnosticsNode>[
+      ErrorSummary('No Overlay widget found.'),
+      ErrorDescription(
+        '${context.widget.runtimeType} widgets require an Overlay widget ancestor.\n'
+        'An overlay lets widgets float on top of other widget children.',
+      ),
+      ErrorHint(
+        'To introduce an Overlay widget, you can either directly '
+        'include one, or use a widget that contains an Overlay itself, '
+        'such as a Navigator, WidgetApp, MaterialApp, or CupertinoApp.',
+      ),
+      ...context.describeMissingAncestor(expectedAncestorType: Overlay),
+    ]);
   }
 
   static InheritedElement? _rootRenderTheaterMarkerOf(InheritedElement? theaterMarkerElement) {
@@ -1792,7 +1818,7 @@
     required this.overlayChild,
     required this.child,
   }) : assert(overlayChild == null || overlayLocation != null),
-       assert(overlayLocation == null || overlayLocation._debugNotDisposed());
+       assert(overlayLocation == null || overlayLocation._debugIsLocationValid());
 
   final Widget? overlayChild;
 
@@ -1863,6 +1889,9 @@
       if (box != null) {
         assert(!box.attached);
         assert(renderObject._deferredLayoutChild == box);
+        // updateChild has not been called at this point so the RenderTheater in
+        // the overlay location could be detached. Adding children to a detached
+        // RenderObject is still allowed however this isn't the most efficient.
         (overlayChild.slot! as _OverlayEntryLocation)._activate(box);
       }
     }
@@ -1872,12 +1901,8 @@
   void deactivate() {
     final Element? overlayChild = _overlayChild;
     // Instead of just detaching the render objects, removing them from the
-    // render subtree entirely such that if the widget gets reparented to a
-    // different overlay entry, the overlay child is inserted in the right
-    // position in the overlay's child list.
-    //
-    // This is also a workaround for the !renderObject.attached assert in the
-    // `RenderObjectElement.deactive()` method.
+    // render subtree entirely. This is a workaround for the
+    // !renderObject.attached assert in the `super.deactive()` method.
     if (overlayChild != null) {
       final _RenderDeferredLayoutBox? box = overlayChild.renderObject as _RenderDeferredLayoutBox?;
       if (box != null) {
@@ -1902,7 +1927,7 @@
   // reparenting between _overlayChild and _child, thus the non-null-typed slots.
   @override
   void moveRenderObjectChild(_RenderDeferredLayoutBox child, _OverlayEntryLocation oldSlot, _OverlayEntryLocation newSlot) {
-    assert(newSlot._debugNotDisposed());
+    assert(newSlot._debugIsLocationValid());
     newSlot._moveChild(child, oldSlot);
   }
 
diff --git a/packages/flutter/test/material/tooltip_test.dart b/packages/flutter/test/material/tooltip_test.dart
index 08140ff..544f883 100644
--- a/packages/flutter/test/material/tooltip_test.dart
+++ b/packages/flutter/test/material/tooltip_test.dart
@@ -2245,6 +2245,39 @@
       reason: 'Tooltip should NOT be visible when hovered and tapped, when trigger mode is tap',
     );
   });
+
+  testWidgetsWithLeakTracking('Tooltip does not rebuild for fade in / fade out animation', (WidgetTester tester) async {
+    await tester.pumpWidget(
+      const MaterialApp(
+        home: Center(
+          child: SizedBox.square(
+            dimension: 10.0,
+            child: Tooltip(
+              message: tooltipText,
+              waitDuration: Duration(seconds: 1),
+              triggerMode: TooltipTriggerMode.longPress,
+              child: SizedBox.expand(),
+            ),
+          ),
+        ),
+      ),
+    );
+    final TooltipState tooltipState = tester.state(find.byType(Tooltip));
+    final Element element = tooltipState.context as Element;
+    // The Tooltip widget itself is almost stateless thus doesn't need
+    // rebuilding.
+    expect(element.dirty, isFalse);
+
+    expect(tooltipState.ensureTooltipVisible(), isTrue);
+    expect(element.dirty, isFalse);
+    await tester.pump(const Duration(seconds: 1));
+    expect(element.dirty, isFalse);
+
+    expect(Tooltip.dismissAllToolTips(), isTrue);
+    expect(element.dirty, isFalse);
+    await tester.pump(const Duration(seconds: 1));
+    expect(element.dirty, isFalse);
+  });
 }
 
 Future<void> setWidgetForTooltipMode(
diff --git a/packages/flutter/test/widgets/overlay_portal_test.dart b/packages/flutter/test/widgets/overlay_portal_test.dart
index e280aeb..1d90ecf 100644
--- a/packages/flutter/test/widgets/overlay_portal_test.dart
+++ b/packages/flutter/test/widgets/overlay_portal_test.dart
@@ -164,6 +164,97 @@
     await tester.pumpWidget(SizedBox(child: widget));
   });
 
+  testWidgets('Safe to hide overlay child and remove OverlayPortal in the same frame', (WidgetTester tester) async {
+    // Regression test for https://github.com/flutter/flutter/issues/129025.
+    final Widget widget = Directionality(
+      key: GlobalKey(debugLabel: 'key'),
+      textDirection: TextDirection.ltr,
+      child: Overlay(
+        initialEntries: <OverlayEntry>[
+          OverlayEntry(
+            builder: (BuildContext context) {
+              return OverlayPortal(
+                controller: controller1,
+                overlayChildBuilder: (BuildContext context) => const SizedBox(),
+                child: const SizedBox(),
+              );
+            },
+          ),
+        ],
+      ),
+    );
+
+    controller1.show();
+    await tester.pumpWidget(widget);
+
+    controller1.hide();
+    await tester.pumpWidget(const SizedBox());
+    expect(tester.takeException(), isNull);
+  });
+
+  testWidgets('Safe to hide overlay child and reparent OverlayPortal in the same frame', (WidgetTester tester) async {
+    final OverlayPortal overlayPortal = OverlayPortal(
+      key: GlobalKey(debugLabel: 'key'),
+      controller: controller1,
+      overlayChildBuilder: (BuildContext context) => const SizedBox(),
+      child: const SizedBox(),
+    );
+
+    List<Widget> children = <Widget>[ const SizedBox(), overlayPortal ];
+
+    late StateSetter setState;
+    final Widget widget = Directionality(
+      textDirection: TextDirection.ltr,
+      child: Overlay(
+        initialEntries: <OverlayEntry>[
+          OverlayStatefulEntry(
+            builder: (BuildContext context, StateSetter setter) {
+              setState = setter;
+              return Column(children: children);
+            },
+          ),
+        ],
+      ),
+    );
+
+    controller1.show();
+    await tester.pumpWidget(widget);
+
+    controller1.hide();
+    setState(() {
+      children = <Widget>[ overlayPortal, const SizedBox() ];
+    });
+    await tester.pumpWidget(widget);
+    expect(tester.takeException(), isNull);
+  });
+
+  testWidgets('Safe to hide overlay child and reparent OverlayPortal in the same frame 2', (WidgetTester tester) async {
+    final Widget widget = Directionality(
+      key: GlobalKey(debugLabel: 'key'),
+      textDirection: TextDirection.ltr,
+      child: Overlay(
+        initialEntries: <OverlayEntry>[
+          OverlayEntry(
+            builder: (BuildContext context) {
+              return OverlayPortal(
+                controller: controller1,
+                overlayChildBuilder: (BuildContext context) => const SizedBox(),
+                child: const SizedBox(),
+              );
+            },
+          ),
+        ],
+      ),
+    );
+
+    controller1.show();
+    await tester.pumpWidget(widget);
+
+    controller1.hide();
+    await tester.pumpWidget(SizedBox(child: widget));
+    expect(tester.takeException(), isNull);
+  });
+
   testWidgets('Throws when the same controller is attached to multiple OverlayPortal', (WidgetTester tester) async {
     final OverlayPortalController controller = OverlayPortalController(debugLabel: 'local controller');
     final Widget widget = Directionality(
@@ -1407,6 +1498,64 @@
         expect(counter2.layoutCount, 3);
       });
     });
+
+    testWidgets('Safe to move the overlay child to a different Overlay and remove the old Overlay', (WidgetTester tester) async {
+      controller1.show();
+      final GlobalKey key = GlobalKey(debugLabel: 'key');
+      final GlobalKey oldOverlayKey = GlobalKey(debugLabel: 'old overlay');
+      final GlobalKey newOverlayKey = GlobalKey(debugLabel: 'new overlay');
+      final GlobalKey overlayChildKey = GlobalKey(debugLabel: 'overlay child key');
+      await tester.pumpWidget(
+        Directionality(
+          textDirection: TextDirection.ltr,
+          child: Overlay(
+            key: oldOverlayKey,
+            initialEntries: <OverlayEntry>[
+              OverlayEntry(
+                builder: (BuildContext context) {
+                  return OverlayPortal(
+                    key: key,
+                    controller: controller1,
+                    overlayChildBuilder: (BuildContext context) => SizedBox(key: overlayChildKey),
+                    child: const SizedBox(),
+                  );
+                },
+              ),
+            ],
+          ),
+        ),
+      );
+
+      expect(find.byKey(overlayChildKey), findsOneWidget);
+      expect(find.byKey(newOverlayKey), findsNothing);
+      expect(find.byKey(oldOverlayKey), findsOneWidget);
+
+      await tester.pumpWidget(
+        Directionality(
+          textDirection: TextDirection.ltr,
+          child: Overlay(
+            key: newOverlayKey,
+            initialEntries: <OverlayEntry>[
+              OverlayEntry(
+                builder: (BuildContext context) {
+                  return OverlayPortal(
+                    key: key,
+                    controller: controller1,
+                    overlayChildBuilder: (BuildContext context) => SizedBox(key: overlayChildKey),
+                    child: const SizedBox(),
+                  );
+                },
+              ),
+            ],
+          ),
+        ),
+      );
+
+      expect(tester.takeException(), isNull);
+      expect(find.byKey(overlayChildKey), findsOneWidget);
+      expect(find.byKey(newOverlayKey), findsOneWidget);
+      expect(find.byKey(oldOverlayKey), findsNothing);
+    });
   });
 
   group('Paint order', () {