The bug where the screen lands underneath
How, in a dish-rating app, a restaurant page kept rendering under the screen we'd just pushed — and the NavigationStack rule that fixes it.
It starts with an innocent-looking bug report. You open a dish's detail, tap the « Served at… »mention, the restaurant page opens. Great. You then tap a dish on its menu… and surprise : you stay on the restaurant page, the dish having slipped underneath.
This odd behaviour — « the screen I push lands under the current one » — isn't exotic. It's the symptom of a single navigation-architecture mistake, repeated in several places. Here's the hunt, the mechanics behind NavigationStack, and a pattern to never fall for it again.
📌 TL;DR
On a single NavigationStack, never mix value-based pushes (NavigationLink(value:) / path.append) and item-based ones (navigationDestination(item:)). A value push placed after an item push renders under it. Unify everything on one value-based NavigationPath.
1. Two ways to push a screen
SwiftUI offers several ways to stack a destination. Two matter here, and it's their coexistence that causes trouble.
① Value-based (driven by the path)
You push a value ; a typed destination, declared at the root, decides which view to show. The stack is an ordered array — the NavigationPath.
NavigationStack(path: $path) {
List(dishes) { dish in
NavigationLink(value: dish) { DishRow(dish) } // pushes a value
}
.navigationDestination(for: CanonicalDish.self) { dish in
CanonicalDishDetailView(dish: dish) // the root resolves the value
}
}
// elsewhere, imperatively:
path.append(dish)② Item-based (driven by an optional)
You bind a destination to an optional @State : as soon as it turns non-nil, the screen is pushed. Handy for an imperative trigger (a tap, a network response…).
@State private var selectedDish: CanonicalDish?
.navigationDestination(item: $selectedDish) { dish in
CanonicalDishDetailView(dish: dish)
}
Button("Open") { selectedDish = dish } // triggered imperativelyBoth work. The trap is mixing them on the same stack.
2. Why it « lands underneath »
A stack with a path manages its destinations as an array anchored at the root. A navigationDestination(item:), on the other hand, pushes into a separate layer, on top. When both coexist, a new value push goes into the path lane, under the item layer already stacked.
❌ Mixed stack (the bug)
✅ Uniform stack (fixed)
In our app, the path to a dish's detail was sometimes value-based (home, ranking), sometimes item-based (the sheet screens — « My reviews », « someone's reviews », the wishlist). And the restaurant page pushed its dishes item-based. As soon as a descent chained an item push then a value push, the latter went underneath.
⚠️ The rule that explains it all
A value push after an item push on the same stack renders underneath. The only reliable setup : everything value-based on one path.
3. The wrong turn
First instinct : « let's make everything item-based then ». Bad idea — stacking two item layers on a value base breaks at the second level, and the tabs are irreducibly value-based (they use a NavigationPathand zoom transitions). No middle ground : you have to unify. With most of the app already value-based, the direction was clear.
4. The fix, step by step
A context-carrying value type
The restaurant page must push a dish with its restaurant. We can't reuse the root's CanonicalDish destination (it would lose the context). So we create a dedicated value type — one that routes to its own destination instead of being shadowed.
struct RestaurantMenuDish: Hashable {
let dish: CanonicalDish
let restaurant: Restaurant
}
// the restaurant page pushes a VALUE (no more navigationDestination(item:)):
NavigationLink(value: RestaurantMenuDish(dish: dish, restaurant: restaurant)) {
DishRow(dish: dish)
}A helper that declares the destinations… and injects a « pusher »
Some pushes (a review's avatar, a profile's favorite dish) come from reusable callback-based cards, inside views that don't own the path. We expose an environment key : a navigationPush closure bound to the stack's path.
// 1. the environment key
private struct NavigationPushKey: EnvironmentKey {
static let defaultValue: (any Hashable) -> Void = { _ in }
}
extension EnvironmentValues {
var navigationPush: (any Hashable) -> Void {
get { self[NavigationPushKey.self] }
set { self[NavigationPushKey.self] = newValue }
}
}
// 2. a helper: declares the shared destinations AND injects the pusher
extension View {
func entityNavigationDestinations(path: Binding<NavigationPath>) -> some View {
self
.navigationDestination(for: Restaurant.self) { RestaurantDetailView(restaurant: $0) }
.navigationDestination(for: RestaurantMenuDish.self) { CanonicalDishDetailView(dish: $0.dish, restaurantContext: $0.restaurant) }
.navigationDestination(for: DishRating.self) { RatingDetailView(rating: $0) }
.navigationDestination(for: UserProfile.self) { PublicProfileView(userId: $0.id, initialName: $0.name) }
.environment(\.navigationPush) { value in
path.wrappedValue.append(value) // value-based, onto this stack's path
}
}
}A « deep » view then navigates like this, without ever owning the path :
@Environment(\.navigationPush) private var navigationPush
RatingCard(rating: rating,
onUserTap: { user in navigationPush(user) }) // → pushes onto the host's path🧩 A small Swift perk
path.append(value) with value of type any Hashable compiles : since Swift 5.7, existential opening lets you pass an existential to a generic function append<V: Hashable>(_:). The value is appended with its dynamic type, so the right navigationDestination(for:) catches it.
5. The killer detail: modifier order
For a pushed view to inherit navigationPush, the .environment must wrap all of the stack's navigationDestination modifiers. Since it's injected at the end of the helper, just apply the helper last, after the root's own destinations :
Group { … }
.navigationDestination(for: CanonicalDish.self) { dishDetail($0) }
.navigationDestination(for: RestaurantBrand.self) { BrandDishesView(brand: $0) }
.entityNavigationDestinations(path: $path) // ← LAST: its .environment wraps everythingPlaced this way, the environment is the outermost modifier : every destination is evaluated insidethe environment that holds the pusher. On iOS 18, environment propagation to navigationDestinationdestinations is reliable ; the pusher reaches even the deepest screens.
✅ The result
The 4 tabs, the sheets (« My reviews », « someone's reviews », wishlist…), the follower lists and the deep links now run on a single value-based NavigationPath. Deep chains — profile → favorite dish → « Served at… » — compose instead of landing underneath.
6. The one deliberate exception
A single navigationDestination(item:)survived, on purpose : on a dish's detail, the push to a review's detail. It keeps a handy hook :
.navigationDestination(item: $selectedRating) { RatingDetailView(rating: $0) }
.onChange(of: selectedRating) { _, value in
if value == nil { Task { await reloadRatings() } } // refresh the ratings on the way back
}A conscious trade-off : this reload-on-return has value, and the only descent that would stay imperfect (dish → review → author) is rare — the author is already tappable directly from the dish, value-based there. Documenting an exception beats suffering it.
7. Takeaways
- One way to push per stack.In practice : everything value-based on a
NavigationPath. - One typed destination per type, per root. Never redeclared in a child view.
- Value after item = underneath. If « the screen I push lands underneath », look for an upstream
navigationDestination(item:). - Shared callback-based cards ? A pusher in
@Environmentlets deep views navigate without owning the path. - Modifier order matters. The
.environmentmust wrap the destinations → helper last.
Moral of the story : what looked like a flaky rendering bug was just a stack mixing two mental models. SwiftUI has a model — an ordered path — and you have to stick to it across the whole stack. Once navigation is uniform, nothing lands underneath anymore.