Breaking changes. Major on API grounds.
Registering a live store
UserDefaultsLiveStore.init(suiteName:) is now failable. Foundation refuses two suite names, the current process's own bundle identifier and NSGlobalDomain, and neither can be recovered from. Previously a refused name produced a store whose every read was empty and every write was discarded, silently, for the life of the process. It now fails at the point the store is composed.
guard let store = UserDefaultsLiveStore(suiteName: "group.com.example.myapp") else {
preconditionFailure("Check the suite name against the App Groups entitlement.")
}static let standard is new, and is the only way to reach the app's own defaults. No suite name reaches them.
Note that a name Foundation accepts is not necessarily the container you meant. A mistyped app group identifier, or one the app has no entitlement for, still produces a working store backed by a private domain. Nothing at this layer can tell the two apart.
Endpoints are no longer isolated to the main actor
Every endpoint was @MainActor and is now a nonisolated @Sendable closure. A store may be resolved and called from any concurrency domain: a background task, a widget extension reading an app group suite, or the main actor. Nothing hops, and nothing became async, so a read still returns its value in the caller's own domain.
Thread safety is now the conformer's to arrange rather than the protocol's. UserDefaultsLiveStore leans on UserDefaults being thread-safe. UserDefaultsTestStore takes a lock around its dictionary.
Three cases, and the third is the one most likely to catch you:
- Calling an endpoint: no change. A nonisolated
@Sendableclosure converts to@MainActor, so existing main-actor call sites compile unchanged. - Conforming to
UserDefaultsStoreProtocol: breaks. The requirement types changed, and a@MainActorclosure cannot witness a nonisolated one. Change the closure types on your conformer. - Supplying endpoints by hand: breaks, and this is caller-side code. A hand-built
UserDefaultsClientwhose closures reach main-actor-isolated state now fails withmain actor-isolated property ... can not be referenced from a Sendable closure. Supplying an endpoint is conformer-shaped even when it happens inside a caller, so a test stub that reads isolated state is affected even though it never conformed to anything. Closures that capture nothing isolated are unaffected, which is the shape shown throughout the documentation.
The test store now matches live behaviour
UserDefaultsTestStore previously diverged from UserDefaultsLiveStore in ways that let a test pass against behaviour production does not have. It now matches the measured live semantics:
- Values coerce across types as Foundation coerces them. A stored
"42"reads as42throughint, where it previously read as0. setObjectwith a valueUserDefaultscannot hold now ends the process, where it previously succeeded.URLis the likely case in the wild, becauseUserDefaultshas a dedicatedset(_:forKey:)overload taking one, and that overload is not this endpoint.
Assertions pinning the old direct-cast results will need revisiting. This is a behaviour change with no API change, so the compiler will not point at it.
Also in this release
Documentation examples across the README and the DocC articles were corrected and are now checked mechanically: every Swift fence is type-checked in CI, so an example that stops compiling fails the build rather than reaching a reader. The package gained continuous integration, a SwiftLint configuration with a clean baseline, and a behavioural contract suite covering both stores.
Compatibility
Building the package requires no newer toolchain than before. Running its test suite requires Swift 6.2 or later, because the trap tests use exit tests. This affects contributors only. Consumers are unaffected, since SwiftPM does not build a dependency's test targets.