Skip to content

Commit bd0ca05

Browse files
fix(router): pop to previous view, not the outlet layout URL (#6533)
* fix(router): pop to previous view, not the outlet layout URL The default on_view_pop handler navigated to chain[-2].resolved_path, which can be an outlet=True layout that shares the leaf view's URL. Popping such a view navigated to the URL already shown, stranding the page route so the next navigation to it was a no-op (e.g. in nested_outlet_views: going back to Home then tapping "Browse Products" did nothing). Compute the pop target from the chain's view entries instead, skipping outlet layouts and componentless grouping routes. Add a test_router_pop unit test for the pop-target computation and extend the nested_outlet_views integration test to cover popping back to Home and re-navigating. Also add a navigation-idempotency step to the nested_routes integration test. * chore(release): prepare 0.85.3 Bump the Dart packages/flet/pubspec.yaml to 0.85.3 (Python packages take their version from CI at release time) and refresh client/pubspec.lock. Add 0.85.3 changelog sections: a Router view-pop bug fix (#6533) in the root CHANGELOG, and a no-Dart-changes coordination note in packages/flet/CHANGELOG.md.
1 parent 0cf0f03 commit bd0ca05

7 files changed

Lines changed: 155 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
## 0.85.3
2+
3+
### Bug fixes
4+
5+
* Fix `flet.Router`'s default `on_view_pop` navigating to the wrong URL when an `outlet=True` layout sits between two views in `manage_views=True` mode. Popping such a view now targets the previous view entry's resolved URL — skipping outlet layouts and componentless grouping routes — instead of `chain[-2]`, which could equal the current view's URL and strand the page route, making the next navigation to it a no-op ([#6533](https://github.com/flet-dev/flet/pull/6533)) by @FeodorFitsner.
6+
17
## 0.85.2
28

39
### New features

client/pubspec.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,7 @@ packages:
359359
path: "../packages/flet"
360360
relative: true
361361
source: path
362-
version: "0.85.2"
362+
version: "0.85.3"
363363
flet_ads:
364364
dependency: "direct main"
365365
description:

packages/flet/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 0.85.3
2+
3+
_No changes in the `flet` Dart package; version bumped for release coordination with a `flet.Router` view-pop fix on the Python side._
4+
15
## 0.85.2
26

37
_No changes in the `flet` Dart package; version bumped for release coordination with `flet.Router` enhancements on the Python side (modal/recursive route flags, chain-based default pop)._

packages/flet/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: flet
22
description: Write entire Flutter app in Python or add server-driven UI experience into existing Flutter app.
33
homepage: https://flet.dev
44
repository: https://github.com/flet-dev/flet/tree/main/packages/flet
5-
version: 0.85.2
5+
version: 0.85.3
66

77
# Supported platforms
88
platforms:

sdk/python/packages/flet/integration_tests/examples/apps/test_router.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,14 @@ async def test_nested_routes(flet_app_function: ftt.FletTestApp):
534534
go_products_btn = await flet_app_function.tester.find_by_text("Go to Products")
535535
assert go_products_btn.count == 1
536536

537+
# Navigation idempotency: re-tap from Home after popping all the way
538+
# back must navigate again (the route must not be stranded by the pop).
539+
await flet_app_function.tester.tap(go_products_btn)
540+
await flet_app_function.tester.pump_and_settle()
541+
542+
products_title = await flet_app_function.tester.find_by_text("All Products")
543+
assert products_title.count == 1
544+
537545

538546
@pytest.mark.parametrize(
539547
"flet_app_function",
@@ -576,6 +584,29 @@ async def test_nested_outlet_views(flet_app_function: ftt.FletTestApp):
576584
products_title = await flet_app_function.tester.find_by_text("All Products")
577585
assert products_title.count == 1
578586

587+
# AppBar back → Home. Popping the products view must navigate to "/"
588+
# (the previous *view*), not to the outlet layout's own "/products"
589+
# URL. If it lands on the layout URL, the page route stays at
590+
# "/products" while Flutter shows Home — and the next "Browse
591+
# Products" tap becomes a no-op navigation to the current URL.
592+
back_btn = await flet_app_function.tester.find_by_tooltip("Back")
593+
assert back_btn.count == 1
594+
await flet_app_function.tester.tap(back_btn)
595+
await flet_app_function.tester.pump_and_settle()
596+
597+
welcome = await flet_app_function.tester.find_by_text("Welcome!")
598+
assert welcome.count == 1
599+
600+
# Re-navigate to products — proves the route wasn't stranded on the
601+
# layout URL after the pop.
602+
browse_btn = await flet_app_function.tester.find_by_text("Browse Products")
603+
assert browse_btn.count == 1
604+
await flet_app_function.tester.tap(browse_btn)
605+
await flet_app_function.tester.pump_and_settle()
606+
607+
products_title = await flet_app_function.tester.find_by_text("All Products")
608+
assert products_title.count == 1
609+
579610

580611
@pytest.mark.parametrize(
581612
"flet_app_function",

sdk/python/packages/flet/src/flet/components/router.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -686,17 +686,26 @@ def on_view_pop(e):
686686
page.navigate(current_modal_pop_to_ref.current)
687687
return
688688

689-
# Non-modal pop: use the matched chain's parent route
690-
# (route-tree-structural) rather than
691-
# `views[-2].route`. This survives shared view keys
692-
# like a tab-root layout that emits `route="/"` for
689+
# Non-modal pop: navigate to the previous *view entry*'s
690+
# resolved URL rather than `chain[-2]` or
691+
# `views[-2].route`. Classifying into view entries skips
692+
# `outlet=True` layouts and componentless grouping routes
693+
# — otherwise `chain[-2]` can point at a layout whose URL
694+
# equals the current view's URL, making the pop navigate
695+
# to where we already are (a no-op that strands the URL).
696+
# Staying route-tree-structural also survives shared view
697+
# keys like a tab-root layout emitting `route="/"` for
693698
# multiple sibling sections.
694699
chain_now = current_chain_ref.current
695-
if chain_now and len(chain_now) > 1:
696-
parent_match = chain_now[-2]
697-
target = parent_match.resolved_path or parent_match.full_path or "/"
698-
page.navigate(target)
699-
return
700+
if chain_now:
701+
_, view_entries = _split_chain_into_view_levels(chain_now)
702+
if len(view_entries) > 1:
703+
parent_match = view_entries[-2][0]
704+
target = (
705+
parent_match.resolved_path or parent_match.full_path or "/"
706+
)
707+
page.navigate(target)
708+
return
700709

701710
# Stack of length 1 — nothing to pop to. (Flutter's
702711
# `Navigator.canPop` is False here anyway.)
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Unit tests for the non-modal view-pop target computation.
2+
3+
The `Router`'s default `on_view_pop` handler navigates to the previous
4+
*view entry*'s resolved URL when the top view is popped. It must derive
5+
that target from the chain's view entries (`_split_chain_into_view_levels`)
6+
rather than `chain[-2]`: an intermediate `outlet=True` layout sits in the
7+
chain between two views but is NOT a view of its own, and its resolved URL
8+
can equal the current view's URL. Using `chain[-2]` there would navigate to
9+
the URL we're already at — a no-op that leaves the page route stranded so
10+
the next navigation to the same URL does nothing.
11+
12+
This replicates the handler's target computation (the handler itself is a
13+
closure inside `Router`) the same way `test_router_modal` replicates the
14+
modal-index lookup.
15+
"""
16+
17+
from flet.components.router import (
18+
Route,
19+
_match_routes,
20+
_split_chain_into_view_levels,
21+
)
22+
23+
24+
def _dummy(): # placeholder component — Route requires a callable
25+
pass
26+
27+
28+
def _pop_target(chain):
29+
"""Replicates the Router's non-modal pop-target computation."""
30+
if not chain:
31+
return None
32+
_, view_entries = _split_chain_into_view_levels(chain)
33+
if len(view_entries) > 1:
34+
parent_match = view_entries[-2][0]
35+
return parent_match.resolved_path or parent_match.full_path or "/"
36+
return None
37+
38+
39+
# Mirrors examples/apps/router/nested_outlet_views: a Home route whose
40+
# child is an `outlet=True` products layout wrapping its own children.
41+
_routes = [
42+
Route(
43+
component=_dummy,
44+
children=[
45+
Route(
46+
path="products",
47+
component=_dummy,
48+
outlet=True,
49+
children=[
50+
Route(
51+
component=_dummy,
52+
children=[
53+
Route(path=":pid", component=_dummy),
54+
],
55+
),
56+
],
57+
),
58+
],
59+
),
60+
]
61+
62+
63+
def test_pop_from_outlet_layout_view_skips_layout():
64+
"""`/products` builds views [Home(/), Products(/products)] but the
65+
chain is [Home, products-layout, ProductsList]. Popping the Products
66+
view must land on Home (`/`) — not the layout's own `/products`,
67+
which `chain[-2]` would wrongly yield."""
68+
chain = _match_routes(_routes, "/products")
69+
70+
assert chain is not None
71+
# The layout really is `chain[-2]` and shares the leaf's resolved URL,
72+
# which is exactly the trap the view-entry computation avoids.
73+
assert chain[-2].route.outlet is True
74+
assert chain[-2].resolved_path == "/products"
75+
76+
assert _pop_target(chain) == "/"
77+
78+
79+
def test_pop_from_nested_product_lands_on_products_list():
80+
"""`/products/1` builds views [Home(/), ProductsList(/products),
81+
ProductDetails(/products/1)]. Popping the details view lands on the
82+
products list (`/products`)."""
83+
chain = _match_routes(_routes, "/products/1")
84+
85+
assert chain is not None
86+
assert _pop_target(chain) == "/products"
87+
88+
89+
def test_single_view_has_no_pop_target():
90+
"""At `/` the stack is one view — nothing to pop to."""
91+
chain = _match_routes(_routes, "/")
92+
93+
assert chain is not None
94+
assert _pop_target(chain) is None

0 commit comments

Comments
 (0)