From 6f826470aabfab7626186eee04e3281a5574f2fd Mon Sep 17 00:00:00 2001 From: johnnewman-square Date: Tue, 1 Sep 2026 10:25:12 -0400 Subject: [PATCH] Expose Perception check suppression as SPI Suppression of Perception's debug-only runtime check is applied at two places in this module: Store state reads, and the workflow render pass that workflowPreview drives from its representable callbacks. Both are covered, but the funnel deciding when suppression applies is internal, so a preview host outside this module cannot reach it. MarketWorkflowUI is such a host. It declares its own PreviewView rather than building on workflowPreview, drives a render pass the same way, and so reproduces the same misreported reads for any workflow previewed through marketPreview. With no entry point here it would have to duplicate the predicate, which means a second copy of the rule in another repository and a new direct dependency on swift-perception for a module that has no other use for one. Expose the existing funnel under the PreviewHosting SPI instead. Its signature names no Perception type, so a host can adopt it with an @_spi import and nothing else. Behavior is unchanged: same availability gate, same predicate, same call sites within this module. Document the pre-iOS 17 limitation while that reasoning is in one place. Suppression stays off below iOS 17 because a view body there genuinely needs WithPerceptionTracking to observe state at all, so a warning about one is actionable and worth keeping. The cost is that a render-pass read still warns on those versions, where nothing can act on it, since WithPerceptionTracking is a view modifier and a workflow's render cannot be wrapped in one. --- .../Sources/PerceptionCheckSuppression.swift | 23 ++++++++++++++++++- .../Sources/Workflow+Preview.swift | 13 +++-------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/WorkflowSwiftUI/Sources/PerceptionCheckSuppression.swift b/WorkflowSwiftUI/Sources/PerceptionCheckSuppression.swift index bcdc01a07..30bd0fbea 100644 --- a/WorkflowSwiftUI/Sources/PerceptionCheckSuppression.swift +++ b/WorkflowSwiftUI/Sources/PerceptionCheckSuppression.swift @@ -11,7 +11,28 @@ import Perception /// 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. -func withPerceptionCheckSuppressed(_ operation: () -> T) -> T { +/// +/// Two kinds of read need this. A ``Store`` read from a view body is the obvious one. The other is +/// a read a workflow makes of its own state during `render`, which never passes through a `Store`: +/// a preview host drives that render pass synchronously from a `UIViewControllerRepresentable` +/// callback, and *SwiftUI* is what calls that callback. Perception decides whether it is looking at +/// a SwiftUI view body by walking the call stack for AttributeGraph frames, so those frames are +/// present and the whole render pass is misreported. The same workflow in an app renders off a +/// runtime update instead, leaving no AttributeGraph frame on the stack, which is why these +/// warnings appear only in the canvas. +/// +/// Known limitation below iOS 17 and its siblings: the availability check leaves suppression off +/// there, because a view body on those versions genuinely does need `WithPerceptionTracking` to +/// observe state at all — a warning about one is actionable, and hiding it would turn a preview +/// that silently stops updating into a preview that silently stops updating for no visible reason. +/// The cost is that a render-pass read still warns on those versions, where nothing can act on it, +/// since `WithPerceptionTracking` is a view modifier and a workflow's `render` cannot be wrapped in +/// one. +/// +/// SPI rather than public API. It exists for preview hosts, of which there are few and all of them +/// library code, and it is meaningless to an app. +@_spi(PreviewHosting) +public func withPerceptionCheckSuppressed(_ operation: () -> T) -> T { #if DEBUG && canImport(Observation) if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *), Runtime.configuration.suppressPerceptionCheckingWhenUsingObservation diff --git a/WorkflowSwiftUI/Sources/Workflow+Preview.swift b/WorkflowSwiftUI/Sources/Workflow+Preview.swift index 091ed5d9c..9abefeafa 100644 --- a/WorkflowSwiftUI/Sources/Workflow+Preview.swift +++ b/WorkflowSwiftUI/Sources/Workflow+Preview.swift @@ -42,16 +42,9 @@ private struct PreviewView: UIViewControllerRepresentabl let customizeEnvironment: (inout ViewEnvironment) -> Void let onOutput: (WorkflowType.Output) -> Void - // Both representable callbacks drive a workflow render pass synchronously, and the check - // Perception performs there decides whether it is looking at a SwiftUI view body by walking the - // call stack for AttributeGraph frames. SwiftUI is what calls these methods, so those frames are - // present and every observable read the render pass makes is misreported — including reads a - // workflow makes of its own state, which never pass through a `Store` and so cannot be covered - // by the suppression there. - // - // These warnings are unique to previews. The same workflow running in an app renders off a - // runtime update rather than a SwiftUI one, so no AttributeGraph frame is on the stack and the - // check correctly stays quiet. + // Both callbacks drive a workflow render pass synchronously, and SwiftUI is what calls them, so + // every observable read the pass makes is misreported. See `withPerceptionCheckSuppressed` for + // why, and for why it only happens in previews. func makeUIViewController(context: Context) -> UIViewControllerType { withPerceptionCheckSuppressed {