From c2a4bd6fcef9a77d7fe2870c2330d05f95f972b2 Mon Sep 17 00:00:00 2001 From: Richard Alcock Date: Sun, 10 May 2026 08:56:00 +0100 Subject: [PATCH] fix(festival-selector): avoid GoRouterState.of() inside gesture callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calling GoRouterState.of(context) from an onTap handler causes a complete app freeze when a notifyListeners() continuation is pending on the Dart microtask queue at tap time — reproduces reliably when switching festivals immediately after data finishes loading. Fix: capture the current path in showFestivalBrowser() before the modal opens, where it is safe to call InheritedWidget lookups, and pass it as a constructor parameter to FestivalSelectorSheet. Co-Authored-By: Claude Sonnet 4.6 --- lib/providers/beer_provider.dart | 3 --- lib/services/beer_api_service.dart | 1 - lib/widgets/festival_menu_sheets.dart | 38 +++++++++++++++++---------- 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/lib/providers/beer_provider.dart b/lib/providers/beer_provider.dart index b121cb25..87dc4f31 100644 --- a/lib/providers/beer_provider.dart +++ b/lib/providers/beer_provider.dart @@ -289,15 +289,12 @@ class BeerProvider extends ChangeNotifier { _error = null; notifyListeners(); - // Log analytics event await _analyticsService.logFestivalSelected(festival); - // Persist festival selection only if requested if (persist) { await _festivalRepository?.setSelectedFestivalId(festival.id); } - // Load drinks for the new festival (loadDrinks will call notifyListeners when done) await _loadDrinksInternal(); } diff --git a/lib/services/beer_api_service.dart b/lib/services/beer_api_service.dart index a19586a5..ae83e3ef 100644 --- a/lib/services/beer_api_service.dart +++ b/lib/services/beer_api_service.dart @@ -52,7 +52,6 @@ class BeerApiService { try { return await fetchDrinks(festival, beverageType); } catch (e) { - // Track the error for this beverage type errors[beverageType] = e.toString(); return []; } diff --git a/lib/widgets/festival_menu_sheets.dart b/lib/widgets/festival_menu_sheets.dart index 246fb3a3..ddafc4db 100644 --- a/lib/widgets/festival_menu_sheets.dart +++ b/lib/widgets/festival_menu_sheets.dart @@ -8,10 +8,21 @@ import '../utils/utils.dart'; /// Shows the festival browser/selector as a modal bottom sheet void showFestivalBrowser(BuildContext context) { final provider = context.read(); + // Capture current route before opening modal — GoRouterState must not be + // accessed inside an onTap handler (gesture callbacks are not build phase). + String? currentPath; + try { + currentPath = GoRouterState.of(context).uri.path; + } catch (_) { + // GoRouterState unavailable (e.g., in tests) + } showModalBottomSheet( context: context, isScrollControlled: true, - builder: (context) => FestivalSelectorSheet(provider: provider), + builder: (context) => FestivalSelectorSheet( + provider: provider, + currentPath: currentPath, + ), ); } @@ -27,8 +38,13 @@ void showSettingsSheet(BuildContext context) { /// Festival selector sheet for browsing all festivals class FestivalSelectorSheet extends StatelessWidget { final BeerProvider provider; + final String? currentPath; - const FestivalSelectorSheet({required this.provider, super.key}); + const FestivalSelectorSheet({ + required this.provider, + this.currentPath, + super.key, + }); String _getStatusLabel(FestivalStatus status) { switch (status) { @@ -185,20 +201,14 @@ class FestivalSelectorSheet extends StatelessWidget { sortedFestivals: festivals, isSelected: isSelected, onTap: () { - // Capture current path first, before any state changes - // Default to festival home; override if currently on favorites + // Preserve user's tab: if on favorites, stay on favorites. + // currentPath was captured before the modal opened to avoid + // calling GoRouterState.of() inside a gesture callback, + // which can cause a freeze during active widget rebuilds. String targetPath = buildFestivalHome(festival.id); - try { - final currentPath = GoRouterState.of(context).uri.path; - // Preserve user's tab: if on favorites, stay on favorites - if (currentPath.endsWith('/favorites')) { - targetPath = buildFavoritesPath(festival.id); - } - } catch (e) { - // GoRouterState unavailable (e.g., in tests), keep default path - debugPrint('Festival selector: Unable to get current route, using default: $e'); + if (currentPath?.endsWith('/favorites') == true) { + targetPath = buildFavoritesPath(festival.id); } - final router = GoRouter.maybeOf(context); provider.setFestival(festival); Navigator.pop(context);