diff --git a/Workflow/Sources/RuntimeConfiguration.swift b/Workflow/Sources/RuntimeConfiguration.swift index 75ed8d398..30367dca2 100644 --- a/Workflow/Sources/RuntimeConfiguration.swift +++ b/Workflow/Sources/RuntimeConfiguration.swift @@ -82,10 +82,17 @@ extension Runtime { /// 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. + /// Whether WorkflowSwiftUI runs Perception's debug-only runtime check on `Store` reads. /// - /// Defaults to `false`, so Store access continues through Perception normally. - public var suppressPerceptionCheckingWhenUsingObservation: Bool = false + /// Defaults to `false`. The check reports state read from a view body that is not wrapped + /// in `WithPerceptionTracking`. That modifier is required for observation to work below + /// iOS 17, but is unnecessary at iOS 17 and above, where native Observation tracks the read + /// on its own — and the check cannot tell the two situations apart, so it reports reads + /// that are already working correctly. + /// + /// Opt in when using WorkflowSwiftUI with a deployment target below iOS 17, where an + /// untracked read is a real defect: without `WithPerceptionTracking`, a view does not + /// update when the state it reads changes. + public var enablePerceptionChecking: Bool = false } } diff --git a/WorkflowSwiftUI/Sources/PerceptionCheckSuppression.swift b/WorkflowSwiftUI/Sources/PerceptionCheckSuppression.swift index bcdc01a07..70b1dbb0f 100644 --- a/WorkflowSwiftUI/Sources/PerceptionCheckSuppression.swift +++ b/WorkflowSwiftUI/Sources/PerceptionCheckSuppression.swift @@ -1,22 +1,16 @@ import Perception @_spi(WorkflowRuntimeConfig) import Workflow -/// Runs `operation` with Perception's debug-only runtime check suppressed, if suppression applies. +/// Runs `operation` with Perception's debug-only runtime check suppressed, unless the check has +/// been opted into. /// -/// Suppression is opt-in through -/// `Runtime.Configuration.suppressPerceptionCheckingWhenUsingObservation`, so `operation` executes -/// normally by default. -/// -/// It is additionally applied whenever the process is rendering Xcode previews. That opt-in is -/// meant to be set once at app startup, and a preview has no equivalent entry point — the canvas -/// instantiates a view directly, with no app delegate and no runtime to configure — so a preview -/// would otherwise have no way to reach the configuration at all. +/// The check is off by default — see `Runtime.Configuration.enablePerceptionChecking` — because at +/// iOS 17 and above it reports `Store` reads that native Observation is already tracking +/// correctly. Clients below iOS 17 opt in, where an untracked read is a real defect rather than a +/// false positive. func withPerceptionCheckSuppressed(_ operation: () -> T) -> T { - #if DEBUG && canImport(Observation) - if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *), - Runtime.configuration.suppressPerceptionCheckingWhenUsingObservation - || XcodePreviews.isRunning - { + #if DEBUG + if !Runtime.configuration.enablePerceptionChecking { return _PerceptionLocals.$skipPerceptionChecking.withValue(true, operation: operation) } #endif diff --git a/WorkflowSwiftUI/Sources/XcodePreviews.swift b/WorkflowSwiftUI/Sources/XcodePreviews.swift deleted file mode 100644 index 9b6fb02eb..000000000 --- a/WorkflowSwiftUI/Sources/XcodePreviews.swift +++ /dev/null @@ -1,25 +0,0 @@ -#if DEBUG - -import Foundation - -/// Detection of the Xcode SwiftUI preview canvas. -/// -/// Debug-only, because its sole consumer is `Store`'s debug-only suppression of Perception's -/// runtime check. -enum XcodePreviews { - /// Whether this process is rendering SwiftUI previews. - /// - /// Xcode sets `XCODE_RUNNING_FOR_PREVIEWS` in the process that hosts the preview canvas and - /// nowhere else, so this is `false` when the app runs on a simulator or a device. - static let isRunning = isRunning(in: ProcessInfo.processInfo.environment) - - /// The environment lookup behind ``isRunning``. - /// - /// Separated so it can be exercised directly. ``isRunning`` reads the process environment - /// once, which a test has no way to vary. - static func isRunning(in environment: [String: String]) -> Bool { - environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" - } -} - -#endif diff --git a/WorkflowSwiftUI/Tests/StoreTests.swift b/WorkflowSwiftUI/Tests/StoreTests.swift index 4fde169df..aa80f1c8a 100644 --- a/WorkflowSwiftUI/Tests/StoreTests.swift +++ b/WorkflowSwiftUI/Tests/StoreTests.swift @@ -776,7 +776,7 @@ final class StoreTests: XCTestCase { // MARK: - Native SwiftUI Bindings @MainActor - func test_perceptionRuntimeWarningsWhenUsingObservation() throws { + func test_perceptionRuntimeWarningsWhenCheckingIsEnabled() throws { #if DEBUG guard #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) else { throw XCTSkip("Requires native Observation") @@ -790,7 +790,12 @@ final class StoreTests: XCTestCase { ) let (store, _) = Store.make(model: model) - let image = ImageRenderer(content: PerceptionRuntimeWarningView(store: store)).cgImage + let image = Runtime.withConfiguration( + override: { $0.enablePerceptionChecking = true }, + operation: { + ImageRenderer(content: PerceptionRuntimeWarningView(store: store)).cgImage + } + ) _ = image #else throw XCTSkip("Perception runtime warnings are debug-only") @@ -798,7 +803,7 @@ final class StoreTests: XCTestCase { } @MainActor - func test_perceptionRuntimeWarningsCanBeSuppressedWhenUsingObservation() throws { + func test_perceptionRuntimeWarningsAreDisabledByDefault() throws { #if DEBUG guard #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) else { throw XCTSkip("Requires native Observation") @@ -812,15 +817,12 @@ 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: { - ImageRenderer(content: SuppressedPerceptionRuntimeWarningView(store: store)).cgImage - } - ) + // Rendering evaluates the Store reads with no configuration override at all. If the check + // runs, Perception reports an unexpected XCTest failure, so the absence of a failure is + // the assertion. + let image = ImageRenderer( + content: SuppressedPerceptionRuntimeWarningView(store: store) + ).cgImage _ = image #else throw XCTSkip("Perception runtime warnings are debug-only") diff --git a/WorkflowSwiftUI/Tests/XcodePreviewsTests.swift b/WorkflowSwiftUI/Tests/XcodePreviewsTests.swift deleted file mode 100644 index 9c24ea0f5..000000000 --- a/WorkflowSwiftUI/Tests/XcodePreviewsTests.swift +++ /dev/null @@ -1,34 +0,0 @@ -#if DEBUG - -import XCTest -@testable import WorkflowSwiftUI - -final class XcodePreviewsTests: XCTestCase { - func test_isRunning_whenXcodeSetsThePreviewFlag() { - XCTAssertTrue(XcodePreviews.isRunning(in: ["XCODE_RUNNING_FOR_PREVIEWS": "1"])) - } - - func test_isRunning_whenTheFlagIsAbsent() { - XCTAssertFalse(XcodePreviews.isRunning(in: [:])) - XCTAssertFalse(XcodePreviews.isRunning(in: ["XCODE_RUNNING_FOR_PREVIEWS_EXTRA": "1"])) - } - - /// Xcode sets the flag to exactly `"1"`. Anything else is not the canvas, and treating a - /// truthy-looking value as one would silence the check outside of previews. - func test_isRunning_whenTheFlagIsNotOne() { - XCTAssertFalse(XcodePreviews.isRunning(in: ["XCODE_RUNNING_FOR_PREVIEWS": "0"])) - XCTAssertFalse(XcodePreviews.isRunning(in: ["XCODE_RUNNING_FOR_PREVIEWS": ""])) - XCTAssertFalse(XcodePreviews.isRunning(in: ["XCODE_RUNNING_FOR_PREVIEWS": "YES"])) - XCTAssertFalse(XcodePreviews.isRunning(in: ["XCODE_RUNNING_FOR_PREVIEWS": "true"])) - } - - /// The suite itself is not the preview canvas, so the process-wide value must be `false`. - /// Without this, a `true` reading would silently disable - /// `test_perceptionRuntimeWarningsWhenUsingObservation`, whose assertion is the *absence* of a - /// Perception failure. - func test_isRunning_isFalseInTheTestProcess() { - XCTAssertFalse(XcodePreviews.isRunning) - } -} - -#endif