From d26ea6619bbedd8917e395db5950d23ccb61195d Mon Sep 17 00:00:00 2001 From: David Miguel Date: Thu, 16 Jul 2026 14:26:50 +0200 Subject: [PATCH 1/2] [go_router] Fix assertion failure when onException fires on initial navigation When onException fires during the initial navigation (e.g., an initial deep link blocked by onEnter returning Block.stop()), routerDelegate.currentConfiguration is empty. Returning it directly from parserExceptionHandler causes GoRouterDelegate.setNewRoutePath to hit `assert(configuration.isNotEmpty || configuration.isError)`. Fall back to the error match list (isError=true) when the current configuration is empty, satisfying the assertion while any recovery navigation queued from onException is processed. Additionally, defer the onParserException call itself to a microtask on that same initial-navigation, blocked-with-no-prior-route path. Calling it synchronously let the app crash-prevention fix land, but a router.go() made synchronously from onException was silently discarded: Flutter's Router mints a new intent token for the re-entrant parse while the initial parse is still in-flight, dropping the outer result. The deferral is scoped to that one parser.dart call site so ordinary initial navigation exceptions (e.g. a plain unmatched route) keep calling onException synchronously, as existing tests rely on. --- packages/go_router/lib/src/parser.dart | 17 +++-- packages/go_router/lib/src/router.dart | 10 ++- .../change_2026_07_16_1784205172418.yaml | 3 + .../test/exception_handling_test.dart | 65 +++++++++++++++++++ 4 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 packages/go_router/pending_changelogs/change_2026_07_16_1784205172418.yaml diff --git a/packages/go_router/lib/src/parser.dart b/packages/go_router/lib/src/parser.dart index 7ed750b4c3f0..8e129be3e357 100644 --- a/packages/go_router/lib/src/parser.dart +++ b/packages/go_router/lib/src/parser.dart @@ -137,10 +137,19 @@ class GoRouteInformationParser extends RouteInformationParser { ), extra: infoState.extra, ); - final RouteMatchList resolved = onParserException != null - ? onParserException!(context, blocked) - : blocked; - return SynchronousFuture(resolved); + if (onParserException != null) { + // Defer past this still-in-flight initial parse, so a recovery + // navigation (e.g. router.go()) made synchronously from + // onParserException/onException isn't discarded by the Router's + // intent-token churn (same reason as the microtask deferral + // below for the onEnter `then` callback). + scheduleMicrotask(() { + if (context.mounted) { + onParserException!(context, blocked); + } + }); + } + return SynchronousFuture(blocked); }, ); } diff --git a/packages/go_router/lib/src/router.dart b/packages/go_router/lib/src/router.dart index 5f1ff5dbb793..7abc683d9c6d 100644 --- a/packages/go_router/lib/src/router.dart +++ b/packages/go_router/lib/src/router.dart @@ -271,8 +271,14 @@ class GoRouter implements RouterConfig { if (onException != null) { parserExceptionHandler = (BuildContext context, RouteMatchList routeMatchList) { onException(context, configuration.buildTopLevelGoRouterState(routeMatchList), this); - // Avoid updating GoRouterDelegate if onException is provided. - return routerDelegate.currentConfiguration; + // Prefer the current configuration to avoid updating + // GoRouterDelegate with the error match list. Fall back to the + // error match list when there is no current configuration yet + // (e.g., initial deep link blocked by onEnter). This prevents + // an assertion failure in GoRouterDelegate.setNewRoutePath + // which requires configuration.isNotEmpty || configuration.isError. + final RouteMatchList current = routerDelegate.currentConfiguration; + return current.isNotEmpty ? current : routeMatchList; }; } else { parserExceptionHandler = null; diff --git a/packages/go_router/pending_changelogs/change_2026_07_16_1784205172418.yaml b/packages/go_router/pending_changelogs/change_2026_07_16_1784205172418.yaml new file mode 100644 index 000000000000..e77b8ef3f248 --- /dev/null +++ b/packages/go_router/pending_changelogs/change_2026_07_16_1784205172418.yaml @@ -0,0 +1,3 @@ +changelog: | + - Fixes an assertion failure when `onException` fires during a blocked initial navigation, and defers the call so a recovery navigation made inside it is no longer silently discarded +version: patch diff --git a/packages/go_router/test/exception_handling_test.dart b/packages/go_router/test/exception_handling_test.dart index f1fbd8ab2518..df1b9ff25248 100644 --- a/packages/go_router/test/exception_handling_test.dart +++ b/packages/go_router/test/exception_handling_test.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; @@ -195,5 +197,68 @@ void main() { expect(find.text('generic error handled'), findsOneWidget); expect(find.text('should not reach here'), findsNothing); }); + + testWidgets('recovers to the fallback route when onException defers router.go ' + 'during a blocked initial navigation', (WidgetTester tester) async { + final router = GoRouter( + initialLocation: '/protected', + onEnter: (_, GoRouterState current, GoRouterState next, GoRouter goRouter) { + if (next.matchedLocation == '/protected') { + return const Block.stop(); + } + return const Allow(); + }, + onException: (_, GoRouterState state, GoRouter router) { + // Deferred to a microtask by the app itself: this should still + // work now that go_router also defers its own call to + // onException on the initial-navigation path (the sibling test + // below covers a router.go() called synchronously from + // onException). + scheduleMicrotask(() => router.go('/fallback')); + }, + routes: [ + GoRoute(path: '/protected', builder: (_, _) => const Text('protected')), + GoRoute(path: '/fallback', builder: (_, _) => const Text('fallback')), + ], + ); + addTearDown(router.dispose); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('fallback'), findsOneWidget); + expect(find.text('protected'), findsNothing); + expect(router.state.uri.path, '/fallback'); + }); + + testWidgets('recovers to the fallback route when onException calls router.go ' + 'synchronously during a blocked initial navigation', (WidgetTester tester) async { + final router = GoRouter( + initialLocation: '/protected', + onEnter: (_, GoRouterState current, GoRouterState next, GoRouter goRouter) { + if (next.matchedLocation == '/protected') { + return const Block.stop(); + } + return const Allow(); + }, + onException: (_, GoRouterState state, GoRouter router) { + router.go('/fallback'); + }, + routes: [ + GoRoute(path: '/protected', builder: (_, _) => const Text('protected')), + GoRoute(path: '/fallback', builder: (_, _) => const Text('fallback')), + ], + ); + addTearDown(router.dispose); + + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('fallback'), findsOneWidget); + expect(find.text('protected'), findsNothing); + expect(router.state.uri.path, '/fallback'); + }); }); } From 00aeed1fdb0eb6bc0cfafae09ab5120e19fe8a4f Mon Sep 17 00:00:00 2001 From: David Miguel Date: Thu, 16 Jul 2026 17:43:28 +0200 Subject: [PATCH 2/2] Preserve valid error configurations in parserExceptionHandler --- packages/go_router/lib/src/router.dart | 7 +-- .../test/exception_handling_test.dart | 47 +++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/packages/go_router/lib/src/router.dart b/packages/go_router/lib/src/router.dart index 7abc683d9c6d..81be1c07fe48 100644 --- a/packages/go_router/lib/src/router.dart +++ b/packages/go_router/lib/src/router.dart @@ -271,14 +271,15 @@ class GoRouter implements RouterConfig { if (onException != null) { parserExceptionHandler = (BuildContext context, RouteMatchList routeMatchList) { onException(context, configuration.buildTopLevelGoRouterState(routeMatchList), this); - // Prefer the current configuration to avoid updating + // Prefer the current configuration whenever it is valid (non-empty + // or an already-installed error configuration), to avoid updating // GoRouterDelegate with the error match list. Fall back to the - // error match list when there is no current configuration yet + // error match list only when nothing has committed yet // (e.g., initial deep link blocked by onEnter). This prevents // an assertion failure in GoRouterDelegate.setNewRoutePath // which requires configuration.isNotEmpty || configuration.isError. final RouteMatchList current = routerDelegate.currentConfiguration; - return current.isNotEmpty ? current : routeMatchList; + return current.isNotEmpty || current.isError ? current : routeMatchList; }; } else { parserExceptionHandler = null; diff --git a/packages/go_router/test/exception_handling_test.dart b/packages/go_router/test/exception_handling_test.dart index df1b9ff25248..c08d8c329645 100644 --- a/packages/go_router/test/exception_handling_test.dart +++ b/packages/go_router/test/exception_handling_test.dart @@ -260,5 +260,52 @@ void main() { expect(find.text('protected'), findsNothing); expect(router.state.uri.path, '/fallback'); }); + + testWidgets('renders the default error screen for an unmatched initial navigation ' + 'when onException does not navigate', (WidgetTester tester) async { + var exceptionCaught = false; + + await createRouter( + [GoRoute(path: '/', builder: (_, GoRouterState state) => const Text('home'))], + tester, + initialLocation: '/unmatched-route', + onException: (_, _, _) { + exceptionCaught = true; + }, + ); + + expect(tester.takeException(), isNull); + expect(exceptionCaught, isTrue); + expect(find.text('Page Not Found'), findsOneWidget); + }); + + testWidgets('preserves the first installed error configuration when a second ' + 'unmatched navigation also has onException not navigate', (WidgetTester tester) async { + final visited = []; + + final GoRouter router = await createRouter( + [GoRoute(path: '/', builder: (_, GoRouterState state) => const Text('home'))], + tester, + initialLocation: '/first-unmatched', + onException: (_, GoRouterState state, _) { + visited.add(state.uri.toString()); + }, + ); + + expect(tester.takeException(), isNull); + expect(visited, ['/first-unmatched']); + expect(router.routerDelegate.currentConfiguration.uri.toString(), '/first-unmatched'); + + router.go('/second-unmatched'); + await tester.pumpAndSettle(); + + // onException fires again for the second exception, but the + // already-installed error configuration from the first is + // preserved instead of being replaced by the second. + expect(tester.takeException(), isNull); + expect(visited, ['/first-unmatched', '/second-unmatched']); + expect(router.routerDelegate.currentConfiguration.uri.toString(), '/first-unmatched'); + expect(find.text('Page Not Found'), findsOneWidget); + }); }); }