Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions example/src/reproducers/Issue407ListUnmount.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import {
useRiveFile,
useRiveList,
useViewModelInstance,
type ViewModelInstance,
} from '@rive-app/react-native';
import { type Metadata } from '../shared/metadata';

/**
* Issue #407: useRiveList's cleanup called a method on a list property that
* useDisposableMemo had already disposed. Debug builds throw (and the hook
* swallowed it); release builds segfault on the first unmount.
*
* Start mounts and unmounts a component using useRiveList every CYCLE_MS, for
* CYCLES rounds. Run it in a release build: expected is reaching
* "done" without the app crashing.
*/

const CYCLES = 20;
const CYCLE_MS = 300;

function TeamList({ instance }: { instance: ViewModelInstance }) {
const { length, error } = useRiveList('team', instance);
return (
<Text style={styles.list}>
team length: {error ? `error: ${error.message}` : String(length)}
</Text>
);
}

export default function Issue407ListUnmount() {
const { riveFile } = useRiveFile(
require('../../assets/rive/databinding_lists.riv')
);
const { instance } = useViewModelInstance(riveFile, {
viewModelName: 'DevRel',
async: true,
});
const [mounted, setMounted] = useState(false);
const [cycle, setCycle] = useState(0);
const [status, setStatus] = useState('idle');
const timer = useRef<ReturnType<typeof setInterval> | undefined>(undefined);

const stop = useCallback(() => {
if (timer.current) clearInterval(timer.current);
timer.current = undefined;
}, []);

const start = useCallback(() => {
stop();
setCycle(0);
setStatus('running');
let n = 0;
timer.current = setInterval(() => {
n += 1;
setMounted((m) => !m);
setCycle(Math.ceil(n / 2));
if (n >= CYCLES * 2) {
stop();
setStatus('done');
}
}, CYCLE_MS);
}, [stop]);

useEffect(() => {
if (instance) start();
return stop;
}, [instance, start, stop]);

return (
<View style={styles.container}>
<Text style={styles.status}>
{status} · cycle {cycle}/{CYCLES}
</Text>
<View style={styles.box}>
{mounted && instance ? <TeamList instance={instance} /> : null}
</View>
<View style={styles.buttons}>
<Pressable style={styles.button} onPress={start}>
<Text style={styles.buttonText}>Start</Text>
</Pressable>
<Pressable
style={styles.button}
onPress={() => {
stop();
setStatus('stopped');
}}
>
<Text style={styles.buttonText}>Stop</Text>
</Pressable>
</View>
</View>
);
}

Issue407ListUnmount.metadata = {
name: 'Issue #407: useRiveList unmount',
description:
'Mount/unmount a useRiveList consumer; crashed in release builds',
} satisfies Metadata;

const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fff', padding: 16 },
status: { fontSize: 16, fontFamily: 'monospace', marginBottom: 12 },
box: {
height: 60,
justifyContent: 'center',
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
paddingHorizontal: 12,
},
list: { fontSize: 15 },
buttons: { flexDirection: 'row', gap: 12, marginTop: 16 },
button: {
backgroundColor: '#007AFF',
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 8,
},
buttonText: { color: '#fff', fontWeight: '600' },
});
99 changes: 99 additions & 0 deletions src/hooks/__tests__/useRiveList.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { renderHook, act } from '@testing-library/react-native';
import { useRiveList } from '../useRiveList';
import type { ViewModelInstance } from '../../specs/ViewModel.nitro';

// Calling a method on a disposed Nitro object crashes release builds (#407),
// so the mock records every such call.
function createMockListProperty() {
let disposed = false;
const callsAfterDispose: string[] = [];
const method =
<T>(name: string, result: T) =>
() => {
if (disposed) callsAfterDispose.push(name);
return result;
};
return {
callsAfterDispose,
addListener: jest.fn(method('addListener', () => {})),
removeListeners: jest.fn(method('removeListeners', undefined)),
getLengthAsync: jest.fn(method('getLengthAsync', Promise.resolve(3))),
getInstanceAtAsync: jest.fn(
method('getInstanceAtAsync', Promise.resolve(undefined))
),
addInstanceAsync: jest.fn(method('addInstanceAsync', Promise.resolve())),
swapAsync: jest.fn(method('swapAsync', Promise.resolve())),
dispose: jest.fn(() => {
disposed = true;
}),
};
}

type MockListProperty = ReturnType<typeof createMockListProperty>;

function createMockViewModelInstance(property: MockListProperty) {
return {
listProperty: jest.fn(() => property),
} as unknown as ViewModelInstance;
}

describe('useRiveList', () => {
const globals = globalThis as { __DEV__?: boolean };
let previousDev: boolean | undefined;

beforeEach(() => {
// Release builds dispose synchronously on unmount, before effect cleanups.
previousDev = globals.__DEV__;
globals.__DEV__ = false;
});

afterEach(() => {
globals.__DEV__ = previousDev;
});

it('does not call into the list property after it is disposed on unmount', async () => {
const property = createMockListProperty();
const { unmount } = renderHook(() =>
useRiveList('team', createMockViewModelInstance(property))
);
Comment on lines +56 to +58
await act(async () => {});
unmount();

expect(property.dispose).toHaveBeenCalled();
expect(property.callsAfterDispose).toEqual([]);
});

it('does not call into a list property disposed by a dependency change', async () => {
const first = createMockListProperty();
const { rerender, unmount } = renderHook(
({ instance }: { instance: ViewModelInstance }) =>
useRiveList('team', instance),
{ initialProps: { instance: createMockViewModelInstance(first) } }
);
await act(async () => {});
rerender({
instance: createMockViewModelInstance(createMockListProperty()),
});
await act(async () => {});

expect(first.dispose).toHaveBeenCalled();
expect(first.callsAfterDispose).toEqual([]);
unmount();
});

it('turns operations called after unmount into no-ops', async () => {
const property = createMockListProperty();
const { result, unmount } = renderHook(() =>
useRiveList('team', createMockViewModelInstance(property))
);
await act(async () => {});
const { getInstanceAt, addInstance, swap } = result.current;
unmount();

await expect(getInstanceAt(0)).resolves.toBeUndefined();
await addInstance({} as ViewModelInstance);
await swap(0, 1);

expect(property.callsAfterDispose).toEqual([]);
});
});
70 changes: 37 additions & 33 deletions src/hooks/useRiveList.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { useCallback, useEffect, useState } from 'react';
import type { ViewModelInstance } from '../specs/ViewModel.nitro';
import { useCallback, useEffect, useRef, useState } from 'react';
import type {
ViewModelInstance,
ViewModelListProperty,
} from '../specs/ViewModel.nitro';
import type { UseRiveListResult } from '../types';
import { useDisposableMemo } from './useDisposableMemo';

Expand All @@ -25,13 +28,17 @@ export function useRiveList(
setError(null);
}, [path, viewModelInstance]);

// Cleared when the property is disposed; calling into a disposed list
// crashes release builds (#407), so the operations read it from here.
const liveRef = useRef<ViewModelListProperty | undefined>(undefined);
const property = useDisposableMemo(
() => {
if (!viewModelInstance) return undefined;
return viewModelInstance.listProperty(path);
},
(p) => p?.dispose(),
[viewModelInstance, path]
[viewModelInstance, path],
liveRef
);

useEffect(() => {
Expand All @@ -50,13 +57,9 @@ export function useRiveList(
});

return () => {
try {
removeListener();
property.removeListeners();
} catch {
// Property may already be disposed by useDisposableMemo (deps change).
// Native dispose() handles listener cleanup, so this is safe to ignore.
}
// Only the unsubscribe function is safe here: the property may already
// be disposed.
removeListener();
};
}, [property]);

Expand Down Expand Up @@ -96,58 +99,59 @@ export function useRiveList(
});
}, []);

const getInstanceAt = useCallback(
(index: number) => {
return property
? property.getInstanceAtAsync(index)
: Promise.resolve(undefined);
},
[property]
);
const getInstanceAt = useCallback((index: number) => {
const list = liveRef.current;
return list ? list.getInstanceAtAsync(index) : Promise.resolve(undefined);
}, []);
Comment on lines +102 to +105

const addInstance = useCallback(
(instance: ViewModelInstance) => {
return property
? afterMutation(property.addInstanceAsync(instance))
const list = liveRef.current;
return list
? afterMutation(list.addInstanceAsync(instance))
: Promise.resolve();
},
[property, afterMutation]
[afterMutation]
);

const addInstanceAt = useCallback(
(instance: ViewModelInstance, index: number) => {
return property
? afterMutation(property.addInstanceAtAsync(instance, index))
const list = liveRef.current;
return list
? afterMutation(list.addInstanceAtAsync(instance, index))
: Promise.resolve();
},
[property, afterMutation]
[afterMutation]
);

const removeInstance = useCallback(
(instance: ViewModelInstance) => {
return property
? afterMutation(property.removeInstanceAsync(instance))
const list = liveRef.current;
return list
? afterMutation(list.removeInstanceAsync(instance))
: Promise.resolve();
},
[property, afterMutation]
[afterMutation]
);

const removeInstanceAt = useCallback(
(index: number) => {
return property
? afterMutation(property.removeInstanceAtAsync(index))
const list = liveRef.current;
return list
? afterMutation(list.removeInstanceAtAsync(index))
: Promise.resolve();
},
[property, afterMutation]
[afterMutation]
);

const swap = useCallback(
(index1: number, index2: number) => {
return property
? afterMutation(property.swapAsync(index1, index2))
const list = liveRef.current;
return list
? afterMutation(list.swapAsync(index1, index2))
: Promise.resolve();
},
[property, afterMutation]
[afterMutation]
);

return {
Expand Down
Loading