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

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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions