Skip to content

Commit 4333700

Browse files
authored
[go_router] Fix chained top-level redirects not being fully resolved (#11108)
Fixes flutter/flutter#178984 The `onEnter` refactor (commit `9ec29b6d23`, PR #8339) split the unified `redirect()` method into `applyTopLegacyRedirect()` (top-level, runs once) and `redirect()` (route-level only). This introduced two regressions: 1. **Top-level redirect chains broken**: `applyTopLegacyRedirect()` returned after one hop — a chain like `/ → /a → /b` stopped at `/a` instead of resolving to `/b`. 2. **Route-level → top-level chains broken**: `processRouteLevelRedirect()` recursed into `redirect()` without re-evaluating the top-level redirect on the new location. ### Fix Unify top-level and route-level redirects back into `redirect()` as the single entry point for all redirect processing, while keeping the separation of concerns via `applyTopLegacyRedirect()` as an internal helper. > **Note:** An earlier revision of this PR (v1) kept the split but added recursive calls in both methods. Based on [reviewer feedback](#11108 (comment)), the approach was refactored to move `applyTopLegacyRedirect()` inside `redirect()` so the caller (`parseRouteInformationWithDependencies`) doesn't need to know about the two-phase process. #### Before (broken) ```mermaid sequenceDiagram participant Parser as parseRouteInformationWithDependencies participant TopRedir as applyTopLegacyRedirect participant Redir as redirect (route-level only) Parser->>TopRedir: / (one hop only) TopRedir-->>Parser: /a (stops here ✗) Parser->>Redir: /a Redir->>Redir: route-level redirects Note right of Redir: No top-level re-evaluation Redir-->>Parser: result ``` #### After (fix) ```mermaid sequenceDiagram participant Parser as parseRouteInformationWithDependencies participant Redir as redirect (unified) participant TopRedir as applyTopLegacyRedirect Parser->>Redir: / (initial matches) Redir->>TopRedir: / → top-level redirect chain TopRedir->>TopRedir: / → /a → /b (self-recursive) TopRedir-->>Redir: /b (fully resolved ✓) Redir->>Redir: route-level redirects on /b Note right of Redir: If route-level changes location,<br/>recurse → top-level re-evaluated Redir-->>Parser: final result ``` #### Key changes - **`configuration.dart` — `redirect()`**: Now calls `applyTopLegacyRedirect()` first at every cycle, then processes route-level redirects on the post-top-level result. Route-level `_processRouteLevelRedirects` extracted as a helper. - **`configuration.dart` — `applyTopLegacyRedirect()`**: Self-recursive to fully resolve top-level chains. No functional change from v1. - **`parser.dart` — `parseRouteInformationWithDependencies()`**: Simplified — no longer calls `applyTopLegacyRedirect` separately. Just passes initial matches to `_navigate()`. - **`parser.dart` — `_navigate()`**: Removed `preSharedHistory` parameter. Added `context.mounted` guard in the result `.then()` to protect the relocated async boundary. Both fixes share the existing `redirectHistory` for loop detection and respect `redirectLimit`. The `onEnter` system is completely unaffected — it runs before redirects in the pipeline. ### Tests - **19 redirect chain tests** (`redirect_chain_test.dart`): top-level chains, async chains, loop detection (including loop-to-initial), route→top cross-type chains, **async cross-type chains** (async top→route, async route→sync top, sync route→async top), **context disposal** during async top-level and route-level redirects, redirect limit boundary (exact limit succeeds, limit+1 fails), shared limit across redirect types. - **3 onEnter interaction tests** (`on_enter_test.dart`): onEnter called once when chains resolve, onEnter block prevents redirect evaluation. - **Full suite**: 418 tests pass, 0 regressions. ## Pre-Review Checklist [^1]: This PR uses `pending_changelogs/` for versioning and changelog, following the go_router batch release process.
1 parent c780d2d commit 4333700

File tree

5 files changed

+1191
-112
lines changed

5 files changed

+1191
-112
lines changed

packages/go_router/lib/src/configuration.dart

Lines changed: 111 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ class RouteConfiguration {
249249

250250
/// Legacy top level page redirect.
251251
///
252-
/// This is handled via [applyTopLegacyRedirect] and runs at most once per navigation.
252+
/// This is handled via [applyTopLegacyRedirect] inside [redirect].
253253
GoRouterRedirect get topRedirect => _routingConfig.value.redirect;
254254

255255
/// Top level page on enter.
@@ -401,80 +401,121 @@ class RouteConfiguration {
401401
return const <RouteMatchBase>[];
402402
}
403403

404-
/// Processes route-level redirects by returning a new [RouteMatchList] representing the new location.
404+
/// Processes all redirects (top-level and route-level) and returns the
405+
/// final [RouteMatchList].
405406
///
406-
/// This method now handles ONLY route-level redirects.
407-
/// Top-level redirects are handled by applyTopLegacyRedirect.
407+
/// Top-level redirects are evaluated first via [applyTopLegacyRedirect],
408+
/// then route-level redirects run on the resulting match list. If a
409+
/// route-level redirect changes the location, this method recurses —
410+
/// re-evaluating top-level redirects on the new location naturally.
408411
FutureOr<RouteMatchList> redirect(
409412
BuildContext context,
410413
FutureOr<RouteMatchList> prevMatchListFuture, {
411414
required List<RouteMatchList> redirectHistory,
412415
}) {
413416
FutureOr<RouteMatchList> processRedirect(RouteMatchList prevMatchList) {
414-
final prevLocation = prevMatchList.uri.toString();
415-
416-
FutureOr<RouteMatchList> processRouteLevelRedirect(
417-
String? routeRedirectLocation,
418-
) {
419-
if (routeRedirectLocation != null &&
420-
routeRedirectLocation != prevLocation) {
421-
final RouteMatchList newMatch = _getNewMatches(
422-
routeRedirectLocation,
423-
prevMatchList.uri,
424-
redirectHistory,
425-
);
417+
// Step 1: Apply top-level redirect first (self-recursive for chains).
418+
final FutureOr<RouteMatchList> afterTopLevel = applyTopLegacyRedirect(
419+
context,
420+
prevMatchList,
421+
redirectHistory: redirectHistory,
422+
);
426423

427-
if (newMatch.isError) {
428-
return newMatch;
429-
}
430-
return redirect(context, newMatch, redirectHistory: redirectHistory);
431-
}
432-
return prevMatchList;
424+
// Step 2: Then apply route-level redirects on the post-top-level result.
425+
if (afterTopLevel is RouteMatchList) {
426+
return _processRouteLevelRedirects(
427+
context,
428+
afterTopLevel,
429+
redirectHistory,
430+
);
433431
}
434-
435-
final routeMatches = <RouteMatchBase>[];
436-
prevMatchList.visitRouteMatches((RouteMatchBase match) {
437-
if (match.route.redirect != null) {
438-
routeMatches.add(match);
432+
return afterTopLevel.then<RouteMatchList>((RouteMatchList ml) {
433+
if (!context.mounted) {
434+
return ml;
439435
}
440-
return true;
436+
return _processRouteLevelRedirects(context, ml, redirectHistory);
441437
});
438+
}
439+
440+
if (prevMatchListFuture is RouteMatchList) {
441+
return processRedirect(prevMatchListFuture);
442+
}
443+
return prevMatchListFuture.then<RouteMatchList>(processRedirect);
444+
}
442445

443-
try {
444-
final FutureOr<String?> routeLevelRedirectResult =
445-
_getRouteLevelRedirect(context, prevMatchList, routeMatches, 0);
446+
/// Processes route-level redirects on [matchList].
447+
///
448+
/// If a route-level redirect changes the location, recurses back into
449+
/// [redirect] which will re-evaluate top-level redirects on the new path.
450+
FutureOr<RouteMatchList> _processRouteLevelRedirects(
451+
BuildContext context,
452+
RouteMatchList matchList,
453+
List<RouteMatchList> redirectHistory,
454+
) {
455+
final prevLocation = matchList.uri.toString();
446456

447-
if (routeLevelRedirectResult is String?) {
448-
return processRouteLevelRedirect(routeLevelRedirectResult);
449-
}
450-
return routeLevelRedirectResult
451-
.then<RouteMatchList>(processRouteLevelRedirect)
452-
.catchError((Object error) {
453-
final GoException goException = error is GoException
454-
? error
455-
: GoException('Exception during route redirect: $error');
456-
return _errorRouteMatchList(
457-
prevMatchList.uri,
458-
goException,
459-
extra: prevMatchList.extra,
460-
);
461-
});
462-
} catch (exception) {
463-
final GoException goException = exception is GoException
464-
? exception
465-
: GoException('Exception during route redirect: $exception');
466-
return _errorRouteMatchList(
467-
prevMatchList.uri,
468-
goException,
469-
extra: prevMatchList.extra,
457+
FutureOr<RouteMatchList> processRouteLevelRedirect(
458+
String? routeRedirectLocation,
459+
) {
460+
if (routeRedirectLocation != null &&
461+
routeRedirectLocation != prevLocation) {
462+
final RouteMatchList newMatch = _getNewMatches(
463+
routeRedirectLocation,
464+
matchList.uri,
465+
redirectHistory,
470466
);
467+
468+
if (newMatch.isError) {
469+
return newMatch;
470+
}
471+
// Recurse into redirect — top-level will be re-evaluated at the
472+
// top of the next cycle.
473+
return redirect(context, newMatch, redirectHistory: redirectHistory);
471474
}
475+
return matchList;
472476
}
473477

474-
if (prevMatchListFuture is RouteMatchList) {
475-
return processRedirect(prevMatchListFuture);
478+
final routeMatches = <RouteMatchBase>[];
479+
matchList.visitRouteMatches((RouteMatchBase match) {
480+
if (match.route.redirect != null) {
481+
routeMatches.add(match);
482+
}
483+
return true;
484+
});
485+
486+
try {
487+
final FutureOr<String?> routeLevelRedirectResult = _getRouteLevelRedirect(
488+
context,
489+
matchList,
490+
routeMatches,
491+
0,
492+
);
493+
494+
if (routeLevelRedirectResult is String?) {
495+
return processRouteLevelRedirect(routeLevelRedirectResult);
496+
}
497+
return routeLevelRedirectResult
498+
.then<RouteMatchList>(processRouteLevelRedirect)
499+
.catchError((Object error) {
500+
final GoException goException = error is GoException
501+
? error
502+
: GoException('Exception during route redirect: $error');
503+
return _errorRouteMatchList(
504+
matchList.uri,
505+
goException,
506+
extra: matchList.extra,
507+
);
508+
});
509+
} catch (exception) {
510+
final GoException goException = exception is GoException
511+
? exception
512+
: GoException('Exception during route redirect: $exception');
513+
return _errorRouteMatchList(
514+
matchList.uri,
515+
goException,
516+
extra: matchList.extra,
517+
);
476518
}
477-
return prevMatchListFuture.then<RouteMatchList>(processRedirect);
478519
}
479520

480521
/// Applies the legacy top-level redirect to [prevMatchList] and returns the
@@ -484,9 +525,10 @@ class RouteConfiguration {
484525
///
485526
/// Shares [redirectHistory] with later route-level redirects for proper loop detection.
486527
///
487-
/// Note: Legacy top-level redirect is executed at most once per navigation,
488-
/// before route-level redirects. It does not re-evaluate if it redirects to
489-
/// a location that would itself trigger another top-level redirect.
528+
/// Recursively re-evaluates the top-level redirect when it produces a new
529+
/// location, so that top-level redirect chains (e.g. `/``/a``/b`) are
530+
/// fully resolved. Loop detection and redirect limit are enforced via the
531+
/// shared [redirectHistory].
490532
FutureOr<RouteMatchList> applyTopLegacyRedirect(
491533
BuildContext context,
492534
RouteMatchList prevMatchList, {
@@ -500,7 +542,15 @@ class RouteConfiguration {
500542
prevMatchList.uri,
501543
redirectHistory,
502544
);
503-
return newMatch;
545+
if (newMatch.isError) {
546+
return newMatch;
547+
}
548+
// Recursively re-evaluate the top-level redirect on the new location.
549+
return applyTopLegacyRedirect(
550+
context,
551+
newMatch,
552+
redirectHistory: redirectHistory,
553+
);
504554
}
505555
return prevMatchList;
506556
}

packages/go_router/lib/src/parser.dart

Lines changed: 23 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -111,54 +111,22 @@ class GoRouteInformationParser extends RouteInformationParser<RouteMatchList> {
111111
);
112112

113113
// ALL navigation types now go through onEnter, and if allowed,
114-
// legacy top-level redirect runs, then route-level redirects.
114+
// redirect() handles both top-level and route-level redirects.
115115
return _onEnterHandler.handleTopOnEnter(
116116
context: context,
117117
routeInformation: effectiveRoute,
118118
infoState: infoState,
119119
onCanEnter: () {
120-
// Compose legacy top-level redirect here (one shared cycle/history).
121120
final RouteMatchList initialMatches = configuration.findMatch(
122121
effectiveRoute.uri,
123122
extra: infoState.extra,
124123
);
125-
final redirectHistory = <RouteMatchList>[];
126-
127-
final FutureOr<RouteMatchList> afterLegacy = configuration
128-
.applyTopLegacyRedirect(
129-
context,
130-
initialMatches,
131-
redirectHistory: redirectHistory,
132-
);
133-
134-
if (afterLegacy is RouteMatchList) {
135-
return _navigate(
136-
effectiveRoute,
137-
context,
138-
infoState,
139-
startingMatches: afterLegacy,
140-
preSharedHistory: redirectHistory,
141-
);
142-
}
143-
return afterLegacy.then((RouteMatchList ml) {
144-
if (!context.mounted) {
145-
return _lastMatchList ??
146-
_OnEnterHandler._errorRouteMatchList(
147-
effectiveRoute.uri,
148-
GoException(
149-
'Navigation aborted because the router context was disposed.',
150-
),
151-
extra: infoState.extra,
152-
);
153-
}
154-
return _navigate(
155-
effectiveRoute,
156-
context,
157-
infoState,
158-
startingMatches: ml,
159-
preSharedHistory: redirectHistory,
160-
);
161-
});
124+
return _navigate(
125+
effectiveRoute,
126+
context,
127+
infoState,
128+
startingMatches: initialMatches,
129+
);
162130
},
163131
onCanNotEnter: () {
164132
// If blocked, stay on the current route by restoring the last known good configuration.
@@ -198,19 +166,17 @@ class GoRouteInformationParser extends RouteInformationParser<RouteMatchList> {
198166
BuildContext context,
199167
RouteInfoState infoState, {
200168
FutureOr<RouteMatchList>? startingMatches,
201-
List<RouteMatchList>? preSharedHistory,
202169
}) {
203170
// If we weren't given matches, compute them here. The URI has already been
204171
// normalized at the parser entry point.
205172
final FutureOr<RouteMatchList> baseMatches =
206173
startingMatches ??
207174
configuration.findMatch(routeInformation.uri, extra: infoState.extra);
208175

209-
// History may be shared with the legacy step done in onEnter.
210-
final List<RouteMatchList> redirectHistory =
211-
preSharedHistory ?? <RouteMatchList>[];
176+
final redirectHistory = <RouteMatchList>[];
212177

213-
FutureOr<RouteMatchList> afterRouteLevel(FutureOr<RouteMatchList> base) {
178+
// redirect() handles both top-level and route-level redirects.
179+
FutureOr<RouteMatchList> applyRedirects(FutureOr<RouteMatchList> base) {
214180
if (base is RouteMatchList) {
215181
return configuration.redirect(
216182
context,
@@ -222,27 +188,33 @@ class GoRouteInformationParser extends RouteInformationParser<RouteMatchList> {
222188
if (!context.mounted) {
223189
return ml;
224190
}
225-
final FutureOr<RouteMatchList> step = configuration.redirect(
191+
return configuration.redirect(
226192
context,
227193
ml,
228194
redirectHistory: redirectHistory,
229195
);
230-
return step;
231196
});
232197
}
233198

234-
// Only route-level redirects from here on out.
235-
final FutureOr<RouteMatchList> redirected = afterRouteLevel(baseMatches);
199+
final FutureOr<RouteMatchList> redirected = applyRedirects(baseMatches);
236200

237201
return debugParserFuture =
238202
(redirected is RouteMatchList
239203
? SynchronousFuture<RouteMatchList>(redirected)
240204
: redirected)
241205
.then((RouteMatchList matchList) {
206+
// Guard against context disposal during async redirects.
207+
if (!context.mounted) {
208+
return _lastMatchList ??
209+
_OnEnterHandler._errorRouteMatchList(
210+
routeInformation.uri,
211+
GoException(
212+
'Navigation aborted because the router context was disposed.',
213+
),
214+
extra: infoState.extra,
215+
);
216+
}
242217
if (matchList.isError && onParserException != null) {
243-
if (!context.mounted) {
244-
return matchList;
245-
}
246218
return onParserException!(context, matchList);
247219
}
248220

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
changelog: |
2+
- Fixes chained top-level redirects not being fully resolved (e.g. `/ → /a → /b` stopping at `/a`).
3+
- Fixes route-level redirects not triggering top-level redirect re-evaluation on the new location.
4+
version: patch

0 commit comments

Comments
 (0)