Repository navigation
Conversation
Browser back navigation removed the page while the Navigator was still building, and the synchronous notifyListeners() from onDidRemovePage triggered setState() during build. Fixes #1184 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
LCOV of commit
|
| Metric | Coverage | Details |
|---|---|---|
| 📈 Lines | 🔴 25.3% | 936 / 3702 lines |
| 🔧 Functions | ⚪ N/A | no data found |
| 🌿 Branches | ⚪ N/A | no data found |
📁 Files Changed Coverage
| File | Lines | Functions | Branches |
|---|---|---|---|
lib/src/code_generation/router_annotation/extended_navigator.dart |
⚪ – (0) | ⚪ – (0) | ⚪ – (0) |
lib/src/router/controller/routing_controller.dart |
🔴 0.0% (192) | ⚪ – (0) | ⚪ – (0) |
🟢 ≥ 90% · 🟡 ≥ 70% · 🔴 below 70% · ⚪ no data
Full coverage report
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
| _pages.removeAt(pageIndex); | ||
| if (pageIndex == -1) { | ||
| // Already removed by another path; nothing to notify. | ||
| return; |
There was a problem hiding this comment.
Early return skips the redirect-guard cleanup and child-router cleanup when the page is already gone from _pages.
removeWhere (and _reset / updateDeclarativeRoutes) take pages out of _pages directly. They never call _removeRedirectGuard or _removeTopRouterOf. Before this PR, when the Navigator then dropped that page, onDidRemovePage -> removeRoute hit pageIndex == -1 but still ran the guard loop and _removeTopRouterOf, so the listener got cleaned up there. With this early return that never happens.
Scenario: a route guarded by a RedirectGuard is removed with router.removeWhere(...). Its listener stays in _redirectGuardsListeners. The next time the guard calls notifyListeners() (for example on an auth state change), _reevaluate runs on this router. No guarded route is left in the stack, so it goes straight to _strategy.reevaluate(...), and that can re-push routes or removeAllAndPush for a page the user already left.
Suggestion: skip only the _pages.removeAt and the notification when pageIndex == -1. Keep the cleanup, but make it null-safe. For example, _removeRedirectGuard should do nothing if _redirectGuardsListeners[guard] is null.
| if (SchedulerBinding.instance.schedulerPhase == | ||
| SchedulerPhase.persistentCallbacks) { | ||
| SchedulerBinding.instance.addPostFrameCallback((_) { | ||
| notifyAll(forceUrlRebuild: true); |
There was a problem hiding this comment.
The deferred notifyAll can run on a router that has already been disposed.
The post-frame closure holds onto this and never checks whether the controller is still alive. Nested routers dispose their StackRouter in State.dispose (nested_router.dart, nested_router_delegate.dart). Unmounting happens during finalizeTree, which is still in the same persistentCallbacks phase that scheduled the callback.
Scenario: a nested router gets onDidRemovePage during the build phase, and its widget is unmounted later in that same frame. The post-frame callback then calls notifyListeners() on the disposed ChangeNotifier. In debug builds this throws "A StackRouter was used after being disposed". It also still triggers root.notifyListeners() and navigationHistory.rebuildUrl() from a stale router, which can overwrite the URL with the torn-down state.
Suggestion: track disposal (for example a _disposed flag set in dispose) and return early from the callback when it is set.
…r dispose Addresses review: the early return skipped redirect-guard and child-router cleanup for pages already taken out of the stack, and the deferred notification could run on a disposed router. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t comments Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
|
||
| // Defers the notification when called mid-build to avoid setState during | ||
| // build. | ||
| void _notifyRouteRemoved() { |
There was a problem hiding this comment.
[medium] The build-phase guard is applied only to _removeRoute, so the reported crash is still reachable through the sibling removal paths.
notifyAll(forceUrlRebuild: true) is also called synchronously from removeWhere (~line 1089) and _removeUntil (~line 1075), and notifyAll() from _addEntry (~line 1181). None of them go through _notifyRouteRemoved, so they still call setState()/markNeedsBuild() during build.
I verified this on this branch: a descendant widget whose build calls router.removeWhere((r) => r.name == 'SecondRoute') (the same shape as the first new test, which uses removeRoute) still throws:
The following assertion was thrown while dispatching notifications for _R:
setState() or markNeedsBuild() called during build.
The widget on which setState() or markNeedsBuild() was called was: _RootRouter
...
The following assertion was thrown while dispatching notifications for NestedRouterDelegate:
setState() or markNeedsBuild() called during build.
The widget on which setState() or markNeedsBuild() was called was: Router<Object>
Note this also shows the second setState source (NestedRouterDelegate._handleRebuild via navigationHistory.rebuildUrl()), which _notifyRouteRemoved happens to cover for the _removeRoute path but not elsewhere.
Since the helper already exists, consider making it a general _notifySafely({bool forceUrlRebuild}) and routing removeWhere, _removeUntil and _addEntry through it too — otherwise the fix is specific to the one stack frame in #1184 rather than to the class of bug.
| await tester.binding.handlePopRoute(); | ||
|
|
||
| for (var i = 0; i < 10; i++) { | ||
| await tester.pump(const Duration(milliseconds: 50)); | ||
| expect(tester.takeException(), isNull); | ||
| } |
There was a problem hiding this comment.
[low] This second test passes on master too, so it does not guard the fix — and it never asserts that the back navigation actually did anything.
I ran the three new test files against the pre-fix routing_controller.dart: browser_back_navigation_test.dart test #1 and nested_router_dispose_test.dart fail (good reproductions), but this handlePopRoute() test passes both before and after. So the "real system/browser back navigation" path as modelled here never actually hit the reported exception.
It is also vacuous in a second way: the only assertion is takeException() == null. If handlePopRoute() silently did nothing, the test would still pass. Instrumenting it shows the pop does happen (router.stack.length == 1, currentUrl == '/'), so those are cheap assertions to add:
| await tester.binding.handlePopRoute(); | |
| for (var i = 0; i < 10; i++) { | |
| await tester.pump(const Duration(milliseconds: 50)); | |
| expect(tester.takeException(), isNull); | |
| } | |
| await tester.binding.handlePopRoute(); | |
| for (var i = 0; i < 10; i++) { | |
| await tester.pump(const Duration(milliseconds: 50)); | |
| expect(tester.takeException(), isNull); | |
| } | |
| expect(router.stack, hasLength(1)); | |
| expect(find.text('Home'), findsOneWidget); | |
| expect(router.currentUrl, '/'); |
Worth flagging in the PR description too, since it is presented as the end-to-end check for #1184 — the only test that actually reproduces the reported exception is test #1, which triggers the removal from a descendant's build rather than from a genuine browser back.
…ing build Addresses review: the build-phase guard only covered _removeRoute. removeWhere, _removeUntil and _addEntry still notified synchronously and could trigger setState during build the same way. Route them through one _notifySafely helper. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Summary
Fixes #1184: browser back navigation on a Stacked web app (default
stacked create app -t webtemplate) throwssetState() or markNeedsBuild() called during buildfrom_RootRouter.Root cause
Matched against the reported stack trace:
Navigator.onDidRemovePageinroute_navigator.dartcallsrouter.removeRoute(routeData).StackRouter._removeRoutecallednotifyAll(forceUrlRebuild: true)unconditionally, even when the page was already gone from_pages.notifyAlltriggers_RootRouterState._handleRebuild, which callssetState().When the removal is a side effect of the Navigator updating while the router widget's own rebuild is still in progress, that synchronous
setState()lands during build and throws.Fix
lib/src/router/controller/routing_controller.dart:_pages, nothing is removed, shared path data is left alone and listeners are not notified. Redirect-guard cleanup and_removeTopRouterOfstill run, as onmaster, so pages taken out directly byremoveWhere,_resetorupdateDeclarativeRoutesstill get their guard listeners and child routers cleaned up._removeRedirectGuardis null-safe. It does nothing when the guard has no registered listener._notifySafelyhelper defers the notification withaddPostFrameCallbackwhen called duringSchedulerPhase.persistentCallbacks, and stays synchronous in every other phase. It is used by_removeRoute,removeWhere,_removeUntiland_addEntry, so the fix covers the class of bug rather than the one stack frame in the report.setActiveIndexand the declarativeupdateRoutespath are left as they are.StackRoutertracks_disposed. On a disposed router the deferred callback skips its own listeners, which would throw, but still notifies the root and rebuilds the URL when it is a nested router, so the browser URL does not keep showing a removed nested path.Tests
browser_back_navigation_test.dart: removing the top page from a descendant'sbuildwhile the router widget is rebuilding, viaremoveRoute,removeWhereandremoveUntil. All three reproduce the reported exception without the fix. Only these reproduce it. ThehandlePopRoute()test is an end-to-end sanity check that a real pop completes and leaves one page, and it passes onmastertoo.router_cleanup_test.dart: a route with aRedirectGuardis removed viaremoveWhere, then the Navigator's follow-upremoveRoutehits the not-found path. Asserts the guard listener is detached and a laterreevaluatedoes not re-push. Failed with the earlier early return, passes now.nested_router_dispose_test.dart: a nested router schedules the deferred notify and is disposed in the same frame. Threw "used after being disposed" before the guard, passes now.Verification
flutter test: 99 passed, 1 pre-existing skipdart analyze lib test: no new issuesNote: #1197 also touches
_removeRoute. Whichever merges second will need a small rebase.🤖 Generated with Claude Code