Skip to content
70 changes: 70 additions & 0 deletions Tests/SwiftUI-UDF-Tests/NavigationStackBoundTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//===--- NavigationStackBoundTests.swift -----------------------------------===//
//
// This source file is part of the UDF open source project
//
// Copyright (c) 2026 You are launched
// Licensed under Apache License v2.0
//
// See https://opensource.org/licenses/Apache-2.0 for license information
//
//===----------------------------------------------------------------------===//

import SwiftUI
@testable import UDF
import Testing

struct NavigationStackBoundTests {
@Test
@MainActor
func navigationStackBoundWithNavigationPath() {
let path = Binding.constant(NavigationPath())
let view = NavigationStackBound(path: path) {
Text("Root")
}

let desc = "\(view)"
// Verify the struct initializes successfully and is correctly identified
#expect(desc.contains("NavigationStackBound"))
}

@Test
@MainActor
func navigationStackBoundWithCollection() {
let path = Binding.constant([Int]())
let view = NavigationStackBound(path: path) {
Text("Root")
}

let desc = "\(view)"
// Verify the struct initializes successfully and is correctly identified
#expect(desc.contains("NavigationStackBound"))
}

@Test
@MainActor
func deprecatedNavigationStackWithNavigationPathResolves() {
let path = Binding.constant(NavigationPath())

let view = NavigationStack(path: path) {
Text("Root")
}

let desc = "\(view)"
// The shadowed function delegates to the standard native NavigationStack
#expect(desc.contains("NavigationStack"))
}

@Test
@MainActor
func deprecatedNavigationStackWithCollectionResolves() {
let path = Binding.constant([String]())

let view = NavigationStack(path: path) {
Text("Root")
}

let desc = "\(view)"
// The shadowed function delegates to the standard native NavigationStack
#expect(desc.contains("NavigationStack"))
}
}
78 changes: 78 additions & 0 deletions UDF/View/Router/NavigationStack+Deprecated.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
//===--- NavigationStack+Deprecated.swift ----------------------------------===//
//
// This source file is part of the UDF open source project
//
// Copyright (c) 2026 You are launched
// Licensed under Apache License v2.0
//
// See https://opensource.org/licenses/Apache-2.0 for license information
//
//===----------------------------------------------------------------------===//

import SwiftUI

/// An internal protocol used to unify the generic constraints of `NavigationPath` and `Array`.
///
/// This protocol exists solely to work around Swift compiler limitations regarding function overloading
/// and generic constraints. By having both `NavigationPath` and `Array` conform to this protocol,
/// we can provide a **single** global `NavigationStack` shadow function, rather than two separate overloads.
/// This completely eliminates "Ambiguous use of NavigationStack" errors when developers use the standard
/// `NavigationStack` API.
public protocol _UDFBindablePath {
/// Builds and returns the underlying native `SwiftUI.NavigationStack`.
///
/// - Parameters:
/// - path: The binding to the navigation path.
/// - root: The root view of the navigation stack.
/// - Returns: A standard `SwiftUI.NavigationStack`.
@MainActor
static func _buildStack<Root: View>(
path: Binding<Self>,
@ViewBuilder root: @escaping () -> Root
) -> SwiftUI.NavigationStack<Self, Root>
}

extension NavigationPath: _UDFBindablePath {
@MainActor
public static func _buildStack<Root: View>(
path: Binding<NavigationPath>,
@ViewBuilder root: @escaping () -> Root
) -> SwiftUI.NavigationStack<NavigationPath, Root> {
SwiftUI.NavigationStack(path: path, root: root)
}
}

extension Array: _UDFBindablePath where Element: Hashable {
@MainActor
public static func _buildStack<Root: View>(
path: Binding<Array<Element>>,
@ViewBuilder root: @escaping () -> Root
) -> SwiftUI.NavigationStack<Array<Element>, Root> {
SwiftUI.NavigationStack(path: path, root: root)
}
}

/// A global shadow function that overrides the standard `NavigationStack` initializers to emit a compile-time warning.
///
/// ## Why this exists
///
/// Using SwiftUI's native `NavigationStack(path:)` with a binding projected from UDF state causes
/// unstable binding identities. This instability forces SwiftUI to speculatively re-evaluate the
/// navigation tree on every state update, leading to phantom `@StateObject` allocations and spurious
/// `onContainerDidLoad` / `onContainerDidUnload` lifecycle triggers.
///
/// This function intercepts those calls and issues a deprecation warning, instructing developers to
/// switch to `NavigationStackBound`, which encapsulates the native stack while stabilizing the binding identity.
///
/// - Parameters:
/// - path: The binding to the navigation path (either `NavigationPath` or `Array`).
/// - root: The root view of the stack.
/// - Returns: A standard `SwiftUI.NavigationStack` (with a compiler warning).
@available(*, deprecated, message: "UDF Warning: Use NavigationStackBound when binding to UDF-managed state to prevent transient view lifecycle issues.")
@MainActor
public func NavigationStack<Data: _UDFBindablePath, Root: View>(
path: Binding<Data>,
@ViewBuilder root: @escaping () -> Root
) -> SwiftUI.NavigationStack<Data, Root> {
Data._buildStack(path: path, root: root)
}
102 changes: 102 additions & 0 deletions UDF/View/Router/NavigationStackBound.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
//===--- NavigationStackBound.swift ----------------------------------------===//
//
// This source file is part of the UDF open source project
//
// Copyright (c) 2026 You are launched
// Licensed under Apache License v2.0
//
// See https://opensource.org/licenses/Apache-2.0 for license information
//
//===----------------------------------------------------------------------===//

import SwiftUI

/// A view that stabilizes the identity of a `NavigationStack`'s path binding.
///
/// In UDF, bindings are often projected from a central store. If the store's state changes,
/// the binding's identity can change, causing SwiftUI to speculatively re-evaluate
/// the navigation tree. This leads to phantom `@StateObject` allocations and spurious
/// `onContainerDidLoad`/`onContainerDidUnload` triggers in descendant views.
///
/// `NavigationStackBound` insulates the `NavigationStack` from this churn by maintaining
/// a local, identity-stable `@State` copy of the path, which it synchronizes with the
/// external UDF state.
///
/// - Parameters:
/// - Data: The type of the navigation path (e.g., `NavigationPath` or `Array`).
/// - Content: The type of the underlying `NavigationStack`.
public struct NavigationStackBound<Data: Equatable, Content: View>: View {
/// The externally-owned path, typically projected from UDF state.
@Binding private var external: Data

/// Stable, locally-owned path used to prevent identity churn.
@State private var local: Data

/// Internal closure that constructs the underlying `NavigationStack` using the stabilized path.
private let content: (Binding<Data>) -> Content

/// Creates a `NavigationStackBound` that bridges an external `NavigationPath`
/// to an internal identity-stable `@State` path.
///
/// - Parameters:
/// - path: A binding to the external `NavigationPath`.
/// - root: A view builder that creates the root view of the navigation stack.
public init<Root: View>(
path: Binding<NavigationPath>,
@ViewBuilder root: @escaping () -> Root
) where Data == NavigationPath, Content == NavigationStack<NavigationPath, Root> {
self._external = path
self._local = State(initialValue: path.wrappedValue)

self.content = { boundPath in
SwiftUI.NavigationStack(path: boundPath, root: root)
}
}

/// Creates a `NavigationStackBound` that bridges an external collection-based path
/// (e.g. `Array<Hashable>`) to an internal identity-stable `@State` path.
///
/// - Parameters:
/// - path: A binding to the external collection-based path.
/// - root: A view builder that creates the root view of the navigation stack.
public init<Root: View>(
path: Binding<Data>,
@ViewBuilder root: @escaping () -> Root
) where Data: MutableCollection & RandomAccessCollection & RangeReplaceableCollection,
Data.Element: Hashable,
Content == NavigationStack<Data, Root> {
self._external = path
self._local = State(initialValue: path.wrappedValue)

self.content = { boundPath in
SwiftUI.NavigationStack(path: boundPath, root: root)
}
}

public var body: some View {
content($local)
.backportOnChange(of: external) { newExternal in
guard local != newExternal else { return }
local = newExternal
}
.backportOnChange(of: local) { newLocal in
guard external != newLocal else { return }
external = newLocal
}
Comment thread
Sashe0 marked this conversation as resolved.
}
}

private extension View {
@ViewBuilder
func backportOnChange<V>(of value: V, _ action: @escaping (_ newValue: V) -> Void) -> some View where V : Equatable {
if #available(iOS 17.0, macOS 14.0, *) {
self.onChange(of: value) { _, newValue in
action(newValue)
}
} else {
self.onChange(of: value) { newValue in
action(newValue)
}
}
}
}
Loading