Summary
In Skip Fuse 1.0.2 (mode: native), the JNI export Java_<package>_<ViewName>_Swift_1composableBody for a SwiftPeerBridged SwiftUI value-type View can silently return another view's body at runtime. The wrong view is consistently whichever Swift View was most recently dismissed from the same Compose tree (a sheet, a navigation push, an overlay).
Root cause: the generated bridge reinterprets the Kotlin shadow's Swift_peer pointer as SwiftValueTypeBox<Self> with no runtime type check, via SwiftObjectPointer.pointee<T>() which is Unmanaged<T>.fromOpaque(...).takeUnretainedValue(). When a stale or address-reused peer reaches the export, the typed cast silently succeeds and body returns the wrong view's content.
Reproduction (real-world)
Observed in the SpotCrown app (production Swift / SwiftUI Skip Fuse codebase, iOS + Android via Skip Fuse 1.0.2 native mode).
Setup: a SwiftUI View AuthenticatedPhotoView declared as:
struct AuthenticatedPhotoView: View {
let url: URL
let bearerName: String
let accessibilityLabel: String
@State var phase: Phase = .idle
enum Phase: Sendable, Equatable {
case idle
case loading
case loaded(Data)
case failed(String)
}
var body: some View {
ZStack {
Color.crownSoft
switch self.phase {
case .idle, .loading: ProgressView()
case .loaded(let data): // …render image bytes…
case .failed(let m): Text(m)
}
}
.task(id: self.url) { await self.load() }
}
…
}
Reproduction flow on Android (medium_phone API 36):
- Open a sheet that presents
GroupSettingsView (or any other SwiftPeerBridged SwiftUI View).
- Dismiss the sheet.
- Navigate to a screen that hosts
AuthenticatedPhotoView at a slot (e.g. SpotDetailView's hero, GroupDetailView's RECENT SPOTS thumbnail).
- Observe: the
AuthenticatedPhotoView slot renders the full body of GroupSettingsView (the steppers + MEMBERS list + INVITE block), not its own switch arms.
The swap target tracks the most-recently-dismissed view. Across four rounds of fix attempts in our project, the wrong-body content changed each round depending on the navigation chain:
- R1 walk: spot positions rendered
AcceptInviteView "Group not found" card; avatar positions rendered a HomeEmptyView-like mini-illustration.
- R2 walk (after a partial fix): same class,
HomeEmptyView in both surfaces.
- R3 walk (after wrapping the inner render in a
ComposeView): swap target became GroupSettingsView.
The swap-target-changes-per-walk is the smoking gun for a peer-pointer collision rather than a static View misbinding.
Root cause (per source inspection)
Generated Kotlin shadow (spot/crown/AuthenticatedPhotoView.kt):
@androidx.annotation.Keep
internal class AuthenticatedPhotoView: skip.ui.View, skip.bridge.SwiftPeerBridged, skip.lib.SwiftProjecting {
var Swift_peer: skip.bridge.SwiftObjectPointer = skip.bridge.SwiftObjectNil
constructor(Swift_peer: skip.bridge.SwiftObjectPointer, marker: skip.bridge.SwiftPeerMarker?) {
this.Swift_peer = Swift_peer
}
override fun body(): skip.ui.View {
return skip.ui.ComposeBuilder { ctx -> Swift_composableBody(Swift_peer)?.Compose(ctx) ?: skip.ui.ComposeResult.ok }
}
private external fun Swift_composableBody(Swift_peer: skip.bridge.SwiftObjectPointer): skip.ui.View?
override fun equals(other: Any?): Boolean {
if (other !is skip.bridge.SwiftPeerBridged) return false
return Swift_peer == other.Swift_peer()
}
override fun hashCode(): Int = Swift_peer.hashCode()
}
Generated bridge (SkipBridgeGenerated/AuthenticatedPhotoView_Bridge.swift):
@_cdecl("Java_spot_crown_AuthenticatedPhotoView_Swift_1composableBody")
public func AuthenticatedPhotoView_Swift_composableBody(
_ Java_env: JNIEnvPointer,
_ Java_target: JavaObjectPointer,
_ Swift_peer: SwiftObjectPointer
) -> JavaObjectPointer? {
let peer_swift: SwiftValueTypeBox<AuthenticatedPhotoView> = Swift_peer.pointee()! // ← unchecked cast
return SkipBridge.assumeMainActorUnchecked {
let body = peer_swift.value.body
return ((body as? SkipUIBridging)?.Java_view as? JConvertible)?.toJavaObject(options: [])
}
}
SwiftObjectPointer.pointee<T>() is implemented in SkipBridge's BridgeSupport.swift as Unmanaged<T>.fromOpaque(...).takeUnretainedValue() — no runtime type check on T. The Kotlin Swift_peer is a raw Int64, and equals/hashCode use only that pointer across all SwiftPeerBridged types — meaning a Kotlin map keyed on peers can collide across distinct Swift View types.
When a Swift SwiftValueTypeBox<DismissedSheet> is deallocated and its memory address reused for a SwiftValueTypeBox<AuthenticatedPhotoView>, but the Kotlin AuthenticatedPhotoView shadow still holds the original (stale-now-recycled) pointer — pointee() will read the freshly-allocated box and return its body. The cast to <AuthenticatedPhotoView> silently succeeds via Unmanaged.fromOpaque, but peer_swift.value is actually the wrong view's struct, and .body walks its computed property accordingly.
Patterns that trigger reliably
Empirically, the bug fires when the affected view has:
@State var phase: SomeEnumWithAssociatedValues
- A
switch self.phase in body with multiple arms
- One arm returning a
ComposeView { ContentComposer(...) }
Removing the @State + switch + enum pattern (making the body a single non-conditional ComposeView) eliminates the symptom — confirming the bug interacts with the SwiftPeerBridged body-resolution path specifically for these compound bodies.
Workaround we applied (production-verified)
For each affected SwiftUI View, make the Android body a single non-conditional ComposeView whose content closure owns the state machine entirely Kotlin-side:
var body: some View {
#if os(Android)
ComposeView {
AndroidNetworkPhotoComposer(
urlString: self.url.absoluteString,
bearerName: self.bearerName
)
}
.accessibilityLabel(Text(self.accessibilityLabel))
#else
// existing iOS body with @State + switch unchanged
#endif
}
#if SKIP
struct AndroidNetworkPhotoComposer: ContentComposer {
let urlString: String
let bearerName: String
@Composable func Compose(context: ComposeContext) {
AndroidNetworkPhoto.NetworkPhoto(url: self.urlString, bearer: self.bearerName)
}
}
#endif
ComposeView is not SwiftPeerBridged — its content closure is held as a Java field and rendered through Render(context) directly, bypassing the broken JNI body lookup entirely. This works around the bug without touching the iOS path.
Suggested upstream fix
Two possible surfaces:
-
Type-tagged boxes. Tag each SwiftValueTypeBox<T> with a runtime type marker (a Swift.ObjectIdentifier(T.self) field or similar) and validate it in SwiftObjectPointer.pointee<T>() before returning. Force-unwrap fail if mismatched.
-
Per-type peer maps. Keep a JNI-side map of allocated peers per Kotlin shadow class so cross-class peer collisions are detected at body-call time.
(1) is the more contained fix and is closer to what the existing SwiftValueTypeBox would naturally hold.
Versions
- Skip Fuse: 1.0.2
- Skip CLI: latest as of 2026-05-23
- Swift toolchain: 6.3.1-RELEASE
- Android target: aarch64-unknown-linux-android28
- Tested on: medium_phone AVD, API 36
Notes
Our full investigation + workaround recipe + reproduction artifacts: https://github.com/FlineDev/SpotCrown (private repo; can share details on request).
The codex/Claude cross-model diagnosis converged on the same root cause independently of our hypothesis — willing to share the verbatim transcript if helpful.
Thanks for an otherwise excellent toolchain — Skip Fuse has been transformative for our cross-platform shipping cadence. This is the one blocker that needs upstream attention to keep us off custom workarounds for image-bearing views.
Summary
In Skip Fuse 1.0.2 (
mode: native), the JNI exportJava_<package>_<ViewName>_Swift_1composableBodyfor a SwiftPeerBridged SwiftUI value-type View can silently return another view's body at runtime. The wrong view is consistently whichever Swift View was most recently dismissed from the same Compose tree (a sheet, a navigation push, an overlay).Root cause: the generated bridge reinterprets the Kotlin shadow's
Swift_peerpointer asSwiftValueTypeBox<Self>with no runtime type check, viaSwiftObjectPointer.pointee<T>()which isUnmanaged<T>.fromOpaque(...).takeUnretainedValue(). When a stale or address-reused peer reaches the export, the typed cast silently succeeds andbodyreturns the wrong view's content.Reproduction (real-world)
Observed in the SpotCrown app (production Swift / SwiftUI Skip Fuse codebase, iOS + Android via Skip Fuse 1.0.2 native mode).
Setup: a SwiftUI View
AuthenticatedPhotoViewdeclared as:Reproduction flow on Android (medium_phone API 36):
GroupSettingsView(or any otherSwiftPeerBridgedSwiftUI View).AuthenticatedPhotoViewat a slot (e.g. SpotDetailView's hero, GroupDetailView's RECENT SPOTS thumbnail).AuthenticatedPhotoViewslot renders the full body ofGroupSettingsView(the steppers + MEMBERS list + INVITE block), not its own switch arms.The swap target tracks the most-recently-dismissed view. Across four rounds of fix attempts in our project, the wrong-body content changed each round depending on the navigation chain:
AcceptInviteView"Group not found" card; avatar positions rendered aHomeEmptyView-like mini-illustration.HomeEmptyViewin both surfaces.ComposeView): swap target becameGroupSettingsView.The swap-target-changes-per-walk is the smoking gun for a peer-pointer collision rather than a static View misbinding.
Root cause (per source inspection)
Generated Kotlin shadow (
spot/crown/AuthenticatedPhotoView.kt):Generated bridge (
SkipBridgeGenerated/AuthenticatedPhotoView_Bridge.swift):SwiftObjectPointer.pointee<T>()is implemented inSkipBridge'sBridgeSupport.swiftasUnmanaged<T>.fromOpaque(...).takeUnretainedValue()— no runtime type check onT. The KotlinSwift_peeris a rawInt64, and equals/hashCode use only that pointer across allSwiftPeerBridgedtypes — meaning a Kotlin map keyed on peers can collide across distinct Swift View types.When a Swift
SwiftValueTypeBox<DismissedSheet>is deallocated and its memory address reused for aSwiftValueTypeBox<AuthenticatedPhotoView>, but the Kotlin AuthenticatedPhotoView shadow still holds the original (stale-now-recycled) pointer —pointee()will read the freshly-allocated box and return its body. The cast to<AuthenticatedPhotoView>silently succeeds viaUnmanaged.fromOpaque, butpeer_swift.valueis actually the wrong view's struct, and.bodywalks its computed property accordingly.Patterns that trigger reliably
Empirically, the bug fires when the affected view has:
@State var phase: SomeEnumWithAssociatedValuesswitch self.phaseinbodywith multiple armsComposeView { ContentComposer(...) }Removing the
@State + switch + enumpattern (making the body a single non-conditionalComposeView) eliminates the symptom — confirming the bug interacts with the SwiftPeerBridged body-resolution path specifically for these compound bodies.Workaround we applied (production-verified)
For each affected SwiftUI View, make the Android body a single non-conditional
ComposeViewwhose content closure owns the state machine entirely Kotlin-side:ComposeViewis notSwiftPeerBridged— itscontentclosure is held as a Java field and rendered throughRender(context)directly, bypassing the broken JNI body lookup entirely. This works around the bug without touching the iOS path.Suggested upstream fix
Two possible surfaces:
Type-tagged boxes. Tag each
SwiftValueTypeBox<T>with a runtime type marker (aSwift.ObjectIdentifier(T.self)field or similar) and validate it inSwiftObjectPointer.pointee<T>()before returning. Force-unwrap fail if mismatched.Per-type peer maps. Keep a JNI-side map of allocated peers per Kotlin shadow class so cross-class peer collisions are detected at body-call time.
(1) is the more contained fix and is closer to what the existing
SwiftValueTypeBoxwould naturally hold.Versions
Notes
Our full investigation + workaround recipe + reproduction artifacts: https://github.com/FlineDev/SpotCrown (private repo; can share details on request).
The codex/Claude cross-model diagnosis converged on the same root cause independently of our hypothesis — willing to share the verbatim transcript if helpful.
Thanks for an otherwise excellent toolchain — Skip Fuse has been transformative for our cross-platform shipping cadence. This is the one blocker that needs upstream attention to keep us off custom workarounds for image-bearing views.