diff --git a/Tests/SwiftUI-UDF-Tests/NavigationStackBoundTests.swift b/Tests/SwiftUI-UDF-Tests/NavigationStackBoundTests.swift new file mode 100644 index 00000000..3068540a --- /dev/null +++ b/Tests/SwiftUI-UDF-Tests/NavigationStackBoundTests.swift @@ -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")) + } +} diff --git a/UDF/View/Router/NavigationStack+Deprecated.swift b/UDF/View/Router/NavigationStack+Deprecated.swift new file mode 100644 index 00000000..ddff3b1f --- /dev/null +++ b/UDF/View/Router/NavigationStack+Deprecated.swift @@ -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( + path: Binding, + @ViewBuilder root: @escaping () -> Root + ) -> SwiftUI.NavigationStack +} + +extension NavigationPath: _UDFBindablePath { + @MainActor + public static func _buildStack( + path: Binding, + @ViewBuilder root: @escaping () -> Root + ) -> SwiftUI.NavigationStack { + SwiftUI.NavigationStack(path: path, root: root) + } +} + +extension Array: _UDFBindablePath where Element: Hashable { + @MainActor + public static func _buildStack( + path: Binding>, + @ViewBuilder root: @escaping () -> Root + ) -> SwiftUI.NavigationStack, 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( + path: Binding, + @ViewBuilder root: @escaping () -> Root +) -> SwiftUI.NavigationStack { + Data._buildStack(path: path, root: root) +} diff --git a/UDF/View/Router/NavigationStackBound.swift b/UDF/View/Router/NavigationStackBound.swift new file mode 100644 index 00000000..07833d68 --- /dev/null +++ b/UDF/View/Router/NavigationStackBound.swift @@ -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: 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) -> 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( + path: Binding, + @ViewBuilder root: @escaping () -> Root + ) where Data == NavigationPath, Content == NavigationStack { + 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`) 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( + path: Binding, + @ViewBuilder root: @escaping () -> Root + ) where Data: MutableCollection & RandomAccessCollection & RangeReplaceableCollection, + Data.Element: Hashable, + Content == NavigationStack { + 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 + } + } +} + +private extension View { + @ViewBuilder + func backportOnChange(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) + } + } + } +}