From f2ac0564554b4c80c09da5877b69e95bfab34c91 Mon Sep 17 00:00:00 2001 From: Maksym Horobets Date: Fri, 17 Apr 2026 15:56:42 +0300 Subject: [PATCH 1/7] Introduce NavigationStackBound as a fix for unpredictable ContainerLifecycle calls --- UDF/View/Router/NavigationStackBound.swift | 125 +++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 UDF/View/Router/NavigationStackBound.swift diff --git a/UDF/View/Router/NavigationStackBound.swift b/UDF/View/Router/NavigationStackBound.swift new file mode 100644 index 00000000..0122af6f --- /dev/null +++ b/UDF/View/Router/NavigationStackBound.swift @@ -0,0 +1,125 @@ +//===--- 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 Foundation +import SwiftUI + +/// A `NavigationStack` wrapper that bridges an externally-owned `NavigationPath` +/// (typically backed by UDF state via a Container's `map(store:)`) to a stable, +/// locally-owned binding suitable for `NavigationStack`. +/// +/// ## Why this exists +/// +/// SwiftUI's `NavigationStack` is sensitive to the *identity* of the `Binding` +/// it receives. When the path lives in a UDF `Form` and is mapped into a +/// Container's `Props` as a freshly-constructed `Binding(get:set:)`, every +/// state mutation reconstructs that binding. During an in-flight UIKit push +/// animation, `NavigationStack` reacts to the new binding identity by +/// speculatively re-walking its destination subtree — which causes phantom +/// `@StateObject` initializations in any descendant `ConnectedContainer`, +/// including spurious `onContainerDidLoad` / `onContainerDidUnload` triggers. +/// +/// `NavigationStackBound` insulates `NavigationStack` from that churn: +/// +/// - It internally owns a `@State`-backed local `NavigationPath` whose +/// projected binding (`$local`) has stable storage identity across body +/// re-evaluations. +/// - It mirrors the external binding into local state on inbound changes and +/// forwards user-driven local mutations back to the external binding. +/// +/// The external binding remains the single source of truth — all navigation +/// mutations still flow through your existing actions. This wrapper changes +/// only *how the binding identity is delivered* to `NavigationStack` itself, +/// not where the path lives. +/// +/// ## Behavioral notes +/// +/// - The first sync seeds the local path from the external binding at +/// construction time, so deep links and restored state appear in the stack +/// on the first frame (no empty-stack flash). +/// - Inbound (state → local) and outbound (local → state) propagation is +/// gated by equality checks alone: the loop terminates because each side +/// reads the *current* value through its own storage, and once both sides +/// agree the guards short-circuit. No suppression flag is needed. +/// - The Container that vends the external binding **must** keep the path's +/// form inside its `scope(for:)`. If the scope excludes the form, the +/// Container's `ContainerState` won't re-evaluate on path changes, the +/// wrapper won't see the updated `Binding`, and pushes will not propagate. +/// +/// ## Usage +/// +/// ```swift +/// // Container +/// func scope(for state: AppState) -> Scope { +/// state.welcomeForm // must include the form that owns the path +/// } +/// +/// func map(store: EnvironmentStore) -> RootComponent.Props { +/// .init( +/// path: Binding( +/// get: { store.state.welcomeForm.navigation.path }, +/// set: { store.dispatch(Actions.UpdateNavigationPath(path: $0)) } +/// ) +/// ) +/// } +/// +/// // Component +/// struct Props { var path: Binding } +/// +/// var body: some View { +/// NavigationStackBound(to: props.path) { +/// WelcomeScreen() +/// } +/// .modifier(GlobalRoutingModifier(routing: AuthRouting.self)) +/// } +/// ``` +public struct NavigationStackBound: View { + /// The externally-owned path (typically projected from UDF state). + @Binding private var external: NavigationPath + + /// Stable, locally-owned path. `$local` is the identity-stable binding + /// that `NavigationStack` consumes. + @State private var local: NavigationPath + + /// The root view of the navigation stack. + private let root: () -> Root + + /// Creates a `NavigationStackBound` that bridges the supplied external + /// path to an internally-owned, identity-stable binding. + /// + /// - Parameters: + /// - path: The externally-owned `NavigationPath` binding (typically + /// constructed by a Container in `map(store:)` to read from and + /// dispatch into UDF state). + /// - root: The root view content of the navigation stack. + public init( + to path: Binding, + @ViewBuilder _ root: @escaping () -> Root + ) { + _external = path + _local = State(initialValue: path.wrappedValue) + self.root = root + } + + public var body: some View { + NavigationStack(path: $local) { + root() + } + .onChange(of: external) { newExternal in + guard local != newExternal else { return } + local = newExternal + } + .onChange(of: local) { newLocal in + guard external != newLocal else { return } + external = newLocal + } + } +} From 59b2a9396dec82fe7ac55603a5111134ee698683 Mon Sep 17 00:00:00 2001 From: Maksym Horobets Date: Fri, 17 Apr 2026 16:17:20 +0300 Subject: [PATCH 2/7] Update DooC --- UDF/View/Router/NavigationStackBound.swift | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/UDF/View/Router/NavigationStackBound.swift b/UDF/View/Router/NavigationStackBound.swift index 0122af6f..2b419153 100644 --- a/UDF/View/Router/NavigationStackBound.swift +++ b/UDF/View/Router/NavigationStackBound.swift @@ -63,12 +63,7 @@ import SwiftUI /// } /// /// func map(store: EnvironmentStore) -> RootComponent.Props { -/// .init( -/// path: Binding( -/// get: { store.state.welcomeForm.navigation.path }, -/// set: { store.dispatch(Actions.UpdateNavigationPath(path: $0)) } -/// ) -/// ) +/// .init(path: store.$state.welcomeForm.path) /// } /// /// // Component @@ -77,8 +72,9 @@ import SwiftUI /// var body: some View { /// NavigationStackBound(to: props.path) { /// WelcomeScreen() +/// .navigationDestination(for: AuthRouting.self) /// } -/// .modifier(GlobalRoutingModifier(routing: AuthRouting.self)) +/// .environment(\.globalRouter, GlobalRouter(path: props.path)) /// } /// ``` public struct NavigationStackBound: View { From b8cbd13a3c6dd90444a85d8157951d493b25f51e Mon Sep 17 00:00:00 2001 From: Maksym Horobets Date: Thu, 30 Apr 2026 21:23:36 +0300 Subject: [PATCH 3/7] Finish NavigationStackBound --- .../Router/NavigationStack+Deprecated.swift | 78 +++++++++ UDF/View/Router/NavigationStackBound.swift | 158 +++++++----------- 2 files changed, 140 insertions(+), 96 deletions(-) create mode 100644 UDF/View/Router/NavigationStack+Deprecated.swift 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 index 2b419153..1193d7f2 100644 --- a/UDF/View/Router/NavigationStackBound.swift +++ b/UDF/View/Router/NavigationStackBound.swift @@ -9,113 +9,79 @@ // //===----------------------------------------------------------------------===// -import Foundation import SwiftUI -/// A `NavigationStack` wrapper that bridges an externally-owned `NavigationPath` -/// (typically backed by UDF state via a Container's `map(store:)`) to a stable, -/// locally-owned binding suitable for `NavigationStack`. +/// A view that stabilizes the identity of a `NavigationStack`'s path binding. /// -/// ## Why this exists +/// 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. /// -/// SwiftUI's `NavigationStack` is sensitive to the *identity* of the `Binding` -/// it receives. When the path lives in a UDF `Form` and is mapped into a -/// Container's `Props` as a freshly-constructed `Binding(get:set:)`, every -/// state mutation reconstructs that binding. During an in-flight UIKit push -/// animation, `NavigationStack` reacts to the new binding identity by -/// speculatively re-walking its destination subtree — which causes phantom -/// `@StateObject` initializations in any descendant `ConnectedContainer`, -/// including spurious `onContainerDidLoad` / `onContainerDidUnload` triggers. +/// `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. /// -/// `NavigationStackBound` insulates `NavigationStack` from that churn: -/// -/// - It internally owns a `@State`-backed local `NavigationPath` whose -/// projected binding (`$local`) has stable storage identity across body -/// re-evaluations. -/// - It mirrors the external binding into local state on inbound changes and -/// forwards user-driven local mutations back to the external binding. -/// -/// The external binding remains the single source of truth — all navigation -/// mutations still flow through your existing actions. This wrapper changes -/// only *how the binding identity is delivered* to `NavigationStack` itself, -/// not where the path lives. -/// -/// ## Behavioral notes -/// -/// - The first sync seeds the local path from the external binding at -/// construction time, so deep links and restored state appear in the stack -/// on the first frame (no empty-stack flash). -/// - Inbound (state → local) and outbound (local → state) propagation is -/// gated by equality checks alone: the loop terminates because each side -/// reads the *current* value through its own storage, and once both sides -/// agree the guards short-circuit. No suppression flag is needed. -/// - The Container that vends the external binding **must** keep the path's -/// form inside its `scope(for:)`. If the scope excludes the form, the -/// Container's `ContainerState` won't re-evaluate on path changes, the -/// wrapper won't see the updated `Binding`, and pushes will not propagate. -/// -/// ## Usage -/// -/// ```swift -/// // Container -/// func scope(for state: AppState) -> Scope { -/// state.welcomeForm // must include the form that owns the path -/// } -/// -/// func map(store: EnvironmentStore) -> RootComponent.Props { -/// .init(path: store.$state.welcomeForm.path) -/// } -/// -/// // Component -/// struct Props { var path: Binding } -/// -/// var body: some View { -/// NavigationStackBound(to: props.path) { -/// WelcomeScreen() -/// .navigationDestination(for: AuthRouting.self) -/// } -/// .environment(\.globalRouter, GlobalRouter(path: props.path)) -/// } -/// ``` -public struct NavigationStackBound: View { - /// The externally-owned path (typically projected from UDF state). - @Binding private var external: NavigationPath +/// - 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 - /// Stable, locally-owned path. `$local` is the identity-stable binding - /// that `NavigationStack` consumes. - @State private var local: NavigationPath - - /// The root view of the navigation stack. - private let root: () -> Root + /// 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 the supplied external - /// path to an internally-owned, identity-stable binding. + /// Creates a `NavigationStackBound` that bridges an external collection-based path + /// (e.g. `Array`) to an internal identity-stable `@State` path. /// /// - Parameters: - /// - path: The externally-owned `NavigationPath` binding (typically - /// constructed by a Container in `map(store:)` to read from and - /// dispatch into UDF state). - /// - root: The root view content of the navigation stack. - public init( - to path: Binding, - @ViewBuilder _ root: @escaping () -> Root - ) { - _external = path - _local = State(initialValue: path.wrappedValue) - self.root = root + /// - 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 { - NavigationStack(path: $local) { - root() - } - .onChange(of: external) { newExternal in - guard local != newExternal else { return } - local = newExternal - } - .onChange(of: local) { newLocal in - guard external != newLocal else { return } - external = newLocal - } + content($local) + .onChange(of: external) { newExternal in + guard local != newExternal else { return } + local = newExternal + } + .onChange(of: local) { newLocal in + guard external != newLocal else { return } + external = newLocal + } } } From 037466100be2d9002086e90fa7719628e78cc64c Mon Sep 17 00:00:00 2001 From: Maksym Horobets Date: Thu, 30 Apr 2026 21:34:27 +0300 Subject: [PATCH 4/7] Implement NavigationStackBoundTests --- .../NavigationStackBoundTests.swift | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 Tests/SwiftUI-UDF-Tests/NavigationStackBoundTests.swift 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")) + } +} From 05f4e2d103941e66bfc0d40877080815457b9cf3 Mon Sep 17 00:00:00 2001 From: Maksym Horobets Date: Tue, 19 May 2026 13:09:52 +0300 Subject: [PATCH 5/7] backportOnChange --- UDF/View/Router/NavigationStackBound.swift | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/UDF/View/Router/NavigationStackBound.swift b/UDF/View/Router/NavigationStackBound.swift index 1193d7f2..357eef33 100644 --- a/UDF/View/Router/NavigationStackBound.swift +++ b/UDF/View/Router/NavigationStackBound.swift @@ -75,13 +75,27 @@ public struct NavigationStackBound: View { public var body: some View { content($local) - .onChange(of: external) { newExternal in + .backportOnChange(of: external) { newExternal in guard local != newExternal else { return } local = newExternal } - .onChange(of: local) { newLocal in + .backportOnChange(of: local) { newLocal in guard external != newLocal else { return } external = newLocal } } } + +private extension View { + func backportOnChange(of value: V, _ action: @escaping (_ newValue: V) -> Void) -> some View where V : Equatable { + if #available(iOS 17.0, *) { + return self.onChange(of: value) { _, newValue in + action(newValue) + } + } else { + return self.onChange(of: value) { newValue in + action(newValue) + } + } + } +} From 1ac0a5c859bea8d3a345b851ea4cb51d974af1af Mon Sep 17 00:00:00 2001 From: Maksym Horobets Date: Tue, 19 May 2026 14:02:30 +0300 Subject: [PATCH 6/7] Add macOS build support to backportOnChange modifier --- UDF/View/Router/NavigationStackBound.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UDF/View/Router/NavigationStackBound.swift b/UDF/View/Router/NavigationStackBound.swift index 357eef33..fe39e9b7 100644 --- a/UDF/View/Router/NavigationStackBound.swift +++ b/UDF/View/Router/NavigationStackBound.swift @@ -88,7 +88,7 @@ public struct NavigationStackBound: View { private extension View { func backportOnChange(of value: V, _ action: @escaping (_ newValue: V) -> Void) -> some View where V : Equatable { - if #available(iOS 17.0, *) { + if #available(iOS 17.0, macOS 14.0, *) { return self.onChange(of: value) { _, newValue in action(newValue) } From 8a85323c3c56b061c960fbf8b7ab58a168069838 Mon Sep 17 00:00:00 2001 From: Maksym Horobets Date: Tue, 19 May 2026 14:03:56 +0300 Subject: [PATCH 7/7] @ViewBuilder on backportOnChange --- UDF/View/Router/NavigationStackBound.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/UDF/View/Router/NavigationStackBound.swift b/UDF/View/Router/NavigationStackBound.swift index fe39e9b7..07833d68 100644 --- a/UDF/View/Router/NavigationStackBound.swift +++ b/UDF/View/Router/NavigationStackBound.swift @@ -87,13 +87,14 @@ public struct NavigationStackBound: View { } 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, *) { - return self.onChange(of: value) { _, newValue in + self.onChange(of: value) { _, newValue in action(newValue) } } else { - return self.onChange(of: value) { newValue in + self.onChange(of: value) { newValue in action(newValue) } }