diff --git a/CHANGELOG.md b/CHANGELOG.md index af4ecc93..c9bfbbed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Fix retain cycle in SubscriptionBox (#278) - @mjarvis, @DivineDominion - Fix bug where using skipRepeats with optional substate would not notify when the substate became nil #55655 - @Ben-G +- Add automatic skipRepeats for Equatable substate selection (#300) - @JoeCherry # 4.0.0 diff --git a/ReSwift.xcodeproj/project.pbxproj b/ReSwift.xcodeproj/project.pbxproj index 41780a3f..db1963d1 100644 --- a/ReSwift.xcodeproj/project.pbxproj +++ b/ReSwift.xcodeproj/project.pbxproj @@ -83,6 +83,7 @@ 73F39F381D3EE46A00DFFE62 /* StoreSubscriptionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73F39F371D3EE46A00DFFE62 /* StoreSubscriptionTests.swift */; }; 73F39F391D3EE46A00DFFE62 /* StoreSubscriptionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73F39F371D3EE46A00DFFE62 /* StoreSubscriptionTests.swift */; }; 73F39F3A1D3EE46A00DFFE62 /* StoreSubscriptionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73F39F371D3EE46A00DFFE62 /* StoreSubscriptionTests.swift */; }; + 759801931FB16D2D006EDE17 /* AutomaticallySkipRepeatsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 759801921FB16D2D006EDE17 /* AutomaticallySkipRepeatsTests.swift */; }; 81BCBECE1C63167A00AA4F03 /* Subscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81BCBECD1C63167A00AA4F03 /* Subscription.swift */; }; 81BCBECF1C63167A00AA4F03 /* Subscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81BCBECD1C63167A00AA4F03 /* Subscription.swift */; }; 81BCBED01C63167A00AA4F03 /* Subscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81BCBECD1C63167A00AA4F03 /* Subscription.swift */; }; @@ -160,6 +161,7 @@ 73E545D91D22BCD600D114E8 /* XCTest+Assertions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "XCTest+Assertions.swift"; sourceTree = ""; }; 73F39F331D3EE3C300DFFE62 /* StoreDispatchTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StoreDispatchTests.swift; sourceTree = ""; }; 73F39F371D3EE46A00DFFE62 /* StoreSubscriptionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StoreSubscriptionTests.swift; sourceTree = ""; }; + 759801921FB16D2D006EDE17 /* AutomaticallySkipRepeatsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomaticallySkipRepeatsTests.swift; sourceTree = ""; }; 81BCBECD1C63167A00AA4F03 /* Subscription.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Subscription.swift; sourceTree = ""; }; 8DA430622E3D093002316DB5 /* Pods-SwiftLintIntegration.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SwiftLintIntegration.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SwiftLintIntegration/Pods-SwiftLintIntegration.debug.xcconfig"; sourceTree = ""; }; B5C08F1806830A13C9006A27 /* libPods-SwiftLintIntegration.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-SwiftLintIntegration.a"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -301,6 +303,7 @@ 621C068B1C278BEF008029AE /* TypeHelperTests.swift */, 73E545D91D22BCD600D114E8 /* XCTest+Assertions.swift */, 73723E031D30AEF3006139F0 /* XCTest+Compatibility.swift */, + 759801921FB16D2D006EDE17 /* AutomaticallySkipRepeatsTests.swift */, ); path = ReSwiftTests; sourceTree = ""; @@ -854,6 +857,7 @@ 73F39F381D3EE46A00DFFE62 /* StoreSubscriptionTests.swift in Sources */, 621C068C1C278BEF008029AE /* TypeHelperTests.swift in Sources */, 259737EA1C2C611600869B8F /* StoreMiddlewareTests.swift in Sources */, + 759801931FB16D2D006EDE17 /* AutomaticallySkipRepeatsTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ReSwift/CoreTypes/Store.swift b/ReSwift/CoreTypes/Store.swift index e0db4450..0ab3743d 100644 --- a/ReSwift/CoreTypes/Store.swift +++ b/ReSwift/CoreTypes/Store.swift @@ -67,14 +67,10 @@ open class Store: StoreType { } } - open func subscribe(_ subscriber: S) - where S.StoreSubscriberStateType == State { - _ = subscribe(subscriber, transform: nil) - } - - open func subscribe( - _ subscriber: S, transform: ((Subscription) -> Subscription)? - ) where S.StoreSubscriberStateType == SelectedState + fileprivate func _subscribe( + _ subscriber: S, originalSubscription: Subscription, + transformedSubscription: Subscription?) + where S.StoreSubscriberStateType == SelectedState { // If the same subscriber is already registered with the store, replace the existing // subscription with the new one. @@ -82,12 +78,6 @@ open class Store: StoreType { subscriptions.remove(at: index) } - // Create a subscription for the new subscriber. - let originalSubscription = Subscription() - // Call the optional transformation closure. This allows callers to modify - // the subscription, e.g. in order to subselect parts of the store's state. - let transformedSubscription = transform?(originalSubscription) - let subscriptionBox = self.subscriptionBox( originalSubscription: originalSubscription, transformedSubscription: transformedSubscription, @@ -101,6 +91,25 @@ open class Store: StoreType { } } + open func subscribe(_ subscriber: S) + where S.StoreSubscriberStateType == State { + _ = subscribe(subscriber, transform: nil) + } + + open func subscribe( + _ subscriber: S, transform: ((Subscription) -> Subscription)? + ) where S.StoreSubscriberStateType == SelectedState + { + // Create a subscription for the new subscriber. + let originalSubscription = Subscription() + // Call the optional transformation closure. This allows callers to modify + // the subscription, e.g. in order to subselect parts of the store's state. + let transformedSubscription = transform?(originalSubscription) + + _subscribe(subscriber, originalSubscription: originalSubscription, + transformedSubscription: transformedSubscription) + } + internal func subscriptionBox( originalSubscription: Subscription, transformedSubscription: Subscription?, @@ -182,4 +191,18 @@ extension Store where State: Equatable { where S.StoreSubscriberStateType == State { _ = subscribe(subscriber, transform: { $0.skipRepeats() }) } + + open func subscribe( + _ subscriber: S, transform: ((Subscription) -> Subscription)? + ) where S.StoreSubscriberStateType == SelectedState + { + let originalSubscription = Subscription() + + var transformedSubscription = transform?(originalSubscription) + transformedSubscription = transformedSubscription?.skipRepeats() + + _subscribe(subscriber, + originalSubscription: originalSubscription, + transformedSubscription: transformedSubscription) + } } diff --git a/ReSwiftTests/AutomaticallySkipRepeatsTests.swift b/ReSwiftTests/AutomaticallySkipRepeatsTests.swift new file mode 100644 index 00000000..9a914b07 --- /dev/null +++ b/ReSwiftTests/AutomaticallySkipRepeatsTests.swift @@ -0,0 +1,82 @@ +// +// AutomaticallySkipRepeatsTests.swift +// ReSwift +// +// Created by Daniel Martín Prieto on 03/11/2017. +// Copyright © 2017 Benjamin Encz. All rights reserved. +// +import XCTest +import ReSwift + +class AutomaticallySkipRepeatsTests: XCTestCase { + + private var store: Store! + fileprivate var subscriptionUpdates: Int = 0 + + override func setUp() { + super.setUp() + // Put setup code here. This method is called before the invocation of each test method in the class. + store = Store(reducer: reducer, state: nil) + subscriptionUpdates = 0 + } + + override func tearDown() { + // Put teardown code here. This method is called after the invocation of each test method in the class. + store = nil + subscriptionUpdates = 0 + super.tearDown() + } + + func testInitialSubscription() { + store.subscribe(self) { $0.select { $0.name } } + XCTAssertEqual(self.subscriptionUpdates, 1) + } + + func testDispatchUnrelatedActionWithExplicitSkipRepeats() { + store.subscribe(self) { $0.select { $0.name }.skipRepeats() } + XCTAssertEqual(self.subscriptionUpdates, 1) + store.dispatch(ChangeAge(newAge: 30)) + XCTAssertEqual(self.subscriptionUpdates, 1) + } + + func testDispatchUnrelatedActionWithoutExplicitSkipRepeats() { + store.subscribe(self) { $0.select { $0.name } } + XCTAssertEqual(self.subscriptionUpdates, 1) + store.dispatch(ChangeAge(newAge: 30)) + XCTAssertEqual(self.subscriptionUpdates, 1) + } + +} + +extension AutomaticallySkipRepeatsTests: StoreSubscriber { + func newState(state: String) { + subscriptionUpdates += 1 + } +} + +private struct State: StateType { + let age: Int + let name: String +} + +extension State: Equatable { + static func == (lhs: State, rhs: State) -> Bool { + return lhs.age == rhs.age && lhs.name == rhs.name + } +} + +struct ChangeAge: Action { + let newAge: Int +} + +private let initialState = State(age: 29, name: "Daniel") + +private func reducer(action: Action, state: State?) -> State { + let defaultState = state ?? initialState + switch action { + case let changeAge as ChangeAge: + return State(age: changeAge.newAge, name: defaultState.name) + default: + return defaultState + } +} diff --git a/ReSwiftTests/StoreSubscriberTests.swift b/ReSwiftTests/StoreSubscriberTests.swift index d67c27c3..1060055c 100644 --- a/ReSwiftTests/StoreSubscriberTests.swift +++ b/ReSwiftTests/StoreSubscriberTests.swift @@ -124,31 +124,28 @@ class StoreSubscriberTests: XCTestCase { XCTAssertEqual(subscriber.newStateCallCount, 1) } - /** - it skips repeated state values by default when the selected substate is `Equatable`. - */ - func testSkipsStateUpdatesForEquatableSubstatesByDefault() { - let reducer = TestValueStringReducer() - let state = TestStringAppState() + func testPassesOnDuplicateSubstateUpdatesByDefault() { + let reducer = TestNonEquatableReducer() + let state = TestNonEquatable() let store = Store(reducer: reducer.handleAction, state: state) - let subscriber = TestFilteredSubscriber() + let subscriber = TestFilteredSubscriber() store.subscribe(subscriber) { $0.select { $0.testValue } } - XCTAssertEqual(subscriber.receivedValue, "Initial") + XCTAssertEqual(subscriber.receivedValue.testValue, "Initial") - store.dispatch(SetValueStringAction("Initial")) + store.dispatch(SetNonEquatableAction(NonEquatable())) - XCTAssertEqual(subscriber.receivedValue, "Initial") - XCTAssertEqual(subscriber.newStateCallCount, 1) + XCTAssertEqual(subscriber.receivedValue.testValue, "Initial") + XCTAssertEqual(subscriber.newStateCallCount, 2) } func testSkipsStateUpdatesForEquatableStateByDefault() { let reducer = TestValueStringReducer() let state = TestStringAppState() - let store = Store(reducer: reducer.handleAction, state: state) + let store = Store(reducer: reducer.handleAction, state: state, middleware: []) let subscriber = TestFilteredSubscriber() store.subscribe(subscriber) @@ -160,7 +157,6 @@ class StoreSubscriberTests: XCTestCase { XCTAssertEqual(subscriber.receivedValue.testValue, "Initial") XCTAssertEqual(subscriber.newStateCallCount, 1) } - } class TestFilteredSubscriber: StoreSubscriber { diff --git a/ReSwiftTests/TestFakes.swift b/ReSwiftTests/TestFakes.swift index 4eeee96c..ed248de3 100644 --- a/ReSwiftTests/TestFakes.swift +++ b/ReSwiftTests/TestFakes.swift @@ -31,6 +31,22 @@ extension TestStringAppState: Equatable { } } +struct TestNonEquatable: StateType { + var testValue: NonEquatable + + init() { + testValue = NonEquatable() + } +} + +struct NonEquatable { + var testValue: String + + init() { + testValue = "Initial" + } +} + struct TestCustomAppState: StateType { var substate: TestCustomSubstate @@ -108,6 +124,15 @@ struct SetCustomSubstateAction: StandardActionConvertible { } } +struct SetNonEquatableAction: Action { + var value: NonEquatable + static let type = "SetNonEquatableAction" + + init (_ value: NonEquatable) { + self.value = value + } +} + struct TestReducer { func handleAction(action: Action, state: TestAppState?) -> TestAppState { var state = state ?? TestAppState() @@ -150,6 +175,21 @@ struct TestCustomAppStateReducer { } } +struct TestNonEquatableReducer { + func handleAction(action: Action, state: TestNonEquatable?) -> + TestNonEquatable { + var state = state ?? TestNonEquatable() + + switch action { + case let action as SetNonEquatableAction: + state.testValue = action.value + return state + default: + return state + } + } +} + class TestStoreSubscriber: StoreSubscriber { var receivedStates: [T] = []