Skip to content

[iOS 18] Context menu preview is blank when using shouldOpenOnLongPress with React Native Fabric (New Architecture) #1201

@vicovictor

Description

@vicovictor

Description

When using <MenuView shouldOpenOnLongPress> on iOS 18 (and likely iOS 15–17) with React Native's New Architecture (Fabric), the context menu preview shows a blank white/colored rectangle with no content. The message text, background gradients, and custom border radii are all missing from the preview. Only the menu action items render correctly.

Image

This does not occur on:

  • iOS 26 (LiquidGlass) — preview renders correctly
  • Android — no preview mechanism, works fine
  • shouldOpenOnLongPress={false} (tap-to-open) — no preview animation, works fine

Environment

  • React Native: 0.79 (New Architecture / Fabric enabled)
  • @react-native-menu/menu: 2.0.0
  • iOS: 18.x (tested on Simulator, iPhone 16 Pro)
  • Xcode: 26.x
  • Architecture: Fabric (bridgeless)

Steps to Reproduce

<MenuView
  actions={[
    { id: 'reply', title: 'Reply', image: 'arrowshape.turn.up.left' },
    { id: 'copy', title: 'Copy', image: 'doc.on.doc' },
  ]}
  onPressAction={({ nativeEvent }) => console.log(nativeEvent.event)}
  shouldOpenOnLongPress
>
  <View style={{
    backgroundColor: '#007AFF',
    borderRadius: 16,
    padding: 12,
  }}>
    <Text style={{ color: 'white' }}>Hello, this is a message bubble</Text>
  </View>
</MenuView>
  1. Long press the view on iOS 18 Simulator or device
  2. Observe: a blank white rectangle lifts up, then transitions to a rounded-corner blank shape as the menu appears
  3. Expected: the blue bubble with "Hello, this is a message bubble" text should be visible in the preview

Root Cause Analysis

Why tap-to-open works but long-press doesn't

The library has two distinct menu presentation paths:

Path 1 — Tap (shouldOpenOnLongPress = false):

// In setup():
self.menu = menu
self.showsMenuAsPrimaryAction = true

UIButton presents the menu immediately on tap — there is no "lift" preview animation. No snapshotting occurs. This works perfectly.

Path 2 — Long press (shouldOpenOnLongPress = true):

// In init:
let interaction = UIContextMenuInteraction(delegate: self)
self.addInteraction(interaction)

// In setup():
self.menu = nil  // UIButton's built-in menu is disabled
self.showsMenuAsPrimaryAction = false

The UIContextMenuInteraction delegate handles the menu. iOS always performs a "lift + preview" animation, which requires snapshotting the source view via CALayer rendering.

Why the snapshot is blank

React Native's Fabric renderer uses Metal-backed CALayer sublayers for text (RCTTextLayer) and custom shapes. These layers are populated asynchronously via Metal GPU textures, not through the standard CALayer backing store.

iOS 18's UITargetedPreview internally uses CALayer snapshotting to capture the preview image. This snapshotting mechanism cannot read Metal texture content — it sees the layers as empty/transparent. The result is a blank rectangle with only the view's backgroundColor visible (if any).

iOS 26 (LiquidGlass) appears to have fixed this in Apple's new rendering pipeline, which is why the preview works there.

Attempted Fixes (All Failed)

Attempt 1: CALayer.render(in:) manual snapshot

public override func contextMenuInteraction(
    _ interaction: UIContextMenuInteraction,
    previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration
) -> UITargetedPreview? {
    let reactView = self.subviews.first(where: { NSStringFromClass(type(of: $0)) == "RCTView" })
    guard let view = reactView ?? self.subviews.last else { return nil }
    
    UIGraphicsBeginImageContextWithOptions(view.bounds.size, false, 0.0)
    defer { UIGraphicsEndImageContext() }
    guard let context = UIGraphicsGetCurrentContext() else { return nil }
    view.layer.render(in: context)
    guard let image = UIGraphicsGetImageFromCurrentImageContext() else { return nil }
    
    let imageView = UIImageView(image: image)
    let params = UIPreviewParameters()
    params.backgroundColor = .clear
    let target = UIPreviewTarget(container: view, center: CGPoint(x: view.bounds.midX, y: view.bounds.midY))
    return UITargetedPreview(view: imageView, parameters: params, target: target)
}

Result: Blank image. CALayer.render(in:) cannot capture Metal-backed sublayers. The rendered UIImage contains only the background color with no text or custom shapes.

Attempt 2: drawHierarchy(in:afterScreenUpdates:)

viewToSnapshot.drawHierarchy(in: bounds, afterScreenUpdates: true)

Result: Still blank. drawHierarchy is supposed to capture on-screen content, but on iOS 18 with Fabric views, the Metal textures are still not committed to the standard rendering pipeline when this is called during the context menu interaction.

Attempt 3: Target the inner RCTView directly (no snapshot)

let reactView = self.subviews.first(where: { NSStringFromClass(type(of: $0)) == "RCTView" })
guard let view = reactView else { return nil }
let params = UIPreviewParameters()
params.backgroundColor = .clear
return UITargetedPreview(view: view, parameters: params)

Result: Blank. iOS's internal UITargetedPreview rendering also uses CALayer snapshotting internally, hitting the same Metal texture limitation.

Attempt 4: Suppress preview via visiblePath

let params = UIPreviewParameters()
params.backgroundColor = .clear
params.visiblePath = UIBezierPath(rect: CGRect(x: 0, y: 0, width: 0.01, height: 0.01))
return UITargetedPreview(view: sourceView, parameters: params)

Result: iOS 18 ignores the near-zero visiblePath and still renders the full blank rectangle with the system's default rounded-corner mask.

Attempt 5: Return nil from preview delegates

public override func contextMenuInteraction(
    _ interaction: UIContextMenuInteraction,
    previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration
) -> UITargetedPreview? {
    return nil  // "Skip" the preview
}

Result: nil does NOT mean "no preview." Per Apple's documentation, returning nil means "use the default preview" — which snapshots the source view, producing the same blank rectangle.

Attempt 6: Use self (the UIButton) as preview target

let params = UIPreviewParameters()
params.backgroundColor = .clear
params.visiblePath = UIBezierPath()
return UITargetedPreview(view: self, parameters: params)

Result: Crash — NSInternalInconsistencyException: Attempting to create a UIPreviewTarget with an invalid location: {nan, nan, 0}. The MenuViewImplementation UIButton has {NaN, NaN} coordinates because React Native Fabric doesn't assign it a valid frame (the child RCTView visually overflows from the 0×0 button).

Key Finding

There is no public UIKit API to disable UIContextMenuInteraction's preview animation. The preview is a mandatory part of the interaction. The only documented ways to customize it (previewForHighlightingMenuWithConfiguration, previewProvider, UIPreviewParameters.visiblePath) all still require a valid view snapshot, which Fabric cannot provide on iOS 18.

Suggested Fix for the Library

Option A: Transparent overlay approach

Attach the UIContextMenuInteraction to an empty transparent UIView overlay instead of the content view itself. The preview animation will "lift" the invisible overlay while the actual content stays in place underneath. The menu still appears anchored correctly.

// In init:
let overlay = UIView()
overlay.backgroundColor = .clear
self.addSubview(overlay)
let interaction = UIContextMenuInteraction(delegate: self)
overlay.addInteraction(interaction)  // interaction on overlay, not self

Option B: Expose a previewDisabled prop

Allow consumers to opt out of the preview entirely by returning a minimal UITargetedPreview or by not adding the UIContextMenuInteraction and instead using UIButton.menu with showsMenuAsPrimaryAction = false (which shows a menu on long press via UIButton's internal mechanism — still has a preview, but it's handled by UIButton's private optimized APIs which may work better).

Option C: iOS version gating in the library

Conditionally skip the UIContextMenuInteraction for shouldOpenOnLongPress on iOS < 26, and fall back to UIButton.menu with showsMenuAsPrimaryAction = false.

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