@@ -107,11 +107,98 @@ public class ViewGraphNode<NodeView: View, Backend: AppBackend> {
107
107
let tag = String ( String ( describing: NodeView . self) . split ( separator: " < " ) [ 0 ] )
108
108
backend. tag ( widget: widget, as: tag)
109
109
110
- // Update the view and its children when state changes (children are always updated first)
110
+ // Updates the view and its children when state changes (children are always updated first).
111
+ // All of the concurrency related code is there to detect when updates can be merged
112
+ // together (a.k.a. when one of the updates is unnecessary).
113
+ let protectingQueue = DispatchQueue ( label: " state update debouncing " )
114
+ let concurrentUpdateHandlingQueue = DispatchQueue (
115
+ label: " concurrent update handling queue " ,
116
+ attributes: . concurrent
117
+ )
118
+ var updateIsQueued = false
119
+ var updateIsRunning = false
120
+ var aCurrentJobDidntHaveToWait = false
121
+ let synchronizationSemaphore = DispatchSemaphore ( value: 1 )
111
122
cancellable = view. state. didChange. observe { [ weak self] in
112
123
guard let self = self else { return }
113
- self . backend. runInMainThread {
114
- self . bottomUpUpdate ( )
124
+
125
+ // I'm sorry if you have to make sense of this... Take my comments as a peace offering.
126
+
127
+ // Hop to a dispatch queue to avoid blocking any threads in the Swift Structured
128
+ // Concurrency thread pool in the case that the state update originated from a task.
129
+ concurrentUpdateHandlingQueue. async {
130
+ // If no one is running, then we run without waiting, and if someone's running
131
+ // but no one's waiting, then we wait and prevent anyone else from waiting.
132
+ // This ensures that at least one update will always happen after every update
133
+ // received so far, without letting unnecessary updates queue up. The reason
134
+ // that we can merge updates like this is that all state updates are built equal;
135
+ // they don't carry any information other than that they happened.
136
+ var shouldWait = false
137
+ protectingQueue. sync {
138
+ if !updateIsQueued {
139
+ shouldWait = true
140
+ }
141
+
142
+ if updateIsRunning {
143
+ updateIsQueued = true
144
+ } else {
145
+ updateIsRunning = true
146
+ aCurrentJobDidntHaveToWait = true
147
+ }
148
+ }
149
+
150
+ guard shouldWait else {
151
+ return
152
+ }
153
+
154
+ // Waiting just involves attempting to jump to the main thread.
155
+ self . backend. runInMainThread {
156
+ // This semaphore is used because some backends don't put us on the main
157
+ // thread since they don't have the concept of a single UI thread like
158
+ // macOS does.
159
+ //
160
+ // If `backend.runInMainThread` is truly putting us on the main thread,
161
+ // then this never have to block significantly, otherwise we're just
162
+ // blocking some random thread, so either way we're fine since we've
163
+ // explicitly hopped to a dispatch queue to escape any cooperative
164
+ // Swift Structured Concurrency thread pool the state update may have
165
+ // originated from.
166
+ synchronizationSemaphore. wait ( )
167
+
168
+ protectingQueue. sync {
169
+ // If a current job didn't have to wait, then that's us. Due to
170
+ // concurrency that doesn't mean we were the first update triggered.
171
+ // That is, we could've been the job that set `updateIsQueued` to
172
+ // true while still being the job that reached this line first (before
173
+ // the one that set `updateIsRunning` to true). And that's why I've
174
+ // implemented the check in this way with a protected 'global' and not
175
+ // a local variable (being first isn't a property we can know ahead
176
+ // of time). I use 'global' in the sense of shared between all calls
177
+ // to the state update handling closure for a given ViewGraphNode.
178
+ //
179
+ // The reason that `aCurrentJobDidntHaveToWait` is needed at all is
180
+ // so that we can know whether `updateIsQueued`'s value is due to us
181
+ // or someone else/no one.
182
+ if aCurrentJobDidntHaveToWait {
183
+ aCurrentJobDidntHaveToWait = false
184
+ } else {
185
+ updateIsQueued = false
186
+ }
187
+ }
188
+
189
+ self . bottomUpUpdate ( )
190
+
191
+ // If someone is waiting then we leave `updateIsRunning` equal to true
192
+ // because they'll immediately begin running as soon as we exit and we
193
+ // don't want an extra person queueing until they've actually started.
194
+ protectingQueue. sync {
195
+ if !updateIsQueued {
196
+ updateIsRunning = false
197
+ }
198
+ }
199
+
200
+ synchronizationSemaphore. signal ( )
201
+ }
115
202
}
116
203
}
117
204
}
0 commit comments