From 7cbc103a030c2edaeeb2ff855d84be388408b6d8 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Tue, 3 Feb 2026 18:09:18 +0700 Subject: [PATCH] Fix "Accessing StateObject's object without being installed on a View" warning Fixes #190 --- Sources/Defaults/SwiftUI.swift | 21 +++++- .../DefaultsTests/DefaultsSwiftUITests.swift | 75 +++++++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/Sources/Defaults/SwiftUI.swift b/Sources/Defaults/SwiftUI.swift index 673d10c6..a00c74cc 100644 --- a/Sources/Defaults/SwiftUI.swift +++ b/Sources/Defaults/SwiftUI.swift @@ -114,13 +114,20 @@ public struct Default: @preconcurrency DynamicProp } public var wrappedValue: Value { - get { observable.value } + get { + syncObservableKeyIfNeeded() + return observable.value + } nonmutating set { + syncObservableKeyIfNeeded() observable.value = newValue } } - public var projectedValue: Binding { $observable.value } + public var projectedValue: Binding { + syncObservableKeyIfNeeded() + return $observable.value + } /** The default value of the key. @@ -134,10 +141,18 @@ public struct Default: @preconcurrency DynamicProp @_documentation(visibility: private) public mutating func update() { - observable.key = key _observable.update() } + private func syncObservableKeyIfNeeded() { + guard observable.key != key else { + return + } + + // Avoid touching `@StateObject` in `update()`, which triggers Xcode 16 warnings. + observable.key = key + } + /** Reset the key back to its default value. diff --git a/Tests/DefaultsTests/DefaultsSwiftUITests.swift b/Tests/DefaultsTests/DefaultsSwiftUITests.swift index def6ae2a..45ca7484 100644 --- a/Tests/DefaultsTests/DefaultsSwiftUITests.swift +++ b/Tests/DefaultsTests/DefaultsSwiftUITests.swift @@ -1,7 +1,13 @@ import Foundation import SwiftUI +#if canImport(AppKit) +import AppKit +#elseif canImport(UIKit) +import UIKit +#endif import Testing import Defaults +import Observation private let suite_ = createSuite() @@ -16,6 +22,8 @@ extension Defaults.Keys { fileprivate static let user = Key("swiftui_user", default: User(username: "Hank", password: "123456"), suite: suite_) fileprivate static let setInt = Key>("swiftui_setInt", default: Set(1...3), suite: suite_) fileprivate static let color = Key("swiftui_color", default: .black, suite: suite_) + fileprivate static let primarySwitchValue = Key("swiftui_primarySwitchValue", default: false, suite: suite_) + fileprivate static let secondarySwitchValue = Key("swiftui_secondarySwitchValue", default: false, suite: suite_) } struct ContentView: View { @@ -31,6 +39,39 @@ struct ContentView: View { } } +@Observable +final class DefaultKeySwitchingState { + var useSecondaryKey = false +} + +struct KeySwitchingHostView: View { + @Bindable var state: DefaultKeySwitchingState + + var body: some View { + KeySwitchingDefaultView( + key: state.useSecondaryKey ? .secondarySwitchValue : .primarySwitchValue, + marker: state.useSecondaryKey + ) + } +} + +struct KeySwitchingDefaultView: View { + @Default private var value: Bool + private let marker: Bool + + init(key: Defaults.Key, marker: Bool) { + self.marker = marker + _value = Default(key) + } + + var body: some View { + Color.clear + .task(id: marker) { + value = marker + } + } +} + @Suite(.serialized) final class DefaultsSwiftUITests { init() { @@ -63,4 +104,38 @@ final class DefaultsSwiftUITests { #expect(XColor(view.color) != XColor(Color.black)) #expect(XColor(view.color) == XColor(Color(.sRGB, red: 100, green: 100, blue: 100, opacity: 1))) } + + @MainActor + @Test + func testSwiftUIDefaultUpdatesKeyOnReinit() async throws { + Defaults[.primarySwitchValue] = false + Defaults[.secondarySwitchValue] = false + + let state = DefaultKeySwitchingState() + + #if os(macOS) + let hostingController = NSHostingController(rootView: KeySwitchingHostView(state: state)) + let window = NSWindow(contentViewController: hostingController) + window.makeKeyAndOrderFront(nil) + #else + let hostingController = UIHostingController(rootView: KeySwitchingHostView(state: state)) + let window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = hostingController + window.makeKeyAndVisible() + #endif + + _ = hostingController.view + _ = window + + try await Task.sleep(for: .milliseconds(100)) + + #expect(!Defaults[.primarySwitchValue]) + #expect(!Defaults[.secondarySwitchValue]) + + state.useSecondaryKey = true + try await Task.sleep(for: .milliseconds(100)) + + #expect(!Defaults[.primarySwitchValue]) + #expect(Defaults[.secondarySwitchValue]) + } }