Skip to content
Merged
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
92 changes: 92 additions & 0 deletions src/hooks/__tests__/useDisposableMemo.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { renderHook, act } from '@testing-library/react-native';
import { type RefObject } from 'react';
import { useDisposableMemo } from '../useDisposableMemo';

function createDisposable(label: string) {
Expand Down Expand Up @@ -239,4 +240,95 @@ describe('useDisposableMemo', () => {
rerender({ dep: -0 });
expect(factory).toHaveBeenCalledTimes(3);
});

describe('liveRef tracking', () => {
it('sets liveRef.current on create', () => {
const liveRef: RefObject<Disposable | undefined> = { current: undefined };

renderHook(() =>
useDisposableMemo(
() => createDisposable('A'),
(d) => d.dispose(),
['dep'],
liveRef
)
);

expect(liveRef.current).toBeDefined();
expect(liveRef.current!.label).toBe('A');
expect(liveRef.current!.isAlive).toBe(true);
});

it('clears liveRef on deps change and sets it to the new value', () => {
const liveRef: RefObject<Disposable | undefined> = { current: undefined };

const { rerender } = renderHook(
(props: { dep: string }) =>
useDisposableMemo(
() => createDisposable(props.dep),
(d) => d.dispose(),
[props.dep],
liveRef
),
{ initialProps: { dep: 'A' } }
);

const first = liveRef.current;
expect(first!.label).toBe('A');

rerender({ dep: 'B' });

expect(first!.isAlive).toBe(false);
expect(liveRef.current!.label).toBe('B');
expect(liveRef.current!.isAlive).toBe(true);
});

it('clears liveRef on unmount (deferred in dev)', () => {
const liveRef: RefObject<Disposable | undefined> = { current: undefined };

const { unmount } = renderHook(() =>
useDisposableMemo(
() => createDisposable('A'),
(d) => d.dispose(),
['dep'],
liveRef
)
);

expect(liveRef.current).toBeDefined();

unmount();

// Deferred in dev — still set before timer fires
expect(liveRef.current).toBeDefined();

act(() => {
jest.runAllTimers();
});

expect(liveRef.current).toBeUndefined();
});

it('works without liveRef (existing behavior unchanged)', () => {
const { result, rerender, unmount } = renderHook(
(props: { dep: string }) =>
useDisposableMemo(
() => createDisposable(props.dep),
(d) => d.dispose(),
[props.dep]
),
{ initialProps: { dep: 'A' } }
);

expect(result.current.label).toBe('A');

rerender({ dep: 'B' });
expect(result.current.label).toBe('B');

unmount();
act(() => {
jest.runAllTimers();
});
});
});
});
130 changes: 130 additions & 0 deletions src/hooks/__tests__/useRiveTrigger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { renderHook, act } from '@testing-library/react-native';
import { useRiveTrigger } from '../useRiveTrigger';
import type { ViewModelInstance } from '../../specs/ViewModel.nitro';

function createMockTriggerProperty() {
let listener: (() => void) | null = null;
return {
trigger: jest.fn(),
addListener: jest.fn((callback: () => void) => {
listener = callback;
return () => {
listener = null;
};
}),
dispose: jest.fn(),
fireListener() {
listener?.();
},
};
}

function createMockViewModelInstance(
propertyMap: Record<string, ReturnType<typeof createMockTriggerProperty>>
) {
return {
triggerProperty: jest.fn((path: string) => propertyMap[path]),
} as unknown as ViewModelInstance;
}

describe('useRiveTrigger', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.spyOn(console, 'warn').mockImplementation(() => {});
});

afterEach(() => {
try {
jest.runAllTimers();
} catch {
// Some tests intentionally dispose
}
jest.useRealTimers();
jest.restoreAllMocks();
});

it('calls trigger on the native property', () => {
const mockProperty = createMockTriggerProperty();
const mockInstance = createMockViewModelInstance({
'Button/Pressed': mockProperty,
});

const { result } = renderHook(() =>
useRiveTrigger('Button/Pressed', mockInstance)
);

act(() => {
result.current.trigger();
});

expect(mockProperty.trigger).toHaveBeenCalledTimes(1);
});

it('warns when trigger is called before property is available', () => {
const { result } = renderHook(() =>
useRiveTrigger('Button/Pressed', undefined)
);

act(() => {
result.current.trigger();
});

expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining('not available yet')
);
});

it('warns when trigger is called after unmount', () => {
const mockProperty = createMockTriggerProperty();
const mockInstance = createMockViewModelInstance({
'Button/Pressed': mockProperty,
});

const { result, unmount } = renderHook(() =>
useRiveTrigger('Button/Pressed', mockInstance)
);

const { trigger } = result.current;

unmount();
act(() => {
jest.runAllTimers();
});

trigger();

expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining('called after dispose')
);
expect(mockProperty.trigger).not.toHaveBeenCalled();
});

it('invokes onTrigger callback when native trigger fires', () => {
const mockProperty = createMockTriggerProperty();
const mockInstance = createMockViewModelInstance({
'Button/Pressed': mockProperty,
});
const onTrigger = jest.fn();

renderHook(() =>
useRiveTrigger('Button/Pressed', mockInstance, { onTrigger })
);

act(() => {
mockProperty.fireListener();
});

expect(onTrigger).toHaveBeenCalledTimes(1);
});

it('returns error when property path is invalid', () => {
const mockInstance = createMockViewModelInstance({});

const { result } = renderHook(() =>
useRiveTrigger('nonexistent/path', mockInstance)
);

expect(result.current.error).toBeInstanceOf(Error);
expect(result.current.error?.message).toContain('nonexistent/path');
});
});
11 changes: 9 additions & 2 deletions src/hooks/useDisposableMemo.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useRef, useEffect, type DependencyList } from 'react';
import { useRef, useEffect, type DependencyList, type RefObject } from 'react';

const UNINITIALIZED = Symbol('UNINITIALIZED');

Expand Down Expand Up @@ -47,7 +47,8 @@ function depsEqual(a: DependencyList, b: DependencyList): boolean {
export function useDisposableMemo<T>(
factory: () => T,
cleanup: (value: T) => void,
deps: DependencyList
deps: DependencyList,
liveRef?: RefObject<T | undefined>
): T {
Comment thread
mfazekas marked this conversation as resolved.
const ref = useRef<{
value: T;
Expand All @@ -60,6 +61,8 @@ export function useDisposableMemo<T>(
});
const cleanupRef = useRef(cleanup);
cleanupRef.current = cleanup;
const liveRefRef = useRef(liveRef);
liveRefRef.current = liveRef;

if (
ref.current.deps === UNINITIALIZED ||
Expand All @@ -70,13 +73,15 @@ export function useDisposableMemo<T>(
ref.current.pendingDisposal = null;
}
if (ref.current.deps !== UNINITIALIZED) {
if (liveRefRef.current) liveRefRef.current.current = undefined;
try {
cleanupRef.current(ref.current.value);
} catch {
// Swallow cleanup errors — the old value is being replaced regardless.
}
}
ref.current = { value: factory(), deps, pendingDisposal: null };
if (liveRefRef.current) liveRefRef.current.current = ref.current.value;
}

useEffect(() => {
Expand All @@ -90,6 +95,7 @@ export function useDisposableMemo<T>(
if (__DEV__) {
const val = ref.current.value;
ref.current.pendingDisposal = setTimeout(() => {
if (liveRefRef.current) liveRefRef.current.current = undefined;
try {
cleanupRef.current(val);
} catch {
Expand All @@ -98,6 +104,7 @@ export function useDisposableMemo<T>(
ref.current.pendingDisposal = null;
}, 0);
} else {
if (liveRefRef.current) liveRefRef.current.current = undefined;
try {
cleanupRef.current(ref.current.value);
} catch {
Expand Down
33 changes: 28 additions & 5 deletions src/hooks/useRiveTrigger.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { type ViewModelInstance } from '../specs/ViewModel.nitro';
import {
type ViewModelInstance,
type ViewModelTriggerProperty,
} from '../specs/ViewModel.nitro';
import type {
UseRiveTriggerResult,
UseViewModelInstanceTriggerParameters,
Expand All @@ -24,6 +27,8 @@ export function useRiveTrigger(
params?: UseViewModelInstanceTriggerParameters
): UseRiveTriggerResult {
const { onTrigger } = params ?? {};
const liveRef = useRef<ViewModelTriggerProperty | undefined>(undefined);
const wasEverLive = useRef(false);

const onTriggerRef = useRef(onTrigger);
onTriggerRef.current = onTrigger;
Expand All @@ -34,9 +39,14 @@ export function useRiveTrigger(
return viewModelInstance.triggerProperty(path);
},
(p) => p?.dispose(),
[viewModelInstance, path]
[viewModelInstance, path],
liveRef
);

if (liveRef.current) {
wasEverLive.current = true;
}

const [error, setError] = useState<Error | null>(null);

useEffect(() => {
Expand Down Expand Up @@ -68,10 +78,23 @@ export function useRiveTrigger(
}, [property]);

const trigger = useCallback(() => {
if (property) {
property.trigger();
if (!liveRef.current) {
if (wasEverLive.current) {
console.warn(
`useRiveTrigger: trigger('${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(
`useRiveTrigger: trigger('${path}') called but the property is not available yet. ` +
'The viewModelInstance may still be loading.'
);
}
return;
Comment thread
mfazekas marked this conversation as resolved.
}
}, [property]);
liveRef.current.trigger();
}, [path]);

return { trigger, error };
}
Loading