Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Samples/Tuist/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 18 additions & 19 deletions WorkflowSwiftUI/Sources/Store.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public final class Store<Model: ObservableModel>: Perceptible {
}
}

/// Reads a value from the state, suppressing Perception's runtime warning on iOS 17+.
/// Suppresses Perception's debug-only runtime warning on iOS 17+.
///
/// On iOS 17+, `Store` conforms to `Observable` and SwiftUI's native observation tracks state
/// access. However, `PerceptionRegistrar.access` resolves to the `Perceptible` overload at
Expand All @@ -67,16 +67,22 @@ public final class Store<Model: ObservableModel>: Perceptible {
/// tracking the access. `WithPerceptionTracking` does not suppress the warning either, because
/// binding getters and child store scoping are evaluated by SwiftUI's attribute graph outside
/// of the `WithPerceptionTracking` closure. Setting `skipPerceptionChecking` directly bypasses
/// the `check()` gate on iOS 17+ while preserving the warning on earlier OS versions.
private func readState<T>(keyPath: KeyPath<State, T>) -> T {
#if canImport(Observation)
/// the debug-only `check()` gate on iOS 17+ while preserving the warning on earlier OS
/// versions.
private func withPerceptionCheckSuppressed<T>(_ operation: () -> T) -> T {
#if DEBUG && canImport(Observation)
if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) {
return _PerceptionLocals.$skipPerceptionChecking.withValue(true) {
state[keyPath: keyPath]
}
return _PerceptionLocals.$skipPerceptionChecking.withValue(true, operation: operation)
}
#endif
return state[keyPath: keyPath]
return operation()
}

/// Reads a value from the state, suppressing Perception's debug-only runtime warning on iOS 17+.
private func readState<T>(keyPath: KeyPath<State, T>) -> T {
withPerceptionCheckSuppressed {
state[keyPath: keyPath]
}
}

fileprivate func setModel(_ newModel: Model) {
Expand Down Expand Up @@ -237,24 +243,17 @@ extension Store {

/// Track access to a child store wrapper.
///
/// On iOS 17+, `skipPerceptionChecking` is set for the same reason as ``readState(keyPath:)``
/// — the `Perceptible` overload is selected at compile time and fires a false-positive warning.
/// On iOS 17+, `skipPerceptionChecking` is set for the same reason as
/// ``readState(keyPath:)`` — the `Perceptible` overload is selected at compile time and fires
/// a false-positive warning in debug builds.
func access(
keyPath key: KeyPath<Model, some Any>,
isChanged: @escaping (Model, Model) -> Bool,
isInvalid: @escaping (Model) -> Bool = { _ in false }
) {
#if canImport(Observation)
if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) {
_PerceptionLocals.$skipPerceptionChecking.withValue(true) {
_$observationRegistrar.access(self, keyPath: (\Store.model).appending(path: key))
}
} else {
withPerceptionCheckSuppressed {
_$observationRegistrar.access(self, keyPath: (\Store.model).appending(path: key))
}
#else
_$observationRegistrar.access(self, keyPath: (\Store.model).appending(path: key))
#endif
if childModelAccesses[key] == nil {
childModelAccesses[key] = ChildModelAccess(
keyPath: key,
Expand Down
39 changes: 39 additions & 0 deletions WorkflowSwiftUI/Tests/StoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -944,6 +944,45 @@ final class StoreTests: XCTestCase {
await fulfillment(of: [childAgeDidChange], timeout: 0)
XCTAssertEqual(childState.age, 1)
}

@MainActor
func test_nativeOptionalChildStoreObservation() async throws {
guard #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) else {
throw XCTSkip("Requires iOS 17+")
}

var childState = ParentModel.ChildState(age: 0)
let childModel = StateAccessor(state: childState) { update in
update(&childState)
}

var model = ParentModel(
accessor: StateAccessor(state: State()) { _ in
XCTFail("parent state should not be mutated")
},
child: StateAccessor(state: .init()) { _ in
XCTFail("child state should not be mutated")
},
array: [],
identified: []
)
model.optional = childModel

let (store, setModel) = Store.make(model: model)

let optionalDidChange = expectation(description: "optional.didChange")
withObservationTracking {
// Reading the optional wrapper exercises Store.access(...).
_ = store.optional
} onChange: {
optionalDidChange.fulfill()
}

model.optional = nil
setModel(model)

await fulfillment(of: [optionalDidChange], timeout: 0)
}
}

@ObservableState
Expand Down
Loading