From b8f970035a03d9890d86abb6fc1d90415ce4a92f Mon Sep 17 00:00:00 2001 From: Eimen2018 Date: Fri, 18 Sep 2026 01:48:31 +0400 Subject: [PATCH 1/3] fix(snackbar): ensure deferred custom snackbar is shown after async gap showCustomSnackBar deferred the show to a post-frame callback but never scheduled a frame, so when called after an await with no pending frame the snackbar never appeared. Fixes Stacked-Org/stacked#1163 Co-Authored-By: Claude Fable 5.1 --- lib/src/snackbar/snackbar_service.dart | 12 +++- test/snackbar_service_test.dart | 98 ++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 test/snackbar_service_test.dart diff --git a/lib/src/snackbar/snackbar_service.dart b/lib/src/snackbar/snackbar_service.dart index f17f984..1557cc7 100644 --- a/lib/src/snackbar/snackbar_service.dart +++ b/lib/src/snackbar/snackbar_service.dart @@ -244,10 +244,20 @@ class SnackbarService { return getBar.show(); } else { Completer completer = Completer(); - sc.ambiguate(WidgetsBinding.instance)!.addPostFrameCallback((_) async { + final binding = sc.ambiguate(WidgetsBinding.instance)!; + binding.addPostFrameCallback((_) async { final result = getBar.show(); completer.complete(result); }); + // addPostFrameCallback only runs the next time a frame is produced. If + // this is called outside of the build/layout/paint pipeline (e.g. after + // an `await` with no pending frame) no frame is scheduled and the + // callback above would never fire, so the snackbar would silently + // never show. ensureVisualUpdate() schedules a frame when the binding + // is idle, and is a no-op while a frame is already underway, so the + // existing instantInit=false behaviour (deferring until after the + // current build) is unchanged. + binding.ensureVisualUpdate(); return completer.future; } } diff --git a/test/snackbar_service_test.dart b/test/snackbar_service_test.dart new file mode 100644 index 0000000..9dbe5dc --- /dev/null +++ b/test/snackbar_service_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; +import 'package:stacked_services/stacked_services.dart'; + +void main() { + group('SnackbarService showCustomSnackBar -', () { + testWidgets( + 'schedules a frame and shows the snackbar when called after an async ' + 'gap (instantInit: false)', + (tester) async { + // Regression test for https://github.com/Stacked-Org/stacked/issues/1163 + // + // showCustomSnackBar defers the actual `getBar.show()` call to a + // WidgetsBinding.addPostFrameCallback when the registered + // SnackbarConfig has instantInit == false (the default). That + // callback only ever runs the next time the engine produces a + // frame. If showCustomSnackBar is called after an `await` with no + // frame already scheduled - i.e. the binding is idle, exactly like + // after an `await Future.delayed(...)` in a real app, which is how + // the issue was reported - nothing would trigger that frame and the + // snackbar would silently never appear. + await tester.pumpWidget( + GetMaterialApp(home: Scaffold(body: Container())), + ); + await tester.pump(); + + final snackbarService = SnackbarService(); + snackbarService.registerCustomSnackbarConfig( + variant: 'regression-1163', + config: SnackbarConfig(), + ); + + // Sanity check: the binding is idle with no frame scheduled - this + // is what the SchedulerBinding looks like after an `await` with no + // other pending work, which is exactly the reported repro. + expect(SchedulerBinding.instance.hasScheduledFrame, isFalse); + + snackbarService.showCustomSnackBar( + message: 'Blue and yellow', + variant: 'regression-1163', + duration: const Duration(milliseconds: 200), + ); + + // Without the fix, nothing here schedules a frame and the queued + // addPostFrameCallback would sit pending forever, so the snackbar + // would never appear. + expect(SchedulerBinding.instance.hasScheduledFrame, isTrue); + + // Run the frame that fires the deferred post-frame callback (where + // getBar.show() actually happens), then one more frame so the + // freshly-inserted overlay entry is actually built and painted. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.text('Blue and yellow'), findsOneWidget); + + // Drain the auto-dismiss timer and the hide animation so nothing is + // left pending (Timer/AnimationController) when the test ends. + await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(const Duration(milliseconds: 1200)); + }, + ); + + testWidgets( + 'still shows immediately when instantInit is true', + (tester) async { + // instantInit: true takes the non-deferred path (no post-frame + // callback at all) and must keep working exactly as before. + await tester.pumpWidget( + GetMaterialApp(home: Scaffold(body: Container())), + ); + await tester.pump(); + + final snackbarService = SnackbarService(); + snackbarService.registerCustomSnackbarConfig( + variant: 'regression-1163-instant', + config: SnackbarConfig(instantInit: true), + ); + + snackbarService.showCustomSnackBar( + message: 'Instant message', + variant: 'regression-1163-instant', + duration: const Duration(milliseconds: 200), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.text('Instant message'), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(const Duration(milliseconds: 1200)); + }, + ); + }); +} From b38529d4eb19803141b08d1c2f08a7ed693b3a3c Mon Sep 17 00:00:00 2001 From: Eimen2018 Date: Fri, 18 Sep 2026 01:48:31 +0400 Subject: [PATCH 2/3] chore: format files flagged by CI dialog_service.dart and example dialog_view.dart were already unformatted on main and fail the Verify Format step for every PR. No code change. Co-Authored-By: Claude Fable 5.1 --- example/lib/ui/views/dialog_view.dart | 28 ++++++++++++++++++--------- lib/src/dialog/dialog_service.dart | 18 ++++++++++++----- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/example/lib/ui/views/dialog_view.dart b/example/lib/ui/views/dialog_view.dart index c89a6db..1c0d6eb 100644 --- a/example/lib/ui/views/dialog_view.dart +++ b/example/lib/ui/views/dialog_view.dart @@ -93,12 +93,16 @@ class DialogView extends StatelessWidget { await _dialogService.showCustomDialog( variant: DialogType.Basic, title: 'This is a custom UI with Text as main button', - description: 'Sheck out the builder in the dialog_ui_register.dart file', + description: + 'Sheck out the builder in the dialog_ui_register.dart file', mainButtonTitle: 'Ok', showIconInMainButton: false, barrierDismissible: true, - routeSettings: RouteSettings(name: '/customDialogWithTransition'), - transitionBuilder: (context, animation, secondaryAnimation, child) => SlideTransition( + routeSettings: + RouteSettings(name: '/customDialogWithTransition'), + transitionBuilder: + (context, animation, secondaryAnimation, child) => + SlideTransition( position: animation.drive( Tween( begin: const Offset(1.0, 0.0), @@ -122,10 +126,13 @@ class DialogView extends StatelessWidget { ), OutlinedButton( onPressed: () async { - final response = await _dialogService.showCustomDialog( + final response = await _dialogService.showCustomDialog< + GenericDialogResponse, GenericDialogRequest>( variant: DialogType.Generic, - title: 'This is a custom Generic UI with Text as main button', - description: 'Sheck out the builder in the dialog_ui_register.dart file', + title: + 'This is a custom Generic UI with Text as main button', + description: + 'Sheck out the builder in the dialog_ui_register.dart file', mainButtonTitle: 'Ok', showIconInMainButton: false, barrierDismissible: true, @@ -144,7 +151,8 @@ class DialogView extends StatelessWidget { await _dialogService.showCustomDialog( variant: DialogType.Basic, title: 'This is a custom UI with icon', - description: 'Sheck out the builder in the dialog_ui_register.dart file', + description: + 'Sheck out the builder in the dialog_ui_register.dart file', showIconInMainButton: true, routeSettings: RouteSettings(name: '/customDialog'), ); @@ -166,7 +174,8 @@ class DialogView extends StatelessWidget { title: 'Test Confirmation Dialog Title', description: 'Test Confirmation Dialog Description', barrierDismissible: true, - routeSettings: RouteSettings(name: '/materialConfirmationDialog'), + routeSettings: + RouteSettings(name: '/materialConfirmationDialog'), ); }, child: Text( @@ -210,7 +219,8 @@ class DialogView extends StatelessWidget { title: 'Test Confirmation Dialog Title', description: 'Test Confirmation Dialog Description', barrierDismissible: true, - routeSettings: RouteSettings(name: '/materialConfirmationDialog'), + routeSettings: + RouteSettings(name: '/materialConfirmationDialog'), ); }, child: Text( diff --git a/lib/src/dialog/dialog_service.dart b/lib/src/dialog/dialog_service.dart index cb3a63e..e217f6a 100644 --- a/lib/src/dialog/dialog_service.dart +++ b/lib/src/dialog/dialog_service.dart @@ -26,9 +26,11 @@ class DialogService { _dialogBuilders = {...?_dialogBuilders, ...builders}; } - Map _customDialogBuilders = Map(); + Map _customDialogBuilders = + Map(); - @Deprecated('Prefer to use the StackedServices.navigatorKey instead of using this key. This will be removed in the next major version update for stacked.') + @Deprecated( + 'Prefer to use the StackedServices.navigatorKey instead of using this key. This will be removed in the next major version update for stacked.') get navigatorKey { return Get.key; } @@ -45,7 +47,9 @@ class DialogService { ) void registerCustomDialogBuilder({ required dynamic variant, - required Widget Function(BuildContext, DialogRequest, Function(DialogResponse)) builder, + required Widget Function( + BuildContext, DialogRequest, Function(DialogResponse)) + builder, }) { _customDialogBuilders[variant] = builder; } @@ -86,7 +90,9 @@ class DialogService { navigatorKey: navigatorKey, ); } else { - var _dialogType = GetPlatform.isAndroid ? DialogPlatform.Material : DialogPlatform.Cupertino; + var _dialogType = GetPlatform.isAndroid + ? DialogPlatform.Material + : DialogPlatform.Cupertino; return _showDialog( title: title, description: description, @@ -192,7 +198,9 @@ class DialogService { RouteSettings? routeSettings, GlobalKey? navigatorKey, RouteTransitionsBuilder? transitionBuilder, - @Deprecated('Prefer to use `data` and pass in a generic type. customData doesn\'t work anymore') dynamic customData, + @Deprecated( + 'Prefer to use `data` and pass in a generic type. customData doesn\'t work anymore') + dynamic customData, R? data, }) { assert( From 57144b8838e7364cfe8c4a6965e84205044dc44d Mon Sep 17 00:00:00 2001 From: Eimen2018 Date: Sat, 19 Sep 2026 01:23:58 +0400 Subject: [PATCH 3/3] chore: trim comments Co-Authored-By: Claude Fable 5.1 --- lib/src/snackbar/snackbar_service.dart | 9 +-------- test/snackbar_service_test.dart | 24 ------------------------ 2 files changed, 1 insertion(+), 32 deletions(-) diff --git a/lib/src/snackbar/snackbar_service.dart b/lib/src/snackbar/snackbar_service.dart index 1557cc7..e97c5ae 100644 --- a/lib/src/snackbar/snackbar_service.dart +++ b/lib/src/snackbar/snackbar_service.dart @@ -249,14 +249,7 @@ class SnackbarService { final result = getBar.show(); completer.complete(result); }); - // addPostFrameCallback only runs the next time a frame is produced. If - // this is called outside of the build/layout/paint pipeline (e.g. after - // an `await` with no pending frame) no frame is scheduled and the - // callback above would never fire, so the snackbar would silently - // never show. ensureVisualUpdate() schedules a frame when the binding - // is idle, and is a no-op while a frame is already underway, so the - // existing instantInit=false behaviour (deferring until after the - // current build) is unchanged. + // Schedule a frame so the callback also fires when the binding is idle. binding.ensureVisualUpdate(); return completer.future; } diff --git a/test/snackbar_service_test.dart b/test/snackbar_service_test.dart index 9dbe5dc..4772562 100644 --- a/test/snackbar_service_test.dart +++ b/test/snackbar_service_test.dart @@ -10,17 +10,6 @@ void main() { 'schedules a frame and shows the snackbar when called after an async ' 'gap (instantInit: false)', (tester) async { - // Regression test for https://github.com/Stacked-Org/stacked/issues/1163 - // - // showCustomSnackBar defers the actual `getBar.show()` call to a - // WidgetsBinding.addPostFrameCallback when the registered - // SnackbarConfig has instantInit == false (the default). That - // callback only ever runs the next time the engine produces a - // frame. If showCustomSnackBar is called after an `await` with no - // frame already scheduled - i.e. the binding is idle, exactly like - // after an `await Future.delayed(...)` in a real app, which is how - // the issue was reported - nothing would trigger that frame and the - // snackbar would silently never appear. await tester.pumpWidget( GetMaterialApp(home: Scaffold(body: Container())), ); @@ -32,9 +21,6 @@ void main() { config: SnackbarConfig(), ); - // Sanity check: the binding is idle with no frame scheduled - this - // is what the SchedulerBinding looks like after an `await` with no - // other pending work, which is exactly the reported repro. expect(SchedulerBinding.instance.hasScheduledFrame, isFalse); snackbarService.showCustomSnackBar( @@ -43,21 +29,13 @@ void main() { duration: const Duration(milliseconds: 200), ); - // Without the fix, nothing here schedules a frame and the queued - // addPostFrameCallback would sit pending forever, so the snackbar - // would never appear. expect(SchedulerBinding.instance.hasScheduledFrame, isTrue); - // Run the frame that fires the deferred post-frame callback (where - // getBar.show() actually happens), then one more frame so the - // freshly-inserted overlay entry is actually built and painted. await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); expect(find.text('Blue and yellow'), findsOneWidget); - // Drain the auto-dismiss timer and the hide animation so nothing is - // left pending (Timer/AnimationController) when the test ends. await tester.pump(const Duration(milliseconds: 200)); await tester.pump(const Duration(milliseconds: 1200)); }, @@ -66,8 +44,6 @@ void main() { testWidgets( 'still shows immediately when instantInit is true', (tester) async { - // instantInit: true takes the non-deferred path (no post-frame - // callback at all) and must keep working exactly as before. await tester.pumpWidget( GetMaterialApp(home: Scaffold(body: Container())), );