Skip to content

Releases: mergesort/Boutique

Boutique 3.0 Beta 1: An Observable Girl, Living In An Observable World

31 Oct 20:10
110becf
Compare
Choose a tag to compare

Boutique 3.0 is the biggest update to Boutique since the release of Boutique 2.0, just over two years ago. Boutique 3.0 isn't a complete rewrite — I love how Boutique works and the last thing I want to do is change everything about it. But I want to continue advancing what Boutique can do, so Boutique has been modernized with rewritten internals to make it even better, simpler, and more flexible.

I want to emphasize, the Boutique you know and love is not changing — it's being extended to become even better, and here's how.


  1. Goodbye ObservableObject, hello @Observable. You can now use Boutique's Store, @StoredValue, and @SecurelyStoredValue with @Observable. This makes your code cleaner, faster, and simpler to reason about. Boutique 3.0 is even easier to integrate into an app, without any additional concepts to learn.

  2. Swift 6 support. Swift 6 is a big change, and you can continue using Boutique in Swift 5 projects — no rush to upgrade. The Swift 6 data race safety upgrades have helped me track down some rare race conditions that may have occurred. Boutique's Store, @StoredValue, and @SecurelyStoredValue are now bound to @mainactor, to prevent loss or inconsistency of data.

  3. New APIs, for now and later. By cleaning up Boutique's internals, I've laid the groundwork for new features that will help Boutique match all that SwiftData has to offer. It's always been a goal for Boutique to be perfect out of the box for 95% of projects, but my goal now is to get Boutique to 99% — making it a fit for almost every project.


Here is a list of important high level changes, eschewing details buried in the code.

Store & @Stored

  • Boutique no longer depends on Combine, and values are now published through AsyncStream. This makes observing the Store's items cleaner, and you can now observe changes through onChange rather than onReceive.
// Before
.onReceive(richNotesController.$notes.$items, perform: {
    self.notes = $0
})

// After
.onChange(of: self.richNotesController.notes, initial: true, { _, newValue in
    self.notes = newValue
})

// Note: The `initial` property being set to true is not required, but it is important for reproducing the previous behavior where `$items` would be called in `onReceive` where the Store was empty.
  • A new events property publishes StoreEvent values, allowing you to observe individual changes when a Store is initialized, loaded, and when insert and remove operations occur.
func monitorNotesStoreEvents() async {
    for await event in self.richNotesController.$notes.events {
        switch event.operation {

        case .initialized:
            print("[Store Event: initialized] Our Notes Store has initialized")

        case .loaded:
            print("[Store Event: loaded] Our Notes Store has loaded with notes", event.items)

        case .insert:
            print("[Store Event: insert] Our Notes Store inserted notes", event.items)

        case .remove:
            print("[Store Event: remove] Our Notes Store removed notes", event.items)
        }
    }
}

@StoredValue & @SecurelyStoredValue

  • By implementing Observation tracking, you can now create a Store, StoredValue, or SecurelyStoredValue inside of an @Observable type. Please note that you will have to add @ObservationIgnored to your properties, but don't worry, they will still be observed properly.
@Observable
final class AppState {
    @ObservationIgnored
    @StoredValue(key: "isRedPandaModeEnabled")
    var isRedPandaModeEnabled = false

    @ObservationIgnored
    @SecurelyStoredValue(key: "redPandaData")
    var redPandaData: RedPandaData?
}
  • Due to ObservableObject restrictions, we previously could not break down an ObservableObject into smaller objects while still observing their changes. Thanks to @Observable, we can now enforce a cleaner separation of concerns by splitting up large objects into smaller ones.
// Before
@MainActor
@Observable
final class Preferences {
    @ObservationIgnored
    @StoredValue(key: "hasSoundEffectsEnabled", storage: Preferences.store)
    public var hasSoundEffectsEnabled = false

    @ObservationIgnored
    @StoredValue(key: "hasHapticsEnabled", storage: Preferences.store)
    public var hasHapticsEnabled = true

    @ObservationIgnored
    @StoredValue(key: "likesRedPandas", storage: Preferences.store)
    public var likesRedPandas = true
}

// After
@Observable
final class Preferences {
    var userExperiencePreferences = UserExperiencePreferences()
    var redPandaPreferences = RedPandaPreferences()
}

@MainActor
@Observable
final class UserExperiencePreferences {
    @ObservationIgnored
    @StoredValue(key: "hasSoundEffectsEnabled")
    public var hasSoundEffectsEnabled = false

    @ObservationIgnored
    @StoredValue(key: "hasHapticsEnabled")
    public var hasHapticsEnabled = true
}

@MainActor
@Observable
final class RedPandaPreferences {
    @ObservationIgnored
    @StoredValue(key: "isRedPandaFan")
    public var isRedPandaFan = true
}
  • You can now use StoredValue and SecurelyStoredValue without a property wrapper, thanks to new initializers. This makes them usable in any object, not just one that has the @Observable macro.
// StoredValue.swift
// NEW
public convenience init(key: String, default defaultValue: Item, storage userDefaults: UserDefaults = UserDefaults.standard)

// SecurelyStoredValue.swift
// SAME AS BEFORE
public init(key: String, service: KeychainService? = nil, group: KeychainGroup? = nil)

Notes & Deprecations

  • Boutique's minimum deployment target is now iOS 17/macOS 14, the first versions of iOS and macOS that support @Observable.
  • AsyncStoreValue has been deprecated, as it was not widely used after its initial introduction.
  • The store.add function is now fully deprecated. Any code calling store.add should instead call store.insert.
  • By removing the dependency on Combine, Boutique can explore adding Linux support, but it is not officially supported yet.

Todo

Before Boutique 3.0 is officially released, I still need to:

  • Update all documentation to reflect the change from ObservableObject to @Observable.
  • Ensure all tests pass (Boutique's tests have been rewritten with the new Swift Testing framework and currently pass, but only when run with the new .serialized trait.)
  • Integrate Boutique 3.0 into existing projects to ensure it works as expected with equal or better performance than Boutique 2.0.

I welcome any feedback or suggestions for Boutique 3.0, especially if you integrate Boutique into one of your projects and find ways to improve it! ❤️

Ambiguity Shambiguity

19 Jun 18:00
28e295c
Compare
Choose a tag to compare

This release updates Boutique to Bodega 2.1.3, to resolve an ambiguous reference to Expression which was added to Foundation in iOS 18/macOS 15. Thank you @samalone for the help!

Bug Fixes And Performance Improvements (I Swear That's Not A Joke — But I Guess Now It Is)

30 Apr 18:53
9f59537
Compare
Choose a tag to compare

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

31 Jan 21:32
f48a087
Compare
Choose a tag to compare

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

13 Dec 04:47
Compare
Choose a tag to compare

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)
  1. If pabu isn't in the array of red pandas then Pabu will be inserted.
  2. 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)

18 Sep 17:56
Compare
Choose a tag to compare

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

28 Aug 13:50
Compare
Choose a tag to compare

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's set and reset functions are now bound to the @MainActor. This is to prevent race conditions that could occur when attempting to modify StoredValue's publisher property.

Self-Evident Truths: All Stores Are Created Equal

21 Aug 15:04
8a2afe4
Compare
Choose a tag to compare

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's Item, now all Item has to conform to is Codable.
  • Adding a do/catch in loadStoreTask 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 to UserDefaults occurred.

Wait Up A Second…

11 Feb 18:07
ad47507
Compare
Choose a tag to compare

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.

  1. 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. The Store 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 your Store was, it could be noticeable.
  2. 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 a StoredValue.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

04 Nov 01:56
Compare
Choose a tag to compare

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 from Store.Operation, now it's back. It will be deprecated later, not now.