mrousavy / mrousavy/react-native-nitro-image
`useImage()` retains decoded native images , but calling `dispose()` can race with `NitroImage` props
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 652
- Forks
- 37
- Avg merge
- 56m
- Merged PRs (30d)
- 6
Description
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 nativeImageinto 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 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.- 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
Imagejust 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
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:
- 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.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by tracing the useImage() path through loadImage(source) and how the resulting Image is passed into . Reproduce rapid filePath changes on Android with and without image.dispose(), then compare the lifecycle implications with the Android ImageLoader change in #123. Done means a decided ownership model prevents both decoded-image retention and disposed hybrid object errors, with the behavior documented or covered by a regression test.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- react-native, typescript
- Domain
- mobile-dev
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Needs clarification
- Newbie friendliness
- 30/100