From ecaee5c2493716c082bd0d6364f41c7b39d13fb0 Mon Sep 17 00:00:00 2001 From: Mike Allen Date: Tue, 21 Jul 2026 10:26:03 -0700 Subject: [PATCH 1/3] fix: make tooltip render-object registry per-instance to stop hit-test crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tooltip child layout was coordinated through a process-wide static map, `RenderObjectManager.renderObjects`, keyed only by TooltipLayoutSlot (tooltipBox / actionBox / arrow). `_RenderPositionDelegate.performLayout` registers its children into that map, lays them out through it, then clears it. When two tooltips are alive at the same time — e.g. one dismissing while the next starts, or a lingering overlay — their layouts share the one map and clobber each other's slots. With asserts disabled (release), a clobbered delegate skips laying out its own children yet still sizes itself to `constraints.biggest` (the full screen), so the next pointer event hit-tests a never-laid-out child and crashes in RenderBox.hitTest ("Cannot hit test a render box that has never been laid out", `_size!`). Two nested `_RenderPositionDelegate.hitTestChildren` frames in the crash stack are the signature of two simultaneously-live tooltips. Fixes: - Move the slot->RenderObjectManager map onto each `_RenderPositionDelegate` instance (`_renderObjects` + `_managerFor`), so two delegates can never see or overwrite each other's children. Drops the `static renderObjects` map, `static clear()`, and the `TooltipLayoutSlot.getObjectManager` extension. - Harden `_RenderPositionDelegate.hitTestChildren` to skip any child that was never laid out (`child.hasSize`). This mirrors `defaultHitTestChildren`; in a healthy single-tooltip layout every child has a size so it is a no-op, and it turns any residual never-laid-out hit test into a harmless miss instead of a crash. Related to the two-simultaneous-showcases family in issue #563. --- lib/src/tooltip/render_object_manager.dart | 19 ++-- lib/src/tooltip/render_position_delegate.dart | 103 ++++++++++++------ lib/src/utils/enum.dart | 4 - 3 files changed, 82 insertions(+), 44 deletions(-) diff --git a/lib/src/tooltip/render_object_manager.dart b/lib/src/tooltip/render_object_manager.dart index a05b69e8..02806646 100644 --- a/lib/src/tooltip/render_object_manager.dart +++ b/lib/src/tooltip/render_object_manager.dart @@ -46,23 +46,24 @@ class RenderObjectManager { /// relative to the target widget. RenderObjectManager({ required this.customRenderBox, - required TooltipLayoutSlot slot, - }) { - renderObjects[slot] = this; - } + required this.slot, + }); final RenderBox customRenderBox; + + /// The layout slot this manager owns. Previously used to register into a + /// process-wide static `renderObjects` map; that map is now per-delegate + /// instance state on `_RenderPositionDelegate` so two simultaneously-live + /// tooltips can never clobber each other's slots (see the fork note in + /// render_position_delegate.dart). + final TooltipLayoutSlot slot; + BoxConstraints? renderConstraints; Size? dryLayoutSize; double? height; double? xOffset; double? yOffset; - static Map renderObjects = {}; - - /// Clears renderObjects map. - static void clear() => renderObjects.clear(); - /// Performs dry layout to calculate the preferred size without actually /// laying out. Size performDryLayout(BoxConstraints constraints) { diff --git a/lib/src/tooltip/render_position_delegate.dart b/lib/src/tooltip/render_position_delegate.dart index 90edc9c4..108591d0 100644 --- a/lib/src/tooltip/render_position_delegate.dart +++ b/lib/src/tooltip/render_position_delegate.dart @@ -86,10 +86,50 @@ class _RenderPositionDelegate extends RenderBox child.parentData = MultiChildLayoutParentData(); } + /// Per-instance registry of this delegate's child render objects, keyed by + /// layout slot. + /// + /// FORK NOTE: upstream 5.1.0 stored this in a process-wide `static` map + /// (`RenderObjectManager.renderObjects`). Two tooltips that were live at the + /// same time — e.g. one being dismissed while the next one starts, or a + /// lingering overlay — shared that one map and clobbered each other's slots + /// during `performLayout`. With asserts off (release), a clobbered delegate + /// would skip laying out its own children yet still size itself to + /// `constraints.biggest` (full screen), so the next pointer event hit-tested + /// a never-laid-out child and crashed in `RenderBox.hitTest` (`_size!`). + /// Making the map instance state removes the shared coupling entirely. + final Map _renderObjects = {}; + + /// Returns the manager for [slot] among this delegate's own children. + RenderObjectManager? _managerFor(TooltipLayoutSlot slot) => + _renderObjects[slot]; + @override bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { - // Standard hit testing implementation for children - return defaultHitTestChildren(result, position: position); + // Defensive guard mirroring `defaultHitTestChildren`, but skipping any + // child that was never laid out. `RenderBox.hitTest` dereferences `size` + // (`_size!`) and throws if the child has no size. In a healthy + // single-tooltip layout every child has a size, so this behaves exactly + // like the default; it only matters during a transient overlap, where it + // turns a hard crash into a harmless missed hit on a tooltip that is on + // its way out anyway. + var child = lastChild; + while (child != null) { + final childParentData = child.parentData! as MultiChildLayoutParentData; + if (child.hasSize) { + final isHit = result.addWithPaintOffset( + offset: childParentData.offset, + position: position, + hitTest: (BoxHitTestResult result, Offset transformed) { + assert(transformed == position - childParentData.offset); + return child!.hitTest(result, position: transformed); + }, + ); + if (isHit) return true; + } + child = childParentData.previousSibling; + } + return false; } // Layout properties - keep only those not managed by RenderObjectManager @@ -108,22 +148,22 @@ class _RenderPositionDelegate extends RenderBox var _minimumActionBoxSize = Size.zero; Size get _toolTipBoxSize => - TooltipLayoutSlot.tooltipBox.getObjectManager?.size ?? Size.zero; + _managerFor(TooltipLayoutSlot.tooltipBox)?.size ?? Size.zero; Size get _actionBoxSize => - TooltipLayoutSlot.actionBox.getObjectManager?.size ?? Size.zero; + _managerFor(TooltipLayoutSlot.actionBox)?.size ?? Size.zero; double get _xOffset => - TooltipLayoutSlot.tooltipBox.getObjectManager?.xOffset ?? 0.0; + _managerFor(TooltipLayoutSlot.tooltipBox)?.xOffset ?? 0.0; set _xOffset(double value) => - TooltipLayoutSlot.tooltipBox.getObjectManager?.xOffset = value; + _managerFor(TooltipLayoutSlot.tooltipBox)?.xOffset = value; double get _yOffset => - TooltipLayoutSlot.tooltipBox.getObjectManager?.yOffset ?? 0.0; + _managerFor(TooltipLayoutSlot.tooltipBox)?.yOffset ?? 0.0; set _yOffset(double value) => - TooltipLayoutSlot.tooltipBox.getObjectManager?.yOffset = value; + _managerFor(TooltipLayoutSlot.tooltipBox)?.yOffset = value; double get _getArrowPadding => hasArrow ? Constants.withArrowToolTipPadding @@ -163,7 +203,7 @@ class _RenderPositionDelegate extends RenderBox _performFinalChildLayout(); // Cleanup - RenderObjectManager.clear(); + _renderObjects.clear(); } /// Initialize layout variables and set size @@ -193,7 +233,8 @@ class _RenderPositionDelegate extends RenderBox continue; } - RenderObjectManager(customRenderBox: child, slot: parentId); + _renderObjects[parentId] = + RenderObjectManager(customRenderBox: child, slot: parentId); child = childParentData.nextSibling; } } @@ -201,7 +242,7 @@ class _RenderPositionDelegate extends RenderBox /// Perform dry layout to determine natural sizes for all children void _performDryLayout() { // Dry layout arrow - TooltipLayoutSlot.arrow.getObjectManager?.performDryLayout( + _managerFor(TooltipLayoutSlot.arrow)?.performDryLayout( const BoxConstraints.tightFor( width: Constants.arrowWidth, height: Constants.arrowHeight, @@ -210,23 +251,23 @@ class _RenderPositionDelegate extends RenderBox // Dry layout main tooltip content with tight constraints to get natural size // Using tightFor() with no parameters allows content to size naturally - TooltipLayoutSlot.tooltipBox.getObjectManager?.performDryLayout( + _managerFor(TooltipLayoutSlot.tooltipBox)?.performDryLayout( const BoxConstraints(), ); // If content exceeds available width, constrain it if (_toolTipBoxSize.width > _availableScreenWidth) { - TooltipLayoutSlot.tooltipBox.getObjectManager?.performDryLayout( + _managerFor(TooltipLayoutSlot.tooltipBox)?.performDryLayout( BoxConstraints(maxWidth: _availableScreenWidth), ); } // Dry layout action box (if exists) with same strategy - TooltipLayoutSlot.actionBox.getObjectManager?.performDryLayout( + _managerFor(TooltipLayoutSlot.actionBox)?.performDryLayout( const BoxConstraints(), ); if (_actionBoxSize.width > _availableScreenWidth) { - TooltipLayoutSlot.actionBox.getObjectManager?.performDryLayout( + _managerFor(TooltipLayoutSlot.actionBox)?.performDryLayout( BoxConstraints(maxWidth: _availableScreenWidth), ); } @@ -236,8 +277,8 @@ class _RenderPositionDelegate extends RenderBox /// Normalize widths between tooltip and action box void _normalizeWidths() { // Make both boxes the same width (use the wider one) - final tooltipBoxManager = TooltipLayoutSlot.tooltipBox.getObjectManager; - final actionBoxManager = TooltipLayoutSlot.actionBox.getObjectManager; + final tooltipBoxManager = _managerFor(TooltipLayoutSlot.tooltipBox); + final actionBoxManager = _managerFor(TooltipLayoutSlot.actionBox); if (actionBoxManager == null || tooltipBoxManager == null) return; @@ -259,12 +300,12 @@ class _RenderPositionDelegate extends RenderBox /// Calculate the total tooltip height including all components double _calculateTooltipHeight() { - final tooltipBoxManager = TooltipLayoutSlot.tooltipBox.getObjectManager; + final tooltipBoxManager = _managerFor(TooltipLayoutSlot.tooltipBox); if (tooltipBoxManager == null) return 0; var tooltipHeight = tooltipBoxManager.size.height; if (hasSecondBox) { - if (TooltipLayoutSlot.actionBox.getObjectManager + if (_managerFor(TooltipLayoutSlot.actionBox) case final actionBoxManager?) { tooltipHeight += actionBoxManager.size.height + gapBetweenContentAndAction; @@ -283,7 +324,7 @@ class _RenderPositionDelegate extends RenderBox /// Calculate initial tooltip position void _calculateInitialPosition() { - final tooltipBoxManager = TooltipLayoutSlot.tooltipBox.getObjectManager; + final tooltipBoxManager = _managerFor(TooltipLayoutSlot.tooltipBox); if (tooltipBoxManager == null) return; final initialPosition = positionToolTip( @@ -308,7 +349,7 @@ class _RenderPositionDelegate extends RenderBox /// Handle horizontal screen boundary constraints void _handleHorizontalBoundaries(double tooltipHeight) { - final tooltipBoxManager = TooltipLayoutSlot.tooltipBox.getObjectManager; + final tooltipBoxManager = _managerFor(TooltipLayoutSlot.tooltipBox); if (tooltipBoxManager == null) return; final offset = tooltipBoxManager.getOffset; @@ -471,7 +512,7 @@ class _RenderPositionDelegate extends RenderBox /// Recalculate max height based on new width constraints void _recalculateMaxHeight() { - _maxHeight = TooltipLayoutSlot.tooltipBox.getObjectManager?.customRenderBox + _maxHeight = _managerFor(TooltipLayoutSlot.tooltipBox)?.customRenderBox .getDryLayout(BoxConstraints.tightFor(width: _maxWidth)) .height ?? 0; @@ -570,7 +611,7 @@ class _RenderPositionDelegate extends RenderBox /// Handle resizing if needed void _handleResizing() { if (!_needToResize || - TooltipLayoutSlot.tooltipBox.getObjectManager == null) { + _managerFor(TooltipLayoutSlot.tooltipBox) == null) { return; } @@ -581,12 +622,12 @@ class _RenderPositionDelegate extends RenderBox } // Resize tooltip box - TooltipLayoutSlot.tooltipBox.getObjectManager?.performLayout( + _managerFor(TooltipLayoutSlot.tooltipBox)?.performLayout( BoxConstraints.tightFor(width: _maxWidth, height: tooltipBoxHeight), ); // Resize action box if exists - TooltipLayoutSlot.actionBox.getObjectManager?.performLayout( + _managerFor(TooltipLayoutSlot.actionBox)?.performLayout( BoxConstraints.tightFor(width: _maxWidth), ); @@ -704,7 +745,7 @@ class _RenderPositionDelegate extends RenderBox /// Layout the arrow element void _layoutArrowElement() { - TooltipLayoutSlot.arrow.getObjectManager?.performLayout( + _managerFor(TooltipLayoutSlot.arrow)?.performLayout( const BoxConstraints.tightFor( width: Constants.arrowWidth, height: Constants.arrowHeight, @@ -714,7 +755,7 @@ class _RenderPositionDelegate extends RenderBox /// Layout the tooltip content box void _layoutTooltipBox() { - TooltipLayoutSlot.tooltipBox.getObjectManager?.performLayout( + _managerFor(TooltipLayoutSlot.tooltipBox)?.performLayout( BoxConstraints.tightFor( width: _toolTipBoxSize.width, height: _toolTipBoxSize.height, @@ -723,13 +764,13 @@ class _RenderPositionDelegate extends RenderBox // Position the tooltip content box final firstBoxParentData = - TooltipLayoutSlot.tooltipBox.getObjectManager?.layoutParentData; + _managerFor(TooltipLayoutSlot.tooltipBox)?.layoutParentData; firstBoxParentData?.offset = Offset(_xOffset, _yOffset); } /// Layout the action box void _layoutActionBox() { - TooltipLayoutSlot.actionBox.getObjectManager?.performLayout( + _managerFor(TooltipLayoutSlot.actionBox)?.performLayout( BoxConstraints.tightFor( width: _actionBoxSize.width, height: _actionBoxSize.height, @@ -737,7 +778,7 @@ class _RenderPositionDelegate extends RenderBox ); // Position the action box differently based on tooltip direction - TooltipLayoutSlot.actionBox.getObjectManager?.layoutParentData.offset = + _managerFor(TooltipLayoutSlot.actionBox)?.layoutParentData.offset = Offset( _xOffset, tooltipPosition.isTop @@ -751,7 +792,7 @@ class _RenderPositionDelegate extends RenderBox /// Position the arrow element void _positionArrow() { final arrowBoxParentData = - TooltipLayoutSlot.arrow.getObjectManager?.layoutParentData; + _managerFor(TooltipLayoutSlot.arrow)?.layoutParentData; if (!hasArrow || arrowBoxParentData == null) return; const halfArrowWidth = Constants.arrowWidth * 0.5; diff --git a/lib/src/utils/enum.dart b/lib/src/utils/enum.dart index a537264d..2af958cc 100644 --- a/lib/src/utils/enum.dart +++ b/lib/src/utils/enum.dart @@ -25,7 +25,6 @@ import 'dart:math'; import 'package:flutter/widgets.dart'; import '../showcase/showcase_view.dart'; -import '../tooltip/render_object_manager.dart'; /// Defines the position of a tooltip relative to its target widget in the /// showcase. @@ -244,9 +243,6 @@ enum TooltipLayoutSlot { /// This component is a small triangle that visually connects the tooltip to /// its target widget. arrow; - - RenderObjectManager? get getObjectManager => - RenderObjectManager.renderObjects[this]; } /// Defines the progress of the showcase widgets. From c7291240c2dab1c5a369c7995bd1cc1924170463 Mon Sep 17 00:00:00 2001 From: Mike Allen Date: Fri, 2 Oct 2026 22:30:51 -0700 Subject: [PATCH 2/3] fix: don't create an overlay entry when there is no overlay to insert it into An entry created while overlayState is null was never inserted but still counted as showing, so the next hide called OverlayEntry.remove() on an entry with no overlay and the _overlay! null check threw. --- lib/src/utils/overlay_manager.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/src/utils/overlay_manager.dart b/lib/src/utils/overlay_manager.dart index 76646eb6..8e35fddf 100644 --- a/lib/src/utils/overlay_manager.dart +++ b/lib/src/utils/overlay_manager.dart @@ -111,9 +111,15 @@ class OverlayManager { _rebuild(); return; } + // With no overlay to insert into, create nothing: an entry that was never + // inserted would count as showing, and hiding it would call + // `OverlayEntry.remove()` on an entry that has no overlay, which throws. + // The next update tries again. + final overlay = overlayState; + if (overlay == null) return; // Create the overlay. _overlayEntry = OverlayEntry(builder: overlayBuilder); - overlayState?.insert(_overlayEntry!); + overlay.insert(_overlayEntry!); } /// Removes and clears the current overlay entry. From 2c1b33c9b684875f5d24ab9a606f2b5719a94f61 Mon Sep 17 00:00:00 2001 From: Mike Allen Date: Sat, 3 Oct 2026 12:35:54 -0700 Subject: [PATCH 3/3] fix: don't insert into an overlay that is no longer mounted Inserting calls setState on the overlay's state, which throws once it has been disposed. --- lib/src/utils/overlay_manager.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/src/utils/overlay_manager.dart b/lib/src/utils/overlay_manager.dart index 8e35fddf..ec0ee58e 100644 --- a/lib/src/utils/overlay_manager.dart +++ b/lib/src/utils/overlay_manager.dart @@ -114,9 +114,10 @@ class OverlayManager { // With no overlay to insert into, create nothing: an entry that was never // inserted would count as showing, and hiding it would call // `OverlayEntry.remove()` on an entry that has no overlay, which throws. - // The next update tries again. + // An overlay whose route has gone is no better: inserting calls setState + // on a disposed state. The next update tries again. final overlay = overlayState; - if (overlay == null) return; + if (overlay == null || !overlay.mounted) return; // Create the overlay. _overlayEntry = OverlayEntry(builder: overlayBuilder); overlay.insert(_overlayEntry!);