Summary
useImage() appears to retain decoded native Image objects in high-churn render flows. However, manually calling .dispose() on the returned image is not currently safe when that image is also passed into <NitroImage />.
This creates a lifecycle problem:
useImage() loads a decoded native Image into memory.
- The hook does not currently expose/own a safe disposal lifecycle.
- If app code tries to dispose the image on source change/unmount, React/Nitro may still read the disposed hybrid object during prop diffing, unmount, recycle, or native cleanup.
- This can trigger errors like:
Cannot get hybrid property `HybridObject.name` - `this`'s `NativeState` is `null`, did you accidentally call `dispose()` on this object?
So useImage() can leak decoded native memory, but consumer-side cleanup can also crash or throw because disposal races with the native view lifecycle.
Environment
- Library:
react-native-nitro-image
- Platform observed: Android
- Usage pattern:
useImage({ filePath }) result passed to <NitroImage image={image} />
- Source type: local file images
- Scenario: rapidly switching images in a modal/list/swipe-viewer style flow
Current behavior
A typical usage looks like:
const { image, error } = useImage({ filePath: uri })
return (
<NitroImage
image={image}
resizeMode="contain"
style={style}
/>
)
The current hook loads a decoded native image:
const result = await loadImage(source)
markHybridObject(result, source)
setImage({ image: result, error: undefined })
But there is no disposal of the previously loaded image when:
- the source changes
- the component unmounts
- an async load resolves after the effect has already been cleaned up
- the rendered
<NitroImage /> stops using that image
In high-churn image flows, this can cause native memory to grow because decoded native Image objects remain alive.
Why consumer-side cleanup is difficult
A naive cleanup implementation such as this is unsafe:
useEffect(() => {
let image: Image | undefined
loadImage(source).then((loadedImage) => {
image = loadedImage
setState({ image: loadedImage })
})
return () => {
image?.dispose()
}
}, [source])
If the returned image is still rendered by <NitroImage image={image} />, disposing it from JS can race with React/Nitro prop diffing and native view cleanup.
In my testing, cleanup attempts can produce:
Cannot get hybrid property `HybridObject.name` - `this`'s `NativeState` is `null`, did you accidentally call `dispose()` on this object?
This suggests React/Nitro is still touching the hybrid object after JS has disposed its native state.
Expected behavior
There should be a safe ownership model for images loaded by useImage().
Possible expected behaviors:
useImage() owns the decoded Image and disposes it safely only after it is no longer used by any <NitroImage />.
<NitroImage /> retains/releases the Image while it is assigned as a prop, so JS cleanup cannot invalidate an object still used by native rendering.
- The docs clearly state that
useImage() returns a decoded native object requiring explicit ownership management, and provide a safe disposal pattern.
- A separate API exists for metadata-only use cases so callers do not need to decode a full native
Image just to get width/height.
Actual behavior
- Not disposing images returned by
useImage() can retain decoded native memory.
- Disposing images from JS cleanup can race with
<NitroImage /> lifecycle and trigger disposed hybrid object errors.
- This makes
useImage() risky for high-churn rendering, especially when used to get width/height/aspect ratio or when rendering rapidly changing images.
Related Android finding
I also worked around a separate Android render-path memory issue for file-backed ImageLoader rendering by changing the native Android path to use lifecycle-managed render bitmaps.
That Android render fix helps for this pattern:
const source = useMemo(() => ({ filePath: uri }), [uri])
const image = useImageLoader(source)
return <NitroImage image={image} />
But it does not fix the useImage() problem, because useImage() eagerly creates decoded native Image objects through loadImage(source).
So even with the Android ImageLoader render path improved, useImage() can still retain decoded native images or produce disposed-object errors when cleanup is attempted.
Reproduction shape
A reproduction should be possible with:
-
Create a component that calls useImage({ filePath }).
-
Pass the returned image into <NitroImage image={image} />.
-
Rapidly switch filePath across many local images.
-
Monitor Android native memory.
-
Attempt to add cleanup by calling image.dispose() on source change/unmount.
-
Observe either:
- native memory growth if images are not disposed, or
- disposed hybrid object errors if cleanup races with
<NitroImage />.
Example error
ERROR [Error: Cannot get hybrid property `HybridObject.name` - `this`'s `NativeState` is `null`, did you accidentally call `dispose()` on this object?]
Possible solutions
Some possible directions:
1. Make useImage() lifecycle-safe
useImage() could own the loaded image and dispose it only after React/Nitro is no longer using it.
This may require native/view participation, not just JS cleanup.
2. Add retain/release semantics for rendered Image props
When <NitroImage /> receives a decoded Image, native could retain it while displayed and release it when replaced/dropped.
Then JS-side disposal could be coordinated safely.
3. Add a metadata-only API
A lot of useImage() usage may be for width/height/aspect ratio. That should not require decoding full native bitmap memory.
Example API:
const info = await getImageInfo({ filePath })
using bounds-only native decode on Android.
4. Document useImage() as explicit decoded image ownership
If useImage() is intended for image processing/manipulation rather than rendering, docs should clarify that it creates a decoded native image and should not be used in high-churn render paths without a safe disposal strategy.
Notes
I verified the issue on Android. I have not verified iOS behavior yet, but the lifecycle concern may apply cross-platform because the problem is partly in the shared JS ownership model around decoded hybrid Image objects.
This issue is not resolved by the android memory leak PR in #123.
Summary
useImage()appears to retain decoded nativeImageobjects in high-churn render flows. However, manually calling.dispose()on the returned image is not currently safe when that image is also passed into<NitroImage />.This creates a lifecycle problem:
useImage()loads a decoded nativeImageinto memory.So
useImage()can leak decoded native memory, but consumer-side cleanup can also crash or throw because disposal races with the native view lifecycle.Environment
react-native-nitro-imageuseImage({ filePath })result passed to<NitroImage image={image} />Current behavior
A typical usage looks like:
The current hook loads a decoded native image:
But there is no disposal of the previously loaded image when:
<NitroImage />stops using that imageIn high-churn image flows, this can cause native memory to grow because decoded native
Imageobjects remain alive.Why consumer-side cleanup is difficult
A naive cleanup implementation such as this is unsafe:
If the returned image is still rendered by
<NitroImage image={image} />, disposing it from JS can race with React/Nitro prop diffing and native view cleanup.In my testing, cleanup attempts can produce:
This suggests React/Nitro is still touching the hybrid object after JS has disposed its native state.
Expected behavior
There should be a safe ownership model for images loaded by
useImage().Possible expected behaviors:
useImage()owns the decodedImageand disposes it safely only after it is no longer used by any<NitroImage />.<NitroImage />retains/releases theImagewhile it is assigned as a prop, so JS cleanup cannot invalidate an object still used by native rendering.useImage()returns a decoded native object requiring explicit ownership management, and provide a safe disposal pattern.Imagejust to get width/height.Actual behavior
useImage()can retain decoded native memory.<NitroImage />lifecycle and trigger disposed hybrid object errors.useImage()risky for high-churn rendering, especially when used to get width/height/aspect ratio or when rendering rapidly changing images.Related Android finding
I also worked around a separate Android render-path memory issue for file-backed
ImageLoaderrendering by changing the native Android path to use lifecycle-managed render bitmaps.That Android render fix helps for this pattern:
But it does not fix the
useImage()problem, becauseuseImage()eagerly creates decoded nativeImageobjects throughloadImage(source).So even with the Android
ImageLoaderrender path improved,useImage()can still retain decoded native images or produce disposed-object errors when cleanup is attempted.Reproduction shape
A reproduction should be possible with:
Create a component that calls
useImage({ filePath }).Pass the returned
imageinto<NitroImage image={image} />.Rapidly switch
filePathacross many local images.Monitor Android native memory.
Attempt to add cleanup by calling
image.dispose()on source change/unmount.Observe either:
<NitroImage />.Example error
Possible solutions
Some possible directions:
1. Make
useImage()lifecycle-safeuseImage()could own the loaded image and dispose it only after React/Nitro is no longer using it.This may require native/view participation, not just JS cleanup.
2. Add retain/release semantics for rendered
ImagepropsWhen
<NitroImage />receives a decodedImage, native could retain it while displayed and release it when replaced/dropped.Then JS-side disposal could be coordinated safely.
3. Add a metadata-only API
A lot of
useImage()usage may be for width/height/aspect ratio. That should not require decoding full native bitmap memory.Example API:
using bounds-only native decode on Android.
4. Document
useImage()as explicit decoded image ownershipIf
useImage()is intended for image processing/manipulation rather than rendering, docs should clarify that it creates a decoded native image and should not be used in high-churn render paths without a safe disposal strategy.Notes
I verified the issue on Android. I have not verified iOS behavior yet, but the lifecycle concern may apply cross-platform because the problem is partly in the shared JS ownership model around decoded hybrid
Imageobjects.This issue is not resolved by the android memory leak PR in #123.