Correct way to implement .refreshable with loneliving effects #2542
-
ScenarioI followed the case study Effects-LongLiving and attempted to create a long-lived effect. func asyncStreamState<T: Collection>(
placeholder: T,
body: @Sendable @escaping () async throws -> T
) async -> AsyncStream<CollectionLoadingState<T>> {
let stream = AsyncStream(CollectionLoadingState<T>.self) { continuation in
continuation.onTermination = { state in
print("🍖 state: \(state)")
}
Task {
continuation.yield(.loading(placeholder: placeholder))
let response = await TaskResult {
try await body()
}
print("🍖🍖 data received")
switch response {
case let .success(data):
continuation.yield(data.isEmpty ? .empty : .loaded(content: data))
case let .failure(error):
continuation.yield(.error(error))
}
continuation.finish()
}
}
return stream
} The main purpose is to encapsulate the network response into the following enum state: enum CollectionLoadingState<Content: Equatable>: Equatable {
case loading(placeholder: Content)
case loaded(content: Content)
case empty
case error(Error)
case unload
} Display different views with a long-lived effect: ScrollView {
// Use `viewStore.state.collectionState` to display views for empty, error, or data states
}
.refreshable {
print("- Refresh started")
await viewStore.send(.refresh)
} In my Reducer, it looks like this: struct ArtistsFeature: Reducer {
@Dependency(\.networkClient) var client
@Dependency(\.mainQueue) private var mainQueue
private enum CancelID { case dataRequest }
struct State: Equatable {
var collectionState: CollectionLoadingState<[Data]> = .unload
}
enum Action: Equatable {
case refresh
case dataResponse(CollectionLoadingState<[Data]>)
}
func reduce(into state: inout State, action: Action) -> Effect<Action> {
switch action {
case .refresh:
return .run { send in
for await state in await asyncStreamState(placeholder: Data.placeholder,
body: {
let result = try await client.fetchData()
return result
})
{
await send(.dataResponse(state))
}
}
.debounce(id: CancelID.dataRequest, for: 0.5, scheduler: mainQueue)
.cancellable(id: CancelID.dataRequest)
case let .dataResponse(response):
state.collectionState = response
return .none
}
}
} Problem I Want to AddressWhen using - Refresh started
🍖 state: cancelled
🍖🍖 data received
- Refresh started
🍖🍖 data received
🍖 state: finished
- Refresh started
🍖 state: cancelled
🍖🍖 data received However, if I remove the await and finish() from viewStore.send(.refresh), everything works fine: - Refresh started
🍖🍖 data received
🍖 state: finished
- Refresh started
🍖🍖 data received
🍖 state: finished
- Refresh started
🍖🍖 data received
🍖 state: finished Could someone please enlighten me on what is causing this issue, or if there's a better way to implement this feature? |
Beta Was this translation helpful? Give feedback.
Replies: 2 comments 2 replies
-
I am not a hundred percent sure but try using different cancelIds in debounce and cancel. AFAIK. when debounce uses the same Id it kinda cancels the request also? might worth looking into it. |
Beta Was this translation helpful? Give feedback.
-
Hi @maja0270558, this is just vanilla SwiftUI behavior. Here's a reproduction of the problem using just struct BuggyView: View {
@State var isLoading = false
var body: some View {
ScrollView {
Text("isLoading: \(self.isLoading.description)")
}
.refreshable {
self.isLoading = true
defer { self.isLoading = false }
do {
try await Task.sleep(for: .seconds(1))
} catch {
print(error)
}
}
}
}
#Preview {
BuggyView()
} If you run this you will find that doing pull-to-refresh causes the async work to be cancelled immediately. It seems that modifying any state while the async work is being performed causes the async work to be cancelled. I'm not sure why SwiftUI behaves this way, but it does. You can work around the bug by not awaiting |
Beta Was this translation helpful? Give feedback.
Hi @maja0270558, this is just vanilla SwiftUI behavior. Here's a reproduction of the problem using just
@State
:If you run this you will find that doing pull-to-refresh causes the async work to be cancelled immediately. It seems that modifying any state while the async work is being performed causes the async…