Skip to content

VSM v1 Release Notes

Compare
Choose a tag to compare
@albertbori albertbori released this 04 Feb 01:14
· 19 commits to main since this release
41113cb

VSM has reached v1.0 after 8 months of use in high-traffic production code, several solidifying bug fixes, and incremental improvements to the ergonomics of the framework.

New Features

VSM Property Wrappers

This release introduces a new, preferred way of working with VSM through property wrappers. (Proposal)

Example Usage of @ViewState (SwiftUI only)

// SwiftUI
struct MyView: View {
    @ViewState var state: MyViewState = .initialized(LoaderModel())

    var body: some View {
        switch state {
        case .initialized(let loaderModel):
            ProgressView()
                .onAppear {
                    $state.observe(loaderModel.load())
                }
        case .loading:
            ProgressView()
        case .error(let errorModel):
            Text(errorModel.message)
            Button("Retry") {
                $state.observe(errorModel.retry())
            }
        case .loaded(let contentModel):
            Text(contentModel.details)
        }
    }
}

Example Usage of @RenderedViewState (UIKit only)

// UIKit
class MyViewController: UIViewController {
    // view properties    
    @RenderedViewState(MyViewController.render)
    var state: MyViewState = .initialized(LoaderModel())

    func viewDidLoad() {
        super.viewDidLoad()
        if case .initialized(let loaderModel) = state {
            $state.observe(loaderModel.load())
        }
        let action = UIAction() { [weak self] action in
            guard let strongSelf = self else { return }
            guard case .error(let errorModel) = strongSelf.state else { return }
            $state.observe(errorModel.retry())
        }
        errorButton.addAction(action, for: .touchUpInside)
    }

    func render() {
        switch state {
        case .initialized, .loading:
            loadingIndicator.isHidden = false
            errorView.isHidden = true
            contentView.isHidden = true
        case .error(let errorModel):
            loadingIndicator.isHidden = true
            errorView.isHidden = false
            errorMessage.text = errorModel.message
            contentView.isHidden = true
        case .loaded(let contentModel):
            loadingIndicator.isHidden = true
            errorView.isHidden = true
            contentView.isHidden = false
            contentLabel.text = contentModel.details
        }
    }
}

StateContainer Publisher

The StateContainer type now provides a publisher of the current State. You can access it via $state.publisher when using view state property wrappers or via statContainer.publisher if using a StateContainer directly.

This new publisher will emit new state values on didSet, as opposed to stateContainer.$state which emits values on willSet.

Debug Functions

The debug logging functionality has been updated and can be configured to output different formats and events. You can access it statically for all state containers or individually per state container.

Static Usage

// prints the state changes for all VSM views in the app
ViewState._debug(options: [...])
// or
RenderedViewState._debug(options: [...])
// or
StateContainer._debug(options: [...])

Local Usage

// prints the current state and future state updates for this VSM view
$state._debug(options: [...])
// or
stateContainer._debug(options: [...])

Invalid State Access

The new SwiftUI property wrapper helps avoid certain data operation and state pitfalls.

In previous versions, the VSM framework allowed engineers to perform data operations within SwiftUI initializers like so:

init() {
    let loaderModel = LoaderModel()
    _container = .init(state: .initialized(loaderModel))
    container.observe(loaderModel.load())
}

This anti-pattern most often results in undesired behavior or data operations because SwiftUI View initializers can be called any number of times.

The new @ViewState property wrapper will now emit a runtime warning if you attempt to observe the state from within the initializer.

init() {
    let loaderModel = LoaderModel()
    _state_ = .init(state: .initialized(loaderModel))
    $state.observe(loaderModel.load()) // runtime warning about accessing the value of a state object before it is assigned to a view
}

This encourages engineers to use the onAppear view modifier instead, which is the recommended approach for SwiftUI:

ProgressView()
    .onAppear {
        if case .initialized(let loaderModel) = state {
            $state.observe(loaderModel.load())
        }
    }

Note: In SwiftUI, onAppear is guaranteed to execute before the first frame is drawn. Any synchronous changes to @ViewState, @State, @ObservedObject, or @StateObject properties will cause the view's body to be reevaluated before the first frame is drawn.

Bug Fixes

  • Extra Frame Render/Flicker - Fixed some cases where an initialization state would accidentally render 1 frame before moving to the next state (ie, a loading state) when observing an action that returns a publisher which emits a value on the main thread. The state change will now happen synchronously on the main thread.
  • Async Closure Confusion - Fixed an issue (or created a workaround for) where the Swift runtime would confuse synchronous closures as asynchronous closures. This was especially evident in protocol extensions. This was done by adding "Async" to the function names that have async closure parameters.

Documentation Updates

  • The documentation, guides, and examples have been updated to promote the new view state property wrappers instead of StateContainer and ViewStateRendering.

Breaking Changes

  • Renamed all asynchronous observe functions from observe({ await foo() }) to observeAsync({ await foo() })
  • (Uncommon) Any functionality requiring state publisher observation to be queued asynchronously on the main thread will no longer work implicitly. The observe(...) function now favors staying on the main thread where possible. If you need main thread async-execution, then you must use .subscribe(on: DispatchQueue.main) explicitly within your model actions.
  • (Uncommon) Some observe(...) function overloads no longer accept closures or function types as parameters.

Internal Framework Changes

  • The demo Shopping app has been fully converted to use these new best practices.
  • UIKit VSM examples have been added to the demo Shopping app.
  • UI tests have been added for the entire demo Shopping app to prevent UI regressions in any future framework updates.
  • Unit tests have been added to observe(somePublisher) to ensure main-thread execution for synchronous execution paths.
  • Files, folders, and types have been reorganized

Migration Instructions

All Frameworks

  1. Resolve any compiler errors resulting from the breaking changes.
  2. (Uncommon) Test features that explicitly rely on async-main thread progression to ensure their behaviors are unchanged.
  3. Remove the ViewStateRendering conformance from your Views, UIViews, or UIViewControllers.

Note: The ViewStateRendering protocol has been deprecated. It will be removed in a future version of the VSM framework. Its usage will produce compiler warnings.

SwiftUI

  1. In each view, replace @StateObject var container: StateContainer<FooViewState> with @ViewState var state: FooViewState.
  2. Change container initialization to initialize the state property instead.
  3. Replace any references to container.state with state.
  4. Replace any references to observe(...) or container.observe(...) with $state.observe(...).
  5. (Uncommon) Move any data observation operations from the view's initializer to the onAppear closure of an appropriate subview.

UIKit

  1. In each view, replace var container: StateContainer<FooViewState> with either:
    • @RenderedViewState var state: FooViewState
    • @RenderedViewState(MyViewController.render) var state: FooViewState = .fooState(BarModel())
  2. Change container initialization to initialize the state property instead.
  3. Replace any references to container.state with state.
  4. Replace any references to observe(...) or container.observe(...) with $state.observe(...).
  5. Add or change your rendering function name and signature to func render() { ... }.
  6. Remove any state subscription code (i.e. stateSubscription = container.$state.sink { ... })