import { useCallback, useEffect, useRef, useState } from 'react'; import { type ObservableProperty, type ViewModelInstance, type ViewModelProperty, } from '../specs/ViewModel.nitro'; import { useDisposableMemo } from './useDisposableMemo'; /** * Base hook for all ViewModelInstance value-property interactions * (number, string, boolean, color, enum). * * Not used for triggers — see {@link useRiveTrigger} which manages its own * property lifecycle to avoid coupling callback identity to native disposal. * * @template P - The type of the property (e.g., ViewModelBooleanProperty) * @template T - The primitive type of the property value (number, boolean, string) * * @param viewModelInstance - The source ViewModelInstance * @param path - Property path in the ViewModelInstance * @param getProperty - Function to get the property from a ViewModelInstance * @returns A tuple containing [value, setter, error, property] */ export function useRiveProperty

( viewModelInstance: ViewModelInstance | null | undefined, path: string, getProperty: (vm: ViewModelInstance, path: string) => P | undefined ): [ T | undefined, (value: T | ((prevValue: T | undefined) => T)) => void, Error | null, P | undefined, ] { // Nulled by useDisposableMemo the moment the property is disposed, so the // setter can tell a live property from a disposed one (see setPropertyValue). const liveRef = useRef | undefined>(undefined); const wasEverLive = useRef(false); const property = useDisposableMemo( () => { if (!viewModelInstance) return undefined; return getProperty( viewModelInstance, path ) as unknown as ObservableViewModelProperty; }, (p) => p?.dispose(), [viewModelInstance, path], liveRef ); if (liveRef.current) { wasEverLive.current = true; } // Always start undefined — the listener delivers the current value as its first emission. // (iOS experimental: via valueStream; iOS/Android legacy: emitted synchronously on subscribe) // This ensures consumers handle the loading state correctly on all backends. const [value, setValue] = useState(undefined); const [error, setError] = useState(null); // Clear error when path or instance changes useEffect(() => { setError(null); }, [path, viewModelInstance]); // Set error if property is not found useEffect(() => { if (viewModelInstance && !property) { setError( new Error(`Property "${path}" not found in the ViewModel instance`) ); } }, [viewModelInstance, property, path]); // Add listener for changes to the property useEffect(() => { if (!property) return; // Deliver the current value immediately so the hook transitions from // undefined → value without waiting for a property change. // (Legacy addListener does NOT emit on subscribe — only on changes. // Experimental valueStream emits the current value as its first element.) setValue(property.value); const removeListener = property.addListener((newValue) => { setValue(newValue); }); return () => { try { removeListener(); } catch { // Property may already be disposed by useDisposableMemo (deps change). // Native dispose() handles listener cleanup, so this is safe to ignore. } }; }, [property]); // Set the value of the property (warn + no-op if the property isn't // available). Uses tracked `value` from state for updater functions — // avoids a synchronous property.value read and is consistent with how // React state works. const setPropertyValue = useCallback( (valueOrUpdater: T | ((prevValue: T | undefined) => T)) => { // Read through liveRef instead of the captured `property`: a stale // closure (e.g. an async callback) can fire after the property was // disposed by a deps change or unmount, and writing to the disposed // hybrid throws ("NativeState is null" — fatal when uncaught in // release). Same guard as useRiveTrigger. const liveProperty = liveRef.current; if (!liveProperty) { if (wasEverLive.current) { console.warn( `useRiveProperty: setValue('${path}') called after dispose. ` + 'The property has been cleaned up — this is likely a stale closure ' + 'from an async callback that fired after unmount.' ); } else { console.warn( `useRiveProperty: setValue('${path}') called but the property is not available yet. ` + 'The viewModelInstance may still be loading.' ); } return; } else { const newValue = typeof valueOrUpdater === 'function' ? (valueOrUpdater as (prevValue: T | undefined) => T)(value) : valueOrUpdater; liveProperty.value = newValue; } }, // `property` kept in deps so the setter identity changes with the // property — consumers' effects keyed on the setter re-fire (see // "should apply value after instance becomes available" test). // eslint-disable-next-line react-hooks/exhaustive-deps [property, path, value] ); return [value, setPropertyValue, error, property as unknown as P]; } /** * This interface extends the ViewModelProperty and ObservableProperty interfaces. * It adds the addListener and value as known properties. * * @template T - The primitive type of the property value (number, boolean, string) */ interface ObservableViewModelProperty extends ViewModelProperty, ObservableProperty { addListener: (onChanged: (value: T) => void) => () => void; value: T; }