Skip to content

TabView page contents render blank/dimmed when previewing Xcode-built targets (.xcodeproj / .xcworkspace) #151

@obj-p

Description

@obj-p

Problem

Previews compiled against Xcode build artifacts (.xcodeproj and .xcworkspace) silently fail to render TabView page contents at runtime. Accessibility structure is intact — the tree exposes the page indicator ("page 1 of 3") and correct frames — but the visual content of each page is missing. Severity differs by platform:

  • iOS simulator: page area renders as an empty white rectangle. No gradient, no text, no page dots visible in the snapshot.
  • macOS (NSHostingView): page area renders dimmed/translucent. Title and counts are faintly visible but the gradient background is mostly absent.

SPM and Bazel build paths render the same TabView correctly — full blue gradient, "Progress", "1/8", "7 remaining", visible page dots — both on macOS and iOS. So the bug is specific to how PreviewsMCP compiles preview dylibs against .swiftmodule / object files produced by xcodebuild.

Reproduction

Discovered while running the integration-test skill end-to-end after merging #150. Steps:

  1. swift build at the repo root.
  2. cd examples/xcodeproj && mint run xcodegen generate && xcodebuild build -project ToDo.xcodeproj -scheme ToDo -destination 'platform=macOS'.
  3. Call preview_start on examples/xcodeproj/Sources/ToDo/ToDoView.swift with projectPath: examples/xcodeproj/. Snapshot the macOS session — Progress card area renders as a dimmed gray box.
  4. Call preview_start again with platform: "ios" on the same file. Snapshot — Progress card area renders as a completely empty white rectangle.
  5. Repeat with examples/xcworkspace/ (workspace build path) — same dimmed/empty rendering.
  6. Repeat with examples/spm/ and examples/bazel/ — Progress card renders correctly with full gradient, text, page dots.

The accessibility structure is the same in all cases:

preview_elements (xcodeproj iOS):
  ...
  {"frame":{"height":119,"width":361,"x":16,"y":150}, "role":"group",
   "children":[{"frame":..., "traits":["adjustable","updatesFrequently"], "value":"page 1 of 3"}]}
  ...

So the view hierarchy is constructed and laid out, but the page closure body either runs into a swallowed runtime error or produces no rendered output.

Hypothesis

Possibilities (none verified):

  • Symbol visibility / linkage divergence. Xcode emits .swiftmodule and object files with default visibility/linkage that differs from swift build output. The TabView page closures, captured across module boundaries when the preview dylib loads, may resolve symbols differently.
  • Resource bundle resolution. TabView's page-style indicators may pull from a SwiftUI resource bundle whose lookup path differs when the consuming dylib is built against xcodebuild artifacts vs. SPM artifacts.
  • Optimization-level mismatch. xcodebuild Debug vs. swift build's default debug differ in inlining behavior; a @_alwaysEmitIntoClient or @inlinable boundary could silently strip page-content rendering when the consumer's optimization differs from the producer's.
  • Closure capture across the bridge generator. Less likely given that other view contents (List rows, Toggles) render fine, but worth checking whether TabView { ForEach... } patterns specifically are mishandled in the generated bridge code when the underlying types come from Xcode artifacts.

Prerequisite cleanup (do these first — they'll make the diagnosis tractable)

The current build system is invisible from progress messages and daemon logs. BuildHelpers.swift:detectAndBuild reports "Detecting project..." then "Building..." — neither names the detector that matched. BuildSystemDetector.detect (Sources/PreviewsCore/BuildSystem.swift:18-73) and the three BuildSystem implementations (SPMBuildSystem, BazelBuildSystem, XcodeBuildSystem) have zero Log.info / print / fputs calls. So when comparing an SPM run against an Xcode run today, you have to infer which detector fired from the projectPath argument or filesystem markers. That makes any otool/nm/swiftc-line diff guess-work.

1. Log the selected build system. One line right after BuildSystemDetector.detect returns, in BuildHelpers.detectAndBuild:

Log.info("buildSystem: \(type(of: buildSystem)) projectRoot=\(buildSystem.projectRoot.path)")

The Log.info line goes to ~/.previewsmcp/serve.log (UDS daemon) and the stdio MCP server's stderr. Tiny change. Bisect becomes trivial.

2. Document the artifact layout for each build system uniformly. docs/build-system-integration.md has a dedicated Artifact Layout section for SPM (lines 46–58) but no parallel sections for Xcode or Bazel. Bazel gets one line ("Find build outputs in bazel-bin/"); Xcode artifact layout appears only inside the comparison section "How Xcode Previews Does It," which describes Xcode's own internal behavior — not the layout PreviewsMCP consumes from xcodebuild. Add explicit "Artifact Layout" subsections for both:

  • Xcode (.xcodeproj / .xcworkspace): where exactly does PreviewsMCP look in DerivedData? Which Build/Intermediates.noindex/<Target>.build/<Configuration>-<Platform>/Objects-normal/<arch>/ paths does it read from? Which .swiftmodule flavor (.private.swiftinterface vs. .swiftmodule)? Where do object files come from (Objects-normal/<arch>/*.o)? What linker artifacts (<Target>.dylib, <Target>.framework)?
  • Bazel: where in bazel-bin/ does PreviewsMCP look for .swiftmodule and .o? How are external dependencies' artifacts located? Does it run bazel build itself, or rely on the user having built first?

The diff between these layouts is the most likely seat of this bug — a comprehensive layout doc would either point at the answer or rule out resource-bundle / search-path divergences.

Suggested approach: diagnose first, don't chase a fix

This is unlikely to be a small bug. The "items render but TabView pages don't" pattern points at something subtle in symbol or resource resolution across the dylib boundary. Plan to spend the first session on diagnosis, not patches:

  1. Land the prerequisite cleanup (detector logging + artifact-layout docs).
  2. Run the SPM repro and the xcodeproj repro back-to-back, capturing the bridge dylib path each session loads (visible in serve.log under setup-dylib / dylib-load lines and now the new buildSystem: line).
  3. otool -L <dylib> on both — diff the dependent libraries and rpaths. Differences in @rpath resolution or which SwiftUI variant is linked are load-bearing.
  4. nm -gU <dylib> | grep -i tab (and the same for Page, IndexView) on both — confirm whether the page-style symbols differ in visibility, mangling, or presence at all.
  5. Diff the swiftc command lines PreviewsMCP generates for the two consumers (capture from Compiler.swift invocation logs). Mismatched optimization levels, search paths, or -l flags will surface here.
  6. Only after the diff produces a concrete delta should a fix be attempted.

The integration-test skill already exercises both paths back-to-back, which makes capturing comparable diagnostic output cheap — restart Claude Code, run the skill against examples/spm/, capture, then against examples/xcodeproj/, capture.

Why this matters

xcodeproj and xcworkspace are first-class supported build systems in PreviewsMCP, and TabView is a common SwiftUI primitive (any paged onboarding flow, dashboard, etc., would hit this). Right now anyone using PreviewsMCP against a real Xcode project to preview a TabView-containing view would see blank page bodies and have no obvious diagnostic.

The integration-test skill already exercises this — examples/xcodeproj/Sources/ToDo/ToDoView.swift and examples/xcworkspace/Sources/ToDo/ToDoView.swift both contain a TabView. The README for each example claims "first summary card (blue 'Progress') should show '1/8' with '7 remaining'", which doesn't match observed behavior. Either the bug regressed silently, or the READMEs were aspirational from the start. git log -p examples/xcodeproj/README.md would clarify.

Out of scope

This is independent of #150 (the build-info staleness check that surfaced the bug). The two PRs touch disjoint code paths.

🤖 Filed from a Claude Code session running the integration-test skill end-to-end to validate PR #150.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions