From 70ebd418aa4dceb78675d0f326f9bd604d11cbde Mon Sep 17 00:00:00 2001 From: johnnewman-square Date: Thu, 16 Jul 2026 16:59:12 -0400 Subject: [PATCH 1/4] Make Perception warning suppression configurable --- Workflow/Sources/RuntimeConfiguration.swift | 9 +- WorkflowSwiftUI/Sources/Store.swift | 27 ++---- WorkflowSwiftUI/Tests/StoreTests.swift | 97 ++++++++++++++++++++- 3 files changed, 113 insertions(+), 20 deletions(-) diff --git a/Workflow/Sources/RuntimeConfiguration.swift b/Workflow/Sources/RuntimeConfiguration.swift index 3811b1067..033dd6d40 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. + public 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..c76d27ab4 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,55 @@ 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) + + 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 +1034,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 From 6e5756ea7d0e4a47c6e1a8309dc80b3c2380daf3 Mon Sep 17 00:00:00 2001 From: johnnewman-square Date: Thu, 16 Jul 2026 17:10:24 -0400 Subject: [PATCH 2/4] Explain Perception suppression assertion --- WorkflowSwiftUI/Tests/StoreTests.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/WorkflowSwiftUI/Tests/StoreTests.swift b/WorkflowSwiftUI/Tests/StoreTests.swift index c76d27ab4..4fde169df 100644 --- a/WorkflowSwiftUI/Tests/StoreTests.swift +++ b/WorkflowSwiftUI/Tests/StoreTests.swift @@ -812,6 +812,9 @@ final class StoreTests: XCTestCase { ) 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: { From a84265dc360cdd74daae11a6cc9deb77e72ddc6d Mon Sep 17 00:00:00 2001 From: johnnewman-square Date: Thu, 16 Jul 2026 17:17:40 -0400 Subject: [PATCH 3/4] fix(ci): Install runtimes manually through xcodebuild --- .github/workflows/swift.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From 8b6c0b9502f2eff6e8ae5ebcb02facc77b7ee766 Mon Sep 17 00:00:00 2001 From: johnnewman-square Date: Thu, 16 Jul 2026 18:10:47 -0400 Subject: [PATCH 4/4] Narrow runtime configuration access --- Workflow/Sources/RuntimeConfiguration.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Workflow/Sources/RuntimeConfiguration.swift b/Workflow/Sources/RuntimeConfiguration.swift index 033dd6d40..75ed8d398 100644 --- a/Workflow/Sources/RuntimeConfiguration.swift +++ b/Workflow/Sources/RuntimeConfiguration.swift @@ -44,7 +44,7 @@ public enum Runtime { private static var _defaultConfiguration = Configuration() /// The configuration active for the current task, falling back to the default configuration. - public static var configuration: Configuration { + package static var configuration: Configuration { _currentConfiguration ?? _defaultConfiguration }