[expo-image-manipulator][Android] Releasing a context while `renderAsync()` is pending cancels the render — `useImageManipulator` does exactly that on unmount
Minimal reproducible example
https://github.com/dev-eyoungmin/expo-image-manipulator-release-race
Steps to reproduce
The repo is npx create-expo-app@latest --template blank-typescript (SDK 57) plus expo-image-manipulator and expo-asset, with no third-party packages. It runs three phases over a bundled 4000x3000 JPEG on mount and renders the failure count for each. The same lines appear in adb logcat -s ReactNativeJS, prefixed REPRO.
git clone https://github.com/dev-eyoungmin/expo-image-manipulator-release-race && cd expo-image-manipulator-release-racenpm installnpx expo run:androidwith an emulator or device attached (development build, npm)- Read the three cards on screen.
- For the platform comparison, run the same commit with
npx expo run:ios.
Every phase runs 10 rounds against the same image with the same transformer pipeline (resize -> rotate x3 -> resize). The only difference between them is when the context is released.
| Phase | When release() happens |
Android | iOS |
|---|---|---|---|
| A · control | after the renderAsync() promise has resolved |
0 / 10 failed | 0 / 10 failed |
| B · explicit | 20 ms after the call, while the promise is pending | 10 / 10 failed | 0 / 10 failed |
C · useImageManipulator |
by the hook, when the component unmounts mid-render | 10 / 10 failed | 0 / 10 failed |
Phase A also prints the measured render duration — ≈81 ms on the Android emulator, ≈579 ms on the iOS simulator — so you can confirm that the 20 ms delay in phases B and C lands while the render is genuinely still in flight.
Expected: a pending renderAsync() resolves with the rendered image, or rejects with a real image-processing error. Releasing the context means "JS is done with this object", not "throw away the result of the call I am still awaiting".
Actual (Android): the promise rejects.
Call to function 'Context.renderAsync' has been rejected.
→ Caused by: kotlinx.coroutines.JobCancellationException: DeferredCoroutine was cancelled; job=DeferredCoroutine{Cancelled}@...
Phase C is the one that matters in an app: nothing in that component calls release(). useImageManipulator is backed by useReleasingSharedObject, which releases the context in its unmount cleanup, so navigating away from a screen while its image is still rendering is enough.
Cause
ImageManipulatorContext.sharedObjectDidRelease() cancels the very Deferred that a pending renderAsync() call is awaiting — ImageManipulatorContext.kt:
override fun sharedObjectDidRelease() {
task.cancel()
}
renderAsync awaits that job:
AsyncFunction("renderAsync") Coroutine { context: ImageManipulatorContext ->
val image = context.render()
ImageRef(image, runtime)
}
iOS has no equivalent: ImageManipulatorContext.swift cancels only in reset(), which is why the same JS resolves there.
Production impact (SDK 56)
We hit the garbage-collected variant of this in production after upgrading SDK 54 → 56: 20 events from 17 users in the first 20 hours, Android only, 0 occurrences in the preceding 90 days on SDK 54 with byte-identical app code, failing 169 ms and 378 ms after manipulate() on 4000x3000 camera JPEGs. That GC path is #49799, with a fix in #49807. This issue is the part that remains once the receiver is kept alive: an explicit release, including the one the hook performs.
Suggested fix
Cancel only work that no call is waiting for:
private val lock = Any()
private var pendingRenders = 0
suspend fun render(): Bitmap {
synchronized(lock) { pendingRenders += 1 }
try {
return task.render()
} finally {
synchronized(lock) { pendingRenders -= 1 }
}
}
override fun sharedObjectDidRelease() {
if (synchronized(lock) { pendingRenders == 0 }) {
task.cancel()
}
}
This keeps the intent of the current code — an abandoned context stops doing work — while not cancelling a render someone is still awaiting, and brings Android in line with iOS. With this change the repro reports 0 / 10 in all three phases. I have a PR ready if it is wanted.
Note for whoever verifies a fix
expo-image-manipulator ships a prebuilt AAR (local-maven-repo/…/expo.modules.imagemanipulator-<version>.aar), so editing the Kotlin in node_modules alone has no effect. The repro repo sets
"expo": { "autolinking": { "buildFromSource": ["expo-image-manipulator"] } }
in package.json so the module is compiled from source.
Devices: Android emulator, Pixel 9 Pro Fold, API 36 (Google Play ARM64); iOS simulator, iPhone 17 Pro, iOS 26.5. Both debug builds, Hermes, New Architecture, expo-image-manipulator 57.0.18.
Environment
expo-env-info 2.1.0 environment info:
System:
OS: macOS 27.0
Shell: 5.9 - /bin/zsh
Binaries:
Node: 22.19.0 - /usr/local/bin/node
npm: 10.9.3 - /usr/local/bin/npm
Watchman: 2025.09.01.00 - /opt/homebrew/bin/watchman
Managers:
CocoaPods: 1.16.2 - /opt/homebrew/bin/pod
SDKs:
iOS SDK:
Platforms: DriverKit 27.0, iOS 27.0, macOS 27.0, tvOS 27.0, visionOS 27.0, watchOS 27.0
Android SDK:
API Levels: 35, 36
Build Tools: 34.0.0, 35.0.0, 36.0.0, 36.1.0
System Images: android-29 | Google APIs ARM 64 v8a, android-36 | Google Play ARM 64 v8a, android-37.0 | 16 KB Page Size Google Play ARM 64 v8a
IDEs:
Android Studio: 2025.2 AI-252.27397.103.2522.14617522
Xcode: 27.0/27A266a - /usr/bin/xcodebuild
npmPackages:
expo: ~57.0.23 => 57.0.23
react: 19.2.3 => 19.2.3
react-native: 0.86.3 => 0.86.3
Expo Workflow: bare
Expo Doctor Diagnostics
Running 21 checks on your project...
21/21 checks passed. No issues detected!
Source: expo/expo