Skip to content

Commit 5433fb9

Browse files
tgrapperonmluisbrown
authored andcommitted
Add _StateObject, an iOS 13 compatible substitute of StateObject (#1336)
* Add `_StateObject` * wip * `Storage` doesn't need to be `ObservableObject` * Improve performance Introduce a magic `@Published` that makes it much faster for some reason. Both Storage and Observed have been merged into one type only, and properties/functions have been renamed like `StateObject` internals. * wip * wip * wip * wip * wip * Cleanup * wip * Remove `ObjectWillChangePublisher.Output` condition This is not used anymore. * Move `_StateObject` to "internal/" * Typo * wip * wip * wip * Rollback `StateObjectViewStore` changes (cherry picked from commit b6ca93d0a3dce20bdb033608c6113fea8b1bde09) # Conflicts: # Sources/ComposableArchitecture/SwiftUI/ForEachStore.swift # Sources/ComposableArchitecture/SwiftUI/WithViewStore.swift # Sources/ComposableArchitecture/ViewStore.swift
1 parent ad6a345 commit 5433fb9

File tree

5 files changed

+1697
-2025
lines changed

5 files changed

+1697
-2025
lines changed

Sources/ComposableArchitecture/Internal/Deprecations.swift

Lines changed: 77 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -13,29 +13,29 @@ import XCTestDynamicOverlay
1313
// NB: Deprecated after 0.39.0:
1414

1515
#if canImport(SwiftUI)
16-
extension CaseLet {
17-
@available(*, deprecated, renamed: "EnumState")
18-
public typealias GlobalState = EnumState
16+
extension CaseLet {
17+
@available(*, deprecated, renamed: "EnumState")
18+
public typealias GlobalState = EnumState
1919

20-
@available(*, deprecated, renamed: "EnumAction")
21-
public typealias GlobalAction = EnumAction
20+
@available(*, deprecated, renamed: "EnumAction")
21+
public typealias GlobalAction = EnumAction
2222

23-
@available(*, deprecated, renamed: "CaseState")
24-
public typealias LocalState = CaseState
23+
@available(*, deprecated, renamed: "CaseState")
24+
public typealias LocalState = CaseState
2525

26-
@available(*, deprecated, renamed: "CaseAction")
27-
public typealias LocalAction = CaseAction
28-
}
26+
@available(*, deprecated, renamed: "CaseAction")
27+
public typealias LocalAction = CaseAction
28+
}
2929
#endif
3030

3131
#if DEBUG
32-
extension TestStore {
33-
@available(*, deprecated, renamed: "ScopedState")
34-
public typealias LocalState = ScopedState
32+
extension TestStore {
33+
@available(*, deprecated, renamed: "ScopedState")
34+
public typealias LocalState = ScopedState
3535

36-
@available(*, deprecated, renamed: "ScopedAction")
37-
public typealias LocalAction = ScopedAction
38-
}
36+
@available(*, deprecated, renamed: "ScopedAction")
37+
public typealias LocalAction = ScopedAction
38+
}
3939
#endif
4040

4141
// NB: Deprecated after 0.38.2:
@@ -44,8 +44,8 @@ extension Effect where Failure == Error {
4444
@_disfavoredOverload
4545
@available(
4646
*,
47-
deprecated,
48-
message: "Use the non-failing version of 'Effect.task'"
47+
deprecated,
48+
message: "Use the non-failing version of 'Effect.task'"
4949
)
5050
public static func task(
5151
priority: TaskPriority? = nil,
@@ -84,8 +84,8 @@ extension Effect where Failure == Error {
8484
/// - environment: The environment of dependencies for the application.
8585
@available(
8686
*,
87-
deprecated,
88-
message:
87+
deprecated,
88+
message:
8989
"""
9090
If you use this initializer, please open a discussion on GitHub and let us know how: \
9191
https://github.com/pointfreeco/swift-composable-architecture/discussions/new
@@ -133,8 +133,8 @@ extension ViewStore {
133133
extension Effect {
134134
@available(
135135
*,
136-
deprecated,
137-
message:
136+
deprecated,
137+
message:
138138
"""
139139
Using a variadic list is no longer supported. Use an array of identifiers instead. For more \
140140
on this change, see: https://github.com/pointfreeco/swift-composable-architecture/pull/1041
@@ -151,8 +151,8 @@ extension Effect {
151151
extension Reducer {
152152
@available(
153153
*,
154-
deprecated,
155-
message: "'pullback' no longer takes a 'breakpointOnNil' argument"
154+
deprecated,
155+
message: "'pullback' no longer takes a 'breakpointOnNil' argument"
156156
)
157157
public func pullback<ParentState, ParentAction, ParentEnvironment>(
158158
state toChildState: CasePath<ParentState, State>,
@@ -173,8 +173,8 @@ extension Reducer {
173173

174174
@available(
175175
*,
176-
deprecated,
177-
message: "'optional' no longer takes a 'breakpointOnNil' argument"
176+
deprecated,
177+
message: "'optional' no longer takes a 'breakpointOnNil' argument"
178178
)
179179
public func optional(
180180
breakpointOnNil: Bool,
@@ -188,8 +188,8 @@ extension Reducer {
188188

189189
@available(
190190
*,
191-
deprecated,
192-
message: "'forEach' no longer takes a 'breakpointOnNil' argument"
191+
deprecated,
192+
message: "'forEach' no longer takes a 'breakpointOnNil' argument"
193193
)
194194
public func forEach<ParentState, ParentAction, ParentEnvironment, ID>(
195195
state toElementsState: WritableKeyPath<ParentState, IdentifiedArray<ID, State>>,
@@ -210,8 +210,8 @@ extension Reducer {
210210

211211
@available(
212212
*,
213-
deprecated,
214-
message: "'forEach' no longer takes a 'breakpointOnNil' argument"
213+
deprecated,
214+
message: "'forEach' no longer takes a 'breakpointOnNil' argument"
215215
)
216216
public func forEach<ParentState, ParentAction, ParentEnvironment, Key>(
217217
state toElementsState: WritableKeyPath<ParentState, [Key: State]>,
@@ -267,13 +267,13 @@ extension Reducer {
267267
var actions = ""
268268
customDump(self.receivedActions.map(\.action), to: &actions)
269269
XCTFail(
270-
"""
271-
Must handle \(self.receivedActions.count) received \
272-
action\(self.receivedActions.count == 1 ? "" : "s") before performing this work: …
270+
"""
271+
Must handle \(self.receivedActions.count) received \
272+
action\(self.receivedActions.count == 1 ? "" : "s") before performing this work: …
273273
274-
Unhandled actions: \(actions)
275-
""",
276-
file: step.file, line: step.line
274+
Unhandled actions: \(actions)
275+
""",
276+
file: step.file, line: step.line
277277
)
278278
}
279279
do {
@@ -287,13 +287,13 @@ extension Reducer {
287287
var actions = ""
288288
customDump(self.receivedActions.map(\.action), to: &actions)
289289
XCTFail(
290-
"""
291-
Must handle \(self.receivedActions.count) received \
292-
action\(self.receivedActions.count == 1 ? "" : "s") before performing this work: …
290+
"""
291+
Must handle \(self.receivedActions.count) received \
292+
action\(self.receivedActions.count == 1 ? "" : "s") before performing this work: …
293293
294-
Unhandled actions: \(actions)
295-
""",
296-
file: step.file, line: step.line
294+
Unhandled actions: \(actions)
295+
""",
296+
file: step.file, line: step.line
297297
)
298298
}
299299
do {
@@ -392,14 +392,14 @@ extension Reducer {
392392
}
393393
}
394394
}
395-
#endif
395+
#endif
396396

397-
// NB: Deprecated after 0.27.1:
398-
#if canImport(SwiftUI)
397+
// NB: Deprecated after 0.27.1:
398+
#if canImport(SwiftUI)
399399
extension AlertState.Button {
400400
@available(
401401
*, deprecated,
402-
message: "Cancel buttons must be given an explicit label as their first argument"
402+
message: "Cancel buttons must be given an explicit label as their first argument"
403403
)
404404
public static func cancel(action: AlertState.ButtonAction? = nil) -> Self {
405405
.init(action: action, label: TextState("Cancel"), role: .cancel)
@@ -430,7 +430,7 @@ extension Reducer {
430430
extension Store {
431431
@available(
432432
*, deprecated,
433-
message:
433+
message:
434434
"""
435435
If you use this method, please open a discussion on GitHub and let us know how: \
436436
https://github.com/pointfreeco/swift-composable-architecture/discussions/new
@@ -476,7 +476,7 @@ extension Reducer {
476476

477477
@available(
478478
*, deprecated,
479-
message:
479+
message:
480480
"""
481481
If you use this method, please open a discussion on GitHub and let us know how: \
482482
https://github.com/pointfreeco/swift-composable-architecture/discussions/new
@@ -494,7 +494,7 @@ extension Reducer {
494494
extension ViewStore where Action: BindableAction, Action.State == State {
495495
@available(
496496
*, deprecated,
497-
message:
497+
message:
498498
"""
499499
Dynamic member lookup is no longer supported for bindable state. Instead of dot-chaining on \
500500
the view store, e.g. 'viewStore.$value', invoke the 'binding' method on view store with a \
@@ -517,7 +517,7 @@ extension Reducer {
517517
extension BindingAction {
518518
@available(
519519
*, deprecated,
520-
message:
520+
message:
521521
"""
522522
For improved safety, bindable properties must now be wrapped explicitly in 'BindableState', \
523523
and accessed via key paths to that 'BindableState', like '\\.$value'
@@ -537,7 +537,7 @@ extension Reducer {
537537

538538
@available(
539539
*, deprecated,
540-
message:
540+
message:
541541
"""
542542
For improved safety, bindable properties must now be wrapped explicitly in 'BindableState', \
543543
and accessed via key paths to that 'BindableState', like '\\.$value'
@@ -554,14 +554,14 @@ extension Reducer {
554554
extension Reducer {
555555
@available(
556556
*, deprecated,
557-
message:
557+
message:
558558
"""
559559
'Reducer.binding()' no longer takes an explicit extract function and instead the reducer's \
560560
'Action' type must conform to 'BindableAction'
561561
"""
562562
)
563563
public func binding(action toBindingAction: @escaping (Action) -> BindingAction<State>?)
564-
-> Self
564+
-> Self
565565
{
566566
Self { state, action, environment in
567567
toBindingAction(action)?.set(&state)
@@ -571,27 +571,27 @@ extension Reducer {
571571
}
572572

573573
#if canImport(SwiftUI)
574-
extension ViewStore {
575-
@available(
576-
*, deprecated,
577-
message:
578-
"""
574+
extension ViewStore {
575+
@available(
576+
*, deprecated,
577+
message:
578+
"""
579579
For improved safety, bindable properties must now be wrapped explicitly in 'BindableState'. \
580580
Bindings are now derived via 'ViewStore.binding' with a key path to that 'BindableState' \
581581
(for example, 'viewStore.binding(\\.$value)'). For dynamic member lookup to be available, \
582582
the view store's 'Action' type must also conform to 'BindableAction'.
583583
"""
584+
)
585+
public func binding<Value: Equatable>(
586+
keyPath: WritableKeyPath<State, Value>,
587+
send action: @escaping (BindingAction<State>) -> Action
588+
) -> Binding<Value> {
589+
self.binding(
590+
get: { $0[keyPath: keyPath] },
591+
send: { action(.set(keyPath, $0)) }
584592
)
585-
public func binding<Value: Equatable>(
586-
keyPath: WritableKeyPath<State, Value>,
587-
send action: @escaping (BindingAction<State>) -> Action
588-
) -> Binding<Value> {
589-
self.binding(
590-
get: { $0[keyPath: keyPath] },
591-
send: { action(.set(keyPath, $0)) }
592-
)
593-
}
594593
}
594+
}
595595
#endif
596596

597597
// NB: Deprecated after 0.23.0:
@@ -706,16 +706,10 @@ extension Reducer {
706706
@ViewBuilder content: @escaping (Store<EachState, EachAction>) -> EachContent
707707
)
708708
where
709-
Data == [EachState],
710-
Content == WithViewStore<
711-
[ID], (Data.Index, EachAction),
712-
_ConditionalContent<
713-
AnyView,
714-
_ObservedObjectViewStore<
715-
[ID], (Int, EachAction), ForEach<[(offset: Int, element: ID)], ID, EachContent>
716-
>
717-
>
718-
>
709+
Data == [EachState],
710+
Content == WithViewStore<
711+
[ID], (Data.Index, EachAction), ForEach<[(offset: Int, element: ID)], ID, EachContent>
712+
>
719713
{
720714
let data = store.state
721715
self.data = data
@@ -739,18 +733,12 @@ extension Reducer {
739733
@ViewBuilder content: @escaping (Store<EachState, EachAction>) -> EachContent
740734
)
741735
where
742-
Data == [EachState],
743-
Content == WithViewStore<
744-
[ID], (Data.Index, EachAction),
745-
_ConditionalContent<
746-
AnyView,
747-
_ObservedObjectViewStore<
748-
[ID], (Int, EachAction), ForEach<[(offset: Int, element: ID)], ID, EachContent>
749-
>
750-
>
751-
>,
752-
EachState: Identifiable,
753-
EachState.ID == ID
736+
Data == [EachState],
737+
Content == WithViewStore<
738+
[ID], (Data.Index, EachAction), ForEach<[(offset: Int, element: ID)], ID, EachContent>
739+
>,
740+
EachState: Identifiable,
741+
EachState.ID == ID
754742
{
755743
self.init(store, id: \.id, content: content)
756744
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import Combine
2+
import SwiftUI
3+
4+
@propertyWrapper
5+
struct _StateObject<Object: ObservableObject>: DynamicProperty {
6+
private final class ObjectWillChange: ObservableObject {
7+
// Manually defining this property allows to keep it `lazy` and improves
8+
// performance, as we ultimately only need this publisher once in the
9+
// lifetime of the view.
10+
lazy var objectWillChange = ObservableObjectPublisher()
11+
private var subscription: AnyCancellable?
12+
13+
init() {}
14+
func relay(from storage: Storage) {
15+
defer { storage.objectWillSendIsRelayed = true }
16+
subscription = storage.object.objectWillChange.sink {
17+
[weak objectWillChange = self.objectWillChange] _ in
18+
guard let objectWillChange = objectWillChange else { return }
19+
objectWillChange.send()
20+
}
21+
}
22+
}
23+
24+
private final class Storage {
25+
lazy var object: Object = initially()
26+
var objectWillSendIsRelayed: Bool = false
27+
var initially: (() -> Object)!
28+
init() {}
29+
}
30+
31+
@ObservedObject private var objectWillChange = ObjectWillChange()
32+
@State private var storage = Storage()
33+
34+
init(wrappedValue: @autoclosure @escaping () -> Object) {
35+
storage.initially = wrappedValue
36+
}
37+
38+
func update() {
39+
if !storage.objectWillSendIsRelayed {
40+
// `View` invalidation still seems to be effective even if the `objectWillChange`
41+
// publisher is issued from another `@ObservedObject` instance than the current
42+
// one. It is likely that these publishers are bound to the `View`'s identity.
43+
objectWillChange.relay(from: storage)
44+
}
45+
}
46+
47+
var wrappedValue: Object {
48+
storage.object
49+
}
50+
}

Sources/ComposableArchitecture/SwiftUI/ForEachStore.swift

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -90,14 +90,8 @@ public struct ForEachStore<
9090
where
9191
Data == IdentifiedArray<ID, EachState>,
9292
Content == WithViewStore<
93-
OrderedSet<ID>, (ID, EachAction),
94-
_ConditionalContent<
95-
AnyView,
96-
_ObservedObjectViewStore<
97-
OrderedSet<ID>, (ID, EachAction), ForEach<OrderedSet<ID>, ID, EachContent>
98-
>
99-
>
100-
>
93+
OrderedSet<ID>, (ID, EachAction), ForEach<OrderedSet<ID>, ID, EachContent>
94+
>
10195
{
10296
self.data = store.state
10397
self.content = {

0 commit comments

Comments
 (0)