Fix: Remove attach target on deactivation of widget from overlay portal controller (#164439)

Fix: Remove attach target on deactivation of widget from overlay portal
controller
fixes: #164376 

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.
This commit is contained in:
Kishan Rathore
2025-03-26 07:46:26 +05:30
committed by GitHub
parent 9b0c5c6c1c
commit 97167ed5a6
2 changed files with 181 additions and 2 deletions

View File

@@ -1920,7 +1920,8 @@ class _OverlayPortalState extends State<OverlayPortal> {
void _setupController(OverlayPortalController controller) {
assert(
controller._attachTarget == null || controller._attachTarget == this,
controller._attachTarget == this ||
!((controller._attachTarget?.context as StatefulElement?)?.debugIsActive ?? false),
'Failed to attach $controller to $this. It is already attached to ${controller._attachTarget}.',
);
final int? controllerZOrderIndex = controller._zOrderIndex;
@@ -1951,8 +1952,13 @@ class _OverlayPortalState extends State<OverlayPortal> {
}
@override
void dispose() {
void activate() {
assert(widget.controller._attachTarget == this);
super.activate();
}
@override
void dispose() {
widget.controller._attachTarget = null;
_locationCache?._debugMarkLocationInvalid();
_locationCache = null;

View File

@@ -1365,6 +1365,179 @@ void main() {
verifyTreeIsClean();
});
testWidgets('PortalController can be assigned to another after deactivate', (
WidgetTester tester,
) async {
final OverlayPortalController controller1 = OverlayPortalController();
final GlobalKey<OverlayState> overlayKey = GlobalKey<OverlayState>();
final OverlayEntry overlayEntry1 = OverlayEntry(
builder: (BuildContext context) {
return OverlayPortal(
controller: controller1,
overlayChildBuilder: (BuildContext context) => const Placeholder(),
);
},
);
final OverlayEntry overlayEntry2 = OverlayEntry(
builder: (BuildContext context) {
return OverlayPortal(
controller: controller1,
overlayChildBuilder: (BuildContext context) => const Placeholder(),
);
},
);
addTearDown(() {
overlayEntry1
..remove()
..dispose();
overlayEntry2.dispose();
});
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Overlay(key: overlayKey, initialEntries: <OverlayEntry>[overlayEntry1]),
),
);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Overlay(key: overlayKey, initialEntries: <OverlayEntry>[overlayEntry2]),
),
);
verifyTreeIsClean();
expect(tester.takeException(), isNull);
});
testWidgets('Reactivation maintains portal state', (WidgetTester tester) async {
final OverlayPortalController controller1 = OverlayPortalController();
final GlobalKey<State<OverlayPortal>> portalKey = GlobalKey<State<OverlayPortal>>();
late OverlayEntry overlayEntry1, overlayEntry2;
addTearDown(() {
overlayEntry1
..remove()
..dispose();
overlayEntry2
..remove()
..dispose();
});
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
overlayEntry1 = OverlayEntry(
builder:
(BuildContext context) => OverlayPortal(
key: portalKey,
controller: controller1,
overlayChildBuilder: (BuildContext context) => const Placeholder(),
),
),
],
),
),
);
controller1.show();
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: SizedBox(
child: Overlay(
initialEntries: <OverlayEntry>[
overlayEntry2 = OverlayEntry(
builder:
(BuildContext context) => OverlayPortal(
key: portalKey,
controller: controller1,
overlayChildBuilder: (BuildContext context) => const Placeholder(),
),
),
],
),
),
),
);
expect(find.byType(Placeholder), findsOneWidget);
expect(controller1.isShowing, equals(true));
});
testWidgets('attachTarget is restored after reparenting', (WidgetTester tester) async {
final GlobalKey<State<OverlayPortal>> portalKey = GlobalKey<State<OverlayPortal>>();
final RenderBox childBox = RenderConstrainedBox(additionalConstraints: const BoxConstraints());
final RenderBox overlayChildBox = RenderConstrainedBox(
additionalConstraints: const BoxConstraints(),
);
bool moveToSecondOverlay = false;
final Widget child = WidgetToRenderBoxAdapter(renderBox: childBox);
final Widget overlayChild = WidgetToRenderBoxAdapter(renderBox: overlayChildBox);
final OverlayEntry overlayEntry1 = OverlayEntry(
builder: (BuildContext context) {
return !moveToSecondOverlay
? OverlayPortal(
key: portalKey,
controller: controller1,
overlayChildBuilder: (BuildContext context) => overlayChild,
child: child,
)
: const SizedBox();
},
);
final OverlayEntry overlayEntry2 = OverlayEntry(
builder: (BuildContext context) {
return moveToSecondOverlay
? OverlayPortal(
key: portalKey,
controller: controller1,
overlayChildBuilder: (BuildContext context) => overlayChild,
child: child,
)
: const SizedBox();
},
);
addTearDown(() {
overlayEntry1
..remove()
..dispose();
overlayEntry2
..remove()
..dispose();
});
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Stack(
children: <Widget>[
Overlay(initialEntries: <OverlayEntry>[overlayEntry1]),
Overlay(initialEntries: <OverlayEntry>[overlayEntry2]),
],
),
),
);
// Move to second overlay
moveToSecondOverlay = true;
overlayEntry1.markNeedsBuild();
overlayEntry2.markNeedsBuild();
await tester.pump();
verifyTreeIsClean();
});
group('GlobalKey Reparenting', () {
testWidgets('child is laid out before overlay child after OverlayEntry shuffle', (
WidgetTester tester,