Skip to content

Suppress Perception checking in Xcode previews - #396

Merged
johnnewman-square merged 2 commits into
mainfrom
johnnewman/suppress-perception-checks-in-xcode-previews
Aug 31, 2026
Merged

Suppress Perception checking in Xcode previews#396
johnnewman-square merged 2 commits into
mainfrom
johnnewman/suppress-perception-checks-in-xcode-previews

Conversation

@johnnewman-square

@johnnewman-square johnnewman-square commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

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 _PerceptionLocals are task-locals and a binding placed around a #Preview body-producing closure has gone out of scope by the time SwiftUI evaluates that body.

The result is that a preview of an ObservableScreen on iOS 17+ reports the warning with no way to act on it. The workarounds available to a consumer are per-preview boilerplate, or reintroducing WithPerceptionTracking in 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

  • Add XcodePreviews, a debug-only namespace that reports whether the process is rendering previews
  • Suppress the check when either the configuration flag is set or the process is rendering previews, at two places: Store state reads, and the render pass that workflowPreview drives
  • No public API change: Runtime.Configuration.suppressPerceptionCheckingWhenUsingObservation keeps the exact semantics Make Perception warning suppression opt-in #393 documented, and remains the only way to suppress outside of previews
  • Both the detection and its consumers are behind #if DEBUG, so release builds are unaffected

Why key on the process rather than on Store.preview

The narrower fix — tag stores built by Store.preview and suppress for those — only covers half the cases.

Store.preview and ObservableScreen.observableScreenPreview produce a static store, and tagging works there. But workflowPreview hosts a real workflow in a WorkflowHostingController, so its stores come out of the ordinary render path through make(model:) and are indistinguishable from an app's. Any wrapper built on workflowPreview inherits 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 _StoreCollection paths.

I also considered defaulting suppressPerceptionCheckingWhenUsingObservation from the environment instead. That puts a UI heuristic in the core Workflow module and needs #if DEBUG around a public property's default value. Keeping it in WorkflowSwiftUI means the read site is already inside #if DEBUG && canImport(Observation).

Why Store alone isn't enough

Suppressing at Store covers reads a view makes. It does not cover reads a workflow makes of its own state inside render, which reach the state accessor directly and never touch a Store — 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; the ObservableComposition sample 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 Store into 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.isRunning reads 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 by test_perceptionRuntimeWarningsCanBeSuppressedWhenUsingObservation.

test_isRunning_isFalseInTheTestProcess exists to protect the negative test. test_perceptionRuntimeWarningsWhenUsingObservation asserts 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 PreviewView 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.

Test plan

  • tuist test --path Samples UnitTests on iPad Air (5th generation), iOS 26.5 — the 4 new XcodePreviewsTests pass
  • test_perceptionRuntimeWarningsWhenUsingObservation still passes, i.e. the warning still fires when neither the flag nor a preview applies
  • test_perceptionRuntimeWarningsCanBeSuppressedWhenUsingObservation still passes
  • swift build --target WorkflowSwiftUI clean; swiftformat --lint clean
  • Reproduced in a live canvas before the fix: MultiCounterView_Previews reports \State.<computed … (Int)> on every canvas update, which is CounterWorkflow.State.count read from CounterWorkflow.render and MultiCounterWorkflow.render
  • Confirmed in a live canvas after the fix — silent on canvas updates, and silent while driving the counters

Checklist

  • Unit Tests
  • UI Tests (not applicable)
  • Snapshot Tests (not applicable)
  • I have made corresponding changes to the documentation

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
johnnewman-square marked this pull request as ready for review August 31, 2026 21:10
@johnnewman-square
johnnewman-square requested review from a team as code owners August 31, 2026 21:10
@johnnewman-square
johnnewman-square merged commit 674e087 into main Aug 31, 2026
14 checks passed
@johnnewman-square
johnnewman-square deleted the johnnewman/suppress-perception-checks-in-xcode-previews branch August 31, 2026 21:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants