You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
App-module host: app module built with -Xfrontend -enable-implicit-dynamic and -enable-testing, thin executable links it, TCP listener dlopens dylibs on command.
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.
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.
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.
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
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.
Injection soak on the app-module host (RSS per injection, cap tuning) mirroring the W7 soak methodology.
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.
Summary
Two related capabilities, with very different costs:
#Previewblock required. Fits the existing bridge-dylib host with modest changes.@_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
#Previewblock has nothing to render today. Agents want to test a view without navigating to it, with state they choose.The architecture gap (read this first)
Today's iOS host (
HostAppSource/HostApp.swift, built byIOSHostBuilder) is a thin app that dlopens a daemon-compiled bridge dylib and dlsymscreatePreviewView. 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:@testable importin Tier 2). An expression bridge is the same compile with a different body.-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, andBuildContextTier 2 (loosesourceFilesminus 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:-Xfrontend -enable-implicit-dynamicand-enable-testing, thin executable links it, TCP listener dlopens dylibs on command.@_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.@_dynamicReplacementsource, compiles, injects.@_cdecl("makeHostedView")returning aUIHostingControllerwrappingViewB(count: 42). Host dlsyms the entry and swaps the root view controller. No navigation, no#Previewblock.Gotchas found:
-enable-implicit-dynamicis frontend-only (needs-Xfrontend);-l<name>fails for dylibs not namedlib*, link the dylib path directly.Proposed UX
Two new tools (twelve become fourteen; update
docs/cli-mcp-parity-spec.mdand the CLI in lockstep):view_list(projectPath)— scan the module forView-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.PreviewSessiontakes an expression as input;PreviewParserbecomes 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:
@_dynamicReplacementdylib, dlopenThe 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)
view_startwith a bad expression errors synchronously and creates no session.window.rootViewControllerfrom the socket thread and gets away with it for full reloads only; Phase B must not.preview_stop. The watcher ignores daemon-authored writes to them (self-edit loop guard).@Stateis lost on structural edits, same as Apple.preview_stopon an app-module host terminates the app, not just the socket.Relationship to existing work
@_dynamicReplacementmechanics on macOS, including inline-wrapper and@State-factory patterns. This issue extends it to iOS (now verified) and to arbitrary app-module functions.Scope decisions needed
IOSHostBuildercaches one host app under one bundle ID (com.previewsmcp.host). App-module hosts need per-project bundle IDs or a host registry.view_startship on both in Phase A?BodyKindgating is SwiftUI-only today (Hot-reload literal-only fast path is silently a no-op for UIKit-bodied previews #160). Define whether aUIViewControllerexpression is hostable in Phase A and which tiers apply.PreviewTraits(probablyview_startparameters mirroringpreview_configure) and whetherPreviewSetupruns for it. Note standalone mode has no setup support today (PreviewSession.swift:158).Verification gates before committing to Phase B
-enable-implicit-dynamicoverhead on a real app module at-Onone(estimate 5-10%, unmeasured). If it blows the interaction budget, Phase B needs a rethink.Open questions
view_listdiscovery mechanics: SwiftSyntax scan vs.swiftmoduleintrospection, and how generic or environment-dependent views are reported.View.bodyedits from tier 3 to tier 2.