Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions packages/go_router/lib/src/configuration.dart
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,20 @@ class RouteConfiguration {
if (newMatch.isError) {
return newMatch;
}
return redirect(context, newMatch, redirectHistory: redirectHistory);
// Re-evaluate top-level redirect on the new location before
// recursing into route-level redirects. This ensures that
// route-level redirects landing on a new path also trigger
// top-level redirect evaluation for that path.
final FutureOr<RouteMatchList> afterTopLevel = applyTopLegacyRedirect(
context,
newMatch,
redirectHistory: redirectHistory,
);
return redirect(
context,
afterTopLevel,
redirectHistory: redirectHistory,
);
}
return prevMatchList;
}
Expand Down Expand Up @@ -484,9 +497,10 @@ class RouteConfiguration {
///
/// Shares [redirectHistory] with later route-level redirects for proper loop detection.
///
/// Note: Legacy top-level redirect is executed at most once per navigation,
/// before route-level redirects. It does not re-evaluate if it redirects to
/// a location that would itself trigger another top-level redirect.
/// Recursively re-evaluates the top-level redirect when it produces a new
/// location, so that top-level redirect chains (e.g. `/` → `/a` → `/b`) are
/// fully resolved. Loop detection and redirect limit are enforced via the
/// shared [redirectHistory].
FutureOr<RouteMatchList> applyTopLegacyRedirect(
BuildContext context,
RouteMatchList prevMatchList, {
Expand All @@ -500,7 +514,15 @@ class RouteConfiguration {
prevMatchList.uri,
redirectHistory,
);
return newMatch;
if (newMatch.isError) {
return newMatch;
}
// Recursively re-evaluate the top-level redirect on the new location.
return applyTopLegacyRedirect(
context,
newMatch,
redirectHistory: redirectHistory,
);
}
return prevMatchList;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
changelog: |
- Fixes chained top-level redirects not being fully resolved (e.g. `/ → /a → /b` stopping at `/a`).
- Fixes route-level redirects not triggering top-level redirect re-evaluation on the new location.
version: patch
171 changes: 171 additions & 0 deletions packages/go_router/test/on_enter_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1582,5 +1582,176 @@ void main() {
expect(find.text('Home'), findsOneWidget);
},
);

// Tests for onEnter interaction with chained redirects.
// These validate that onEnter works correctly when top-level and
// route-level redirects produce chains.
group('onEnter with chained redirects', () {
testWidgets('onEnter called once when top-level redirect chains', (
WidgetTester tester,
) async {
// Regression test for https://github.com/flutter/flutter/issues/178984
//
// Top-level redirect: / -> /a -> /b
// onEnter should allow navigation and be called exactly once
// for the final resolved location.
var onEnterCallCount = 0;
var redirectCallCount = 0;

router = GoRouter(
initialLocation: '/',
onEnter:
(
BuildContext context,
GoRouterState current,
GoRouterState next,
GoRouter goRouter,
) async {
onEnterCallCount++;
return const Allow();
},
redirect: (BuildContext context, GoRouterState state) {
redirectCallCount++;
if (state.matchedLocation == '/') {
return '/a';
}
if (state.matchedLocation == '/a') {
return '/b';
}
return null;
},
routes: <RouteBase>[
GoRoute(path: '/', builder: (_, __) => const Text('Home')),
GoRoute(path: '/a', builder: (_, __) => const Text('A')),
GoRoute(path: '/b', builder: (_, __) => const Text('B')),
],
);

await tester.pumpWidget(MaterialApp.router(routerConfig: router));
await tester.pumpAndSettle();

// Chain should resolve to /b.
expect(router.routerDelegate.currentConfiguration.uri.toString(), '/b');
expect(find.text('B'), findsOneWidget);
// onEnter should be called exactly once for the initial navigation.
expect(onEnterCallCount, 1);
// Redirect should be called for /, /a, and /b.
expect(redirectCallCount, 3);
});

testWidgets(
'onEnter called once when route-level triggers top-level redirect',
(WidgetTester tester) async {
// Route-level on /src: /src -> /dst
// Top-level: /dst -> /final
// onEnter should be called exactly once.
var onEnterCallCount = 0;

router = GoRouter(
initialLocation: '/src',
onEnter:
(
BuildContext context,
GoRouterState current,
GoRouterState next,
GoRouter goRouter,
) async {
onEnterCallCount++;
return const Allow();
},
redirect: (BuildContext context, GoRouterState state) {
if (state.matchedLocation == '/dst') {
return '/final';
}
return null;
},
routes: <RouteBase>[
GoRoute(
path: '/',
builder: (_, __) => const Text('Home'),
routes: <RouteBase>[
GoRoute(
path: 'src',
builder: (_, __) => const Text('Src'),
redirect: (BuildContext context, GoRouterState state) =>
'/dst',
),
],
),
GoRoute(path: '/dst', builder: (_, __) => const Text('Dst')),
GoRoute(path: '/final', builder: (_, __) => const Text('Final')),
],
);

await tester.pumpWidget(MaterialApp.router(routerConfig: router));
await tester.pumpAndSettle();

// Chain should resolve to /final.
expect(
router.routerDelegate.currentConfiguration.uri.toString(),
'/final',
);
expect(find.text('Final'), findsOneWidget);
// onEnter should be called exactly once for the initial navigation.
expect(onEnterCallCount, 1);
},
);

testWidgets('onEnter block prevents redirect chain evaluation', (
WidgetTester tester,
) async {
// onEnter blocks navigation to /a.
// Top-level redirect: /a -> /b (should never be evaluated).
var redirectCallCount = 0;

router = GoRouter(
initialLocation: '/',
onEnter:
(
BuildContext context,
GoRouterState current,
GoRouterState next,
GoRouter goRouter,
) async {
// Block all navigation except to /.
if (next.uri.path == '/') {
return const Allow();
}
return const Block.stop();
},
redirect: (BuildContext context, GoRouterState state) {
redirectCallCount++;
if (state.matchedLocation == '/a') {
return '/b';
}
return null;
},
routes: <RouteBase>[
GoRoute(path: '/', builder: (_, __) => const Text('Home')),
GoRoute(path: '/a', builder: (_, __) => const Text('A')),
GoRoute(path: '/b', builder: (_, __) => const Text('B')),
],
);

await tester.pumpWidget(MaterialApp.router(routerConfig: router));
await tester.pumpAndSettle();

// Initial navigation to / is allowed.
expect(find.text('Home'), findsOneWidget);
final redirectCountAfterInit = redirectCallCount;

// Navigate to /a — should be blocked by onEnter.
router.go('/a');
await tester.pumpAndSettle();

// Navigation was blocked; still on Home.
expect(find.text('Home'), findsOneWidget);
expect(find.text('A'), findsNothing);
expect(find.text('B'), findsNothing);
// Redirect function should NOT have been called for /a since
// onEnter blocked before redirects were evaluated.
expect(redirectCallCount, redirectCountAfterInit);
});
});
});
}
Loading