Skip to content

body() for SwiftPeerBridged SwiftUI Views can silently return the wrong view's body (type-unchecked peer-pointer cast in SwiftObjectPointer.pointee()) #695

@Jeehut

Description

@Jeehut

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):

  1. Open a sheet that presents GroupSettingsView (or any other SwiftPeerBridged SwiftUI View).
  2. Dismiss the sheet.
  3. Navigate to a screen that hosts AuthenticatedPhotoView at a slot (e.g. SpotDetailView's hero, GroupDetailView's RECENT SPOTS thumbnail).
  4. 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:

  1. 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.

  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions