Skip to content

Commit 30473a5

Browse files
committed
fix: survive fast refresh without disposing native objects
Introduce useDisposableMemo — a useMemo replacement for native-backed objects that need deterministic cleanup. Disposal happens in the render phase on deps change, and on unmount via deferred setTimeout(0) in dev (cancelled if fast refresh re-mounts) or synchronously in production. Migrates useRiveProperty, useRiveList, and useViewModelInstance to use it, fixing the "NativeState is null" crash on HMR.
1 parent c31b359 commit 30473a5

4 files changed

Lines changed: 121 additions & 56 deletions

File tree

src/hooks/useDisposableMemo.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { useRef, useEffect, type DependencyList } from 'react';
2+
3+
const UNINITIALIZED = Symbol('UNINITIALIZED');
4+
5+
function depsEqual(a: DependencyList, b: DependencyList): boolean {
6+
if (a.length !== b.length) return false;
7+
for (let i = 0; i < a.length; i++) {
8+
if (!Object.is(a[i], b[i])) return false;
9+
}
10+
return true;
11+
}
12+
13+
/**
14+
* Like `useMemo`, but with a cleanup callback for disposable native resources.
15+
*
16+
* - Value is created synchronously during render (available on first render).
17+
* - When deps change, the old value is cleaned up during render and a new one
18+
* is created.
19+
* - On unmount in production: cleaned up synchronously in effect cleanup.
20+
* - On unmount in development: cleanup is deferred via `setTimeout(0)` so that
21+
* fast refresh and Strict Mode can cancel it when effects re-run.
22+
*/
23+
export function useDisposableMemo<T>(
24+
factory: () => T,
25+
cleanup: (value: T) => void,
26+
deps: DependencyList
27+
): T {
28+
const ref = useRef<{
29+
value: T;
30+
deps: DependencyList | typeof UNINITIALIZED;
31+
pendingDisposal: ReturnType<typeof setTimeout> | null;
32+
}>({
33+
value: undefined as T,
34+
deps: UNINITIALIZED,
35+
pendingDisposal: null,
36+
});
37+
const cleanupRef = useRef(cleanup);
38+
cleanupRef.current = cleanup;
39+
40+
if (
41+
ref.current.deps === UNINITIALIZED ||
42+
!depsEqual(ref.current.deps, deps)
43+
) {
44+
if (__DEV__ && ref.current.pendingDisposal !== null) {
45+
clearTimeout(ref.current.pendingDisposal);
46+
ref.current.pendingDisposal = null;
47+
}
48+
if (ref.current.deps !== UNINITIALIZED) {
49+
cleanupRef.current(ref.current.value);
50+
}
51+
ref.current = { value: factory(), deps, pendingDisposal: null };
52+
}
53+
54+
useEffect(() => {
55+
if (__DEV__) {
56+
if (ref.current.pendingDisposal !== null) {
57+
clearTimeout(ref.current.pendingDisposal);
58+
ref.current.pendingDisposal = null;
59+
}
60+
}
61+
return () => {
62+
if (__DEV__) {
63+
const val = ref.current.value;
64+
ref.current.pendingDisposal = setTimeout(() => {
65+
cleanupRef.current(val);
66+
ref.current.pendingDisposal = null;
67+
}, 0);
68+
} else {
69+
cleanupRef.current(ref.current.value);
70+
}
71+
};
72+
}, []);
73+
74+
return ref.current.value;
75+
}

src/hooks/useRiveList.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useCallback, useEffect, useState, useMemo } from 'react';
44
import type { ViewModelInstance } from '../specs/ViewModel.nitro';
55
import type { UseRiveListResult } from '../types';
6+
import { useDisposableMemo } from './useDisposableMemo';
67

78
/**
89
* Hook for interacting with list ViewModel instance properties.
@@ -22,10 +23,14 @@ export function useRiveList(
2223
setError(null);
2324
}, [path, viewModelInstance]);
2425

25-
const property = useMemo(() => {
26-
if (!viewModelInstance) return undefined;
27-
return viewModelInstance.listProperty(path);
28-
}, [viewModelInstance, path]);
26+
const property = useDisposableMemo(
27+
() => {
28+
if (!viewModelInstance) return undefined;
29+
return viewModelInstance.listProperty(path);
30+
},
31+
(p) => p?.dispose(),
32+
[viewModelInstance, path]
33+
);
2934

3035
useEffect(() => {
3136
if (viewModelInstance && !property) {
@@ -45,7 +50,6 @@ export function useRiveList(
4550
return () => {
4651
removeListener();
4752
property.removeListeners();
48-
property.dispose();
4953
};
5054
}, [property]);
5155

src/hooks/useRiveProperty.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { useCallback, useEffect, useState, useMemo } from 'react';
1+
import { useCallback, useEffect, useState } from 'react';
22
import {
33
type ObservableProperty,
44
type ViewModelInstance,
55
type ViewModelProperty,
66
} from '../specs/ViewModel.nitro';
7+
import { useDisposableMemo } from './useDisposableMemo';
78

89
/**
910
* Base hook for all ViewModelInstance property interactions.
@@ -34,13 +35,17 @@ export function useRiveProperty<P extends ViewModelProperty, T>(
3435
Error | null,
3536
P | undefined,
3637
] {
37-
const property = useMemo(() => {
38-
if (!viewModelInstance) return;
39-
return options.getProperty(
40-
viewModelInstance,
41-
path
42-
) as unknown as ObservableViewModelProperty<T>;
43-
}, [options, viewModelInstance, path]);
38+
const property = useDisposableMemo(
39+
() => {
40+
if (!viewModelInstance) return undefined;
41+
return options.getProperty(
42+
viewModelInstance,
43+
path
44+
) as unknown as ObservableViewModelProperty<T>;
45+
},
46+
(p) => p?.dispose(),
47+
[options, viewModelInstance, path]
48+
);
4449

4550
// Always start undefined — the listener delivers the current value as its first emission.
4651
// (iOS experimental: via valueStream; iOS/Android legacy: emitted synchronously on subscribe)
@@ -82,7 +87,6 @@ export function useRiveProperty<P extends ViewModelProperty, T>(
8287

8388
return () => {
8489
removeListener();
85-
property.dispose();
8690
};
8791
}, [options, property]);
8892

src/hooks/useViewModelInstance.ts

Lines changed: 24 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
// TODO: migrate createInstance/createInstanceByName/etc to async equivalents
22
/* eslint-disable @typescript-eslint/no-deprecated */
3-
import { useMemo, useEffect, useRef } from 'react';
3+
import { useMemo, useRef } from 'react';
44
import type { ViewModel, ViewModelInstance } from '../specs/ViewModel.nitro';
55
import type { RiveFile } from '../specs/RiveFile.nitro';
66
import type { RiveViewRef } from '../index';
77
import { callDispose } from '../core/callDispose';
88
import { ArtboardByName } from '../specs/ArtboardBy';
9+
import { useDisposableMemo } from './useDisposableMemo';
910

1011
interface UseViewModelInstanceBaseParams {
1112
/**
@@ -336,49 +337,30 @@ export function useViewModelInstance(
336337
const required = params?.required ?? false;
337338
const onInit = params?.onInit;
338339

339-
const prevInstanceRef = useRef<{
340-
instance: ViewModelInstance | null | undefined;
341-
needsDispose: boolean;
342-
} | null>(null);
340+
const onInitRef = useRef(onInit);
341+
onInitRef.current = onInit;
343342

344-
const result = useMemo(() => {
345-
const created = createInstance(
346-
source,
347-
instanceName,
348-
artboardName,
349-
viewModelName,
350-
useNew
351-
);
352-
if (created.instance && onInit) {
353-
onInit(created.instance);
354-
}
355-
return created;
356-
// eslint-disable-next-line react-hooks/exhaustive-deps -- onInit excluded intentionally
357-
}, [source, instanceName, artboardName, viewModelName, useNew]);
358-
359-
// Dispose previous instance if it changed and needed disposal
360-
if (
361-
prevInstanceRef.current &&
362-
prevInstanceRef.current.instance !== result.instance &&
363-
prevInstanceRef.current.needsDispose &&
364-
prevInstanceRef.current.instance
365-
) {
366-
callDispose(prevInstanceRef.current.instance);
367-
}
368-
prevInstanceRef.current = result;
369-
370-
// Cleanup on unmount
371-
useEffect(() => {
372-
return () => {
373-
if (
374-
prevInstanceRef.current?.needsDispose &&
375-
prevInstanceRef.current.instance
376-
) {
377-
callDispose(prevInstanceRef.current.instance);
378-
prevInstanceRef.current = null;
343+
const result = useDisposableMemo(
344+
() => {
345+
const created = createInstance(
346+
source,
347+
instanceName,
348+
artboardName,
349+
viewModelName,
350+
useNew
351+
);
352+
if (created.instance && onInitRef.current) {
353+
onInitRef.current(created.instance);
379354
}
380-
};
381-
}, []);
355+
return created;
356+
},
357+
(r) => {
358+
if (r.needsDispose && r.instance) {
359+
callDispose(r.instance);
360+
}
361+
},
362+
[source, instanceName, artboardName, viewModelName, useNew]
363+
);
382364

383365
const error = useMemo(
384366
() => (result.error ? new Error(result.error) : null),

0 commit comments

Comments
 (0)