Skip to content

Commit 8192b09

Browse files
authored
Fix infinite recursion in @ObservableDefault with mutating methods (#218)
Fixes #217
1 parent 9a16755 commit 8192b09

File tree

3 files changed

+61
-4
lines changed

3 files changed

+61
-4
lines changed

Sources/DefaultsMacrosDeclarations/ObservableDefaultMacro.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,19 @@ extension ObservableDefaultMacro: AccessorMacro {
2727
// The get accessor also sets up an observation to update the value when the UserDefaults
2828
// changes from elsewhere. Doing so requires attaching it as an Objective-C associated
2929
// object due to limitations with current macro capabilities and Swift concurrency.
30+
//
31+
// To prevent infinite recursion, we use Defaults.withoutPropagation in the observation
32+
// callback. This ensures that when the callback updates the property, it doesn't trigger
33+
// observers again, while still allowing normal writes to propagate to other observers.
3034
return [
3135
#"""
3236
get {
3337
if objc_getAssociatedObject(self, &Self.\#(associatedKey)) == nil {
3438
let cancellable = Defaults.publisher(\#(expression))
35-
.sink { [weak self] in
36-
self?.\#(property) = $0.newValue
39+
.sink { [weak self] change in
40+
Defaults.withoutPropagation {
41+
self?.\#(property) = change.newValue
42+
}
3743
}
3844
objc_setAssociatedObject(self, &Self.\#(associatedKey), cancellable, .OBJC_ASSOCIATION_RETAIN)
3945
}

Tests/DefaultsMacrosDeclarationsTests/ObservableDefaultMacroTests.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,10 @@ final class ObservableDefaultMacroTests: XCTestCase {
8888
get {
8989
if objc_getAssociatedObject(self, &Self._objcAssociatedKey_name) == nil {
9090
let cancellable = Defaults.publisher(\#(keyExpression))
91-
.sink { [weak self] in
92-
self?.name = $0.newValue
91+
.sink { [weak self] change in
92+
Defaults.withoutPropagation {
93+
self?.name = change.newValue
94+
}
9395
}
9496
objc_setAssociatedObject(self, &Self._objcAssociatedKey_name, cancellable, .OBJC_ASSOCIATION_RETAIN)
9597
}

Tests/DefaultsMacrosTests/ObservableDefaultTests.swift

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@ private let colorKey = "colorKey"
1212
private let defaultColor = "blue"
1313
private let newColor = "purple"
1414

15+
private let testSetKey = "testSetKey"
16+
1517
extension Defaults.Keys {
1618
static let animal = Defaults.Key(animalKey, default: defaultAnimal)
1719
static let color = Defaults.Key(colorKey, default: defaultColor)
20+
static let testSet = Defaults.Key(testSetKey, default: Set<Int>())
1821
}
1922

2023
func getKey() -> Defaults.Key<String> {
@@ -67,12 +70,21 @@ private final class TestModelWithMultipleValues {
6770
var color: String
6871
}
6972

73+
@available(macOS 14, iOS 17, tvOS 17, watchOS 10, visionOS 1, *)
74+
@Observable
75+
private final class TestModelWithSet {
76+
@ObservableDefault(.testSet)
77+
@ObservationIgnored
78+
var testSet: Set<Int>
79+
}
80+
7081
@Suite(.serialized)
7182
final class ObservableDefaultTests {
7283
init() {
7384
Defaults.removeAll()
7485
Defaults[.animal] = defaultAnimal
7586
Defaults[.color] = defaultColor
87+
Defaults[.testSet] = []
7688
}
7789

7890
deinit {
@@ -194,4 +206,41 @@ final class ObservableDefaultTests {
194206
#expect(model.animal == newAnimal)
195207
#expect(model.color == newColor)
196208
}
209+
210+
@available(macOS 14, iOS 17, tvOS 17, watchOS 10, visionOS 1, *)
211+
@Test
212+
func testMacroWithSetNoInfiniteRecursion() async {
213+
let model = TestModelWithSet()
214+
#expect(model.testSet.isEmpty)
215+
216+
// This should not cause infinite recursion
217+
model.testSet.formUnion(1...10)
218+
219+
#expect(model.testSet == Set(1...10))
220+
#expect(Defaults[.testSet] == Set(1...10))
221+
}
222+
223+
@available(macOS 14, iOS 17, tvOS 17, watchOS 10, visionOS 1, *)
224+
@Test
225+
func testMacroObserversPropagateAcrossModels() async {
226+
let model1 = TestModelWithSet()
227+
let model2 = TestModelWithSet()
228+
229+
#expect(model1.testSet.isEmpty)
230+
#expect(model2.testSet.isEmpty)
231+
232+
await confirmation { confirmation in
233+
_ = withObservationTracking {
234+
model2.testSet
235+
} onChange: {
236+
confirmation()
237+
}
238+
239+
// Write through model1
240+
model1.testSet = [1, 2, 3]
241+
}
242+
243+
// model2 should have observed the change
244+
#expect(model2.testSet == [1, 2, 3])
245+
}
197246
}

0 commit comments

Comments
 (0)