Skip to content

Commit f583315

Browse files
Deprecate unchecked store (#1206)
* Deprecate unchecked stores. * warning tests * rearrange * Update Deprecations.swift * wip * test for binding warning Co-authored-by: Stephen Celis <[email protected]>
1 parent 437f065 commit f583315

File tree

4 files changed

+241
-39
lines changed

4 files changed

+241
-39
lines changed

Sources/ComposableArchitecture/Internal/Deprecations.swift

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,38 @@ import SwiftUI
99
import os
1010
#endif
1111

12+
// NB: Deprecated after 0.38.2:
13+
14+
/// Initializes a store from an initial state, a reducer, and an environment, and the main thread
15+
/// check is disabled for all interactions with this store.
16+
///
17+
/// - Parameters:
18+
/// - initialState: The state to start the application in.
19+
/// - reducer: The reducer that powers the business logic of the application.
20+
/// - environment: The environment of dependencies for the application.
21+
@available(
22+
*, deprecated,
23+
message:
24+
"""
25+
If you use this initializer, please open a discussion on GitHub and let us know how: \
26+
https://github.com/pointfreeco/swift-composable-architecture/discussions/new
27+
"""
28+
)
29+
extension Store {
30+
public static func unchecked<Environment>(
31+
initialState: State,
32+
reducer: Reducer<State, Action, Environment>,
33+
environment: Environment
34+
) -> Self {
35+
Self(
36+
initialState: initialState,
37+
reducer: reducer,
38+
environment: environment,
39+
mainThreadChecksEnabled: false
40+
)
41+
}
42+
}
43+
1244
// NB: Deprecated after 0.38.0:
1345

1446
extension Effect {

Sources/ComposableArchitecture/Store.swift

Lines changed: 8 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -172,26 +172,6 @@ public final class Store<State, Action> {
172172
self.threadCheck(status: .`init`)
173173
}
174174

175-
/// Initializes a store from an initial state, a reducer, and an environment, and the main thread
176-
/// check is disabled for all interactions with this store.
177-
///
178-
/// - Parameters:
179-
/// - initialState: The state to start the application in.
180-
/// - reducer: The reducer that powers the business logic of the application.
181-
/// - environment: The environment of dependencies for the application.
182-
public static func unchecked<Environment>(
183-
initialState: State,
184-
reducer: Reducer<State, Action, Environment>,
185-
environment: Environment
186-
) -> Self {
187-
Self(
188-
initialState: initialState,
189-
reducer: reducer,
190-
environment: environment,
191-
mainThreadChecksEnabled: false
192-
)
193-
}
194-
195175
/// Scopes the store to one that exposes local state and actions.
196176
///
197177
/// This can be useful for deriving new stores to hand to child views in an application. For
@@ -454,11 +434,10 @@ public final class Store<State, Action> {
454434
%@
455435
456436
Make sure to use ".receive(on:)" on any effects that execute on background threads to \
457-
receive their output on the main thread, or create your store via "Store.unchecked" to \
458-
opt out of the main thread checker.
437+
receive their output on the main thread.
459438
460439
The "Store" class is not thread-safe, and so all interactions with an instance of \
461-
"Store" (including all of its scopes and derived view stores) must be done on the same \
440+
"Store" (including all of its scopes and derived view stores) must be done on the main \
462441
thread.
463442
""",
464443
[debugCaseOutput(action)]
@@ -469,11 +448,8 @@ public final class Store<State, Action> {
469448
"""
470449
A store initialized on a non-main thread. …
471450
472-
If a store is intended to be used on a background thread, create it via \
473-
"Store.unchecked" to opt out of the main thread checker.
474-
475451
The "Store" class is not thread-safe, and so all interactions with an instance of \
476-
"Store" (including all of its scopes and derived view stores) must be done on the same \
452+
"Store" (including all of its scopes and derived view stores) must be done on the main \
477453
thread.
478454
"""
479455
)
@@ -483,11 +459,8 @@ public final class Store<State, Action> {
483459
"""
484460
"Store.scope" was called on a non-main thread. …
485461
486-
Make sure to use "Store.scope" on the main thread, or create your store via \
487-
"Store.unchecked" to opt out of the main thread checker.
488-
489462
The "Store" class is not thread-safe, and so all interactions with an instance of \
490-
"Store" (including all of its scopes and derived view stores) must be done on the same \
463+
"Store" (including all of its scopes and derived view stores) must be done on the main \
491464
thread.
492465
"""
493466
)
@@ -497,11 +470,8 @@ public final class Store<State, Action> {
497470
"""
498471
"ViewStore.send" was called on a non-main thread with: %@ …
499472
500-
Make sure that "ViewStore.send" is always called on the main thread, or create your \
501-
store via "Store.unchecked" to opt out of the main thread checker.
502-
503473
The "Store" class is not thread-safe, and so all interactions with an instance of \
504-
"Store" (including all of its scopes and derived view stores) must be done on the same \
474+
"Store" (including all of its scopes and derived view stores) must be done on the main \
505475
thread.
506476
""",
507477
[debugCaseOutput(action)]
@@ -519,11 +489,10 @@ public final class Store<State, Action> {
519489
%@
520490
521491
Make sure to use ".receive(on:)" on any effects that execute on background threads to \
522-
receive their output on the main thread, or create this store via "Store.unchecked" to \
523-
disable the main thread checker.
492+
receive their output on the main thread.
524493
525494
The "Store" class is not thread-safe, and so all interactions with an instance of \
526-
"Store" (including all of its scopes and derived view stores) must be done on the same \
495+
"Store" (including all of its scopes and derived view stores) must be done on the main \
527496
thread.
528497
""",
529498
[
@@ -535,7 +504,7 @@ public final class Store<State, Action> {
535504
#endif
536505
}
537506

538-
private init<Environment>(
507+
init<Environment>(
539508
initialState: State,
540509
reducer: Reducer<State, Action, Environment>,
541510
environment: Environment,

Sources/ComposableArchitecture/SwiftUI/Binding.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,3 +596,4 @@ extension Reducer where Action: BindableAction, State == Action.State {
596596
}
597597
}
598598
#endif
599+
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import Combine
2+
import ComposableArchitecture
3+
import XCTest
4+
5+
final class RuntimeWarningTests: XCTestCase {
6+
func testStoreCreationMainThread() {
7+
XCTExpectFailure {
8+
$0.compactDescription == """
9+
A store initialized on a non-main thread. …
10+
11+
The "Store" class is not thread-safe, and so all interactions with an instance of "Store" \
12+
(including all of its scopes and derived view stores) must be done on the main thread.
13+
"""
14+
}
15+
16+
Task {
17+
_ = Store<Int, Void>(initialState: 0, reducer: .empty, environment: ())
18+
}
19+
_ = XCTWaiter.wait(for: [.init()], timeout: 0.1)
20+
}
21+
22+
func testEffectFinishedMainThread() {
23+
XCTExpectFailure {
24+
$0.compactDescription == """
25+
An effect completed on a non-main thread. …
26+
27+
Effect returned from:
28+
Action.tap
29+
30+
Make sure to use ".receive(on:)" on any effects that execute on background threads to \
31+
receive their output on the main thread.
32+
33+
The "Store" class is not thread-safe, and so all interactions with an instance of "Store" \
34+
(including all of its scopes and derived view stores) must be done on the main thread.
35+
"""
36+
}
37+
38+
enum Action { case tap, response }
39+
let store = Store(
40+
initialState: 0,
41+
reducer: Reducer<Int, Action, Void> { state, action, _ in
42+
switch action {
43+
case .tap:
44+
return Empty()
45+
.receive(on: DispatchQueue(label: "background"))
46+
.eraseToEffect()
47+
case .response:
48+
return .none
49+
}
50+
},
51+
environment: ()
52+
)
53+
ViewStore(store).send(.tap)
54+
_ = XCTWaiter.wait(for: [.init()], timeout: 0.1)
55+
}
56+
57+
func testStoreScopeMainThread() {
58+
XCTExpectFailure {
59+
[
60+
"""
61+
"Store.scope" was called on a non-main thread. …
62+
63+
The "Store" class is not thread-safe, and so all interactions with an instance of \
64+
"Store" (including all of its scopes and derived view stores) must be done on the main \
65+
thread.
66+
""",
67+
"""
68+
A store initialized on a non-main thread. …
69+
70+
The "Store" class is not thread-safe, and so all interactions with an instance of "Store" \
71+
(including all of its scopes and derived view stores) must be done on the main thread.
72+
"""
73+
].contains($0.compactDescription)
74+
}
75+
76+
let store = Store<Int, Void>(initialState: 0, reducer: .empty, environment: ())
77+
Task {
78+
_ = store.scope(state: { $0 })
79+
}
80+
_ = XCTWaiter.wait(for: [.init()], timeout: 0.1)
81+
}
82+
83+
func testViewStoreSendMainThread() {
84+
XCTExpectFailure {
85+
[
86+
"""
87+
"ViewStore.send" was called on a non-main thread with: () …
88+
89+
The "Store" class is not thread-safe, and so all interactions with an instance of \
90+
"Store" (including all of its scopes and derived view stores) must be done on the main \
91+
thread.
92+
""",
93+
"""
94+
An effect completed on a non-main thread. …
95+
96+
Effect returned from:
97+
()
98+
99+
Make sure to use ".receive(on:)" on any effects that execute on background threads to \
100+
receive their output on the main thread.
101+
102+
The "Store" class is not thread-safe, and so all interactions with an instance of "Store" \
103+
(including all of its scopes and derived view stores) must be done on the main thread.
104+
"""
105+
].contains($0.compactDescription)
106+
}
107+
108+
let store = Store(initialState: 0, reducer: Reducer<Int, Void, Void>.empty, environment: ())
109+
Task {
110+
ViewStore(store).send(())
111+
}
112+
_ = XCTWaiter.wait(for: [.init()], timeout: 0.1)
113+
}
114+
115+
func testEffectEmitMainThread() {
116+
XCTExpectFailure {
117+
[
118+
"""
119+
An effect completed on a non-main thread. …
120+
121+
Effect returned from:
122+
Action.response
123+
124+
Make sure to use ".receive(on:)" on any effects that execute on background threads to \
125+
receive their output on the main thread.
126+
127+
The "Store" class is not thread-safe, and so all interactions with an instance of "Store" \
128+
(including all of its scopes and derived view stores) must be done on the main thread.
129+
""",
130+
"""
131+
An effect published an action on a non-main thread. …
132+
133+
Effect published:
134+
Action.response
135+
136+
Effect returned from:
137+
Action.tap
138+
139+
Make sure to use ".receive(on:)" on any effects that execute on background threads to \
140+
receive their output on the main thread.
141+
142+
The "Store" class is not thread-safe, and so all interactions with an instance of "Store" \
143+
(including all of its scopes and derived view stores) must be done on the main thread.
144+
"""
145+
]
146+
.contains($0.compactDescription)
147+
}
148+
149+
enum Action { case tap, response }
150+
let store = Store(
151+
initialState: 0,
152+
reducer: Reducer<Int, Action, Void> { state, action, _ in
153+
switch action {
154+
case .tap:
155+
return .run { subscriber in
156+
DispatchQueue(label: "background").async {
157+
subscriber.send(.response)
158+
}
159+
return AnyCancellable {
160+
}
161+
}
162+
case .response:
163+
return .none
164+
}
165+
},
166+
environment: ()
167+
)
168+
ViewStore(store).send(.tap)
169+
_ = XCTWaiter.wait(for: [.init()], timeout: 0.2)
170+
}
171+
172+
func testBindingUnhandledAction() {
173+
struct State: Equatable {
174+
@BindableState var value = 0
175+
}
176+
enum Action: BindableAction, Equatable {
177+
case binding(BindingAction<State>)
178+
}
179+
let store = Store(
180+
initialState: .init(),
181+
reducer: Reducer<State, Action, ()>.empty,
182+
environment: ()
183+
)
184+
185+
var line: UInt!
186+
XCTExpectFailure {
187+
line = #line
188+
ViewStore(store).binding(\.$value).wrappedValue = 42
189+
} issueMatcher: {
190+
$0.compactDescription == """
191+
A binding action sent from a view store at "ComposableArchitectureTests/RuntimeWarningTests.swift:\(line+1)" was not handled:
192+
193+
Action:
194+
Action.binding(.set(_, 42))
195+
196+
To fix this, invoke the "binding()" method on your feature's reducer.
197+
"""
198+
}
199+
}
200+
}

0 commit comments

Comments
 (0)