Skip to content

Commit 49bb66c

Browse files
authored
Merge pull request #24 from shapehq/bugfix/initial-observed-value
Ensures observers receive correct initial value
2 parents 46bd25d + 7225e6a commit 49bb66c

11 files changed

+349
-59
lines changed

README.md

+3
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,9 @@ class ContentViewController: UIViewController {
225225
}
226226
```
227227

228+
> [!IMPORTANT]
229+
> If an observation is setup before the corresponding property has been read or written, you must explicitly call `prepareIfNeeded()` on the spice store to avoid accessing an unprepared state. If the SpiceStore is not prepared, accessing a projected value will trigger an assertion failure.
230+
228231
## 🧪 Example Projects
229232

230233
The example projects in the [Examples](/Examples) folder shows how Spices can be used to add an in-app debug menu to iOS apps with SwiftUI and UIKit lifecycles.
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,33 @@
11
import Combine
22

33
final class AnyStorage<Value>: ObservableObject {
4+
private(set) var isPrepared = false
45
let publisher: AnyPublisher<Value, Never>
56
var value: Value {
67
get {
7-
backingValue
8+
read()
89
}
910
set {
11+
objectWillChange.send()
1012
write(newValue)
11-
backingValue = newValue
1213
}
1314
}
1415

1516
private let read: () -> Value
1617
private let write: (Value) -> Void
1718
private let prepare: (String, any SpiceStore) -> Void
18-
private var cancellables: Set<AnyCancellable> = []
19-
@Published private var backingValue: Value
2019

21-
convenience init<S: Storage>(_ storage: S) where S.Value == Value {
22-
self.init(storage: storage)
23-
storage.publisher.sink { [weak self] newValue in
24-
self?.backingValue = newValue
25-
}
26-
.store(in: &cancellables)
27-
}
28-
29-
convenience init<S: Storage>(_ storage: S) where S.Value == Value, S.Value: Equatable {
30-
self.init(storage: storage)
31-
// Remove duplicates to reduce publishes from updating backing value,
32-
// ultimately reducing number of view updates.
33-
storage.publisher.removeDuplicates().sink { [weak self] newValue in
34-
self?.backingValue = newValue
35-
}
36-
.store(in: &cancellables)
37-
}
38-
39-
private init<S: Storage>(storage: S) where S.Value == Value {
40-
publisher = storage.publisher
41-
backingValue = storage.value
20+
init<S: Storage>(_ storage: S) where S.Value == Value {
4221
read = { storage.value }
4322
write = { storage.value = $0 }
4423
prepare = { storage.prepare(propertyName: $0, ownedBy: $1) }
24+
publisher = storage.publisher
4525
}
4626
}
4727

4828
extension AnyStorage: Preparable {
4929
func prepare(propertyName: String, ownedBy spiceStore: any SpiceStore) {
5030
prepare(propertyName, spiceStore)
51-
backingValue = read()
31+
isPrepared = true
5232
}
5333
}

Sources/Spices/Internal/Storage/UserDefaultsStorage.swift

+27-20
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,15 @@ import Foundation
44
final class UserDefaultsStorage<Value>: Storage {
55
var value: Value {
66
get {
7-
internalValue
7+
backingValue
88
}
99
set {
10-
write?(newValue)
11-
internalValue = newValue
10+
if !isValuesEqual(newValue, backingValue) {
11+
write?(newValue)
12+
spiceStoreOrThrow.publishObjectWillChange()
13+
backingValue = newValue
14+
subject.send(newValue)
15+
}
1216
}
1317
}
1418
var publisher: AnyPublisher<Value, Never> {
@@ -39,23 +43,15 @@ final class UserDefaultsStorage<Value>: Storage {
3943
}
4044
return spiceStore
4145
}
42-
private var _internalValue: Value
43-
private var internalValue: Value {
44-
get {
45-
_internalValue
46-
}
47-
set {
48-
spiceStoreOrThrow.publishObjectWillChange()
49-
_internalValue = newValue
50-
subject.send(newValue)
51-
}
52-
}
46+
private var backingValue: Value
47+
private let isValuesEqual: (Value, Value) -> Bool
5348
private var cancellables: Set<AnyCancellable> = []
5449

55-
init(default value: Value, key: String?) {
56-
_internalValue = value
50+
init(default value: Value, key: String?) where Value: Equatable {
51+
backingValue = value
5752
preferredKey = key
5853
subject = CurrentValueSubject(value)
54+
isValuesEqual = { $0 == $1 }
5955
read = { [weak self] in
6056
guard let self else {
6157
return value
@@ -70,10 +66,11 @@ final class UserDefaultsStorage<Value>: Storage {
7066
}
7167
}
7268

73-
init(default value: Value, key: String?) where Value: RawRepresentable {
74-
_internalValue = value
69+
init(default value: Value, key: String?) where Value: RawRepresentable, Value.RawValue: Equatable {
70+
backingValue = value
7571
preferredKey = key
7672
subject = CurrentValueSubject(value)
73+
isValuesEqual = { $0.rawValue == $1.rawValue }
7774
read = { [weak self] in
7875
guard let self, let rawValue = self.userDefaults.object(forKey: self.key) as? Value.RawValue else {
7976
return value
@@ -98,7 +95,13 @@ private extension UserDefaultsStorage {
9895
guard let self, let read = self.read else {
9996
return
10097
}
101-
self.internalValue = read()
98+
let value = read()
99+
guard !self.isValuesEqual(value, backingValue) else {
100+
return
101+
}
102+
self.spiceStoreOrThrow.publishObjectWillChange()
103+
self.backingValue = value
104+
self.subject.send(value)
102105
}
103106
.store(in: &cancellables)
104107
}
@@ -109,7 +112,11 @@ extension UserDefaultsStorage: Preparable {
109112
self.propertyName = propertyName
110113
self.spiceStore = spiceStore
111114
if let read {
112-
_internalValue = read()
115+
let value = read()
116+
if !isValuesEqual(value, backingValue) {
117+
backingValue = value
118+
subject.send(value)
119+
}
113120
}
114121
observeUserDefaults()
115122
}

Sources/Spices/Spice.swift

+6-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// swiftlint:disable file_length
12
import Combine
23
import Foundation
34
import SwiftUI
@@ -74,7 +75,9 @@ import SwiftUI
7475
/// }
7576
/// ```
7677
public var projectedValue: AnyPublisher<Value, Never> {
77-
storage.publisher
78+
// swiftlint:disable:next line_length
79+
assert(storage.isPrepared, "The projected value of a Spice cannot be accessed until its owning spice store has been prepared. This happens automatically unless the projected value is accessed before the property has been read or written, in which case you must manually call prepareIfNeeded() on the spice store.")
80+
return storage.publisher
7881
}
7982

8083
let name: Name
@@ -158,7 +161,7 @@ import SwiftUI
158161
key: String? = nil,
159162
name: String? = nil,
160163
requiresRestart: Bool = false
161-
) where Value: RawRepresentable & CaseIterable {
164+
) where Value: RawRepresentable & CaseIterable, Value.RawValue: Equatable {
162165
self.name = Name(name)
163166
self.storage = AnyStorage(UserDefaultsStorage(default: wrappedValue, key: key))
164167
self.menuItem = PickerMenuItem(
@@ -481,3 +484,4 @@ private extension Spice {
481484
}
482485

483486
extension Spice: MenuItemProvider {}
487+
// swiftlint:enable file_length

Sources/Spices/SpiceStore.swift

+21-8
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,27 @@ public extension SpiceStore {
4141
}
4242
}
4343

44+
public extension SpiceStore {
45+
/// Ensures that the `SpiceStore` is prepared before accessing its projected values.
46+
///
47+
/// This method checks whether the `SpiceStore` has already been prepared. If not, it marks it as prepared
48+
/// and invokes the `prepare()` method.
49+
///
50+
/// You typically do not need to call this method manually, as preparation happens automatically. However,
51+
/// if ``Spice/projectedValue`` is accessed before the corresponding property has been read or written,
52+
/// you must explicitly call `prepareIfNeeded()` to avoid accessing an unprepared state.
53+
///
54+
/// - Important: If the `SpiceStore` is not prepared, accessing a projected value will trigger an assertion failure.
55+
///
56+
func prepareIfNeeded() {
57+
guard !isPrepared else {
58+
return
59+
}
60+
isPrepared = true
61+
prepare()
62+
}
63+
}
64+
4465
extension SpiceStore {
4566
var id: String {
4667
if let value = objc_getAssociatedObject(self, &idKey) as? String {
@@ -108,14 +129,6 @@ extension SpiceStore {
108129
parent?.publishObjectWillChange()
109130
}
110131

111-
func prepareIfNeeded() {
112-
guard !isPrepared else {
113-
return
114-
}
115-
isPrepared = true
116-
prepare()
117-
}
118-
119132
private func prepare() {
120133
let mirror = Mirror(reflecting: self)
121134
for (name, value) in mirror.children {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# ``SpiceStore``
2+
3+
## Topics
4+
5+
### Storage
6+
7+
- ``SpiceStore/userDefaults``
8+
9+
### Preparation
10+
11+
- ``SpiceStore/prepareIfNeeded()``
+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import Combine
2+
import Foundation
3+
@testable import Spices
4+
import Testing
5+
6+
@Suite
7+
final class AnyStorageTests {
8+
private var cancellables: Set<AnyCancellable> = []
9+
10+
@Test
11+
func it_reads_value() async throws {
12+
let storage = MockStorage(default: "Hello world!")
13+
let sut = AnyStorage(storage)
14+
#expect(sut.value == "Hello world!")
15+
}
16+
17+
@Test
18+
func it_writes_value() async throws {
19+
let storage = MockStorage(default: "Hello world!")
20+
let sut = AnyStorage(storage)
21+
sut.value = "Foo"
22+
#expect(storage.value == "Foo")
23+
}
24+
25+
@Test
26+
func it_notifies_observer_of_changes() async throws {
27+
let storage = MockStorage(default: "Hello world!")
28+
let sut = AnyStorage(storage)
29+
var didReceiveChange = false
30+
sut.objectWillChange.sink {
31+
didReceiveChange = true
32+
}
33+
.store(in: &cancellables)
34+
sut.value = "Foo"
35+
#expect(didReceiveChange == true)
36+
}
37+
38+
@Test
39+
func it_updates_prepared_state() async throws {
40+
let storage = MockStorage(default: "Hello world!")
41+
let sut = AnyStorage(storage)
42+
#expect(sut.isPrepared == false)
43+
sut.prepare(propertyName: "foo", ownedBy: MockSpiceStore())
44+
#expect(sut.isPrepared == true)
45+
}
46+
}

Tests/SpicesTests/CamelCaseToNaturalTextTests.swift

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import Foundation
2-
import Testing
3-
42
@testable import Spices
3+
import Testing
54

65
@Suite
76
struct CamelCaseToNaturalTextTests {
+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import Combine
2+
@testable import Spices
3+
4+
final class MockStorage<Value>: Storage, Preparable {
5+
var publisher: AnyPublisher<Value, Never> {
6+
subject.eraseToAnyPublisher()
7+
}
8+
var value: Value {
9+
get {
10+
subject.value
11+
}
12+
set {
13+
subject.send(newValue)
14+
}
15+
}
16+
17+
private let subject: CurrentValueSubject<Value, Never>
18+
19+
init(default value: Value) {
20+
subject = CurrentValueSubject(value)
21+
}
22+
23+
func prepare(propertyName: String, ownedBy spiceStore: any SpiceStore) {}
24+
}

Tests/SpicesTests/SpiceTests.swift

+16-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import Foundation
33
@testable import Spices
44
import Testing
55

6-
@MainActor @Suite
6+
@MainActor @Suite(.serialized)
77
final class SpiceTests {
88
private var cancellables: Set<AnyCancellable> = []
99

@@ -63,17 +63,32 @@ final class SpiceTests {
6363
var initialValue: MockEnvironment?
6464
let sut = MockSpiceStore()
6565
sut.userDefaults.removeAll()
66+
sut.prepareIfNeeded()
6667
sut.$enumValue.sink { newValue in
6768
initialValue = newValue
6869
}
6970
.store(in: &cancellables)
7071
#expect(initialValue == .production)
7172
}
7273

74+
@Test func it_sink_receives_initial_value_if_it_has_been_changed() async throws {
75+
var initialValue: MockEnvironment?
76+
let sut = MockSpiceStore()
77+
sut.userDefaults.removeAll()
78+
sut.userDefaults.set(MockEnvironment.staging.rawValue, forKey: "enumValue")
79+
sut.prepareIfNeeded()
80+
sut.$enumValue.sink { newValue in
81+
initialValue = newValue
82+
}
83+
.store(in: &cancellables)
84+
#expect(initialValue == .staging)
85+
}
86+
7387
@Test func it_publishes_values() async throws {
7488
var publishedValue: MockEnvironment?
7589
let sut = MockSpiceStore()
7690
sut.userDefaults.removeAll()
91+
sut.prepareIfNeeded()
7792
sut.$enumValue.sink { newValue in
7893
publishedValue = newValue
7994
}

0 commit comments

Comments
 (0)