Wrap #Preview body in @ViewBuilder func to accept if #available et al#77
Merged
Wrap #Preview body in @ViewBuilder func to accept if #available et al#77
Conversation
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>
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.
Summary
BridgeGeneratoremitted invalid Swift when a#Previewbody started withif #available/if #unavailable, used a leadinglet/var, or hadif/switchbranches with different concreteViewtypes. Reported against Prism iOS (CatalogChatThread.swift:360).AnyView(...), stripping the@ViewBuildercontext that Xcode's#Previewmacro provides viaDeveloperToolsSupport.Preview.init.@ViewBuilder func __previewBody() -> some SwiftUI.Viewinside the generated bridge and call it fromAnyView(__previewBody()). Same runtime semantics as Xcode's#Preview, zero added view types in the render tree.Why not
Group { }?Groupwould have worked functionally, but it has a canvas-level quirk in the legacyPreviewProviderAPI where Xcode's preview inspector walks into a top-levelGroupand renders each child as a separate preview card. That quirk doesn't apply to our runtime render path (we go throughNSHostingView/UIHostingController, not Xcode's canvas) or to the new#Previewmacro, but seeingGroupin generated bridge code is a code smell that invites confusion. A nested@ViewBuilderfunction gives unambiguous intent and the same compiler machinery Xcode's macro uses.The rationale is documented inline in
BridgeGenerator.swift.Cases this now handles
MyView()if #available(iOS X, *) { A() } else { B() }let model = Model(); MyView(model: model)switch state { case .a: A(); case .b: B() }with different branch types_ConditionalContent)if cond { A() } else { B() }with different branch types_ConditionalContent)Test plan
swift test --filter "BridgeGeneratorTraits"— 40/40 pass, including:fullPipelineWithIfAvailable— realCompilerprovesif #availablebody compilesfullPipelineWithMultiStatement— realCompilerproveslet x = ...; MyView()body compiles@ViewBuilder func __previewBody()precedes the body it wraps__previewBody(), not inside its bodyswift test --filter "PreviewsCoreTests"— 141/141 pass (no regressions in other test suites)swift build— cleanswift-format lint --strict— cleanCatalogChatThread.swift:360) — reporter to verify🤖 Generated with Claude Code