Suppress Perception checking in Xcode previews - #396
Merged
johnnewman-square merged 2 commits intoAug 31, 2026
Merged
Conversation
Suppression of Perception's debug-only runtime check is opt-in through Runtime.Configuration, which is designed to be set once at app startup. A SwiftUI preview has no equivalent entry point: the canvas instantiates a view directly, with no app delegate and no runtime to configure. The task-local nature of the underlying flags means a preview cannot opt in for itself either, since a binding placed around a #Preview body closure has gone out of scope by the time SwiftUI evaluates that body. So a preview of an ObservableScreen on iOS 17+ reports the warning with no way to act on it, and the only workarounds available to a consumer are per-preview boilerplate or reintroducing WithPerceptionTracking in production view bodies that no longer need it. Suppress unconditionally when the process is rendering previews, which Xcode signals through XCODE_RUNNING_FOR_PREVIEWS. This composes with the existing configuration flag rather than replacing it, so the documented opt-in behavior is unchanged everywhere else. Deliberately keyed on the process rather than on how the store was built. Store.preview is not the only way a preview reaches a store: workflowPreview hosts a real workflow in a WorkflowHostingController, whose stores come from the ordinary render path and are indistinguishable from an app's. Tagging preview-created stores would miss those. Both the detection and its sole consumer are behind #if DEBUG, so release builds are unaffected. The environment lookup is factored into a pure function so it can be tested directly, since the process-wide value is read once and a test cannot vary it. A companion test pins that value false in the test process, because a true reading there would silently turn test_perceptionRuntimeWarningsWhenUsingObservation into a vacuous pass -- its assertion is the absence of a Perception failure.
Suppressing the check for Xcode previews only covered reads that go through a Store, which leaves out the reads a workflow makes of its own state inside render(). Those reach the state accessor directly and never touch a Store, so neither the configuration flag nor the preview process check could reach them. They are misreported for the same reason Store reads were: Perception decides whether it is looking at a SwiftUI view body by walking the call stack for AttributeGraph frames. PreviewView's representable callbacks drive a render pass synchronously and are themselves called by SwiftUI, so those frames are present and every observable read the pass makes trips the check. 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. Wrap both callbacks, and lift the predicate deciding when suppression applies out of Store into a module-level funnel, so that rule lives in one place rather than being duplicated at every site that needs it. No test accompanies the wrap. UIViewControllerRepresentableContext cannot be constructed outside of SwiftUI, so the callbacks cannot be driven from a test, and the stack-walk heuristic they work around cannot be reproduced without a real SwiftUI update.
johnnewman-square
marked this pull request as ready for review
August 31, 2026 21:10
blakemcanally
approved these changes
Aug 31, 2026
robmaceachern
approved these changes
Aug 31, 2026
johnnewman-square
deleted the
johnnewman/suppress-perception-checks-in-xcode-previews
branch
August 31, 2026 21:37
8 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This description was generated with an agent.
#393 made Perception warning suppression opt-in through
Runtime.Configuration, which is the right default — but that opt-in is designed to be set once at app startup, and a SwiftUI preview has no equivalent entry point. The canvas instantiates a view directly: no app delegate, no runtime to configure. A preview cannot opt in for itself either, because_PerceptionLocalsare task-locals and a binding placed around a#Previewbody-producing closure has gone out of scope by the time SwiftUI evaluates that body.The result is that a preview of an
ObservableScreenon iOS 17+ reports the warning with no way to act on it. The workarounds available to a consumer are per-preview boilerplate, or reintroducingWithPerceptionTrackingin production view bodies that no longer need it.This suppresses the check whenever the process is rendering previews, which Xcode signals through
XCODE_RUNNING_FOR_PREVIEWS.Summary
XcodePreviews, a debug-only namespace that reports whether the process is rendering previewsStorestate reads, and the render pass thatworkflowPreviewdrivesRuntime.Configuration.suppressPerceptionCheckingWhenUsingObservationkeeps the exact semantics Make Perception warning suppression opt-in #393 documented, and remains the only way to suppress outside of previews#if DEBUG, so release builds are unaffectedWhy key on the process rather than on
Store.previewThe narrower fix — tag stores built by
Store.previewand suppress for those — only covers half the cases.Store.previewandObservableScreen.observableScreenPreviewproduce a static store, and tagging works there. ButworkflowPreviewhosts a real workflow in aWorkflowHostingController, so its stores come out of the ordinary render path throughmake(model:)and are indistinguishable from an app's. Any wrapper built onworkflowPreviewinherits that, and there are such wrappers in the wild.Keying on the process covers both, and avoids threading a flag through
scope(...)'s child-store construction and the_StoreCollectionpaths.I also considered defaulting
suppressPerceptionCheckingWhenUsingObservationfrom the environment instead. That puts a UI heuristic in the coreWorkflowmodule and needs#if DEBUGaround a public property's default value. Keeping it inWorkflowSwiftUImeans the read site is already inside#if DEBUG && canImport(Observation).Why
Storealone isn't enoughSuppressing at
Storecovers reads a view makes. It does not cover reads a workflow makes of its own state insiderender, which reach the state accessor directly and never touch aStore— so nothing gated on the flag or on the process could reach them. Any workflow that reads its own observable state while rendering trips this, which is most of them; theObservableCompositionsample does it in two places.Those reads are misreported for the same underlying reason: Perception decides whether it is looking at a SwiftUI view body by walking the call stack for AttributeGraph frames.
PreviewView's representable callbacks drive a render pass synchronously and are themselves called by SwiftUI, so those frames are on the stack and every observable read the pass makes trips the check.This is 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 present and the check correctly stays quiet — which is why the warnings appear in the canvas and nowhere else.
Wrapping both callbacks fixes it. The predicate deciding when suppression applies is lifted out of
Storeinto a module-level funnel at the same time, so that rule lives in one place rather than being duplicated at each site that needs it.On testing
XcodePreviews.isRunningreads the process environment once, which a test cannot vary, so the lookup is factored into a pure function that is tested directly. The composed predicate is a one-line||whose other operand is already covered bytest_perceptionRuntimeWarningsCanBeSuppressedWhenUsingObservation.test_isRunning_isFalseInTheTestProcessexists to protect the negative test.test_perceptionRuntimeWarningsWhenUsingObservationasserts that the warning fires when unsuppressed, and its assertion is the absence of a Perception failure — so if the test process ever read as a preview, that test would silently become a vacuous pass.No test accompanies the
PreviewViewwrap.UIViewControllerRepresentableContextcannot be constructed outside of SwiftUI, so the callbacks cannot be driven from a test, and the stack-walk heuristic they work around cannot be reproduced without a real SwiftUI update.Test plan
tuist test --path Samples UnitTestson iPad Air (5th generation), iOS 26.5 — the 4 newXcodePreviewsTestspasstest_perceptionRuntimeWarningsWhenUsingObservationstill passes, i.e. the warning still fires when neither the flag nor a preview appliestest_perceptionRuntimeWarningsCanBeSuppressedWhenUsingObservationstill passesswift build --target WorkflowSwiftUIclean;swiftformat --lintcleanMultiCounterView_Previewsreports\State.<computed … (Int)>on every canvas update, which isCounterWorkflow.State.countread fromCounterWorkflow.renderandMultiCounterWorkflow.renderChecklist