Skip to content

Commit cb8ebc4

Browse files
committed
test: add useDisposableMemo tests
Covers first render, deps change, unmount, rapid cycling, undefined factory, throwing cleanup, sequential changes, and Object.is semantics. Also wraps deferred setTimeout cleanup in try-catch matching the render-phase protection.
1 parent 62fd7b5 commit cb8ebc4

2 files changed

Lines changed: 254 additions & 2 deletions

File tree

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
import { renderHook, act } from '@testing-library/react-native';
2+
import { useDisposableMemo } from '../useDisposableMemo';
3+
4+
function createDisposable(label: string) {
5+
let alive = true;
6+
return {
7+
label,
8+
get value(): string {
9+
if (!alive) throw new Error(`${label} was disposed`);
10+
return `value-of-${label}`;
11+
},
12+
dispose() {
13+
if (!alive) throw new Error(`${label} double-disposed`);
14+
alive = false;
15+
},
16+
get isAlive() {
17+
return alive;
18+
},
19+
};
20+
}
21+
22+
type Disposable = ReturnType<typeof createDisposable>;
23+
24+
describe('useDisposableMemo', () => {
25+
beforeEach(() => {
26+
jest.useFakeTimers();
27+
});
28+
29+
afterEach(() => {
30+
// Flush any pending deferred disposals from the test
31+
try {
32+
jest.runAllTimers();
33+
} catch {
34+
// Some tests intentionally have throwing cleanups
35+
}
36+
jest.useRealTimers();
37+
});
38+
39+
it('creates value on first render, available immediately', () => {
40+
const { result } = renderHook(() =>
41+
useDisposableMemo(
42+
() => createDisposable('A'),
43+
(d) => d.dispose(),
44+
['dep-a']
45+
)
46+
);
47+
48+
expect(result.current.isAlive).toBe(true);
49+
expect(result.current.value).toBe('value-of-A');
50+
});
51+
52+
it('returns same value on re-render with unchanged deps', () => {
53+
const factory = jest.fn(() => createDisposable('A'));
54+
55+
const { result, rerender } = renderHook(() =>
56+
useDisposableMemo(factory, (d) => d.dispose(), ['stable'])
57+
);
58+
59+
const firstValue = result.current;
60+
rerender({});
61+
rerender({});
62+
63+
expect(result.current).toBe(firstValue);
64+
expect(factory).toHaveBeenCalledTimes(1);
65+
expect(result.current.isAlive).toBe(true);
66+
});
67+
68+
it('disposes old value and creates new one when deps change', () => {
69+
const { result, rerender } = renderHook(
70+
(props: { dep: string }) =>
71+
useDisposableMemo(
72+
() => createDisposable(props.dep),
73+
(d) => d.dispose(),
74+
[props.dep]
75+
),
76+
{ initialProps: { dep: 'A' } }
77+
);
78+
79+
const first = result.current;
80+
expect(first.label).toBe('A');
81+
expect(first.isAlive).toBe(true);
82+
83+
rerender({ dep: 'B' });
84+
85+
expect(first.isAlive).toBe(false);
86+
expect(result.current.label).toBe('B');
87+
expect(result.current.isAlive).toBe(true);
88+
});
89+
90+
it('disposes on unmount (via deferred timeout in dev)', () => {
91+
const { result, unmount } = renderHook(() =>
92+
useDisposableMemo(
93+
() => createDisposable('A'),
94+
(d) => d.dispose(),
95+
['dep']
96+
)
97+
);
98+
99+
const obj = result.current;
100+
expect(obj.isAlive).toBe(true);
101+
102+
unmount();
103+
104+
// Not yet disposed — waiting for setTimeout(0)
105+
expect(obj.isAlive).toBe(true);
106+
107+
act(() => {
108+
jest.runAllTimers();
109+
});
110+
111+
expect(obj.isAlive).toBe(false);
112+
});
113+
114+
it('handles rapid deps cycling A → B → A', () => {
115+
const disposed: string[] = [];
116+
117+
const { result, rerender } = renderHook(
118+
(props: { dep: string }) =>
119+
useDisposableMemo(
120+
() => createDisposable(props.dep),
121+
(d) => {
122+
disposed.push(d.label);
123+
d.dispose();
124+
},
125+
[props.dep]
126+
),
127+
{ initialProps: { dep: 'A' } }
128+
);
129+
130+
const first = result.current;
131+
132+
rerender({ dep: 'B' });
133+
expect(disposed).toEqual(['A']);
134+
const second = result.current;
135+
136+
rerender({ dep: 'A' });
137+
expect(disposed).toEqual(['A', 'B']);
138+
139+
// New 'A' is a fresh instance, not the original
140+
expect(result.current).not.toBe(first);
141+
expect(result.current.label).toBe('A');
142+
expect(result.current.isAlive).toBe(true);
143+
expect(first.isAlive).toBe(false);
144+
expect(second.isAlive).toBe(false);
145+
});
146+
147+
it('handles factory returning undefined', () => {
148+
const cleanup = jest.fn();
149+
150+
const { result, rerender } = renderHook(
151+
(props: { dep: string }) =>
152+
useDisposableMemo(
153+
() => undefined as Disposable | undefined,
154+
cleanup,
155+
[props.dep]
156+
),
157+
{ initialProps: { dep: 'A' } }
158+
);
159+
160+
expect(result.current).toBeUndefined();
161+
162+
rerender({ dep: 'B' });
163+
164+
// cleanup called with undefined — should not throw
165+
expect(cleanup).toHaveBeenCalledWith(undefined);
166+
});
167+
168+
it('survives cleanup throwing', () => {
169+
const { result, rerender } = renderHook(
170+
(props: { dep: string }) =>
171+
useDisposableMemo(
172+
() => createDisposable(props.dep),
173+
() => {
174+
throw new Error('cleanup exploded');
175+
},
176+
[props.dep]
177+
),
178+
{ initialProps: { dep: 'A' } }
179+
);
180+
181+
expect(result.current.label).toBe('A');
182+
183+
// Deps change — cleanup throws but new value is still created
184+
rerender({ dep: 'B' });
185+
186+
expect(result.current.label).toBe('B');
187+
expect(result.current.isAlive).toBe(true);
188+
});
189+
190+
it('disposes each intermediate value on sequential deps changes', () => {
191+
const disposed: string[] = [];
192+
193+
const { result, rerender, unmount } = renderHook(
194+
(props: { dep: string }) =>
195+
useDisposableMemo(
196+
() => createDisposable(props.dep),
197+
(d) => {
198+
disposed.push(d.label);
199+
d.dispose();
200+
},
201+
[props.dep]
202+
),
203+
{ initialProps: { dep: 'A' } }
204+
);
205+
206+
rerender({ dep: 'B' });
207+
rerender({ dep: 'C' });
208+
rerender({ dep: 'D' });
209+
210+
expect(disposed).toEqual(['A', 'B', 'C']);
211+
expect(result.current.label).toBe('D');
212+
expect(result.current.isAlive).toBe(true);
213+
214+
unmount();
215+
act(() => {
216+
jest.runAllTimers();
217+
});
218+
219+
expect(disposed).toEqual(['A', 'B', 'C', 'D']);
220+
});
221+
222+
it('compares deps with Object.is semantics', () => {
223+
const factory = jest.fn(() => createDisposable('A'));
224+
225+
const { rerender } = renderHook(
226+
(props: { dep: number }) =>
227+
useDisposableMemo(factory, (d) => d.dispose(), [props.dep]),
228+
{ initialProps: { dep: NaN } }
229+
);
230+
231+
expect(factory).toHaveBeenCalledTimes(1);
232+
233+
// NaN === NaN under Object.is
234+
rerender({ dep: NaN });
235+
expect(factory).toHaveBeenCalledTimes(1);
236+
237+
// 0 !== -0 under Object.is
238+
rerender({ dep: 0 });
239+
expect(factory).toHaveBeenCalledTimes(2);
240+
241+
rerender({ dep: -0 });
242+
expect(factory).toHaveBeenCalledTimes(3);
243+
});
244+
});

src/hooks/useDisposableMemo.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,19 @@ export function useDisposableMemo<T>(
9090
if (__DEV__) {
9191
const val = ref.current.value;
9292
ref.current.pendingDisposal = setTimeout(() => {
93-
cleanupRef.current(val);
93+
try {
94+
cleanupRef.current(val);
95+
} catch {
96+
// Swallow — object may already be in a bad state.
97+
}
9498
ref.current.pendingDisposal = null;
9599
}, 0);
96100
} else {
97-
cleanupRef.current(ref.current.value);
101+
try {
102+
cleanupRef.current(ref.current.value);
103+
} catch {
104+
// Swallow — object may already be in a bad state.
105+
}
98106
}
99107
};
100108
}, []);

0 commit comments

Comments
 (0)