Skip to content

Commit 1cb003b

Browse files
Fix: Memory leak in UndoHistory widget because it never de-registered itself as global UndoManager client (Resolves flutter#148291) (flutter#150661)
Unsets a global `client` variable that was missed.
1 parent 88e6f62 commit 1cb003b

File tree

2 files changed

+122
-0
lines changed

2 files changed

+122
-0
lines changed

packages/flutter/lib/src/widgets/undo_history.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,10 @@ class UndoHistoryState<T> extends State<UndoHistory<T>> with UndoManagerClient {
199199

200200
void _handleFocus() {
201201
if (!widget.focusNode.hasFocus) {
202+
if (UndoManager.client == this) {
203+
UndoManager.client = null;
204+
}
205+
202206
return;
203207
}
204208
UndoManager.client = this;
@@ -257,6 +261,10 @@ class UndoHistoryState<T> extends State<UndoHistory<T>> with UndoManagerClient {
257261

258262
@override
259263
void dispose() {
264+
if (UndoManager.client == this) {
265+
UndoManager.client = null;
266+
}
267+
260268
widget.value.removeListener(_push);
261269
widget.focusNode.removeListener(_handleFocus);
262270
_effectiveController.onUndo.removeListener(undo);

packages/flutter/test/widgets/undo_history_test.dart

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,120 @@ void main() {
3030
Future<void> sendUndo(WidgetTester tester) => sendUndoRedo(tester);
3131
Future<void> sendRedo(WidgetTester tester) => sendUndoRedo(tester, true);
3232

33+
testWidgets('UndoHistory widget registers as global undo/redo client', (WidgetTester tester) async {
34+
final FocusNode focusNode = FocusNode(debugLabel: 'UndoHistory Node');
35+
final GlobalKey undoHistoryGlobalKey = GlobalKey();
36+
final ValueNotifier<int> value = ValueNotifier<int>(0);
37+
addTearDown(value.dispose);
38+
39+
await tester.pumpWidget(
40+
MaterialApp(
41+
home: UndoHistory<int>(
42+
key: undoHistoryGlobalKey,
43+
value: value,
44+
onTriggered: (_) {},
45+
focusNode: focusNode,
46+
child: Focus(
47+
focusNode: focusNode,
48+
child: Container(),
49+
),
50+
),
51+
),
52+
);
53+
54+
// Initially the UndoHistory doesn't have focus, therefore it should
55+
// not be the global undo/redo client. Ensure that's the case.
56+
expect(UndoManager.client, isNull);
57+
58+
// Give focus to the UndoHistory widget.
59+
focusNode.requestFocus();
60+
await tester.pump();
61+
62+
// Now that the UndoHistory widget has focus, it should have registered
63+
// itself as the global undo/redo client.
64+
final State? undoHistoryState = undoHistoryGlobalKey.currentState;
65+
expect(UndoManager.client, undoHistoryState);
66+
});
67+
68+
testWidgets('UndoHistory widget deregisters as global undo/redo client when it loses focus',
69+
(WidgetTester tester) async {
70+
final FocusNode focusNode = FocusNode(debugLabel: 'UndoHistory Node');
71+
final GlobalKey undoHistoryGlobalKey = GlobalKey();
72+
final ValueNotifier<int> value = ValueNotifier<int>(0);
73+
addTearDown(value.dispose);
74+
75+
await tester.pumpWidget(
76+
MaterialApp(
77+
home: UndoHistory<int>(
78+
key: undoHistoryGlobalKey,
79+
value: value,
80+
onTriggered: (_) {},
81+
focusNode: focusNode,
82+
child: Focus(
83+
focusNode: focusNode,
84+
child: Container(),
85+
),
86+
),
87+
),
88+
);
89+
90+
// Give focus to the UndoHistory widget.
91+
focusNode.requestFocus();
92+
await tester.pump();
93+
94+
// Ensure that UndoHistory is the global undo/redo client.
95+
final State? undoHistoryState = undoHistoryGlobalKey.currentState;
96+
expect(UndoManager.client, undoHistoryState);
97+
98+
// Remove focus from UndoHistory widget.
99+
focusNode.unfocus();
100+
await tester.pump();
101+
102+
// Ensure the UndoHistory widget is no longer the global client
103+
expect(UndoManager.client, null);
104+
});
105+
106+
testWidgets('UndoHistory widget deregisters as global undo/redo client when disposed', (WidgetTester tester) async {
107+
final FocusNode focusNode = FocusNode(debugLabel: 'UndoHistory Node');
108+
final GlobalKey undoHistoryGlobalKey = GlobalKey();
109+
final ValueNotifier<int> value = ValueNotifier<int>(0);
110+
addTearDown(value.dispose);
111+
112+
await tester.pumpWidget(
113+
MaterialApp(
114+
home: UndoHistory<int>(
115+
key: undoHistoryGlobalKey,
116+
value: value,
117+
onTriggered: (_) {},
118+
focusNode: focusNode,
119+
child: Focus(
120+
focusNode: focusNode,
121+
child: Container(),
122+
),
123+
),
124+
),
125+
);
126+
127+
// Give focus to the UndoHistory widget.
128+
focusNode.requestFocus();
129+
await tester.pump();
130+
131+
// Ensure that UndoHistory is the global undo/redo client.
132+
final State? undoHistoryState = undoHistoryGlobalKey.currentState;
133+
expect(UndoManager.client, undoHistoryState);
134+
135+
// Cause the UndoHistory widget to dispose its state.
136+
await tester.pumpWidget(
137+
const MaterialApp(
138+
home: SizedBox(),
139+
),
140+
);
141+
142+
// Ensure that the disposed UndoHistory state is not still the global
143+
// undo/redo history client.
144+
expect(UndoManager.client, isNull);
145+
});
146+
33147
testWidgets('allows undo and redo to be called programmatically from the UndoHistoryController', (WidgetTester tester) async {
34148
final ValueNotifier<int> value = ValueNotifier<int>(0);
35149
addTearDown(value.dispose);

0 commit comments

Comments
 (0)