pointfreeco / swift-composable-architecture

A library for building applications in a consistent and understandable way, with composition, testing, and ergonomics in mind.
https://www.pointfree.co/collections/composable-architecture
MIT License
12.62k stars 1.46k forks source link

`@Shared` with `appStorage` doesn't update in extensions #3439

Closed bcapps closed 1 month ago

bcapps commented 1 month ago

Description

Recently, we were attempting to use @Shared(.appStorage(key)) with several TCA reducer properties in a widget extension, having already used it successfully in the parent app. We updated dependencies.defaultAppStorage to our own UserDefaults with the correct suite in both the main app and extension, and while this allowed the correct value to be read into each property in the widget initially, the extension never receives updates when the main app writes to the defaults.

While tracing down this issue, we found that it was due to the implementation of AppStorageKey: it's using UserDefaults.didChangeNotification to observe changes and update the value, but this notification is documented to not work across processes. Therefore, the values in the extension never update as expected.

The documentation points to KVO as the solution for observing changes even across processes, but it has the very annoying issue that keys with periods won't work due to key paths using periods differently. I tried a solution of this nature and failed on the included test with a key containing periods. So I'm not sure what the correct answer is here, but I think it is somewhat expected that @Shared(.appStorage(key)) properties in an extension with a shared app group would update so I'm filing this issue.

Checklist

Expected behavior

@Shared(.appStorage(key)) properties on TCA reducers used in extensions stay in sync with the main app values when defaultAppStorage uses UserDefaults(withSuite:)

Actual behavior

@Shared(.appStorage(key)) properties on TCA reducers used in extensions are correct initially, and then are out of sync with the main app values when defaultAppStorage uses UserDefaults(withSuite:)

Reproducing project

No response

The Composable Architecture version information

main

Destination operating system

iOS 18

Xcode version information

Xcode 16

Swift Compiler version information

swift-driver version: 1.115 Apple Swift version 6.0 (swiftlang-6.0.0.9.10 clang-1600.0.26.2)
stephencelis commented 1 month ago

Hi @bcapps, thanks for raising this. This is kind of a known issue at the moment but we're not sure the right way to proceed given the underlying behavior of user defaults. We've chosen the firehose subscription to Notification Center route so that keys like "compound.key" still work, but it has the trade-off you mention.

I'd be curious if you've explored the behavior of @AppStorage in SwiftUI. It seems to support "compound.key" key names, so I'm wondering if it also has problems with extensions or if it somehow works around the issue. Can you look into the behavior of @AppStorage in extensions and report back?

Now one thing we could explore is a "hybrid" solution of sorts, where if the key contains a period we do a firehose subscription, and otherwise we do a direct KVO subscription. This is of course a tricky line to walk, though, where the format of the key leads to a completely different code path, and so we're not sure it's advisable, and we'd need to be careful to document the difference. Ideally user defaults would provide a better universal solution here, but we're not aware of it.

One option you have would be to copy and paste the source of AppStorageKey into your project, give it a new name, and swap out the firehose notification for KVO. It should compile in full isolation.

For now I'm going to convert this to a discussion since it is the current expected behavior, but we are open to better solutions here if anyone has ideas.