Releases: mergesort/Boutique
Bug Fixes And Performance Improvements (I Swear That's Not A Joke — But I Guess Now It Is)
Important
This release contains an important fix and significant performance improvements. I would highly recommend updating your version of Boutique, especially if you're using the chained operations syntax.
Store
When using a chained operation it was possible for not all values to be removed properly, leading to the incorrect storage of extra data.
try await self.$items
.remove(oldItems)
.insert(newItems)
.run()
More tests have been added to test all sorts of chaining scenarios to prevent this regression from occurring again.
SecurelyStoredValue
When you had a keychain value which existed but it's shape changed (such as adding or removing a property from a type), it was impossible to remove that value. Now the .remove()
function will remove a value when it cannot properly decode the old value, allowing you to overwrite values when adding/removing properties or changing the underlying type of a SecurelyStoredValue
.
StoredValue
An additional layer of caching has been added to StoredValue
so that when you access a StoredValue
it no longer has to decode JSON every time. This will still occur on an app's first load of that value, but future accesses come with significant performance improvements, especially for more complicated objects.
*Don't* Remove All
Important
This release contains a crucial fix, please update your library.
This release fixes an bug in Boutique that could lead to data-loss in specific circumstances when chaining .remove()
and .insert()
using Boutique.
Boutique was exhibiting incorrect behavior when chaining the remove()
function with an insert()
after, due to an underlying implementation bug. The code below demonstrates how the bug would manifest.
// We start with self.items = [1, 2, 3, 4, 5, 6, 7, 8]
// An API call is made and we receive [1, 2, 3, 8, 9, 10] to be inserted into to self.items.
// We pass that `updatedItems` array into an `update` function that removes any items that need to be removed, and then inserts the newly updated items.
func update(_ updatedItems: [Int]) async throws {
let items = self.items.filter({ updatedItems.contains($0) })
try await self.$items
.remove(items)
.insert(updatedItems)
.run()
}
// `self.items` now should be [1, 2, 3, 4, 5, 6, 7, 8]
// `self.items` is actually [10]
There was an assumption built into how chained operations work, based on how Boutique was being used in the early days of the library.
Internally Boutique has two ItemRemovalStrategy
properties, .removeAll
which removes all the items by deleting the underlying table, and removeItems(items)
to remove a specific set of items. Unfortunately due to a logic error .removeAll
would be called whenever the amount of items to remove matched the amount of items that were being inserted in a chain, which is not always the developer's intention. That would delete the underlying data and insert the last item, leaving users with only one item.
My sincerest apologies for this bug, and since this pattern is not necessarily common I hope that it has not affected many users.
Your Presence Is Requested
StoredValue
and AsyncStoredValue
have a new API when the Item
stored is an Array
.
The new togglePresence
function is a handy little shortcut to insert or remove an item from a StoredValue
(or AsyncStoredValue
) based on whether the currently StoredValue
already contains that value.
It's very simple to use.
self.$redPandas.togglePresence(.pabu)
- If
pabu
isn't in the array of red pandas then Pabu will be inserted. - If
pabu
is in the array of red pandas then Pabu will be removed.
Why add this function? I found myself reaching for a function of this shape when interacting with stateful interfaces in SwiftUI, and thought it would make your life easier as it's made mine. 🦊
At Your Service (And Group)
Boutique's SecurelyStoredValue
is meant to be a simple layer to over a whole complex set of keychain APIs to build a simple solution for simple use cases. Occasionally a little additional complexity is valuable though, complexity that allows for more powerful use cases.
This release provides two new properties when initializing a SecurelyStoredValue
, group
and service
. These two properties represent a Keychain's group and a Keychain's service, which control how and where data is stored in the system Keychain. The group
and service
properties are of types KeychainGroup
and KeychainService
respectively.
Note
Previously no group
was ever set, and the service
always mapped to Bundle.main.bundleIdentifier
. This made it so values could not be shared between two targets (for example an app and a widget). The same SecurelyStoredValue
would have a different bundle identifier based on where the value was being accessed, and would return no value for one target's valid keychain entry.
The group
and service
properties are optional so you can keep your code the same way it was before.
@SecurelyStoredValue<AuthToken>(key: "authToken")
Or if you'd like to share a value across targets, you can use the group
or service
parameters, or both together.
@SecurelyStoredValue<AuthToken>(key: "authToken", group: keychainGroup)
@SecurelyStoredValue<AuthToken>(key: "authToken", service: keychainService)
@SecurelyStoredValue<AuthToken>(key: "authToken", service: keychainService, group: keychainGroup)
Both KeychainGroup
and KeychainService
conform to ExpressibleByStringLiteral
, so you can also use a string in place of these types.
@SecurelyStoredValue<AuthToken>(key: "authToken", service: "com.boutique.service", group: "com.boutique.group")
Now let's go be more secure than ever!
No YOU'RE Insecure
This is a big release, adding a new @SecurelyStoredValue
property wrapper to make Boutique a one stop shop for all your persistence needs.
The @SecurelyStoredValue
property wrapper can do everything a @StoredValue
does, but instead of persisting values in UserDefaults
a @SecurelyStoredValue
will save items in the system's Keychain. This is perfect for storing sensitive values such as passwords or auth tokens, which you would not want to store in UserDefaults
.
Using a SecurelyStoredValue
is drop dead simple. Declare the property:
@SecurelyStoredValue<String>(key: "authToken")
private var authToken
Set a value:
$authToken.set("super_secret_p@ssw0rd")
And now it's ready to use anywhere you need.
self.apiController.authenticatedAPICall(withToken: self.authToken)
Breaking change:
- One small API update in this release,
@StoredValue
'sset
andreset
functions are now bound to the@MainActor
. This is to prevent race conditions that could occur when attempting to modifyStoredValue
'spublisher
property.
Self-Evident Truths: All Stores Are Created Equal
This release makes a few subtle improvements to improve some of Boutique's ergonomics and potential race conditions.
- Removing the
Equatable
constraint on a Store'sItem
, now allItem
has to conform to isCodable
.- Thank you @connor-ricks so much for #53!
- Adding a
do/catch
inloadStoreTask
to make debugging Store load failures easier. This isn't strictly necessary but I found myself doing this often when I couldn't figure out why a Store was throwing an error, and thought it might be helpful to expose. StoredValue
is now bound to@MainActor
, which is more in line with expectations.- This also addresses any potential race conditions where the
publisher
property could emit at a different time than the underlying change toUserDefaults
occurred.
- This also addresses any potential race conditions where the
Wait Up A Second…
The highlight of this release a new async initializer for a Boutique Store
, thanks to the contribution of @rl-pavel. This initializer solves two problems.
- Previously when an app was starting up you would have to wait for a
Store
to finish loading before moving onto your next task, ostensibly acting as a blocking procedure. TheStore
was fast so it was not very noticeable from a performance perspective, but depending on the state-driven interface you were constructing and how big yourStore
was, it could be noticeable. - The main problem this caused was not being able to tell whether the items in your
Store
still hadn't loaded, or if they had loaded with zero items. I call this the empty state problem, where you would see your empty state screen displayed for a split second, and then your items would load into place. This was a suboptimal experience, but is now a thing of the past.
You shouldn't notice any changes when using the Store's initializer, but you will now have this fancy method that shows you if the store has finished loading.
await store.itemsHaveLoaded()
What this allows you to do is to drive a SwiftUI/UIKit/AppKit view based on the Store's state. A simplified example looks like this.
struct ItemListView: View {
@State private var itemsHaveLoaded = false
var body: some View {
VStack {
AlwaysVisibleBanner()
if self.itemsHaveLoaded {
if self.items.isEmpty {
EmptyStateView()
} else {
ItemView(items: self.items)
}
} else {
LoadingStateView()
}
}
.task({
try await self.itemsController.items.itemsHaveLoaded()
self.itemsHaveLoaded = true
})
}
}
This is a a really readable solution to a tricky problem, so once again, thank you Pavel. 👏🏻
Breaking Changes
StoredValue.binding
is now computed property rather than aStoredValue.binding()
function.- I've added back the
Store.Operation.add
functions which allowed for chained operations, they were accidentally marked as deprecated, oops.
Little Touches
The work never stops at the Boutique! One touch up, and one oops to fix. (My bad…)
- In 2.0.4 I added a
binding()
function on@StoredValue
and@AsyncStoredValue
, it's now a computed property. - In 2.1.0 I accidentally removed the
add
function fromStore.Operation
, now it's back. It will be deprecated later, not now.
Goodbye Add, Hello Insert
As discussed in #36, the Store's add
function can be a little bit ambiguously named. When you call add
on the Store
it will either add an item or update an existing item, if that item already exists in the `Store.
Naming this function add
makes sense if you think of the Store
as a bag of items that you can add or remove items from, but when an update occurs, the name is no longer as obvious. Having had a few months to use Boutique in production I've come to believe that insert
is a better and less ambiguous name than add
. The cool thing about being the benevolent dictator of Boutique is that I can decide to treat the Store
like a set if the Store is going to act like a Set
. (I also consulted with many developers to get their feedback, I'm not some kind of monster.)
From this day forward add
will be renamed insert
, to match how Swift's naming convention in Set
. The functionality of the add
and insert
are identical, which means that add
will continue to work for some time going forward (with a warning in Xcode), and migrating to insert
will change none of your app's functionality.
Even though you don't have to migrate yet, migrating will be as simple as changing one line of code.
// Before
store.add(item)
// After
store.insert(item)
This process will be made even easier by providing an Xcode fixit in the deprecation warning that the user can click to change the function name on their behalf. And don't worry, the add
function will continue working as it has until it is fully removed.
Sincerely,
Your benevolent dictator (of Boutique) 👑
Can You Help? I'm In A Bit Of A Bind...ing
Do you use SwiftUI? Cool, me too, and boy are there a lot of Bindings. This release includes a small improvement for @StoredValue
and @AsyncStoredValue
, allowing you to create a binding from a value backed by either of our property wrappers.
Before you would have to write out this isEnabled
Binding manually.
Checkbox(
title: "Red Panda Button",
description: "They're the best, aren't they? Press this button to enable some red pandas.",
isEnabled: Binding(get: {
self.preferences.isRedPandaModeEnabled
}, set: {
self.preferences.$isRedPandaModeEnabled.set($0)
})
)
But now, automagically generated for you with the power of functions.
Checkbox(
title: "Red Panda Button",
description: "They're the best, aren't they? Press this button to enable some red pandas.",
isEnabled: self.preferences.$isRedPandaModeEnabled.binding()
)
How nice is that? @StoredValue
and @AsyncStoredValue
are more powerful than ever.