Skip to content

Expression-hosted sessions: host any view without a #Preview block, edit live process code via dynamic replacement #192

@obj-p

Description

@obj-p

Summary

Two related capabilities, with very different costs:

  • Phase A — Expression-hosted sessions: host any view from a Swift expression, no #Preview block required. Fits the existing bridge-dylib host with modest changes.
  • Phase B — Live process code editing: edits to the app module's function bodies go live in the running host via @_dynamicReplacement. This requires a new host architecture and is the real project here.

A local spike (2026-06-04, iOS 26.2 simulator, Xcode 26.2) validated the Phase B mechanism end to end, closing the iOS-parity question left open by the dynamic-replacement spike (#178).

Motivation

  • A file with no #Preview block has nothing to render today. Agents want to test a view without navigating to it, with state they choose.
  • Hot-reload currently covers the preview thunk / editable unit only. Stable-module callers are bound at link time and never see new code. Editing the process code itself needs a different mechanism.

The architecture gap (read this first)

Today's iOS host (HostAppSource/HostApp.swift, built by IOSHostBuilder) is a thin app that dlopens a daemon-compiled bridge dylib and dlsyms createPreviewView. The user's app module is never linked into the host process. Dynamic replacement can only replace functions in a module that is linked into the running process. So:

  • Phase A works on the current host. The bridge already compiles against the user module (@testable import in Tier 2). An expression bridge is the same compile with a different body.
  • Phase B does not. It needs an app-module host: build the user's target with -Xfrontend -enable-implicit-dynamic + -enable-testing, link it into a host executable, install and launch that. This is a new build mode with no SPM/Xcode/Bazel path today, and BuildContext Tier 2 (loose sourceFiles minus the preview file) is not it. Per-build-system support is the bulk of the work.

Spike results

Standalone spike at ~/Projects/process-edit-spike (separate from this repo). All four chunks passed on the simulator:

  1. App-module host: app module built with -Xfrontend -enable-implicit-dynamic and -enable-testing, thin executable links it, TCP listener dlopens dylibs on command.
  2. Hand-written replacement: @_dynamicReplacement(for: greeting()) dylib compiled with @testable import, ad-hoc signed, dlopened. Same PID, function returns new value immediately, SwiftUI re-renders after an ObservableObject bump.
  3. Auto-generated replacement: edit a source file on disk, a differ extracts changed top-level functions only against a build-time snapshot, wraps them in generated @_dynamicReplacement source, compiles, injects.
  4. Expression hosting: a 5-line file with @_cdecl("makeHostedView") returning a UIHostingController wrapping ViewB(count: 42). Host dlsyms the entry and swaps the root view controller. No navigation, no #Preview block.

Gotchas found: -enable-implicit-dynamic is frontend-only (needs -Xfrontend); -l<name> fails for dylibs not named lib*, link the dylib path directly.

Proposed UX

Two new tools (twelve become fourteen; update docs/cli-mcp-parity-spec.md and the CLI in lockstep):

  • view_list(projectPath) — scan the module for View-conforming types, return names and init signatures.
  • view_start(projectPath, expression) — e.g. "ProfileView(user: .mock)" or "NavigationStack { ProfileView(user: .mock) }". The daemon materializes the expression into a generated scratch file, compiles, hosts.

Downstream tools (preview_snapshot, preview_touch, preview_elements, session_list) operate on the session unchanged. preview_start(filePath, index) becomes sugar: the parser harvests the block's closure body as the expression. PreviewSession takes an expression as input; PreviewParser becomes one producer of expressions rather than the only entry point.

Code edits need no tool call. The session watcher classifies each save into a tier:

Tier Edit kind Mechanism Cost Phase
1 Literal-only DesignTimeStore value push ~no compile A
2 Function/computed-property body Generated @_dynamicReplacement dylib, dlopen one-file compile B
3 Structural (new type, stored property, signature) Rebuild + relaunch + re-host same expression full rebuild B

The reload ack reports which tier ran, its latency, and on failure a compile-error payload.

Tier 2 honesty: the spike's differ handles top-level functions. Struct members (e.g. View.body) need the extension-based replacement patterns from #178 plus SwiftSyntax-grade extraction. Until then, struct-member edits classify as tier 3.

Defined behaviors (previously unspecified)

  • Compile failure (any tier): the last-good view stays up. The ack carries the compiler error. view_start with a bad expression errors synchronously and creates no session.
  • Root-VC swap and replacement loads: serialized onto the main thread through a single reload queue. The current host mutates window.rootViewController from the socket thread and gets away with it for full reloads only; Phase B must not.
  • Snapshot re-baseline: after a tier-3 rebuild the differ snapshot resets to the rebuilt sources. After a successful tier-2 inject, only the changed files re-snapshot.
  • Scratch files: live under the session's temp dir, owned by the daemon, deleted on preview_stop. The watcher ignores daemon-authored writes to them (self-edit loop guard).
  • Dylib accumulation: replacement dylibs never unload and metadata never deregisters. Apply the JIT track's respawn-on-cap lesson (soak: ~87KB/gen RSS growth): after N injections (default ~100), the daemon transparently does a tier-3 relaunch and re-host.
  • Tier-3 state: v1 is re-host only, no state replay. Matches the JIT track's stance that runtime @State is lost on structural edits, same as Apple.
  • Session teardown: preview_stop on an app-module host terminates the app, not just the socket.

Relationship to existing work

  • W3 finding (phase-3 plan, "Apple does not use dynamic replacement"): acknowledged, and it is why this track exists as a separate phase rather than a change to the JIT track. Apple's respawn model cannot edit stable-module code in a live process at all; that is the capability being added. The JIT track stays the primary path for preview-thunk edits.
  • spike: @_dynamicReplacement viability (per-shape) #178 validated the @_dynamicReplacement mechanics on macOS, including inline-wrapper and @State-factory patterns. This issue extends it to iOS (now verified) and to arbitrary app-module functions.
  • W6's literal model applies to generated scratch files, since the daemon owns them and can thunk freely.

Scope decisions needed

  • Build context for the app-module host per build system (SPM first?). This is the largest unknown in Phase B.
  • Concurrent sessions: IOSHostBuilder caches one host app under one bundle ID (com.previewsmcp.host). App-module hosts need per-project bundle IDs or a host registry.
  • macOS parity: spike: @_dynamicReplacement viability (per-shape) #178 was macOS, the spike was iOS. Does view_start ship on both in Phase A?
  • UIKit expressions: BodyKind gating is SwiftUI-only today (Hot-reload literal-only fast path is silently a no-op for UIKit-bodied previews #160). Define whether a UIViewController expression is hostable in Phase A and which tiers apply.
  • Traits and PreviewSetup for expression sessions: how an expression session acquires PreviewTraits (probably view_start parameters mirroring preview_configure) and whether PreviewSetup runs for it. Note standalone mode has no setup support today (PreviewSession.swift:158).
  • Codesigning cadence: the spike ad-hoc signed one dylib once. Per-save sign-and-dlopen on the simulator needs a soak; device support is presumed blocked and out of scope.

Verification gates before committing to Phase B

  1. Measure -enable-implicit-dynamic overhead on a real app module at -Onone (estimate 5-10%, unmeasured). If it blows the interaction budget, Phase B needs a rethink.
  2. Injection soak on the app-module host (RSS per injection, cap tuning) mirroring the W7 soak methodology.
  3. Tier-2 latency distribution on a large module (expect ~200ms, single-file compile plus codesign plus dlopen).

Open questions

  • view_list discovery mechanics: SwiftSyntax scan vs .swiftmodule introspection, and how generic or environment-dependent views are reported.
  • Struct-member extraction design (spike: @_dynamicReplacement viability (per-shape) #178 patterns + SwiftSyntax) to move View.body edits from tier 3 to tier 2.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions