Skip to content

makeImageFromView leaks on iOS — snapshot pixel copy never freed after dispose()/GC (footprint grows unbounded) #3869

Description

@yigithanyucedag

Description

On iOS, makeImageFromView leaks the snapshot's pixel buffer. Each call adds roughly width × height × 4 bytes to the app's physical footprint that is never reclaimed — not after the JS SkImage handle is .dispose()d, not after it is GC'd, and not after the component that took the snapshot unmounts. On a screen that snapshots repeatedly (e.g. a page-curl / transition that snapshots a view per interaction) the footprint grows without bound until the app is jetsammed.

Where

apple/ViewScreenshotService.mm-screenshotOfViewWithTag: (called from RNSkApplePlatformContext::takeScreenshotFromViewTag, which backs the JS makeImageFromView).

The pixel buffer is a copy:

// ~W*H*4-byte copy of the rendered bitmap:
auto dataRef = CGDataProviderCopyData(CGImageGetDataProvider(cgImage));
...
auto skData = SkData::MakeWithProc(
    data, length,
    [](const void *ptr, void *context) {
      CFDataRef dataRef = (CFDataRef)context;
      CFRelease(dataRef);            // frees the copy — but only when the SkData dies
    },
    (void *)dataRef);
return SkImages::RasterFromData(info, skData, bytesPerRow);

The release proc itself is correct: the CFDataRef copy is CFReleased when the SkData is destroyed. The problem is that the returned SkImage is apparently never destroyed, so that release proc never runs and every CGDataProviderCopyData copy stays resident.

Evidence (Release build, iOS Simulator)

  • footprint -p <pid> climbs by ~3.7 MB (one full-screen BGRA8888 bitmap) per makeImageFromView call and never comes back down, including after the snapshotting screen is fully unmounted.
  • malloc_history (with SIMCTL_CHILD_MallocStackLogging=1) attributes the resident allocations to:
    RNSkApplePlatformContext::takeScreenshotFromViewTag-[ViewScreenshotService screenshotOfViewWithTag:]CGDataProviderCopyDatacreate_protected_copymmap.
  • leaks <pid> reports no classic leak (~1 KB total) → the buffers are still referenced, i.e. retained, not orphaned. Something keeps the SkImage / SkData alive.
  • App-side mitigations that did not free the memory (each measured before/after on Release builds): calling SkImage.dispose() on every code path; an LRU cache that disposes evicted snapshots; gating + disposing on screen unmount. Footprint still climbed ~40 MB per open/close cycle, and the takeScreenshotFromViewTag allocations stayed resident with the screen unmounted.

Expected

After the JS SkImage returned by makeImageFromView is dispose()d (or GC'd), its backing SkData should be destroyed and the CFData pixel copy CFReleased, returning the memory. Repeated snapshots should reach a steady-state footprint rather than grow unbounded.

Reproduction

  1. Mount a Canvas and repeatedly call makeImageFromView(viewRef) (e.g. on a gesture / navigation / per "page turn").
  2. dispose() each returned image when finished with it.
  3. Watch footprint -p <pid> (or vmmap physical footprint). It grows ~one bitmap per call and never recovers — even after the screen is unmounted.

(Happy to put together a minimal repro repo if it would help.)

Environment

  • react-native-skia 2.5.2. Note: the ViewScreenshotService.mm source above is unchanged through 2.6.4 — only a null-data guard, macOS support, and the iosapple rename have landed since, none of which touch the lifetime of the returned image.
  • React Native 0.84.1, New Architecture (Fabric / bridgeless).
  • iOS 26 Simulator (iPhone 17 Pro), Apple Silicon (arm64).

Questions

  • Is SkImage.dispose() expected to synchronously drop the underlying C++ sk_sp<SkImage> so the SkData release proc runs? On iOS the backing image appears to be retained past dispose().
  • Is there an internal cache / dependency-manager registry that keeps snapshot SkImages alive for the lifetime of the JS runtime?

Possibly related, but distinct from: #2909 (Image re-rendering memory), #605.

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