Skip to content

fix(navigation): keep the stack alive when the unwind target is absent - #718

Open
EugeneSusla wants to merge 4 commits into
simonoppowa:developfrom
EugeneSusla:fix/nav-stack-without-add-meal
Open

fix(navigation): keep the stack alive when the unwind target is absent#718
EugeneSusla wants to merge 4 commits into
simonoppowa:developfrom
EugeneSusla:fix/nav-stack-without-add-meal

Conversation

@EugeneSusla

Copy link
Copy Markdown

The bug

Logging an intake from a recipe leaves the app on a black screen. The
intake itself is saved correctly — only the screen after it is lost.

The two unwinds that run at the end of the logging flow name a route that
is not always on the stack:

  • edit_meal_screen.dart:911pushNamedAndRemoveUntil(mealDetailRoute, ModalRoute.withName(addMealRoute))
  • meal_detail_bottom_sheet.dart:319popUntil(ModalRoute.withName(mainRoute))

ModalRoute.withName(x) matches nothing when x is absent, and both
popUntil and pushNamedAndRemoveUntil respond to a predicate that never
matches by removing every route. An empty navigator renders as a black
screen with nothing to navigate back to.

Reachable chain

  1. Recipes tab → a recipe → Log → pick an intake type.
    recipe_detail_screen.dart:247 pushes mealDetailRoute. Nothing on
    this path pushed addMealRoute, so the stack is [main, mealDetail].
  2. On that meal detail, the edit pencil
    (meal_detail_screen.dart:300-311) pushes editMealRoute with an
    EditMealScreenArguments that passes no editOnly. It defaults to
    false → the create-and-log save path.
  3. Saving runs the unwind at edit_meal_screen.dart:911. No addMealRoute
    matches, so every route below the new meal detail is removed — main
    included. The stack is now [mealDetail].
  4. The intake sheet's Add (meal_detail_bottom_sheet.dart:319) writes
    the intake, then runs popUntil(withName(mainRoute)). No match, so it
    pops the last remaining route. Empty navigator → black screen.

The other Edit Meal entry points are not affected, which is why this went
unnoticed: recipes_page.dart:164 and custom_meals_tab.dart:160 both
pass editOnly: true and therefore pop() instead of unwinding, and
add_meal_screen.dart:530 reaches Edit Meal with addMealRoute genuinely
on the stack.

The fix

namedRouteOrFirst(routeName)(route) => route.isFirst || route.settings.name == routeName.

isFirst is the right floor because the main screen is the first route
once the app is running: splash_screen.dart:67 reaches it with
pushReplacementNamed, and the onboarding exits
(onboarding_screen.dart:150, :505,
onboarding_intro_page_body.dart:220) replace or clear the stack the same
way. So a stack that has no mainRoute by name still has the main screen
at the bottom, and stopping there is what the unwind was aiming for
anyway.

The predicate deliberately does not assert that the first route is main.
Landing on the bottom route beats an empty navigator in every case, and
for the addMealRoute call site the first route is legitimately something
else.

activity_detail_screen.dart:323 carried the identical
popUntil(withName(mainRoute)) and is fixed defensively — it is only
reachable after step 3 has already removed main, so it cannot empty
the stack on its own today, but it would break the same way if it ever
could.

Tests

test/features/edit_meal/presentation/edit_meal_unwind_test.dart drives
the real chain: the real MealDetailScreen's edit pencil, the real
EditMealScreen's save, and the real MealDetailBottomSheet's Add, on
the [main, mealDetail] stack a recipe log leaves behind. Only the main
screen is stubbed — all these unwinds want from it is a route named main
to survive.

Run unchanged against develop it reproduces the whole bug: the intake is
written, the navigator ends up empty, and nothing renders at all.

test/core/utils/navigation_predicates_test.dart covers the predicate
directly, including a test that pins Flutter's stack-emptying behaviour —
kept on purpose, as the signal for when this helper can be deleted.

Logging an intake could leave the app on a black screen. Two unwinds use
ModalRoute.withName, which removes every route when the named one is not on
the stack: Edit Meal's save does pushNamedAndRemoveUntil(mealDetail,
withName(addMeal)), and the intake bottom sheet then does
popUntil(withName(main)). Reached from the home shortcut's scanner — a stack
of main -> scanner with no addMeal below — the first call wipes main, the
second finds nothing to stop at and pops the last route too, leaving an
empty navigator.

Both now stop at the first route as well. After the splash screen replaces
itself the first route is the main screen, which is the floor both unwinds
were aiming for, so the flows that do have addMeal on the stack behave
exactly as before.

The intake itself was always written; only the screen after it was lost.
Logging an activity ran the same popUntil(ModalRoute.withName(main)) as the
intake sheet, so it emptied the navigator on any stack that had lost the
main route. Same predicate, same fix.

Also drops the .vscode SDK path that fvm rewrote into the branch: it pins
the version a second time next to .fvmrc, and .fvm/flutter_sdk is the
symlink fvm keeps current for exactly that reason.
Drives the real EditMealScreen save on the stack a recipe log leaves
behind — [main, mealDetail] with no addMealRoute anywhere — and asserts
the main screen is still there afterwards.

Reverting the call site to ModalRoute.withName(addMealRoute) fails this
test: the save removes every route below the new meal detail, so the
intake sheet's popUntil(mainRoute) has nothing left to stop at and empties
the navigator.

The screens either side are stubs. Pushing editMealRoute is four
unconditional lines in the meal-detail edit pencil, and standing up
MealDetailScreen's seven-usecase bloc would not make the unwind under test
any more real; the popUntil that follows it is covered in
navigation_predicates_test.dart.
The first cut stubbed both screens either side of Edit Meal, so two of its
claims were not actually tested: the `editOnly` assertion only re-read the
constructor's own default, and the intake sheet's unwind was re-typed in
the test rather than driven through meal_detail_bottom_sheet.dart.

Both screens are real now. The test taps the real meal-detail edit pencil,
the real Edit Meal save, and the real Add button on the real intake sheet,
with only the main screen stubbed — all these unwinds want from it is a
route named `main` to survive.

Run unchanged against origin/develop (9b2b421) it reproduces the whole
bug: the intake is written, every route under the new meal detail is
removed, Add's popUntil finds no `main`, and the navigator ends up empty —
no Scaffold rendering at all.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant