Skip to content

useImage() retains decoded native images , but calling dispose() can race with NitroImage props #124

@FrederickEngelhardt

Description

@FrederickEngelhardt

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:

  1. useImage() owns the decoded Image and disposes it safely only after it is no longer used by any <NitroImage />.
  2. <NitroImage /> retains/releases the Image while it is assigned as a prop, so JS cleanup cannot invalidate an object still used by native rendering.
  3. The docs clearly state that useImage() returns a decoded native object requiring explicit ownership management, and provide a safe disposal pattern.
  4. 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:

  1. Create a component that calls useImage({ filePath }).

  2. Pass the returned image into <NitroImage image={image} />.

  3. Rapidly switch filePath across many local images.

  4. Monitor Android native memory.

  5. Attempt to add cleanup by calling image.dispose() on source change/unmount.

  6. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions