From dbfe54ce54b18e6a5d6efdb10e9ff750f0854028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Thu, 2 Apr 2026 14:59:37 +0200 Subject: [PATCH 1/7] fix: eagerly dispose RiveView and break referenced asset retain cycle RiveView now calls dispose() on the native HybridObject when the React component unmounts, freeing the RiveView, ViewModel, and C++ scene graph immediately instead of waiting for Hermes GC. Also removes the withExtendedLifetime(fileRef) retain cycle in ReferencedAssetLoader that kept RiveFile alive permanently through the LoadAsset closure stored on the file itself. --- .../com/margelo/nitro/rive/HybridRiveView.kt | 6 ++ ios/HybridRiveFileFactory.swift | 6 +- ios/HybridRiveView.swift | 7 +++ ios/ReferencedAssetLoader.swift | 33 ++++++++-- ios/RiveReactNativeView.swift | 2 +- src/core/RiveView.tsx | 61 +++++++++---------- 6 files changed, 71 insertions(+), 44 deletions(-) diff --git a/android/src/main/java/com/margelo/nitro/rive/HybridRiveView.kt b/android/src/main/java/com/margelo/nitro/rive/HybridRiveView.kt index bb3316e5..f9e975fe 100644 --- a/android/src/main/java/com/margelo/nitro/rive/HybridRiveView.kt +++ b/android/src/main/java/com/margelo/nitro/rive/HybridRiveView.kt @@ -51,6 +51,12 @@ class HybridRiveView(val context: ThemedReactContext) : HybridRiveViewSpec() { private const val TAG = "HybridRiveView" } + //region Lifecycle + override fun dispose() { + view.dispose() + } + //endregion + //region State override val view: RiveReactNativeView = RiveReactNativeView(context) private var needsReload = false diff --git a/ios/HybridRiveFileFactory.swift b/ios/HybridRiveFileFactory.swift index 3b7cb736..154f1d02 100644 --- a/ios/HybridRiveFileFactory.swift +++ b/ios/HybridRiveFileFactory.swift @@ -36,11 +36,9 @@ final class HybridRiveFileFactory: HybridRiveFileFactorySpec, @unchecked Sendabl let referencedAssetCache = SendableRef(ReferencedAssetCache()) let factoryCache: SendableRef = .init(nil) - let fileRef: SendableRef = .init(nil) let customLoader = self.assetLoader.createCustomLoader( referencedAssets: referencedAssets, cache: referencedAssetCache, - factory: factoryCache, - fileRef: fileRef + factory: factoryCache ) let riveFile = @@ -49,7 +47,7 @@ final class HybridRiveFileFactory: HybridRiveFileFactorySpec, @unchecked Sendabl } else { try file(prepared) } - fileRef.value = riveFile + self.assetLoader.setFileRef(riveFile) let result = ( file: riveFile, cache: referencedAssetCache.value, factory: factoryCache.value, diff --git a/ios/HybridRiveView.swift b/ios/HybridRiveView.swift index 2067e618..b8c470a7 100644 --- a/ios/HybridRiveView.swift +++ b/ios/HybridRiveView.swift @@ -197,6 +197,13 @@ class HybridRiveView: HybridRiveViewSpec { } } + func dispose() { + let riveView = view as? RiveReactNativeView + DispatchQueue.main.async { + riveView?.cleanup() + } + } + // MARK: Internal State private var needsReload = false private var dataBindingChanged = false diff --git a/ios/ReferencedAssetLoader.swift b/ios/ReferencedAssetLoader.swift index 052805a2..3f8b617b 100644 --- a/ios/ReferencedAssetLoader.swift +++ b/ios/ReferencedAssetLoader.swift @@ -11,6 +11,27 @@ func createAssetFileError(_ assetName: String) -> NitroRiveError { } final class ReferencedAssetLoader { + // Keeps the RiveFile alive while asset loads are in flight, without creating a retain cycle + // through the LoadAsset closure stored on the file itself. + private var activeLoadCount = 0 + private var activeFileRef: RiveFile? + + func setFileRef(_ file: RiveFile) { + activeFileRef = file + } + + private func retainFile() { + activeLoadCount += 1 + } + + private func releaseFile() { + activeLoadCount -= 1 + if activeLoadCount <= 0 { + activeLoadCount = 0 + activeFileRef = nil + } + } + private func handleRiveError(error: Error) { RCTLogError("\(error)") } @@ -115,8 +136,7 @@ final class ReferencedAssetLoader { func createCustomLoader( referencedAssets: ReferencedAssetsType?, cache: SendableRef, - factory factoryOut: SendableRef, - fileRef: SendableRef + factory factoryOut: SendableRef ) -> LoadAsset? { @@ -124,7 +144,7 @@ final class ReferencedAssetLoader { else { return nil } - return { (asset: RiveFileAsset, _: Data, factory: RiveFactory) -> Bool in + return { [weak self] (asset: RiveFileAsset, _: Data, factory: RiveFactory) -> Bool in let assetByUniqueName = referencedAssets[asset.uniqueName()] guard let assetData = assetByUniqueName ?? referencedAssets[asset.name()] else { return false @@ -133,10 +153,11 @@ final class ReferencedAssetLoader { cache.value[asset.uniqueName()] = asset factoryOut.value = factory - self.loadAssetInternal( + self?.retainFile() + self?.loadAssetInternal( source: assetData, asset: asset, factory: factory, - completion: { - withExtendedLifetime(fileRef) {} + completion: { [weak self] in + self?.releaseFile() }) return true diff --git a/ios/RiveReactNativeView.swift b/ios/RiveReactNativeView.swift index a74ba50d..324688f4 100644 --- a/ios/RiveReactNativeView.swift +++ b/ios/RiveReactNativeView.swift @@ -242,7 +242,7 @@ class RiveReactNativeView: UIView, RiveStateMachineDelegate { } } - private func cleanup() { + func cleanup() { riveView?.removeFromSuperview() riveView?.stateMachineDelegate = nil riveView = nil diff --git a/src/core/RiveView.tsx b/src/core/RiveView.tsx index ab09ab89..3cba3399 100644 --- a/src/core/RiveView.tsx +++ b/src/core/RiveView.tsx @@ -1,6 +1,8 @@ -import type { ComponentProps } from 'react'; +import { useEffect, useRef, type ComponentProps } from 'react'; import { NitroRiveView } from './NitroRiveViewComponent'; import { RiveErrorType, type RiveError } from './Errors'; +import { callDispose } from './callDispose'; +import type { RiveViewRef } from '../index'; export interface RiveViewProps extends Omit, 'onError'> { @@ -10,39 +12,32 @@ export interface RiveViewProps const defaultOnError = (error: RiveError) => console.error(`[${RiveErrorType[error.type]}] ${error.message}`); -/** - * RiveView is a React Native component that renders Rive graphics. - * It provides a seamless way to display and control Rive graphics in your app. - * - * @example - * ```tsx - * - * ``` - * - * @property {RiveFile} file - The Rive file to be displayed - * @property {string} [artboardName] - Name of the artboard to display from the Rive file - * @property {string} [stateMachineName] - Name of the state machine to play - * @property {ViewModelInstance | DataBindMode | DataBindByName} [dataBind] - Data binding configuration for the state machine, defaults to DataBindMode.Auto - * @property {boolean} [autoPlay=true] - Whether to automatically start playing the state machine - * @property {Alignment} [alignment] - How the Rive graphic should be aligned within its container - * @property {Fit} [fit] - How the Rive graphic should fit within its container - * @property {Object} [style] - React Native style object for container customization - * @property {(error: RiveError) => void} [onError] - Callback function that is called when an error occurs - * - * The component also exposes methods for controlling playback: - * - play(): Starts playing the Rive graphic - * - pause(): Pauses the Rive graphic - */ export function RiveView(props: RiveViewProps) { - const { onError, ...rest } = props; + const { onError, hybridRef: userHybridRef, ...rest } = props; const wrappedOnError = onError ?? defaultOnError; + const viewRef = useRef(null); - return ; + useEffect(() => { + return () => { + if (viewRef.current) { + callDispose(viewRef.current); + viewRef.current = null; + } + }; + }, []); + + const setRef = (ref: RiveViewRef) => { + viewRef.current = ref; + if (userHybridRef?.f) { + userHybridRef.f(ref); + } + }; + + return ( + + ); } From 91a4d2bcf5bd2db2657240362adacf677d807a2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Thu, 2 Apr 2026 17:02:30 +0200 Subject: [PATCH 2/7] fix: restore RiveView JSDoc, remove unnecessary comment --- ios/ReferencedAssetLoader.swift | 2 -- src/core/RiveView.tsx | 16 ++++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/ios/ReferencedAssetLoader.swift b/ios/ReferencedAssetLoader.swift index 3f8b617b..d51abbb2 100644 --- a/ios/ReferencedAssetLoader.swift +++ b/ios/ReferencedAssetLoader.swift @@ -11,8 +11,6 @@ func createAssetFileError(_ assetName: String) -> NitroRiveError { } final class ReferencedAssetLoader { - // Keeps the RiveFile alive while asset loads are in flight, without creating a retain cycle - // through the LoadAsset closure stored on the file itself. private var activeLoadCount = 0 private var activeFileRef: RiveFile? diff --git a/src/core/RiveView.tsx b/src/core/RiveView.tsx index 3cba3399..80ef219b 100644 --- a/src/core/RiveView.tsx +++ b/src/core/RiveView.tsx @@ -12,6 +12,22 @@ export interface RiveViewProps const defaultOnError = (error: RiveError) => console.error(`[${RiveErrorType[error.type]}] ${error.message}`); +/** + * RiveView is a React Native component that renders Rive graphics. + * It provides a seamless way to display and control Rive graphics in your app. + * + * @example + * ```tsx + * + * ``` + */ export function RiveView(props: RiveViewProps) { const { onError, hybridRef: userHybridRef, ...rest } = props; const wrappedOnError = onError ?? defaultOnError; From 83399310f330b49c2dcab0c3818cfd07d878e7d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Tue, 7 Apr 2026 07:43:29 +0200 Subject: [PATCH 3/7] fix: restore full JSDoc on RiveView --- src/core/RiveView.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/core/RiveView.tsx b/src/core/RiveView.tsx index 80ef219b..4023e4df 100644 --- a/src/core/RiveView.tsx +++ b/src/core/RiveView.tsx @@ -27,6 +27,20 @@ const defaultOnError = (error: RiveError) => * style={styles.riveContainer} * /> * ``` + * + * @property {RiveFile} file - The Rive file to be displayed + * @property {string} [artboardName] - Name of the artboard to display from the Rive file + * @property {string} [stateMachineName] - Name of the state machine to play + * @property {ViewModelInstance | DataBindMode | DataBindByName} [dataBind] - Data binding configuration for the state machine, defaults to DataBindMode.Auto + * @property {boolean} [autoPlay=true] - Whether to automatically start playing the state machine + * @property {Alignment} [alignment] - How the Rive graphic should be aligned within its container + * @property {Fit} [fit] - How the Rive graphic should fit within its container + * @property {Object} [style] - React Native style object for container customization + * @property {(error: RiveError) => void} [onError] - Callback function that is called when an error occurs + * + * The component also exposes methods for controlling playback: + * - play(): Starts playing the Rive graphic + * - pause(): Pauses the Rive graphic */ export function RiveView(props: RiveViewProps) { const { onError, hybridRef: userHybridRef, ...rest } = props; From b874a6763125cc7df4f4855264a0980c6182bf7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Tue, 7 Apr 2026 07:48:56 +0200 Subject: [PATCH 4/7] fix: add thread safety to asset load tracking and guard double cleanup --- ios/ReferencedAssetLoader.swift | 10 +++++++++- ios/RiveReactNativeView.swift | 4 ++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/ios/ReferencedAssetLoader.swift b/ios/ReferencedAssetLoader.swift index d51abbb2..1c026b7c 100644 --- a/ios/ReferencedAssetLoader.swift +++ b/ios/ReferencedAssetLoader.swift @@ -11,23 +11,31 @@ func createAssetFileError(_ assetName: String) -> NitroRiveError { } final class ReferencedAssetLoader { + private let lock = NSLock() private var activeLoadCount = 0 private var activeFileRef: RiveFile? func setFileRef(_ file: RiveFile) { + lock.lock() activeFileRef = file + lock.unlock() } private func retainFile() { + lock.lock() activeLoadCount += 1 + lock.unlock() } private func releaseFile() { + lock.lock() activeLoadCount -= 1 - if activeLoadCount <= 0 { + let shouldRelease = activeLoadCount <= 0 + if shouldRelease { activeLoadCount = 0 activeFileRef = nil } + lock.unlock() } private func handleRiveError(error: Error) { diff --git a/ios/RiveReactNativeView.swift b/ios/RiveReactNativeView.swift index 324688f4..bd4bb99f 100644 --- a/ios/RiveReactNativeView.swift +++ b/ios/RiveReactNativeView.swift @@ -242,7 +242,11 @@ class RiveReactNativeView: UIView, RiveStateMachineDelegate { } } + private var didCleanup = false + func cleanup() { + guard !didCleanup else { return } + didCleanup = true riveView?.removeFromSuperview() riveView?.stateMachineDelegate = nil riveView = nil From 8c850b32fcd0dc654fbbfd68a1868fc62481c08b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Tue, 7 Apr 2026 07:52:43 +0200 Subject: [PATCH 5/7] fix: remove unnecessary NSLock from asset load tracking --- ios/ReferencedAssetLoader.swift | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/ios/ReferencedAssetLoader.swift b/ios/ReferencedAssetLoader.swift index 1c026b7c..d51abbb2 100644 --- a/ios/ReferencedAssetLoader.swift +++ b/ios/ReferencedAssetLoader.swift @@ -11,31 +11,23 @@ func createAssetFileError(_ assetName: String) -> NitroRiveError { } final class ReferencedAssetLoader { - private let lock = NSLock() private var activeLoadCount = 0 private var activeFileRef: RiveFile? func setFileRef(_ file: RiveFile) { - lock.lock() activeFileRef = file - lock.unlock() } private func retainFile() { - lock.lock() activeLoadCount += 1 - lock.unlock() } private func releaseFile() { - lock.lock() activeLoadCount -= 1 - let shouldRelease = activeLoadCount <= 0 - if shouldRelease { + if activeLoadCount <= 0 { activeLoadCount = 0 activeFileRef = nil } - lock.unlock() } private func handleRiveError(error: Error) { From 4269d5a8f14b8f3546831a40ae1ef2639cd9320c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Tue, 7 Apr 2026 07:54:02 +0200 Subject: [PATCH 6/7] fix: add main thread assertion to releaseFile --- ios/ReferencedAssetLoader.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/ios/ReferencedAssetLoader.swift b/ios/ReferencedAssetLoader.swift index d51abbb2..b46e3caf 100644 --- a/ios/ReferencedAssetLoader.swift +++ b/ios/ReferencedAssetLoader.swift @@ -23,6 +23,7 @@ final class ReferencedAssetLoader { } private func releaseFile() { + dispatchPrecondition(condition: .onQueue(.main)) activeLoadCount -= 1 if activeLoadCount <= 0 { activeLoadCount = 0 From 0a3c7d2fff5a7fea69d5658b8f23cabab3d6a05d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Tue, 7 Apr 2026 07:55:00 +0200 Subject: [PATCH 7/7] fix: remove unnecessary didCleanup guard --- ios/RiveReactNativeView.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ios/RiveReactNativeView.swift b/ios/RiveReactNativeView.swift index bd4bb99f..324688f4 100644 --- a/ios/RiveReactNativeView.swift +++ b/ios/RiveReactNativeView.swift @@ -242,11 +242,7 @@ class RiveReactNativeView: UIView, RiveStateMachineDelegate { } } - private var didCleanup = false - func cleanup() { - guard !didCleanup else { return } - didCleanup = true riveView?.removeFromSuperview() riveView?.stateMachineDelegate = nil riveView = nil