Skip to content

Add Effect.send #1859

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jan 24, 2023
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
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ final class LoginSwiftUITests: XCTestCase {
initialState: Login.State(),
reducer: Login(),
observe: LoginView.ViewState.init,
send: action: Login.Action.init
send: Login.Action.init
) {
$0.authenticationClient.login = { _ in
AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: false)
Expand Down Expand Up @@ -45,7 +45,7 @@ final class LoginSwiftUITests: XCTestCase {
initialState: Login.State(),
reducer: Login(),
observe: LoginView.ViewState.init,
send: action: Login.Action.init
send: Login.Action.init
) {
$0.authenticationClient.login = { _ in
AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: true)
Expand Down Expand Up @@ -82,7 +82,7 @@ final class LoginSwiftUITests: XCTestCase {
initialState: Login.State(),
reducer: Login(),
observe: LoginView.ViewState.init,
send: action: Login.Action.init
send: Login.Action.init
) {
$0.authenticationClient.login = { _ in
throw AuthenticationError.invalidUserPassword
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ test-examples:
for scheme in "CaseStudies (SwiftUI)" "CaseStudies (UIKit)" Integration Search SpeechRecognition TicTacToe Todos VoiceMemos; do \
xcodebuild test \
-scheme "$$scheme" \
-destination platform="$(PLATFORM_IOS)"; \
-destination platform="$(PLATFORM_IOS)" || exit 1; \
done

benchmark:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -236,15 +236,15 @@ struct Feature: ReducerProtocol {
switch action {
case .buttonTapped:
state.count += 1
return EffectTask(value: .sharedComputation)
return .send(.sharedComputation)

case .toggleChanged:
state.isEnabled.toggle()
return EffectTask(value: .sharedComputation)
return .send(.sharedComputation)

case let .textFieldChanged(text):
state.description = text
return EffectTask(value: .sharedComputation)
return .send(.sharedComputation)

case .sharedComputation:
// Some shared work to compute something.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- ``EffectPublisher/task(priority:operation:catch:file:fileID:line:)``
- ``EffectPublisher/run(priority:operation:catch:file:fileID:line:)``
- ``EffectPublisher/fireAndForget(priority:_:)``
- ``EffectPublisher/send(_:)``
- ``TaskResult``

### Cancellation
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# ``ComposableArchitecture/EffectPublisher/send(_:)``

## Topics

### Animating actions

- ``EffectPublisher/send(_:animation:)``
28 changes: 28 additions & 0 deletions Sources/ComposableArchitecture/Effect.swift
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,34 @@ extension EffectPublisher where Failure == Never {
) -> Self {
Self.run(priority: priority) { _ in try? await work() }
}

/// Initializes an effect that immediately emits the action passed in.
///
/// > Note: We do not recommend using `Effect.send` to share logic. Instead, limit usage to
/// > child-parent communication, where a child may want to emit a "delegate" action for a parent
/// > to listen to.
/// >
/// > For more information, see <doc:Performance#Sharing-logic-with-actions>.
///
/// - Parameter action: The action that is immediately emitted by the effect.
public static func send(_ action: Action) -> Self {
Self(value: action)
}

/// Initializes an effect that immediately emits the action passed in.
///
/// > Note: We do not recommend using `Effect.send` to share logic. Instead, limit usage to
/// > child-parent communication, where a child may want to emit a "delegate" action for a parent
/// > to listen to.
/// >
/// > For more information, see <doc:Performance#Sharing-logic-with-actions>.
///
/// - Parameters:
/// - action: The action that is immediately emitted by the effect.
/// - animation: An animation.
public static func send(_ action: Action, animation: Animation? = nil) -> Self {
Self(value: action).animation(animation)
}
}

/// A type that can send actions back into the system when used from
Expand Down
6 changes: 4 additions & 2 deletions Sources/ComposableArchitecture/TestStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1885,7 +1885,8 @@ extension TestStore {
@available(
*,
deprecated,
message: """
message:
"""
Use 'TestStore.init(initialState:reducer:observe:send:)' to scope a test store's state and actions.
"""
)
Expand Down Expand Up @@ -1915,7 +1916,8 @@ extension TestStore {
@available(
*,
deprecated,
message: """
message:
"""
Use 'TestStore.init(initialState:reducer:observe:)' to scope a test store's state.
"""
)
Expand Down
2 changes: 1 addition & 1 deletion Tests/ComposableArchitectureTests/CompatibilityTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ final class CompatibilityTests: XCTestCase {
.cancellable(id: cancelID)

case .kickOffAction:
return EffectTask(value: .actionSender(OnDeinit { passThroughSubject.send(.stop) }))
return .send(.actionSender(OnDeinit { passThroughSubject.send(.stop) }))

case .actionSender:
return .none
Expand Down
78 changes: 40 additions & 38 deletions Tests/ComposableArchitectureTests/ReducerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,56 +22,58 @@ final class ReducerTests: XCTestCase {
#if swift(>=5.7) && (canImport(RegexBuilder) || !os(macOS) && !targetEnvironment(macCatalyst))
func testCombine_EffectsAreMerged() async throws {
if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) {
enum Action: Equatable {
case increment
}
try await _withMainSerialExecutor {
enum Action: Equatable {
case increment
}

struct Delayed: ReducerProtocol {
typealias State = Int
struct Delayed: ReducerProtocol {
typealias State = Int

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

let delay: Duration
let setValue: @Sendable () async -> Void
let delay: Duration
let setValue: @Sendable () async -> Void

func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
state += 1
return .fireAndForget {
try await self.clock.sleep(for: self.delay)
await self.setValue()
func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
state += 1
return .fireAndForget {
try await self.clock.sleep(for: self.delay)
await self.setValue()
}
}
}
}

var fastValue: Int? = nil
var slowValue: Int? = nil
var fastValue: Int? = nil
var slowValue: Int? = nil

let clock = TestClock()
let clock = TestClock()

let store = TestStore(
initialState: 0,
reducer: CombineReducers {
Delayed(delay: .seconds(1), setValue: { @MainActor in fastValue = 42 })
Delayed(delay: .seconds(2), setValue: { @MainActor in slowValue = 1729 })
let store = TestStore(
initialState: 0,
reducer: CombineReducers {
Delayed(delay: .seconds(1), setValue: { @MainActor in fastValue = 42 })
Delayed(delay: .seconds(2), setValue: { @MainActor in slowValue = 1729 })
}
) {
$0.continuousClock = clock
}
) {
$0.continuousClock = clock
}

await store.send(.increment) {
$0 = 2
await store.send(.increment) {
$0 = 2
}
// Waiting a second causes the fast effect to fire.
await clock.advance(by: .seconds(1))
try await Task.sleep(nanoseconds: NSEC_PER_SEC / 3)
XCTAssertEqual(fastValue, 42)
XCTAssertEqual(slowValue, nil)
// Waiting one more second causes the slow effect to fire. This proves that the effects
// are merged together, as opposed to concatenated.
await clock.advance(by: .seconds(1))
await store.finish()
XCTAssertEqual(fastValue, 42)
XCTAssertEqual(slowValue, 1729)
}
// Waiting a second causes the fast effect to fire.
await clock.advance(by: .seconds(1))
try await Task.sleep(nanoseconds: NSEC_PER_SEC / 3)
XCTAssertEqual(fastValue, 42)
XCTAssertEqual(slowValue, nil)
// Waiting one more second causes the slow effect to fire. This proves that the effects
// are merged together, as opposed to concatenated.
await clock.advance(by: .seconds(1))
await store.finish()
XCTAssertEqual(fastValue, 42)
XCTAssertEqual(slowValue, 1729)
}
}
#endif
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -584,7 +584,10 @@

func testCasePathReceive_Exhaustive_NonEquatable() async {
struct NonEquatable {}
enum Action { case tap, response(NonEquatable) }
enum Action {
case tap
case response(NonEquatable)
}

let store = TestStore(
initialState: 0,
Expand All @@ -604,7 +607,10 @@

func testPredicateReceive_Exhaustive_NonEquatable() async {
struct NonEquatable {}
enum Action { case tap, response(NonEquatable) }
enum Action {
case tap
case response(NonEquatable)
}

let store = TestStore(
initialState: 0,
Expand Down
43 changes: 19 additions & 24 deletions Tests/ComposableArchitectureTests/ViewStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -167,33 +167,28 @@ final class ViewStoreTests: XCTestCase {
XCTAssertEqual(results, Array(repeating: [0, 1, 2], count: 10).flatMap { $0 })
}

func testSendWhile() {
let expectation = self.expectation(description: "await")
Task {
enum Action {
case response
case tapped
}
let reducer = Reduce<Bool, Action> { state, action in
switch action {
case .response:
state = false
return .none
case .tapped:
state = true
return .task { .response }
}
func testSendWhile() async {
enum Action {
case response
case tapped
}
let reducer = Reduce<Bool, Action> { state, action in
switch action {
case .response:
state = false
return .none
case .tapped:
state = true
return .task { .response }
}
}

let store = Store(initialState: false, reducer: reducer)
let viewStore = ViewStore(store, observe: { $0 })
let store = Store(initialState: false, reducer: reducer)
let viewStore = ViewStore(store, observe: { $0 })

XCTAssertEqual(viewStore.state, false)
await viewStore.send(.tapped, while: { $0 })
XCTAssertEqual(viewStore.state, false)
expectation.fulfill()
}
self.wait(for: [expectation], timeout: 1)
XCTAssertEqual(viewStore.state, false)
await viewStore.send(.tapped, while: { $0 })
XCTAssertEqual(viewStore.state, false)
}

func testSuspend() {
Expand Down