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');
+}