Skip to content

Wrap #Preview body in @ViewBuilder func to accept if #available et al#77

Merged
obj-p merged 3 commits intomainfrom
fix/viewbuilder-wrap-preview-body
Apr 10, 2026
Merged

Wrap #Preview body in @ViewBuilder func to accept if #available et al#77
obj-p merged 3 commits intomainfrom
fix/viewbuilder-wrap-preview-body

Conversation

@obj-p
Copy link
Copy Markdown
Owner

@obj-p obj-p commented Apr 10, 2026

Summary

  • Fixes a bug where BridgeGenerator emitted invalid Swift when a #Preview body started with if #available / if #unavailable, used a leading let/var, or had if/switch branches with different concrete View types. Reported against Prism iOS (CatalogChatThread.swift:360).
  • The root cause: we were pasting the closure body as a bare expression argument to AnyView(...), stripping the @ViewBuilder context that Xcode's #Preview macro provides via DeveloperToolsSupport.Preview.init.
  • The fix: declare a nested @ViewBuilder func __previewBody() -> some SwiftUI.View inside the generated bridge and call it from AnyView(__previewBody()). Same runtime semantics as Xcode's #Preview, zero added view types in the render tree.

Why not Group { }?

Group would have worked functionally, but it has a canvas-level quirk in the legacy PreviewProvider API where Xcode's preview inspector walks into a top-level Group and renders each child as a separate preview card. That quirk doesn't apply to our runtime render path (we go through NSHostingView / UIHostingController, not Xcode's canvas) or to the new #Preview macro, but seeing Group in generated bridge code is a code smell that invites confusion. A nested @ViewBuilder function gives unambiguous intent and the same compiler machinery Xcode's macro uses.

The rationale is documented inline in BridgeGenerator.swift.

Cases this now handles

Pattern Previously Now
MyView() ✅ worked ✅ works
if #available(iOS X, *) { A() } else { B() } ❌ invalid Swift ✅ tested end-to-end
let model = Model(); MyView(model: model) ❌ silent bug ✅ tested end-to-end
switch state { case .a: A(); case .b: B() } with different branch types ❌ type error ✅ (via _ConditionalContent)
if cond { A() } else { B() } with different branch types ❌ type error ✅ (via _ConditionalContent)

Test plan

  • swift test --filter "BridgeGeneratorTraits" — 40/40 pass, including:
    • fullPipelineWithIfAvailable — real Compiler proves if #available body compiles
    • fullPipelineWithMultiStatement — real Compiler proves let x = ...; MyView() body compiles
    • Source-level assertions that @ViewBuilder func __previewBody() precedes the body it wraps
    • Ordering assertion that trait modifiers land on __previewBody(), not inside its body
  • swift test --filter "PreviewsCoreTests" — 141/141 pass (no regressions in other test suites)
  • swift build — clean
  • swift-format lint --strict — clean
  • Smoke test against the original repro (Prism iOS CatalogChatThread.swift:360) — reporter to verify

🤖 Generated with Claude Code

obj-p and others added 3 commits April 10, 2026 19:24
BridgeGenerator previously passed the extracted `#Preview` closure body
directly as an expression argument to `AnyView(...)`. That rejects any
body that isn't a plain `View` expression:

  - `if #available` / `if #unavailable` (statement-only in Swift)
  - Multi-statement bodies with leading `let`/`var` declarations
  - `if`/`switch` whose branches produce different concrete `View` types

Xcode's `#Preview` macro avoids this because it forwards the trailing
closure to `DeveloperToolsSupport.Preview.init`, which takes a
`@ViewBuilder` closure — the result builder handles all of the above via
`_ConditionalContent` and friends. Our bridge was stripping that context.

Fix: declare a nested `@ViewBuilder func __previewBody() -> some SwiftUI.View`
inside the generated bridge and call it from `AnyView(__previewBody())`.
Same runtime semantics as Xcode's `#Preview`, zero added view types in the
render tree, and unambiguous intent (avoiding the code smell of `Group {}`,
which has a canvas-inspection quirk in the legacy `PreviewProvider` API
that could confuse readers of the generated source).

Reported against Prism iOS: CatalogChatThread.swift:360 contained an
`if #available` preview body that emitted invalid Swift and failed to
compile via previewsmcp.

Tests: added end-to-end compile-pipeline coverage for both `if #available`
and multi-statement bodies, plus source-level assertions that the
`@ViewBuilder` function precedes the body it wraps and that trait
modifiers land on the `__previewBody()` call (not inside the function).
All 40 BridgeGenerator tests pass; full PreviewsCore suite (141 tests) green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…vention

The example's Package.resolved has never been committed across the repo's
history (checked via git log --all -- examples/spm/Package.resolved) —
not since the original SPM example in #3, nor through #75 which just added
a new remote dependency on lottie-spm. Meanwhile the root /Package.resolved
IS tracked, and CI hashes only the root one (.github/workflows/ci.yml).

The rationale is that examples/spm is a testbed for PreviewsMCP's SPM
build system. Leaving its Package.resolved unpinned means every fresh
checkout / CI run exercises a fresh dependency resolution, so upstream
breakage in deps like lottie-spm surfaces promptly instead of being
masked by pinned versions.

Previously this convention was implicit — the file was just left
untracked, polluting every `git status`. Make it explicit with a scoped
gitignore entry (the specific path, not a blanket `Package.resolved`
that would also affect the tracked root file) and a comment documenting
the rationale.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous commit added the exclusion to the root .gitignore, but the
project convention is per-example local .gitignore files — see
examples/xcodeproj/.gitignore, examples/bazel/.gitignore, and
examples/xcworkspace/.gitignore. Move the rule to examples/spm/.gitignore
to match that convention and keep the root .gitignore focused on
repo-wide concerns.

Verified with `git check-ignore -v`:
  examples/spm/.gitignore:5:Package.resolved  examples/spm/Package.resolved
Root /Package.resolved remains tracked as before.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@obj-p obj-p enabled auto-merge (squash) April 10, 2026 23:34
@obj-p obj-p merged commit e5707e8 into main Apr 10, 2026
4 checks passed
@obj-p obj-p deleted the fix/viewbuilder-wrap-preview-body branch April 10, 2026 23:50
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.

1 participant