Skip to content

Commit 35b7f64

Browse files
committed
Fix infinite recursion in @ObservableDefault with mutating methods
Fixes #217
1 parent 9a16755 commit 35b7f64

File tree

2 files changed

+31
-1
lines changed

2 files changed

+31
-1
lines changed

Sources/DefaultsMacrosDeclarations/ObservableDefaultMacro.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ 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 when the setter triggers the observation handler,
32+
// we use Defaults.withoutPropagation to suppress observer callbacks during the write.
3033
return [
3134
#"""
3235
get {
@@ -44,7 +47,9 @@ extension ObservableDefaultMacro: AccessorMacro {
4447
#"""
4548
set {
4649
withMutation(keyPath: \.\#(property)) {
47-
Defaults[\#(expression)] = newValue
50+
Defaults.withoutPropagation {
51+
Defaults[\#(expression)] = newValue
52+
}
4853
}
4954
}
5055
"""#

Tests/DefaultsMacrosTests/ObservableDefaultTests.swift

Lines changed: 25 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,17 @@ 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 == [])
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+
}
197222
}

0 commit comments

Comments
 (0)