From 7ac47b63e5e0e9b7838b1b5ec27bd075c01c114c Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 16 Oct 2024 10:09:09 -0700 Subject: [PATCH 1/3] Support eager navigation destination resolution Currently we steer folks to using `viewDidLoad` for all observation, but this laziness can lead to issues with deep-linking in type-erased navigation stacks, where a destination may declare a sub-destination in its `viewDidLoad`, causing the first drill-down to occur lazily over multiple animated steps instead of a single one. This behavior can be seen in the type-erased navigation case study in the repository. This PR is a proof of concept to show that the library can populate a "current navigation stack" in the UI transaction so that a destination's initializer can declare its dependent destinations, fixing the case study behavior. --- .../ErasedNavigationStackController.swift | 16 +++++++-------- .../NavigationStackController.swift | 20 +++++++++++++++++-- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/Examples/CaseStudies/UIKit/ErasedNavigationStackController.swift b/Examples/CaseStudies/UIKit/ErasedNavigationStackController.swift index c77aae3e3..e2eb3fb37 100644 --- a/Examples/CaseStudies/UIKit/ErasedNavigationStackController.swift +++ b/Examples/CaseStudies/UIKit/ErasedNavigationStackController.swift @@ -81,6 +81,10 @@ private class NumberFeatureViewController: UIViewController { self.number = number super.init(nibName: nil, bundle: nil) title = "Feature \(number)" + + navigationDestination(for: String.self) { string in + StringFeatureViewController(string: string) + } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") @@ -89,10 +93,6 @@ private class NumberFeatureViewController: UIViewController { super.viewDidLoad() view.backgroundColor = .systemBackground - navigationDestination(for: String.self) { string in - StringFeatureViewController(string: string) - } - let numberButton = UIButton( type: .system, primaryAction: UIAction { [weak self] _ in @@ -137,6 +137,10 @@ private class StringFeatureViewController: UIViewController { self.string = string super.init(nibName: nil, bundle: nil) title = "Feature '\(string)'" + + navigationDestination(for: Bool.self) { bool in + BoolFeatureViewController(bool: bool) + } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") @@ -145,10 +149,6 @@ private class StringFeatureViewController: UIViewController { super.viewDidLoad() view.backgroundColor = .systemBackground - navigationDestination(for: Bool.self) { bool in - BoolFeatureViewController(bool: bool) - } - let numberButton = UIButton( type: .system, primaryAction: UIAction { [weak self] _ in diff --git a/Sources/UIKitNavigation/Navigation/NavigationStackController.swift b/Sources/UIKitNavigation/Navigation/NavigationStackController.swift index 54cc23f48..feda12692 100644 --- a/Sources/UIKitNavigation/Navigation/NavigationStackController.swift +++ b/Sources/UIKitNavigation/Navigation/NavigationStackController.swift @@ -338,14 +338,19 @@ ) return } - stackController.path.append(.lazy(.element(value))) + withUITransaction(\.stackController, stackController) { + stackController.path.append(.lazy(.element(value))) + } } public func navigationDestination( for data: D.Type, destination: @escaping (D) -> UIViewController ) { - guard let navigationController = navigationController ?? self as? UINavigationController + guard + let navigationController = UITransaction.current.stackController + ?? navigationController + ?? self as? UINavigationController else { reportIssue( """ @@ -442,4 +447,15 @@ } } } + + private extension UITransaction { + var stackController: NavigationStackController? { + get { self[NavigationStackControllerKey.self] } + set { self[NavigationStackControllerKey.self] = newValue } + } + } + + private enum NavigationStackControllerKey: UITransactionKey { + static let defaultValue: NavigationStackController? = nil + } #endif From fe04fb866822a699dbaf08a95ae9fc07d0d16de9 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 19 Jun 2026 13:55:38 -0700 Subject: [PATCH 2/3] test --- .../NavigationPathTests.swift | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/Examples/CaseStudiesTests/NavigationPathTests.swift b/Examples/CaseStudiesTests/NavigationPathTests.swift index 1dcc28214..a3cf1efb8 100644 --- a/Examples/CaseStudiesTests/NavigationPathTests.swift +++ b/Examples/CaseStudiesTests/NavigationPathTests.swift @@ -483,6 +483,32 @@ final class NavigationPathTests: XCTestCase { await assertEventuallyNoDifference(path.elements, [.eager(1), .eager("Hello"), .eager(true)]) } + @MainActor + func testPushMultipleFeaturesAtOnce_InitRegisteredNavigationDestination() async throws { + @UIBinding var path = UINavigationPath() + let nav = NavigationStackController(path: $path) { + InitRootViewController() + } + try await setUp(controller: nav) + + let root = nav.viewControllers[0] + withUITransaction(\.uiKit.disablesAnimations, true) { + root.traitCollection.push(value: 2) + root.traitCollection.push(value: "Hello") + root.traitCollection.push(value: true) + } + + await assertEventuallyEqual(nav.viewControllers.count, 4, timeout: 2) + await assertEventuallyNoDifference( + nav.values, + [2, "Hello", true] as [AnyHashable] + ) + await assertEventuallyNoDifference( + path.elements, + [.eager(2), .eager("Hello"), .eager(true)] + ) + } + @MainActor func testRegisterNavigationDestinationTypeMultipleTimes_LastOneWins() async throws { @UIBinding var path = UINavigationPath() @@ -625,6 +651,43 @@ private final class BoolViewController: UIViewController, _ValueViewController { } } +private final class InitRootViewController: UIViewController { + override func viewDidLoad() { + super.viewDidLoad() + navigationDestination(for: Int.self) { int in + InitIntegerViewController(value: int) + } + } +} + +private final class InitIntegerViewController: UIViewController, _ValueViewController { + let value: Int + init(value: Int) { + self.value = value + super.init(nibName: nil, bundle: nil) + navigationDestination(for: String.self) { string in + InitStringViewController(value: string) + } + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private final class InitStringViewController: UIViewController, _ValueViewController { + let value: String + init(value: String) { + self.value = value + super.init(nibName: nil, bundle: nil) + navigationDestination(for: Bool.self) { bool in + BoolViewController(value: bool) + } + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + private final class UserViewController: UIViewController, _ValueViewController { let value: User init(value: User) { From e0086155d91310d8f9ea894ed3e393f7744a00bb Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 19 Jun 2026 14:47:41 -0700 Subject: [PATCH 3/3] wip --- .../NavigationPathTests.swift | 27 +++++++++++++++++++ .../NavigationStackController.swift | 8 +++--- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/Examples/CaseStudiesTests/NavigationPathTests.swift b/Examples/CaseStudiesTests/NavigationPathTests.swift index a3cf1efb8..f40a22c23 100644 --- a/Examples/CaseStudiesTests/NavigationPathTests.swift +++ b/Examples/CaseStudiesTests/NavigationPathTests.swift @@ -509,6 +509,33 @@ final class NavigationPathTests: XCTestCase { ) } + @MainActor + func testDeepLink_InitRegisteredNavigationDestination() async throws { + var initialPath = UINavigationPath() + initialPath.append(1) + initialPath.append("Hello") + initialPath.append(true) + @UIBinding var path = initialPath + + let nav = NavigationStackController(path: $path) { + UIViewController() + } + nav.navigationDestination(for: Int.self) { int in + InitIntegerViewController(value: int) + } + try await setUp(controller: nav) + + await assertEventuallyEqual(nav.viewControllers.count, 4, timeout: 2) + await assertEventuallyNoDifference( + nav.values, + [1, "Hello", true] as [AnyHashable] + ) + await assertEventuallyNoDifference( + path.elements, + [.eager(1), .eager("Hello"), .eager(true)] + ) + } + @MainActor func testRegisterNavigationDestinationTypeMultipleTimes_LastOneWins() async throws { @UIBinding var path = UINavigationPath() diff --git a/Sources/UIKitNavigation/Navigation/NavigationStackController.swift b/Sources/UIKitNavigation/Navigation/NavigationStackController.swift index 58ec5fef3..7207bfd9d 100644 --- a/Sources/UIKitNavigation/Navigation/NavigationStackController.swift +++ b/Sources/UIKitNavigation/Navigation/NavigationStackController.swift @@ -168,7 +168,9 @@ guard let destinationType = navigationID.elementType, let destination = destinations[DestinationType(destinationType)], - let (viewController, element) = destination(navigationID) + let (viewController, element) = withUITransaction(\.stackController, self, { + destination(navigationID) + }) else { return nil } @@ -357,9 +359,7 @@ ) return } - withUITransaction(\.stackController, stackController) { - stackController.path.append(.lazy(.element(value))) - } + stackController.path.append(.lazy(.element(value))) } public func navigationDestination(