Repository navigation
Description
Steps to reproduce
Repeated nonclosing drags on a modal bottom sheet leave obsolete CurvedAnimation helpers subscribed to the route animation. They accumulate while the sheet is open. After the sheet is dismissed and its state is disposed, the subscriptions also remain if the application keeps its caller-owned transitionAnimationController alive.
The affected framework code is _ModalBottomSheetState in bottom_sheet.dart. The reproduction uses package:flutter/material.dart and the framework implementation in that checkout.
A caller-owned controller may be retained for its host widget's lifetime and disposed when that widget is removed. Controller ownership does not require retaining helpers belonging to an already-disposed sheet. If the route owns and eventually disposes its controller, disposal releases these subscriptions; the accumulation while the sheet is open still occurs.
Ordinary usage that triggers the problem passes a host-owned controller and repeatedly performs short drags that return the sheet to its open position:
showModalBottomSheet<void>(
context: context,
transitionAnimationController: controller,
builder: (BuildContext context) =>
const SizedBox(height: 300, child: Text('Bottom sheet')),
);Automated demo
The single-file demo has one Run 200 cycles button. It opens the sheet, runs 200 actual library callback cycles, closes the sheet automatically, and prints numeric readings before, during, and after the run. The application keeps its controller alive after closing and releases it before the next run.
The fast loop invokes the actual BottomSheet callbacks:
for (var i = 1; i <= 200; i++) {
sheet.onDragStart!(DragStartDetails());
sheet.onDragEnd!(DragEndDetails(primaryVelocity: 0), isClosing: false);
}This bypasses pointer recognition and gesture animation waiting. These are callback cycles, not 200 simulated user gestures.
To make the retention visible quickly, the demo deliberately attaches a Uint8List(1000000) test buffer to each forwarded status subscription. The callback captures and accesses that buffer; unregistering the callback releases the subscription's ownership of it. The printed MB value is the number of registered subscriptions multiplied by 1,000,000 bytes.
The 1 MB buffers are deliberate amplification. Ordinary drags do not leak 1 MB each. The readings describe test buffers still owned by registered callbacks; they are not a measured browser heap, total Chrome memory, or proof that garbage collection has already reclaimed released buffers.
Run the reproduction
On Windows, use PowerShell and Git. Clone the branch and check out the demo commit:
git clone --single-branch --branch demo-modal-bottom-sheet-memory https://github.com/hangy233/flutter.git flutter-sheet-leak
cd flutter-sheet-leak
git checkout --detach 540676e356949938eff22c70ade2186b1e14bb2b
.\bin\flutter.bat pub get
cd dev\manual_tests
..\..\bin\flutter.bat run -d web-server --web-hostname 127.0.0.1 --web-port 8768 -t lib/bottom_sheet_memory.dartThe first Flutter command initializes this checkout's SDK and downloads its dependencies. Open http://127.0.0.1:8768, select Run 200 cycles, and wait for After close in the log.
Stop the server with q before switching commits. From dev/manual_tests, run the same demo with the fix:
cd ..\..
git checkout --detach eaa927e74927edc5033df9ba06e2edbdfe074542
.\bin\flutter.bat pub get
cd dev\manual_tests
..\..\bin\flutter.bat run -d web-server --web-hostname 127.0.0.1 --web-port 8768 -t lib/bottom_sheet_memory.dartRefresh the browser and select Run 200 cycles again. Use bin/flutter.bat from this checkout so the selected commit's framework is compiled. No PACKAGE_FIXED flag, package override, or patch-preparation script is involved; the second commit changes the framework source directly.
Observed readings
Native widget testing of the same demo produced:
| Stage | Demo commit: listeners / test buffers | Fix commit: listeners / test buffers |
|---|---|---|
| Before cycles | 2 / 2 MB | 2 / 2 MB |
| After 200 cycles, while open | 202 / 202 MB | 3 / 3 MB |
| After closing | 200 / 200 MB | 0 / 0 MB |
The ordinary regression test passes with no drags on the original framework, but fails after three real nonclosing drags because the caller-owned controller is still listening after dismissal. Both cases pass after the fix.
Cause and proposed fix
handleDragEnd creates a new CurvedAnimation and assigns it to _sheetAnimation.parent. Its constructor registers a status listener on its parent animation. Rebinding the ProxyAnimation detaches the proxy's listeners, but does not dispose the outgoing curve's own status subscription. The state only stores and disposes its initial _curvedSheetAnimation.
The fix commit retains a reference to the current drag-end curve, disposes it after detaching on the next drag start, and also disposes it when the sheet state is removed:
CurvedAnimation? _dragEndCurvedAnimation;
void handleDragStart(DragStartDetails details) {
_sheetAnimation.parent = widget.route.animation;
_dragEndCurvedAnimation?.dispose();
_dragEndCurvedAnimation = null;
}
void handleDragEnd(DragEndDetails details, {bool? isClosing}) {
final double currentProgress = widget.route.animation!.value;
_dragEndCurvedAnimation = CurvedAnimation(
parent: widget.route.animation!,
curve: Split(
currentProgress,
endCurve: widget.animationStyle?.curve ?? _kModalBottomSheetCurve,
),
reverseCurve: Split(
currentProgress,
endCurve: widget.animationStyle?.reverseCurve ?? _kModalBottomSheetCurve,
),
);
_sheetAnimation.parent = _dragEndCurvedAnimation;
}
@override
void dispose() {
_sheetAnimation.parent = kAlwaysDismissedAnimation;
_dragEndCurvedAnimation?.dispose();
_curvedSheetAnimation.dispose();
super.dispose();
}Expected results
Expected: obsolete drag-end helpers should be disposed when replaced. After the exit animation completes and the sheet state is disposed, the sheet should leave no animation listeners on a caller-owned controller.
Actual results
Actual: each completed nonclosing drag adds another helper that remains subscribed. Dismissing the sheet removes its current widget listeners but leaves the obsolete helpers attached. Disposing the caller's controller later releases this retention path.
Code sample
See above
Screenshots or Video
Screenshots / Video demonstration
[Upload media here]
Logs
Logs
[Paste your logs here]Flutter Doctor output
Full flutter doctor -v output
[√] Flutter (Channel stable, 3.47.6, on Microsoft Windows [Version 10.0.26200.9457], locale en-US) [525ms]
• Flutter version 3.47.6 on channel stable at E:\projects\flutter\sdk-repro
• Upstream repository https://github.com/flutter/flutter.git
• Framework revision 5fc346839b (6 days ago), 2026-09-30 15:02:49 -0700
• Engine revision 692136cb65
• Dart version 3.13.5
• DevTools version 2.60.0
• Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, no-enable-windows-desktop, no-enable-android, enable-ios, cli-animations, enable-native-assets, enable-record-use, enable-swift-package-manager, omit-legacy-version-file, enable-lldb-debugging, enable-uiscene-migration
[√] Windows Version (Windows 11 or higher, 25H2, 2009) [991ms]
[√] Chrome - develop for the web [18ms]
• Chrome at C:\Program Files\Google\Chrome\Application\chrome.exe
[√] Connected device (2 available) [210ms]
• Chrome (web) • chrome • web-javascript • Google Chrome 154.0.8037.98
• Edge (web) • edge • web-javascript • Microsoft Edge 154.0.4258.53
[√] Network resources [519ms]
• All expected network resources are available.
• No issues found!
Activity
mbcorona commentedon Oct 7, 2026
Hi @hangy233, thanks for the writeup and the demo.
Confirmed on the current framework: handleDragEnd assigns a new CurvedAnimation to
_sheetAnimation.parent on each drag and the outgoing one is never disposed, so its
status listener stays on the route animation. dispose only releases the initial
_curvedSheetAnimation.
flutter/packages/flutter/lib/src/material/bottom_sheet.dart
Lines 736 to 755 in 19946f9
This is the modal counterpart of #167899, which fixed the same kind of leak for the
persistent sheet.
Routing to team-design.
ppamorim commentedon Nov 6, 2026
Hi, I can confirm this bug i reproducible on my side. The proposal looks good.
hangy233 commentedon Nov 6, 2026
Thanks for confirming, @ppamorim. A fix is in progress in flutter/packages#13167. It disposes the obsolete drag-end
CurvedAnimationinstances and adds a regression test. The PR has been approved and is awaiting merge.[material_ui] Dispose drag-end CurvedAnimation in modal bottom sheet (f…