diff --git a/.github/workflows/swift.yaml b/.github/workflows/swift.yaml index 5f391b3fd..e79d31e85 100644 --- a/.github/workflows/swift.yaml +++ b/.github/workflows/swift.yaml @@ -55,7 +55,8 @@ jobs: - name: Install iOS ${{ matrix.sdk }} if: ${{ matrix.installation_required }} run: | - sudo xcodes runtimes install "iOS ${{ matrix.sdk }}" + sudo xcodebuild -downloadPlatform iOS -buildVersion "${{ matrix.sdk }}" + sudo xcodebuild -runFirstLaunch xcrun simctl list - name: Ensure sim exists diff --git a/Workflow/Sources/RuntimeConfiguration.swift b/Workflow/Sources/RuntimeConfiguration.swift index 3811b1067..75ed8d398 100644 --- a/Workflow/Sources/RuntimeConfiguration.swift +++ b/Workflow/Sources/RuntimeConfiguration.swift @@ -43,7 +43,8 @@ public enum Runtime { private static var _defaultConfiguration = Configuration() - static var configuration: Configuration { + /// The configuration active for the current task, falling back to the default configuration. + package static var configuration: Configuration { _currentConfiguration ?? _defaultConfiguration } @@ -80,5 +81,11 @@ extension Runtime { /// Whether action handling should be delegated to the `SinkEventHandler` type. /// This is expected to eventually be removed and become the default behavior. public var useSinkEventHandler: Bool = false + + /// Whether WorkflowSwiftUI suppresses Perception's debug-only runtime warning when using + /// native Observation. + /// + /// Defaults to `false`, so Store access continues through Perception normally. + public var suppressPerceptionCheckingWhenUsingObservation: Bool = false } } diff --git a/WorkflowSwiftUI/Sources/Store.swift b/WorkflowSwiftUI/Sources/Store.swift index 097e3e778..dddb919cb 100644 --- a/WorkflowSwiftUI/Sources/Store.swift +++ b/WorkflowSwiftUI/Sources/Store.swift @@ -2,7 +2,7 @@ import CasePaths import IdentifiedCollections import Perception import SwiftUI -import Workflow +@_spi(WorkflowRuntimeConfig) import Workflow /// Provides access to a workflow's state and actions from within an ``ObservableScreen``. /// @@ -57,28 +57,23 @@ public final class Store: Perceptible { } } - /// Suppresses Perception's debug-only runtime warning on iOS 17+. + /// Funnel point for suppressing Perception's debug-only runtime warning when state is accessed + /// outside of `WithPerceptionTracking`. /// - /// On iOS 17+, `Store` conforms to `Observable` and SwiftUI's native observation tracks state - /// access. However, `PerceptionRegistrar.access` resolves to the `Perceptible` overload at - /// compile time (the `Observable` overload is unavailable since `Store.state` is not - /// `@available(iOS 17, *)`). That overload calls `check()`, which fires a debug warning when - /// state is accessed outside of `WithPerceptionTracking` — even though native observation is - /// 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 debug-only `check()` gate on iOS 17+ while preserving the warning on earlier OS - /// versions. + /// Suppression is opt-in through + /// `Runtime.Configuration.suppressPerceptionCheckingWhenUsingObservation`, so Store access + /// executes normally by default. private func withPerceptionCheckSuppressed(_ operation: () -> T) -> T { #if DEBUG && canImport(Observation) - if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *), + Runtime.configuration.suppressPerceptionCheckingWhenUsingObservation + { return _PerceptionLocals.$skipPerceptionChecking.withValue(true, operation: operation) } #endif return operation() } - /// Reads a value from the state, suppressing Perception's debug-only runtime warning on iOS 17+. private func readState(keyPath: KeyPath) -> T { withPerceptionCheckSuppressed { state[keyPath: keyPath] @@ -242,10 +237,6 @@ 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 in debug builds. func access( keyPath key: KeyPath, isChanged: @escaping (Model, Model) -> Bool, diff --git a/WorkflowSwiftUI/Tests/StoreTests.swift b/WorkflowSwiftUI/Tests/StoreTests.swift index 66a682b7f..4fde169df 100644 --- a/WorkflowSwiftUI/Tests/StoreTests.swift +++ b/WorkflowSwiftUI/Tests/StoreTests.swift @@ -2,7 +2,7 @@ import CasePaths import IdentifiedCollections import Perception import SwiftUI -import Workflow +@_spi(WorkflowRuntimeConfig) import Workflow import XCTest @testable import WorkflowSwiftUI @@ -775,6 +775,58 @@ final class StoreTests: XCTestCase { // MARK: - Native SwiftUI Bindings + @MainActor + func test_perceptionRuntimeWarningsWhenUsingObservation() throws { + #if DEBUG + guard #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) else { + throw XCTSkip("Requires native Observation") + } + + let child = StateAccessor(state: ParentModel.ChildState()) { _ in } + let model = ParentModel( + accessor: StateAccessor(state: State()) { _ in }, + child: child, + optional: child + ) + let (store, _) = Store.make(model: model) + + let image = ImageRenderer(content: PerceptionRuntimeWarningView(store: store)).cgImage + _ = image + #else + throw XCTSkip("Perception runtime warnings are debug-only") + #endif + } + + @MainActor + func test_perceptionRuntimeWarningsCanBeSuppressedWhenUsingObservation() throws { + #if DEBUG + guard #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) else { + throw XCTSkip("Requires native Observation") + } + + let child = StateAccessor(state: ParentModel.ChildState()) { _ in } + let model = ParentModel( + accessor: StateAccessor(state: State()) { _ in }, + child: child, + optional: child + ) + let (store, _) = Store.make(model: model) + + // Rendering evaluates the Store reads inside the override. If suppression fails, + // Perception reports an unexpected XCTest failure, so the absence of a failure is the + // assertion. + let image = Runtime.withConfiguration( + override: { $0.suppressPerceptionCheckingWhenUsingObservation = true }, + operation: { + ImageRenderer(content: SuppressedPerceptionRuntimeWarningView(store: store)).cgImage + } + ) + _ = image + #else + throw XCTSkip("Perception runtime warnings are debug-only") + #endif + } + @MainActor func test_nativeBindings() async throws { guard #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) else { @@ -985,6 +1037,52 @@ final class StoreTests: XCTestCase { } } +/// Reads Store values from SwiftUI so Perception recognizes the AttributeGraph call stack. +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +private struct PerceptionRuntimeWarningView: View { + let store: Store + + var body: some View { + VStack { + Text( + expectPerceptionRuntimeWarning { + store.count + }.description + ) + Text( + expectPerceptionRuntimeWarning { + store.optional == nil + }.description + ) + } + } +} + +/// Reads Store values from SwiftUI without expecting Perception runtime warnings. +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +private struct SuppressedPerceptionRuntimeWarningView: View { + let store: Store + + var body: some View { + VStack { + Text(store.count.description) + Text((store.optional == nil).description) + } + } +} + +/// Runs a Store read and verifies Perception reports the untracked-state runtime warning. +private func expectPerceptionRuntimeWarning( + _ operation: () -> Result +) -> Result { + XCTExpectFailure(failingBlock: operation) { + $0.compactDescription.contains("Perceptible state") + && $0.compactDescription.contains( + "was accessed from a view but is not being tracked" + ) + } +} + @ObservableState private struct State { var count = 0