Skip to content

Commit a856fde

Browse files
authored
Merge pull request #10 from GoodRequest/route-async
fix: Yield to run loop when routing multiple steps
2 parents 9b2aaad + 3233be9 commit a856fde

File tree

1 file changed

+143
-56
lines changed

1 file changed

+143
-56
lines changed

Sources/GoodCoordinator/Router.swift

Lines changed: 143 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -33,80 +33,167 @@ private extension AnyReactor {
3333

3434
// MARK: - Public
3535

36-
public func route<R: Reactor>(_ reactor: R.Type = R.self, _ destination: R.Destination) {
37-
route(type: reactor, destination: destination)
36+
/// Routes to a specified destination within a reactor. The reactor must be present in the navigation hierarchy.
37+
///
38+
/// This function searches for the specified reactor type within the navigation hierarchy and attempts to route to
39+
/// the given destination if the reactor is found.
40+
///
41+
/// - Parameters:
42+
/// - reactor: The type of reactor to search for in the navigation hierarchy
43+
/// - destination: The destination associated with the given reactor
44+
/// - Returns: `true` if routing succeeded (the reactor was found in the hierarchy), `false` otherwise
45+
@discardableResult
46+
public func route<R: Reactor>(_ reactor: R.Type = R.self, _ destination: R.Destination) -> Bool {
47+
let lastFoundReactor = navigationPath.root.depthFirstSearch(NavigationStep(), predicate: { lhs, rhs in
48+
guard let rReactor = rhs.reactor else { return false }
49+
return rReactor.is(ofType: reactor)
50+
})
51+
52+
guard let lastFoundReactor else { return false }
53+
lastFoundReactor.value.mutator?((destination as! AnyDestination))
54+
55+
return true
3856
}
39-
40-
public func route<each R: Reactor>(type: repeat (each R).Type, destination: repeat (each R).Destination) {
57+
58+
/// Routes through a path of destinations across multiple reactors.
59+
///
60+
/// Routing starts with the first reactor, which must be present in the navigation hierarchy.
61+
/// For subsequent destinations, if their reactors are not in the navigation hierarchy, this function yields
62+
/// to the main run loop, allowing the UI to update its state. This process enables SwiftUI to create
63+
/// destination reactors from previous steps.
64+
///
65+
/// - Parameters:
66+
/// - type: A pack representing the reactors to route through
67+
/// - destination: A pack representing the destinations associated with each reactor type
68+
public func route<each R: Reactor>(type: repeat (each R).Type, destination: repeat (each R).Destination) async {
4169
for (type, destination) in repeat (each type, each destination) {
42-
let lastSuchReactor = navigationPath.root.depthFirstSearch(NavigationStep(), predicate: { lhs, rhs in
43-
guard let rReactor = rhs.reactor else { return false }
44-
return rReactor.is(ofType: type)
45-
})
46-
47-
guard let lastSuchReactor else { continue }
48-
lastSuchReactor.value.mutator?((destination as! AnyDestination))
70+
var attempts = 0
71+
var success = false
72+
repeat {
73+
attempts += 1
74+
success = route(type, destination)
75+
if !success {
76+
await Task.yield()
77+
}
78+
} while !success && attempts < 10
4979
}
5080
}
5181

52-
public func pop(last count: Int = 1) {
82+
/// Pops the current destination from the navigation hierarchy.
83+
///
84+
/// Performs a single back navigation step. If the current screen is presenting a destination,
85+
/// the presentation is cleared; otherwise the screen itself is dismissed by its parent.
86+
/// Does nothing when already at the root level.
87+
public func pop() {
88+
let currentDepth = navigationPath.lastActiveNode.depth
89+
guard currentDepth > 1 else { return }
90+
pop(node: navigationPath.lastActiveNode)
91+
}
92+
93+
/// Pops multiple destinations from the navigation hierarchy.
94+
///
95+
/// Pops up to `count` steps back. Between each step, this function yields to the main
96+
/// run loop, allowing the UI to update its state. If `count` exceeds the available depth,
97+
/// this operation pops to the root.
98+
///
99+
/// - Parameters:
100+
/// - count: The number of steps to pop. Defaults to 1.
101+
public func pop(last count: Int = 1) async {
53102
let currentDepth = navigationPath.lastActiveNode.depth
54103
guard currentDepth > 1 else { return }
55-
guard count < currentDepth else { return pop(last: currentDepth - 1) }
104+
guard count < currentDepth else { return await pop(last: currentDepth - 1) }
56105

57106
for _ in 0..<count {
58-
let currentNode = navigationPath.lastActiveNode
59-
let parentNode = currentNode.parent
60-
61-
if let parentNode {
62-
if parentNode.value.isTabs {
63-
// parent is tabs and screen may be presenting something directly
64-
if currentNode.value.currentDestination != nil && !currentNode.value.isTabs {
65-
currentNode.value.mutator?(nil)
66-
} else {
67-
popTabAttemptDetected()
68-
return
69-
}
70-
} else {
71-
// current destination is a reactor, pop from parent
72-
parentNode.value.mutator?(nil)
73-
}
107+
guard pop(node: navigationPath.lastActiveNode) else { return }
108+
await Task.yield()
109+
}
110+
}
111+
112+
/// Pops back to the nearest destination whose reactor matches the given type.
113+
///
114+
/// If a match is found, screens are popped one by one, yielding to the main run loop between steps.
115+
/// If no matching destination exists, no action is taken.
116+
///
117+
/// - Parameters:
118+
/// - reactor: The reactor type to pop to
119+
public func popTo<R: Reactor>(_ reactor: R.Type) async {
120+
var nodesToPop: [TreeNode<NavigationStep>] = []
121+
var currentNode = navigationPath.lastActiveNode
122+
var foundMatch = false
123+
124+
// iterate upwards to find matching reactor
125+
while let parentNode = currentNode.parent {
126+
nodesToPop.append(currentNode)
127+
128+
if currentNode.value.reactor?.is(ofType: reactor) ?? false {
129+
foundMatch = true
130+
break
74131
}
132+
133+
currentNode = parentNode
134+
}
135+
136+
// check if there is a match
137+
guard foundMatch else { return }
138+
139+
// pop screens only if possible
140+
for node in nodesToPop {
141+
guard pop(node: node) else { return }
142+
await Task.yield()
75143
}
76144
}
77145

146+
/// Drops inactive navigation branches (including tabs) and preserves only the active path.
147+
///
148+
/// Use this to reset previous tab and screen state once a new path becomes active. It is
149+
/// safe to call after switching tabs or routing across tabs to forget the previous tab’s
150+
/// state. This is also appropriate after major context changes (for example, transitioning
151+
/// from a logged-out flow to a logged-in flow, or resetting an onboarding).
152+
///
153+
/// Notes:
154+
/// - Internally, the navigation tree is pruned to keep only the path from root to the last
155+
/// active node, effectively resetting tabs.
156+
/// - When context tabs are switched, sibling tabs are marked inactive. Calling
157+
/// `cleanup()` prunes those inactive siblings and any inactive branches, effectively
158+
/// removing old branches and inactive tabs while keeping the current tab/screen intact.
159+
/// - The currently active screen and tab remain unchanged.
160+
/// - The function is idempotent, calling it when there are no inactive branches is a no-op.
78161
public func cleanup() {
79162
navigationPath.cleanup()
80163
}
81164

82-
public func popTo<R: Reactor>(_ reactor: R.Type) {
83-
var currentNode = navigationPath.lastActiveNode
84-
while var parentNode = currentNode.parent {
85-
if parentNode.value.isTabs {
86-
// parent is tabs, something is presented and presenting node has specified type
87-
if currentNode.value.currentDestination != nil && !currentNode.value.isTabs {
88-
if currentNode.value.reactor?.is(ofType: reactor) ?? false {
89-
currentNode.value.mutator?(nil)
90-
return
91-
} else {
92-
currentNode = parentNode
93-
continue
94-
}
95-
} else {
96-
popTabAttemptDetected()
97-
return
98-
}
165+
}
166+
167+
// MARK: - Private
168+
169+
private extension Router {
170+
171+
@discardableResult
172+
func pop(node currentNode: TreeNode<NavigationStep>) -> Bool {
173+
guard let parentNode = currentNode.parent else {
174+
// cannot pop root
175+
return false
176+
}
177+
178+
if parentNode.value.isTabs {
179+
if currentNode.value.currentDestination != nil && !currentNode.value.isTabs {
180+
// even when parent is a tab, it may be presenting something directly
181+
currentNode.value.mutator?(nil)
182+
return true
99183
} else {
100-
// current destination is a reactor, pop from parent if this is the expected destination
101-
// or continue searching up the hierarchy
102-
let parentReactor = parentNode.value.reactor
103-
if parentReactor?.is(ofType: reactor) ?? false {
104-
parentNode.value.mutator?(nil)
105-
return
106-
} else {
107-
currentNode = parentNode
108-
continue
109-
}
184+
// tab is not presenting anything
185+
popTabAttemptDetected()
186+
return false
187+
}
188+
} else {
189+
if currentNode.value.currentDestination != nil && !currentNode.value.isTabs {
190+
// current node is a screen and is presenting something directly
191+
currentNode.value.mutator?(nil)
192+
return true
193+
} else {
194+
// current node is a screen and is not presenting, popped from parent
195+
parentNode.value.mutator?(nil)
196+
return true
110197
}
111198
}
112199
}

0 commit comments

Comments
 (0)