Skip to content

Commit 72b0eb4

Browse files
committed
Document and test UserDefaults fallback order
1 parent 73cdfce commit 72b0eb4

3 files changed

Lines changed: 95 additions & 19 deletions

File tree

README.md

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -600,7 +600,19 @@ This flag helps bypass macro validation in CI environments where the full macro
600600

601601
### Default Value Behavior for UserDefaults and iCloud Key-Value Store
602602

603-
All persistent properties (those marked with @DefaultsBacked or @CloudBacked, either explicitly or implicitly) must be declared with default values. The framework captures these declaration-time defaults and maintains them as immutable fallback values throughout the object's lifetime. When keys are missing from the underlying storage (UserDefaults or iCloud Key-Value Store), properties automatically revert to these preserved default values, ensuring consistent behavior regardless of external storage modifications.
603+
All persistent properties (those marked with @DefaultsBacked or @CloudBacked, either explicitly or implicitly) must be declared with default values. The framework captures these declaration-time defaults and maintains them as immutable model defaults throughout the object's lifetime.
604+
605+
Fallback order depends on the backing store:
606+
607+
- `@ObservableDefaults` (`UserDefaults`)
608+
1. Persisted value in the selected `UserDefaults` domain
609+
2. Value provided by `UserDefaults.register(defaults:)`
610+
3. Declaration-time model default captured by ObservableDefaults
611+
- `@ObservableCloud` (`NSUbiquitousKeyValueStore`)
612+
1. Persisted cloud value
613+
2. Declaration-time model default captured by ObservableDefaults
614+
615+
This means `removeObject(forKey:)` does not always revert to the declaration default for `UserDefaults`. If the key has a registered default, that registered default is used first.
604616

605617
```swift
606618
@ObservableDefaults(autoInit: false) // @ObservableCloud(autoInit: false) is the same
@@ -625,12 +637,15 @@ let user = User(username: "alice", age: 25)
625637

626638
user.username = "bob" // Changes current value, default value stays "guest"
627639

628-
// If UserDefaults keys are deleted externally:
629-
UserDefaults.standard.removeObject(forKey: "username")
630-
UserDefaults.standard.removeObject(forKey: "age")
640+
let defaults = UserDefaults.standard
641+
defaults.register(defaults: ["username": "registered-user"])
642+
defaults.set("bob", forKey: "username")
643+
defaults.set(25, forKey: "age")
644+
defaults.removeObject(forKey: "username")
645+
defaults.removeObject(forKey: "age")
631646

632-
print(user.username) // "guest" (reverts to declaration default)
633-
print(user.age) // 18 (reverts to declaration default)
647+
print(user.username) // "registered-user" (registered default wins)
648+
print(user.age) // 18 (no registered default, so declaration default is used)
634649
```
635650

636651
> **Recommendation**: Unless you have specific requirements, use `autoInit: true` (default) to generate the standard initializer automatically. This helps avoid the misconception that default values can be modified through custom initializers.

README_zh.md

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -600,7 +600,19 @@ build_app(
600600

601601
### UserDefaults 和 iCloud Key-Value Store 的默认值行为
602602

603-
所有持久化属性(那些明确或隐式标记为 @DefaultsBacked@CloudBacked 的属性)必须用默认值声明。框架捕获这些声明时的默认值,并在对象的整个生命周期内将它们保持为不可变的回退值。当底层存储(UserDefaults 或 iCloud Key-Value Store)中缺少键时,属性会自动恢复到这些保留的默认值,确保行为一致,无论外部存储修改如何。
603+
所有持久化属性(那些明确或隐式标记为 @DefaultsBacked@CloudBacked 的属性)都必须用默认值声明。框架会捕获这些声明时的默认值,并在对象整个生命周期内将其保持为不可变的模型默认值。
604+
605+
回退顺序取决于底层存储:
606+
607+
- `@ObservableDefaults``UserDefaults`
608+
1. 所选 `UserDefaults` 域中的持久化值
609+
2. 通过 `UserDefaults.register(defaults:)` 注册的默认值
610+
3. ObservableDefaults 捕获的声明时模型默认值
611+
- `@ObservableCloud``NSUbiquitousKeyValueStore`
612+
1. 云端持久化值
613+
2. ObservableDefaults 捕获的声明时模型默认值
614+
615+
这意味着对于 `UserDefaults``removeObject(forKey:)` 并不一定直接回退到声明默认值。如果该 key 存在 registered default,会优先使用 registered default。
604616

605617
```swift
606618
@ObservableDefaults(autoInit: false) // @ObservableCloud(autoInit: false) 相同
@@ -625,12 +637,15 @@ let user = User(username: "alice", age: 25)
625637

626638
user.username = "bob" // 更改当前值,默认值保持 "guest"
627639

628-
// 如果 UserDefaults 键被外部删除:
629-
UserDefaults.standard.removeObject(forKey: "username")
630-
UserDefaults.standard.removeObject(forKey: "age")
640+
let defaults = UserDefaults.standard
641+
defaults.register(defaults: ["username": "registered-user"])
642+
defaults.set("bob", forKey: "username")
643+
defaults.set(25, forKey: "age")
644+
defaults.removeObject(forKey: "username")
645+
defaults.removeObject(forKey: "age")
631646

632-
print(user.username) // "guest"(恢复到声明默认值
633-
print(user.age) // 18(恢复到声明默认值
647+
print(user.username) // "registered-user"(优先使用 registered default
648+
print(user.age) // 18(没有 registered default,因此回退到声明默认值
634649
```
635650

636651
> **建议**: 除非您有特定要求,否则使用 `autoInit: true`(默认)来自动生成标准初始化器。这有助于避免认为可以通过自定义初始化器修改默认值的误解。

Tests/ObservableDefaultsTests/ExternalChangeEqualityTests.swift

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,16 @@ import ObservableDefaults
1111
import Observation
1212
import Testing
1313

14-
@Suite("External Change Equality Check")
14+
@Suite("External Change Equality Check", .serialized)
1515
struct ExternalChangeEqualityTests {
16+
private func makeUserDefaults(testName: String = #function) -> UserDefaults {
17+
UserDefaults.getTestInstance(
18+
suiteName: "ExternalChangeEqualityTests.\(testName).\(UUID().uuidString)")
19+
}
1620

1721
@Test("Unrelated property not notified when another property changes externally")
1822
func unrelatedPropertyNotNotified() {
19-
let userDefaults = UserDefaults.getTestInstance(suiteName: #function)
23+
let userDefaults = makeUserDefaults()
2024
let model = MockModel(userDefaults: userDefaults)
2125

2226
// Track age - expect NO mutation when only name changes
@@ -26,7 +30,7 @@ struct ExternalChangeEqualityTests {
2630

2731
@Test("Same value write does not notify for backed property")
2832
func sameValueWriteNotNotified() {
29-
let userDefaults = UserDefaults.getTestInstance(suiteName: #function)
33+
let userDefaults = makeUserDefaults()
3034
let model = MockModel(userDefaults: userDefaults)
3135

3236
// Track name - expect NO mutation when writing the same value
@@ -37,7 +41,7 @@ struct ExternalChangeEqualityTests {
3741
@MainActor
3842
@Test("MainActor unrelated property not notified when another property changes externally")
3943
func mainActorUnrelatedPropertyNotNotified() async {
40-
let userDefaults = UserDefaults.getTestInstance(suiteName: #function)
44+
let userDefaults = makeUserDefaults()
4145
let model = MockModelMainActor(userDefaults: userDefaults)
4246

4347
// Track count - expect NO mutation when only name changes
@@ -53,7 +57,7 @@ struct ExternalChangeEqualityTests {
5357
@MainActor
5458
@Test("MainActor same value write does not notify for backed property")
5559
func mainActorSameValueWriteNotNotified() async {
56-
let userDefaults = UserDefaults.getTestInstance(suiteName: #function)
60+
let userDefaults = makeUserDefaults()
5761
let model = MockModelMainActor(userDefaults: userDefaults)
5862

5963
// Track name - expect NO mutation when writing the same value
@@ -69,7 +73,7 @@ struct ExternalChangeEqualityTests {
6973

7074
@Test("Removing key from UserDefaults triggers mutation back to default value")
7175
func removingKeyTriggersMutation() {
72-
let userDefaults = UserDefaults.getTestInstance(suiteName: #function)
76+
let userDefaults = makeUserDefaults()
7377
let model = MockModel(userDefaults: userDefaults)
7478

7579
// Set a non-default value first
@@ -83,7 +87,7 @@ struct ExternalChangeEqualityTests {
8387

8488
@Test("Optional property: removing key triggers mutation back to nil")
8589
func optionalRemovingKeyTriggersMutation() {
86-
let userDefaults = UserDefaults.getTestInstance(suiteName: #function)
90+
let userDefaults = makeUserDefaults()
8791
let model = MockModelOptional(userDefaults: userDefaults)
8892

8993
// Set a non-nil value first
@@ -94,4 +98,46 @@ struct ExternalChangeEqualityTests {
9498
userDefaults.removeObject(forKey: "optionalName")
9599
#expect(model.optionalName == nil)
96100
}
101+
102+
@Test("Removing key falls back to registered default before model default")
103+
func removingKeyFallsBackToRegisteredDefault() {
104+
let userDefaults = makeUserDefaults()
105+
userDefaults.register(defaults: ["name": "Registered"])
106+
let model = MockModel(userDefaults: userDefaults)
107+
108+
model.name = "Changed"
109+
110+
tracking(model, \.name, .userDefaults)
111+
userDefaults.removeObject(forKey: "name")
112+
#expect(model.name == "Registered")
113+
}
114+
115+
@Test("Optional property falls back to registered default before nil")
116+
func optionalRemovingKeyFallsBackToRegisteredDefault() {
117+
let userDefaults = makeUserDefaults()
118+
userDefaults.register(defaults: ["optionalName": "RegisteredOptional"])
119+
let model = MockModelOptional(userDefaults: userDefaults)
120+
121+
model.optionalName = "Hello"
122+
123+
tracking(model, \.optionalName, .userDefaults)
124+
userDefaults.removeObject(forKey: "optionalName")
125+
#expect(model.optionalName == "RegisteredOptional")
126+
}
127+
128+
@MainActor
129+
@Test("MainActor removing key falls back to registered default")
130+
func mainActorRemovingKeyFallsBackToRegisteredDefault() async {
131+
let userDefaults = makeUserDefaults()
132+
userDefaults.register(defaults: ["name": "MainActorRegistered"])
133+
let model = MockModelMainActor(userDefaults: userDefaults)
134+
135+
model.name = "Changed"
136+
137+
tracking(model, \.name, .userDefaults)
138+
userDefaults.removeObject(forKey: "name")
139+
140+
try? await Task.sleep(nanoseconds: 100_000_000)
141+
#expect(model.name == "MainActorRegistered")
142+
}
97143
}

0 commit comments

Comments
 (0)