From 053c00cdeab7b3413ad8756bd8ee5ff53f2c92d2 Mon Sep 17 00:00:00 2001 From: johnnewman-square Date: Wed, 2 Sep 2026 09:47:28 -0400 Subject: [PATCH] Make Perception runtime checking opt-in Perception's debug-only runtime check reports Store state read from a view body that is not wrapped in WithPerceptionTracking. That modifier is required for observation to work below iOS 17, but unnecessary at iOS 17 and above, where native Observation tracks the read on its own. The check cannot tell those two situations apart, so on 17 and above it reports reads that are already working correctly. Leaving the check on by default made sense while clients were still on iOS 16, since a warning there is diagnosing something real. As clients bump to 17 the default has instead become a recurring cost: ios-register turns the warnings off in debug builds, Market Catalog would need to do the same, Market snapshot tests trip the check through modal content, and Xcode previews needed a suppression path of their own. Invert the default. Rename the flag to enablePerceptionChecking, defaulting to false, so that a client using WorkflowSwiftUI below iOS 17 asks for the check rather than everyone else asking to be rid of it. The availability gate on the suppression funnel goes away with it: an explicit opt-in states what the gate was previously inferring from the OS version. Drop the Xcode preview special case too, and with it the XcodePreviews type and its tests, which have no other consumer. Previews were detected and suppressed unconditionally because the check was on by default and a canvas runs no app startup code, leaving a preview no way to reach the configuration. With the check off by default, a preview gets the quiet behavior it wanted from the default itself, and a preview that does want checking can reach the configuration through Runtime.updateDefaultConfiguration, which writes a process-global rather than a task local and so survives past the call. Previews now follow the same configuration as everything else instead of overriding it. The render pass wrapping in Workflow+Preview.swift stays. It is not a preview exception but the plumbing that routes a workflow's reads of its own state through the configuration at all, since those reads never pass through a Store. Two consequences worth being explicit about. This is a source-breaking rename plus a behavior change for every client, so it wants a major version bump. And below iOS 17 it is a real loss of signal, because an untracked read there is a genuine defect -- the view will not update when the state it reads changes -- and such a client now gets no diagnostic at all until it opts in. --- Workflow/Sources/RuntimeConfiguration.swift | 15 +++++--- .../Sources/PerceptionCheckSuppression.swift | 22 +++++------- WorkflowSwiftUI/Sources/XcodePreviews.swift | 25 -------------- WorkflowSwiftUI/Tests/StoreTests.swift | 26 +++++++------- .../Tests/XcodePreviewsTests.swift | 34 ------------------- 5 files changed, 33 insertions(+), 89 deletions(-) delete mode 100644 WorkflowSwiftUI/Sources/XcodePreviews.swift delete mode 100644 WorkflowSwiftUI/Tests/XcodePreviewsTests.swift 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