diff --git a/example/src/App.tsx b/example/src/App.tsx index 3d93c04a..e4bd4663 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -14,6 +14,7 @@ import EventsExample from './pages/RiveEventsExample'; import StateMachineInputsExample from './pages/RiveStateMachineInputsExample'; import TextRunExample from './pages/RiveTextRunExample'; import OutOfBandAssets from './pages/OutOfBandAssets'; +import RiveSuspenseExample from './pages/RiveSuspenseExample'; const Examples = [ { @@ -46,6 +47,11 @@ const Examples = [ screenId: 'OutOfBandAssets', component: OutOfBandAssets, }, + { + title: 'Rive Suspense Example', + screenId: 'RiveSuspense', + component: RiveSuspenseExample, + }, { title: 'Template Page', screenId: 'Template', component: TemplatePage }, ] as const; diff --git a/example/src/pages/RiveSuspenseExample.tsx b/example/src/pages/RiveSuspenseExample.tsx new file mode 100644 index 00000000..68aa7f72 --- /dev/null +++ b/example/src/pages/RiveSuspenseExample.tsx @@ -0,0 +1,184 @@ +import { + Text, + View, + StyleSheet, + ActivityIndicator, + ScrollView, +} from 'react-native'; +import { Fit, RiveView, RiveSuspense } from 'react-native-rive'; +import { Suspense, Component, type ReactNode } from 'react'; + +class ErrorBoundary extends Component< + { children: ReactNode; fallback: ReactNode }, + { hasError: boolean; error?: Error } +> { + constructor(props: { children: ReactNode; fallback: ReactNode }) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error) { + return { hasError: true, error }; + } + + render() { + if (this.state.hasError) { + return this.props.fallback; + } + return this.props.children; + } +} + +const RiveCard = ({ title, input }: { title: string; input: any }) => { + const riveFile = RiveSuspense.useRiveFile(input); + + return ( + + {title} + + + ); +}; + +const SuspenseContent = () => { + return ( + + + This example demonstrates the Suspense API. All three cards below use + the same Rive file, which is cached and shared efficiently. + + + + + + + + Note: All three cards share the same cached RiveFile instance, loaded + only once! + + + ); +}; + +export default function RiveSuspenseExample() { + return ( + + RiveSuspense Example + + + + + Failed to load Rive animation + + + } + > + + + Loading animations... + + } + > + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + }, + title: { + fontSize: 24, + fontWeight: 'bold', + textAlign: 'center', + marginTop: 20, + marginBottom: 10, + color: '#333', + }, + description: { + fontSize: 14, + textAlign: 'center', + marginHorizontal: 20, + marginBottom: 20, + color: '#666', + lineHeight: 20, + }, + scrollView: { + flex: 1, + }, + card: { + margin: 20, + padding: 15, + backgroundColor: '#f5f5f5', + borderRadius: 10, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + cardTitle: { + fontSize: 18, + fontWeight: '600', + marginBottom: 10, + color: '#333', + }, + riveView: { + height: 200, + backgroundColor: '#fff', + borderRadius: 8, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + marginTop: 10, + fontSize: 16, + color: '#007AFF', + }, + errorContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + errorText: { + color: 'red', + fontSize: 16, + textAlign: 'center', + padding: 20, + }, + note: { + fontSize: 12, + fontStyle: 'italic', + textAlign: 'center', + marginHorizontal: 20, + marginBottom: 40, + color: '#007AFF', + }, +}); diff --git a/src/core/RiveFile.ts b/src/core/RiveFile.ts index 8be1ccfe..7ff0cee0 100644 --- a/src/core/RiveFile.ts +++ b/src/core/RiveFile.ts @@ -4,9 +4,7 @@ import type { RiveFileFactory as RiveFileFactoryInternal, } from '../specs/RiveFile.nitro'; -// This import path isn't handled by @types/react-native -// @ts-ignore -import resolveAssetSource from 'react-native/Libraries/Image/resolveAssetSource'; +import { Image } from 'react-native'; import type { ResolvedReferencedAssets } from '../hooks/useRiveFile'; const RiveFileInternal = @@ -119,7 +117,9 @@ export namespace RiveFileFactory { const assetID = typeof source === 'number' ? source : null; const sourceURI = typeof source === 'object' ? source.uri : null; - const assetURI = assetID ? resolveAssetSource(assetID)?.uri : sourceURI; + const assetURI = assetID + ? Image.resolveAssetSource(assetID)?.uri + : sourceURI; if (!assetURI) { throw new Error( diff --git a/src/hooks/useReferencedAssetsUpdate.ts b/src/hooks/useReferencedAssetsUpdate.ts new file mode 100644 index 00000000..401a6761 --- /dev/null +++ b/src/hooks/useReferencedAssetsUpdate.ts @@ -0,0 +1,19 @@ +import { useRef, useEffect } from 'react'; +import type { RiveFile } from '../specs/RiveFile.nitro'; +import type { ResolvedReferencedAssets } from '../utils/riveFileLoading'; + +export function useReferencedAssetsUpdate( + riveFile: RiveFile | null, + referencedAssets: ResolvedReferencedAssets | undefined +) { + const initialReferencedAssets = useRef(referencedAssets); + + useEffect(() => { + if (initialReferencedAssets.current !== referencedAssets) { + if (riveFile && referencedAssets) { + riveFile.updateReferencedAssets({ data: referencedAssets }); + initialReferencedAssets.current = referencedAssets; + } + } + }, [referencedAssets, riveFile]); +} diff --git a/src/hooks/useRiveFile.ts b/src/hooks/useRiveFile.ts index 5eea5525..58037377 100644 --- a/src/hooks/useRiveFile.ts +++ b/src/hooks/useRiveFile.ts @@ -1,7 +1,11 @@ -import { useState, useEffect, useMemo, useRef } from 'react'; -import { Image } from 'react-native'; -import { RiveFileFactory, type RiveFile } from '../index'; +import { useState, useEffect, useMemo } from 'react'; import type { ResolvedReferencedAsset } from '../specs/RiveFile.nitro'; +import type { RiveFile } from '../index'; +import { + loadRiveFile, + resolveReferencedAssets, +} from '../utils/riveFileLoading'; +import { useReferencedAssetsUpdate } from './useReferencedAssetsUpdate'; export type RiveFileInput = number | { uri: string } | string | ArrayBuffer; @@ -11,67 +15,19 @@ export interface ReferencedAssets { [assetName: string]: ReferencedAsset; } -export type ResolvedReferencedAssets = { - [assetName: string]: ResolvedReferencedAsset; -}; - export type UseRiveFileOptions = { referencedAssets?: ReferencedAssets; }; -function parsePossibleSources( - source: ReferencedAsset['source'] -): ResolvedReferencedAsset { - if (typeof source === 'number') { - const resolvedAsset = Image.resolveAssetSource(source); - if (resolvedAsset && resolvedAsset.uri) { - return { sourceAssetId: resolvedAsset.uri }; - } else { - throw new Error('Invalid asset source provided.'); - } - } - - const uri = (source as any).uri; - if (typeof source === 'object' && uri) { - return { sourceUrl: uri }; - } - - const asset = (source as any).fileName; - const path = (source as any).path; - - if (typeof source === 'object' && asset) { - const result: ResolvedReferencedAsset = { sourceAsset: asset }; - - if (path) { - result.path = path; - } - - return result; - } - - throw new Error('Invalid source provided.'); -} - -function transformFilesHandledMapping( - mapping?: ReferencedAssets -): ResolvedReferencedAssets | undefined { - const transformedMapping: ResolvedReferencedAssets = {}; - if (mapping === undefined) { - return undefined; - } - - Object.entries(mapping).forEach(([key, option]) => { - transformedMapping[key] = parsePossibleSources(option.source); - }); - - return transformedMapping; -} - type RiveFileHookResult = | { riveFile: RiveFile; isLoading: false; error: null } | { riveFile: null; isLoading: true; error: null } | { riveFile: null; isLoading: false; error: string }; +export type ResolvedReferencedAssets = { + [assetName: string]: ResolvedReferencedAsset; +}; + export function useRiveFile( input: RiveFileInput | undefined, options: UseRiveFileOptions = {} @@ -82,20 +38,16 @@ export function useRiveFile( error: null, }); const referencedAssets = useMemo( - () => transformFilesHandledMapping(options.referencedAssets), + () => resolveReferencedAssets(options.referencedAssets), [options.referencedAssets] ); - const initialReferencedAssets = useRef(referencedAssets); - const initialInput = useRef(input); useEffect(() => { let currentFile: RiveFile | null = null; - const loadRiveFile = async () => { + const loadFile = async () => { try { - const currentInput = input; - - if (currentInput == null) { + if (input == null) { setResult({ riveFile: null, isLoading: false, @@ -103,34 +55,9 @@ export function useRiveFile( }); return; } - if (typeof currentInput === 'string') { - if ( - currentInput.startsWith('http://') || - currentInput.startsWith('https://') - ) { - currentFile = await RiveFileFactory.fromURL( - currentInput, - initialReferencedAssets.current - ); - } else { - currentFile = await RiveFileFactory.fromResource( - currentInput, - initialReferencedAssets.current - ); - } - } else if (typeof currentInput === 'number' || 'uri' in currentInput) { - currentFile = await RiveFileFactory.fromSource( - currentInput, - initialReferencedAssets.current - ); - } else if (currentInput instanceof ArrayBuffer) { - currentFile = await RiveFileFactory.fromBytes( - currentInput, - initialReferencedAssets.current - ); - } - setResult({ riveFile: currentFile!, isLoading: false, error: null }); + currentFile = await loadRiveFile(input, referencedAssets); + setResult({ riveFile: currentFile, isLoading: false, error: null }); } catch (err) { console.error(err); setResult({ @@ -144,30 +71,16 @@ export function useRiveFile( } }; - loadRiveFile(); + loadFile(); return () => { if (currentFile) { currentFile.release(); } }; - }, [input]); - - const { riveFile } = result; - useEffect(() => { - if (initialReferencedAssets.current !== referencedAssets) { - if (riveFile && referencedAssets) { - riveFile.updateReferencedAssets({ data: referencedAssets }); - initialReferencedAssets.current = referencedAssets; - } - } - }, [referencedAssets, riveFile]); + }, [input, referencedAssets]); - if (initialInput.current !== input) { - console.warn( - 'useRiveFile: Changing input after initial render is not supported.' - ); - } + useReferencedAssetsUpdate(result.riveFile, referencedAssets); return { riveFile: result.riveFile, diff --git a/src/index.tsx b/src/index.tsx index 659e1301..28b0c0ac 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -78,3 +78,5 @@ export { useRiveColor } from './hooks/useRiveColor'; export { useRiveTrigger } from './hooks/useRiveTrigger'; export { useRiveFile } from './hooks/useRiveFile'; export { type RiveFileInput } from './hooks/useRiveFile'; +import * as RiveSuspense from './suspense'; +export { RiveSuspense }; diff --git a/src/suspense/cache.ts b/src/suspense/cache.ts new file mode 100644 index 00000000..2d1bac3f --- /dev/null +++ b/src/suspense/cache.ts @@ -0,0 +1,84 @@ +import type { RiveFile } from '../specs/RiveFile.nitro'; +import type { RiveFileInput } from '../hooks/useRiveFile'; + +export interface CacheEntry { + riveFile: RiveFile; + promise?: Promise; + error?: Error; + refCount: number; +} + +export interface RiveFileCache { + get(key: string): CacheEntry | undefined; + set(key: string, entry: CacheEntry): void; + has(key: string): boolean; + delete(key: string): void; + clear(): void; + size: number; +} + +export function generateCacheKey(input: RiveFileInput): string { + if (typeof input === 'number') { + return `require:${input}`; + } + + if (typeof input === 'string') { + return `url:${input}`; + } + + if (typeof input === 'object' && 'uri' in input) { + return `uri:${input.uri}`; + } + + if (input instanceof ArrayBuffer) { + const view = new Uint8Array(input); + let hash = 0; + for (let i = 0; i < Math.min(view.length, 1000); i++) { + const byte = view[i]; + // eslint-disable-next-line no-bitwise + hash = ((hash << 5) - hash + (byte ?? 0)) | 0; + } + return `arraybuffer:${hash}:${input.byteLength}`; + } + + throw new Error('Invalid RiveFileInput type'); +} + +export class RiveFileCacheImpl implements RiveFileCache { + private entries = new Map(); + + get(key: string): CacheEntry | undefined { + return this.entries.get(key); + } + + set(key: string, entry: CacheEntry): void { + this.entries.set(key, entry); + } + + has(key: string): boolean { + return this.entries.has(key); + } + + delete(key: string): void { + const entry = this.entries.get(key); + if (entry) { + entry.riveFile.release(); + this.entries.delete(key); + } + } + + clear(): void { + for (const entry of this.entries.values()) { + entry.riveFile.release(); + } + this.entries.clear(); + } + + get size(): number { + return this.entries.size; + } +} + +export function createRiveFileCache(): RiveFileCache { + return new RiveFileCacheImpl(); +} diff --git a/src/suspense/context/RiveFileCacheContext.tsx b/src/suspense/context/RiveFileCacheContext.tsx new file mode 100644 index 00000000..f0cf4495 --- /dev/null +++ b/src/suspense/context/RiveFileCacheContext.tsx @@ -0,0 +1,50 @@ +import type { ReactNode } from 'react'; +import { createContext, useContext, useEffect, useMemo } from 'react'; +import { createRiveFileCache, type RiveFileCache } from '../cache'; + +export interface RiveFileCacheProviderProps { + children: ReactNode; +} + +interface RiveFileCacheContextValue { + cache: RiveFileCache; +} + +const RiveFileCacheContext = createContext( + null +); + +export function RiveFileCacheProvider({ + children, +}: RiveFileCacheProviderProps) { + const cache = useMemo(() => createRiveFileCache(), []); + + useEffect(() => { + return () => { + cache.clear(); + }; + }, [cache]); + + const contextValue = useMemo( + () => ({ cache }), + [cache] + ); + + return ( + + {children} + + ); +} + +export function useRiveFileCacheContext(): RiveFileCacheContextValue { + const context = useContext(RiveFileCacheContext); + + if (!context) { + throw new Error( + 'useRiveFileCacheContext must be used within RiveSuspense.Provider' + ); + } + + return context; +} diff --git a/src/suspense/hooks/useRiveFile.ts b/src/suspense/hooks/useRiveFile.ts new file mode 100644 index 00000000..db3ead15 --- /dev/null +++ b/src/suspense/hooks/useRiveFile.ts @@ -0,0 +1,91 @@ +import { useMemo } from 'react'; +import type { RiveFile } from '../../specs/RiveFile.nitro'; +import type { RiveFileInput, ReferencedAssets } from '../../hooks/useRiveFile'; +import { useRiveFileCacheContext } from '../context/RiveFileCacheContext'; +import { generateCacheKey } from '../cache'; +import { + loadRiveFile, + resolveReferencedAssets, +} from '../../utils/riveFileLoading'; +import { useReferencedAssetsUpdate } from '../../hooks/useReferencedAssetsUpdate'; + +export interface UseRiveFileSuspenseOptions { + cacheId?: string; + referencedAssets?: ReferencedAssets; +} + +export function useRiveFile( + input: RiveFileInput, + options: UseRiveFileSuspenseOptions = {} +): RiveFile { + const { cache } = useRiveFileCacheContext(); + + const cacheKey = useMemo(() => { + return options.cacheId ?? generateCacheKey(input); + }, [input, options.cacheId]); + + const referencedAssets = useMemo( + () => resolveReferencedAssets(options.referencedAssets), + [options.referencedAssets] + ); + + let entry = cache.get(cacheKey); + + if (!entry) { + const promise = loadRiveFile(input, referencedAssets) + .then((riveFile) => { + const currentEntry = cache.get(cacheKey); + if (currentEntry) { + currentEntry.riveFile = riveFile; + currentEntry.promise = undefined; + currentEntry.refCount++; + } else { + cache.set(cacheKey, { + riveFile, + promise: undefined, + refCount: 1, + }); + } + return riveFile; + }) + .catch((error) => { + const currentEntry = cache.get(cacheKey); + if (currentEntry) { + currentEntry.error = error; + currentEntry.promise = undefined; + } else { + cache.set(cacheKey, { + riveFile: null as any, + promise: undefined, + error, + refCount: 0, + }); + } + throw error; + }); + + entry = { + riveFile: null as any, + promise, + refCount: 1, + }; + + cache.set(cacheKey, entry); + } else { + entry.refCount++; + } + + if (entry.error) { + throw entry.error; + } + + if (entry.promise) { + throw entry.promise; + } + + const riveFile = entry.riveFile; + + useReferencedAssetsUpdate(riveFile, referencedAssets); + + return riveFile; +} diff --git a/src/suspense/index.ts b/src/suspense/index.ts new file mode 100644 index 00000000..519e5843 --- /dev/null +++ b/src/suspense/index.ts @@ -0,0 +1,8 @@ +export { + RiveFileCacheProvider as Provider, + type RiveFileCacheProviderProps, +} from './context/RiveFileCacheContext'; +export { + useRiveFile, + type UseRiveFileSuspenseOptions, +} from './hooks/useRiveFile'; diff --git a/src/utils/riveFileLoading.ts b/src/utils/riveFileLoading.ts new file mode 100644 index 00000000..d7bd01a0 --- /dev/null +++ b/src/utils/riveFileLoading.ts @@ -0,0 +1,78 @@ +import { Image } from 'react-native'; +import { RiveFileFactory } from '../core/RiveFile'; +import type { RiveFile } from '../specs/RiveFile.nitro'; +import type { ResolvedReferencedAsset } from '../specs/RiveFile.nitro'; +import type { RiveFileInput, ReferencedAssets } from '../hooks/useRiveFile'; + +export type ReferencedAsset = { source: number | { uri: string } }; + +export type ResolvedReferencedAssets = { + [assetName: string]: ResolvedReferencedAsset; +}; + +export function parsePossibleSources( + source: ReferencedAsset['source'] +): ResolvedReferencedAsset { + if (typeof source === 'number') { + const resolvedAsset = Image.resolveAssetSource(source); + if (resolvedAsset && resolvedAsset.uri) { + return { sourceAssetId: resolvedAsset.uri }; + } else { + throw new Error('Invalid asset source provided.'); + } + } + + const uri = (source as any).uri; + if (typeof source === 'object' && uri) { + return { sourceUrl: uri }; + } + + const asset = (source as any).fileName; + const path = (source as any).path; + + if (typeof source === 'object' && asset) { + const result: ResolvedReferencedAsset = { sourceAsset: asset }; + + if (path) { + result.path = path; + } + + return result; + } + + throw new Error('Invalid source provided.'); +} + +export function resolveReferencedAssets( + mapping?: ReferencedAssets +): ResolvedReferencedAssets | undefined { + const transformedMapping: ResolvedReferencedAssets = {}; + if (mapping === undefined) { + return undefined; + } + + Object.entries(mapping).forEach(([key, option]) => { + transformedMapping[key] = parsePossibleSources(option.source); + }); + + return transformedMapping; +} + +export async function loadRiveFile( + input: RiveFileInput, + referencedAssets?: ResolvedReferencedAssets +): Promise { + if (typeof input === 'string') { + if (input.startsWith('http://') || input.startsWith('https://')) { + return await RiveFileFactory.fromURL(input, referencedAssets); + } else { + return await RiveFileFactory.fromResource(input, referencedAssets); + } + } else if (typeof input === 'number' || 'uri' in input) { + return await RiveFileFactory.fromSource(input, referencedAssets); + } else if (input instanceof ArrayBuffer) { + return await RiveFileFactory.fromBytes(input, referencedAssets); + } + + throw new Error('Invalid RiveFileInput type'); +}