Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Examples/Github-App/Github-App/View/RepositoryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ struct RepositoryView: View {

init(repository: Repository) {
self.repository = repository
self.store = Store(
store = Store(
reducer: RepositoryReducer(),
initialReducerState: RepositoryReducer.ReducerState(url: repository.url)
)
Expand Down
50 changes: 36 additions & 14 deletions Examples/Github-App/Github-App/View/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,51 @@ import SwiftUI
@Reducer
struct RootReducer {
enum ViewAction: Equatable {
case onSearchButtonTapped
case onTextChanged(String)
case textChanged
case onAppear
}

enum ReducerAction: Equatable {
case fetchRepositoriesResponse(TaskResult<[Repository]>)
case alert(Alert)
case queryChangeDebounced

enum Alert: Equatable {
case retry
}
}

enum CancelID {
case response
}

@Dependency(\.repositoryClient.fetchRepositories) var fetchRepositories
@Dependency(\.continuousClock) var clock

func reduce(into state: StateContainer<RootView>, action: Action) -> SideEffect<Self> {
switch action {
case .onSearchButtonTapped:
state.isLoading = true
return fetchRepositories(query: state.searchText)
case .onAppear:
return fetchRepositories(query: "Swift")

case let .onTextChanged(text):
if text.isEmpty {
case .textChanged:
if state.searchText.isEmpty {
state.repositories = []
return .none
} else {
return .send(.queryChangeDebounced)
.debounce(
id: CancelID.response,
for: .seconds(0.3),
clock: clock
)
}
return .none

case .queryChangeDebounced:
guard !state.searchText.isEmpty else {
return .none
}
state.isLoading = true
return fetchRepositories(query: state.searchText)

case let .fetchRepositoriesResponse(.success(repositories)):
state.isLoading = false
Expand Down Expand Up @@ -93,14 +112,17 @@ struct RootView: View {
ProgressView()
}
}
.searchable(text: $searchText)
.onSubmit(of: .search) {
send(.onSearchButtonTapped)
}
.onChange(of: searchText) { _, newValue in
send(.onTextChanged(newValue))
.searchable(
text: $searchText,
placement: .navigationBarDrawer
)
.onChange(of: searchText) { _, _ in
send(.textChanged)
}
.alert(target: self, unwrapping: $alertState)
.onAppear {
send(.onAppear)
}
}
}
}
Expand Down
39 changes: 16 additions & 23 deletions Examples/Github-App/Github-AppTests/RootReducerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import XCTest

@MainActor
final class RootReducerTests: XCTestCase {
func testSearchButtonTapped() async {
let store = makeStore(
repositoryClient: RepositoryClient(
fetchRepositories: { _ in [.stub] }
)
)
func testTextChanged() async {
let store = RootView().testStore(viewState: .init(searchText: "text")) {
$0.repositoryClient.fetchRepositories = { _ in [.stub] }
$0.continuousClock = ImmediateClock()
}

await store.send(.onSearchButtonTapped) {
await store.send(.textChanged)
await store.receive(.queryChangeDebounced) {
$0.isLoading = true
}
await store.receive(.fetchRepositoriesResponse(.success([.stub]))) {
Expand All @@ -20,15 +20,15 @@ final class RootReducerTests: XCTestCase {
}
}

func testSearchButtonTappedWithFailure() async {
func testTextChangedWithFailure() async {
let error = CancellationError()
let store = makeStore(
repositoryClient: RepositoryClient(
fetchRepositories: { _ in throw error }
)
)
let store = RootView().testStore(viewState: .init(searchText: "text")) {
$0.repositoryClient.fetchRepositories = { _ in throw error }
$0.continuousClock = ImmediateClock()
}

await store.send(.onSearchButtonTapped) {
await store.send(.textChanged)
await store.receive(.queryChangeDebounced) {
$0.isLoading = true
}
await store.receive(.fetchRepositoriesResponse(.failure(error))) {
Expand All @@ -48,19 +48,12 @@ final class RootReducerTests: XCTestCase {
}
}

func testTextChanged() async {
func testEmptyTextChanged() async {
let store = RootView().testStore(viewState: .init(repositories: [.stub]))
await store.send(.onTextChanged("test"))
await store.send(.onTextChanged("")) {
await store.send(.textChanged) {
$0.repositories = []
}
}

func makeStore(repositoryClient: RepositoryClient) -> TestStore<RootReducer> {
RootView().testStore(viewState: .init()) {
$0.repositoryClient = repositoryClient
}
}
}

extension Repository {
Expand Down
37 changes: 37 additions & 0 deletions Sources/SimplexArchitecture/SideEffect.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ public struct SideEffect<Reducer: ReducerProtocol>: Sendable {
case concurrentAction([Reducer.Action])
case serialEffect([SideEffect<Reducer>])
case concurrentEffect([SideEffect<Reducer>])
indirect case debounce(base: Self, id: AnyHashable, sleep: () async throws -> Void)
}

// The kind of side effect.
@usableFromInline
let kind: EffectKind

@usableFromInline
Expand Down Expand Up @@ -118,3 +120,38 @@ public extension SideEffect {
.init(effectKind: .concurrentEffect(effects))
}
}

public extension SideEffect {
/// Turns an side effect into one that can be debounced.
///
/// debounce is an operator that plays only the last event in a sequence of SideEffects that have been issued for more than a certain time interval.
/// To turn an effect into a debounce-able one you must provide an identifier, which is used to determine which in-flight effect should be canceled in order to start a new effect.
///
/// - Parameters:
/// - id: The effect's identifier.
/// - duration: The duration you want to debounce for.
/// - clock: A clock conforming to the `Clock` protocol, used to measure the passing of time for the debounce duration.
/// - Returns: A debounced version of the effect.
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
@inlinable
func debounce(
id: some Hashable,
for duration: Duration,
clock: any Clock<Duration>
) -> Self {
switch kind {
case .none, .debounce:
self
default:
.init(
effectKind: .debounce(
base: kind,
id: AnyHashable(id),
sleep: {
try await clock.sleep(for: duration)
}
)
)
}
}
}
22 changes: 17 additions & 5 deletions Sources/SimplexArchitecture/Store/Store+send.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ extension Store {
return .never
} else {
let send = _send ?? makeSend(for: container)
let tasks = runEffect(sideEffect, send: send)
let tasks = runEffect(sideEffect.kind, send: send)

return reduce(tasks: tasks)
}
Expand Down Expand Up @@ -128,10 +128,10 @@ extension Store {
}

func runEffect(
_ sideEffect: borrowing SideEffect<Reducer>,
_ sideEffect: SideEffect<Reducer>.EffectKind,
send: Send<Reducer>
) -> [SendTask] {
switch sideEffect.kind {
switch sideEffect {
case let .run(priority, operation, `catch`):
let task = Task.withEffectContext(priority: priority ?? .medium) {
do {
Expand Down Expand Up @@ -179,7 +179,7 @@ extension Store {
case let .serialEffect(effects):
let task = Task.detached {
for effect in effects {
await self.reduce(tasks: self.runEffect(effect, send: send)).wait()
await self.reduce(tasks: self.runEffect(effect.kind, send: send)).wait()
}
}
return [SendTask(task: task)]
Expand All @@ -189,12 +189,24 @@ extension Store {
partialResult.append(
SendTask(
task: Task.detached {
await self.reduce(tasks: self.runEffect(effect, send: send)).wait()
await self.reduce(tasks: self.runEffect(effect.kind, send: send)).wait()
}
)
)
}

case let .debounce(base, id, sleep):
cancellableTasks[id]?.cancel()
let cancellableTask = Task.withEffectContext {
try? await sleep()
guard !Task.isCancelled else {
return
}
await reduce(tasks: runEffect(base, send: send)).wait()
}
cancellableTasks.updateValue(cancellableTask, forKey: id)
return [SendTask(task: cancellableTask)]

case .none:
return []
}
Expand Down
8 changes: 8 additions & 0 deletions Sources/SimplexArchitecture/Store/Store.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ public final class Store<Reducer: ReducerProtocol> {
// Buffer to store Actions recurrently invoked through SideEffect in a single Action sent from View
@TestOnly
var sentFromEffectActions: [ActionTransition<Reducer>] = []
// If debounce or cancel is used in SideEffect, the task is stored here
var cancellableTasks: [AnyHashable: Task<Void, Never>] = [:]

var _send: Send<Reducer>?
var initialReducerState: (() -> Reducer.ReducerState)?
Expand Down Expand Up @@ -52,6 +54,12 @@ public final class Store<Reducer: ReducerProtocol> {
self.initialReducerState = initialReducerState
}

deinit {
cancellableTasks.values.forEach { task in
task.cancel()
}
}

@discardableResult
@usableFromInline
func setContainerIfNeeded(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public struct ReducerMacro: MemberMacro {
name: .identifier("Action"),
inheritanceClause: InheritanceClauseSyntax {
(viewAction.inheritanceClause?.inheritedTypes ?? []) +
[InheritedTypeSyntax(type: TypeSyntax(stringLiteral: "ActionProtocol"))]
[InheritedTypeSyntax(type: TypeSyntax(stringLiteral: "ActionProtocol"))]
}
) {
MemberBlockItemListSyntax {
Expand All @@ -92,7 +92,7 @@ public struct ReducerMacro: MemberMacro {
reducerActionToAction
}
}
.formatted().cast(EnumDeclSyntax.self)
.formatted().cast(EnumDeclSyntax.self)
).formatted().cast(DeclSyntax.self),
].compactMap { $0 }
}
Expand Down Expand Up @@ -204,33 +204,33 @@ public struct ReducerMacro: MemberMacro {
private static func changeAnyNestedDeclToTypealias(action: EnumDeclSyntax, reducerAccessModifier: String) -> EnumDeclSyntax {
action.with(
\.memberBlock,
action.memberBlock.with(
action.memberBlock.with(
\.members,
MemberBlockItemListSyntax(
MemberBlockItemListSyntax(
action.memberBlock.members.compactMap {
if let name = $0.hasName?.name.text,
!name.contains(action.name.text)
{
$0.with(
\.decl,
DeclSyntax(
DeclSyntax(
TypeAliasDeclSyntax(
modifiers: .init(arrayLiteral: DeclModifierSyntax(name: .identifier(reducerAccessModifier))),
name: .identifier(name),
initializer: TypeInitializerClauseSyntax(
value: IdentifierTypeSyntax(name: .identifier("\(action.name.text).\(name)"))
)
)
)
)
)
} else {
$0
}
}
)
)
.formatted()
.cast(MemberBlockSyntax.self)
)
)
.formatted()
.cast(MemberBlockSyntax.self)
)
}

Expand Down
22 changes: 22 additions & 0 deletions Tests/SimplexArchitectureTests/ReducerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,18 @@ final class ReducerTests: XCTestCase {
await testStore.receiveWithoutStateCheck(.decrement)
await testStore.receiveWithoutStateCheck(.increment)
}

func testDebounced() async {
let testStore = TestView().testStore(
viewState: .init()) {
$0.continuousClock = ImmediateClock()
}

await testStore.send(.debounced)
await testStore.receive(.increment) {
$0.count = 1
}
}
}

struct TestDependency: DependencyKey {
Expand Down Expand Up @@ -195,6 +207,7 @@ private struct TestReducer {
case testDependencies
case runEffectsSerially
case runEffectsConcurrently
case debounced
}

@Dependency(\.continuousClock) private var clock
Expand Down Expand Up @@ -281,6 +294,15 @@ private struct TestReducer {
await send(.increment)
}
)

case .debounced:
enum CancelID { case debounce }
return .send(.increment)
.debounce(
id: CancelID.debounce,
for: .seconds(0.3),
clock: clock
)
}
}
}
Expand Down
Loading