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.22k stars 1.42k forks source link

Lazy loading of Shared initial values #3060

Closed seanmrich closed 4 months ago

seanmrich commented 4 months ago

Extends PR #3057 to include initial values in addition to default values.

Persistent Shared values are often declared with an initial value.

@Shared(.appStorage("key")) var value = 0

This initial value is eagerly evaluated and passed to the Shared initializer. When there's an existing reference, this initial value is thrown away. For simple types like the one above, this isn't a problem. However, when the initial value is more complex, it can be wasteful if the Shared value is declared in many features of an app.

More significantly, the initial value may have side effects. For example, consider a Folder model that creates a UUID id using the uuid dependency. If you want to persist the Folder, you would declare a PersistenceKey.

extension PersistenceKey where Self == FileStorageKey<Folder> {
  public static var rootFolder: Self {
    fileStorage(.documentsDirectory.appendingPathComponent("rootFolder", conformingTo: .json))
  }
}

Your feature declares the shared folder in its state.

@ObservableState
struct State: Equatable {
  @Shared(.rootFolder) var root = Folder(…)
}

Now you want to override that root folder in a test.

func testAddFolder() async {
  let store = TestStore(
    initialState: Feature.State(),
    reducer: { Feature() },
    withDependencies: {
      $0.uuid = .incrementing
      @Shared(.rootFolder) var root = Folder()
    }
  )
  await store.send(.addFolderButtonTapped) {
    $0.root.subfolders = [
      Folder(id: UUID(???))
    ]
  }
}

Because the Feature.State initializer evaluated the Folder declaration, the uuid was incremented. The value was thrown away because the Shared value in the dependencies block already created the reference, but the side effect still happened.

This PR makes that initial value load lazily so there are no side effects unless the value is actually needed. State initializers and local declarations are more efficient, and tests are deterministic.