Skip to content

Commit d835504

Browse files
committed
fix: race condition in single *Once hooks when reference changes
When a reference changes, the result of the first request is now ignored.
1 parent f7c3e4c commit d835504

File tree

2 files changed

+145
-38
lines changed

2 files changed

+145
-38
lines changed

src/internal/useGet.spec.ts

Lines changed: 126 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,129 +1,225 @@
1-
import { renderHook, waitFor } from "@testing-library/react";
1+
import { configure, renderHook, waitFor } from "@testing-library/react";
22
import { newPromise, newSymbol } from "../__testfixtures__/index.js";
33
import { useGet } from "./useGet.js";
4-
import { it, expect, beforeEach, describe, vi } from "vitest";
4+
import { it, expect, beforeEach, describe, vi, afterEach } from "vitest";
55

66
const result1 = newSymbol<string>("Result 1");
77
const result2 = newSymbol<string>("Result 2");
8-
const error = newSymbol("Error");
8+
const error1 = newSymbol("Error 1");
9+
const error2 = newSymbol("Error 2");
910

1011
const refA1 = newSymbol("Ref A1");
1112
const refA2 = newSymbol("Ref A2");
1213

1314
const refB1 = newSymbol("Ref B1");
1415
const refB2 = newSymbol("Ref B2");
1516

16-
const getData = vi.fn();
1717
const isEqual = (a: unknown, b: unknown) =>
1818
[a, b].every((x) => [refA1, refA2].includes(x)) || [a, b].every((x) => [refB1, refB2].includes(x));
1919

20-
beforeEach(() => {
21-
vi.resetAllMocks();
20+
afterEach(() => {
21+
configure({ reactStrictMode: false });
2222
});
2323

24-
describe("initial state", () => {
25-
it("defined reference", () => {
26-
getData.mockReturnValue(new Promise(() => {}));
27-
const { result } = renderHook(() => useGet(refA1, getData, isEqual));
28-
expect(result.current).toStrictEqual([undefined, true, undefined]);
29-
});
24+
describe.each([{ reactStrictMode: true }, { reactStrictMode: false }])(
25+
`strictMode=$reactStrictMode`,
26+
({ reactStrictMode }) => {
27+
beforeEach(() => {
28+
configure({ reactStrictMode });
29+
});
3030

31-
it("undefined reference", () => {
32-
const { result } = renderHook(() => useGet(undefined, getData, isEqual));
33-
expect(result.current).toStrictEqual([undefined, false, undefined]);
34-
});
35-
});
31+
describe("initial state", () => {
32+
it("defined reference", () => {
33+
const getData = vi.fn().mockReturnValue(new Promise(() => {}));
34+
const { result } = renderHook(() => useGet(refA1, getData, isEqual));
35+
expect(result.current).toStrictEqual([undefined, true, undefined]);
36+
});
37+
38+
it("undefined reference", () => {
39+
const getData = vi.fn();
40+
const { result } = renderHook(() => useGet(undefined, getData, isEqual));
41+
expect(result.current).toStrictEqual([undefined, false, undefined]);
42+
});
43+
});
44+
},
45+
);
3646

3747
describe("initial load", () => {
3848
it("should return success result", async () => {
3949
const { promise, resolve } = newPromise<string>();
40-
getData.mockReturnValue(promise);
50+
const getData = vi.fn().mockReturnValue(promise);
4151

4252
const { result } = renderHook(() => useGet(refA1, getData, isEqual));
53+
54+
// initial state
4355
expect(result.current).toStrictEqual([undefined, true, undefined]);
56+
57+
// resolve promise
4458
resolve(result1);
4559
await waitFor(() => expect(result.current).toStrictEqual([result1, false, undefined]));
4660
});
4761

4862
it("should return error result", async () => {
4963
const { promise, reject } = newPromise<string>();
50-
getData.mockReturnValue(promise);
64+
const getData = vi.fn().mockReturnValue(promise);
5165

5266
const { result } = renderHook(() => useGet(refA1, getData, isEqual));
67+
68+
// initial state
5369
expect(result.current).toStrictEqual([undefined, true, undefined]);
54-
reject(error);
55-
await waitFor(() => expect(result.current).toStrictEqual([undefined, false, error]));
70+
71+
// reject promise
72+
reject(error1);
73+
await waitFor(() => expect(result.current).toStrictEqual([undefined, false, error1]));
5674
});
5775
});
5876

5977
describe("when ref changes", () => {
6078
describe("to equal ref", () => {
6179
it("should not update success result", async () => {
62-
getData.mockResolvedValueOnce(result1);
80+
const getData = vi.fn().mockResolvedValueOnce(result1);
6381

6482
const { result, rerender } = renderHook(({ ref }) => useGet(ref, getData, isEqual), {
6583
initialProps: { ref: refA1 },
6684
});
6785

86+
// initial state
6887
expect(result.current).toStrictEqual([undefined, true, undefined]);
6988
await waitFor(() => expect(result.current).toStrictEqual([result1, false, undefined]));
7089
expect(getData).toHaveBeenCalledTimes(1);
7190

91+
// change ref
7292
rerender({ ref: refA2 });
7393
expect(result.current).toStrictEqual([result1, false, undefined]);
7494
expect(getData).toHaveBeenCalledTimes(1);
7595
});
7696

7797
it("should not update error result", async () => {
78-
getData.mockRejectedValueOnce(error);
98+
const getData = vi.fn().mockRejectedValueOnce(error1);
7999

80100
const { result, rerender } = renderHook(({ ref }) => useGet(ref, getData, isEqual), {
81101
initialProps: { ref: refA1 },
82102
});
83103

104+
// initial state
84105
expect(result.current).toStrictEqual([undefined, true, undefined]);
85-
await waitFor(() => expect(result.current).toStrictEqual([undefined, false, error]));
106+
await waitFor(() => expect(result.current).toStrictEqual([undefined, false, error1]));
86107
expect(getData).toHaveBeenCalledTimes(1);
87108

109+
// change ref
88110
rerender({ ref: refA2 });
89-
expect(result.current).toStrictEqual([undefined, false, error]);
111+
expect(result.current).toStrictEqual([undefined, false, error1]);
90112
expect(getData).toHaveBeenCalledTimes(1);
91113
});
92114
});
93115

94116
describe("to unequal ref", () => {
95117
it("should update success result", async () => {
96-
getData.mockResolvedValueOnce(result1).mockResolvedValueOnce(result2);
118+
const getData = vi.fn().mockResolvedValueOnce(result1).mockResolvedValueOnce(result2);
97119

98120
const { result, rerender } = renderHook(({ ref }) => useGet(ref, getData, isEqual), {
99121
initialProps: { ref: refA1 },
100122
});
101123

124+
// initial state
102125
expect(result.current).toStrictEqual([undefined, true, undefined]);
103126
expect(getData).toHaveBeenCalledTimes(1);
104127
await waitFor(() => expect(result.current).toStrictEqual([result1, false, undefined]));
105128

129+
// change ref
106130
rerender({ ref: refB1 });
107131
expect(getData).toHaveBeenCalledTimes(2);
108132
await waitFor(() => expect(result.current).toStrictEqual([result2, false, undefined]));
109133
});
110134

111135
it("should update error result", async () => {
112-
getData.mockRejectedValueOnce(error).mockResolvedValueOnce(result2);
136+
const getData = vi.fn().mockRejectedValueOnce(error1).mockResolvedValueOnce(result2);
113137

114138
const { result, rerender } = renderHook(({ ref }) => useGet(ref, getData, isEqual), {
115139
initialProps: { ref: refA1 },
116140
});
117141

142+
// initial state
118143
expect(result.current).toStrictEqual([undefined, true, undefined]);
119144
expect(getData).toHaveBeenCalledTimes(1);
120-
await waitFor(() => expect(result.current).toStrictEqual([undefined, false, error]));
145+
await waitFor(() => expect(result.current).toStrictEqual([undefined, false, error1]));
121146

147+
// change ref
122148
rerender({ ref: refB1 });
123149
expect(result.current).toStrictEqual([undefined, true, undefined]);
124150
expect(getData).toHaveBeenCalledTimes(2);
125151
await waitFor(() => expect(result.current).toStrictEqual([result2, false, undefined]));
126152
});
153+
154+
describe("if changed before first `getData` is settled", () => {
155+
it("should ignore the first result", async () => {
156+
const { promise: promise1, resolve: resolve1 } = newPromise<string>();
157+
const { promise: promise2, resolve: resolve2 } = newPromise<string>();
158+
const getData = vi.fn().mockReturnValueOnce(promise1).mockReturnValueOnce(promise2);
159+
160+
const { result, rerender } = renderHook(({ ref }) => useGet(ref, getData, isEqual), {
161+
initialProps: { ref: refA1 },
162+
});
163+
164+
// initial state
165+
await waitFor(() => expect(result.current).toStrictEqual([undefined, true, undefined]));
166+
167+
// change ref
168+
rerender({ ref: refB1 });
169+
await waitFor(() => expect(result.current).toStrictEqual([undefined, true, undefined]));
170+
171+
// first promise resolves
172+
resolve1(result1);
173+
174+
// ensure that the first result is ignored
175+
await expect(
176+
waitFor(
177+
() => {
178+
expect(result.current).toStrictEqual([result1, false, undefined]);
179+
},
180+
{ timeout: 200 },
181+
),
182+
).rejects.toThrow();
183+
184+
// second promise resolves
185+
resolve2(result2);
186+
await waitFor(() => expect(result.current).toStrictEqual([result2, false, undefined]));
187+
});
188+
189+
it("should ignore the first thrown error", async () => {
190+
const { promise: promise1, reject: reject1 } = newPromise<string>();
191+
const { promise: promise2, reject: reject2 } = newPromise<string>();
192+
const getData = vi.fn().mockReturnValueOnce(promise1).mockReturnValueOnce(promise2);
193+
194+
const { result, rerender } = renderHook(({ ref }) => useGet(ref, getData, isEqual), {
195+
initialProps: { ref: refA1 },
196+
});
197+
198+
// initial state
199+
await waitFor(() => expect(result.current).toStrictEqual([undefined, true, undefined]));
200+
201+
// change ref
202+
rerender({ ref: refB1 });
203+
await waitFor(() => expect(result.current).toStrictEqual([undefined, true, undefined]));
204+
205+
// first promise rejects
206+
reject1(error1);
207+
208+
// ensure that the first result is ignored
209+
await expect(
210+
waitFor(
211+
() => {
212+
expect(result.current).toStrictEqual([undefined, false, error1]);
213+
},
214+
{ timeout: 200 },
215+
),
216+
).rejects.toThrow();
217+
218+
// second promise rejects
219+
reject2(error2);
220+
await waitFor(() => expect(result.current).toStrictEqual([undefined, false, error2]));
221+
});
222+
});
127223
});
128224
});
129225

@@ -135,13 +231,14 @@ it("refetches if `getData` changes", async () => {
135231
initialProps: { getData: getData1 },
136232
});
137233

234+
// initial state
138235
await waitFor(() => {
139236
expect(result.current).toStrictEqual([result1, false, undefined]);
140237
});
141238
expect(getData1).toHaveBeenCalledTimes(1);
142239

240+
// changing `getData`
143241
rerender({ getData: getData2 });
144-
145242
await waitFor(() => {
146243
expect(result.current).toStrictEqual([result2, false, undefined]);
147244
});

src/internal/useGet.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useMemo } from "react";
1+
import { useEffect, useMemo, useRef } from "react";
22
import { ValueHookResult } from "../common/index.js";
33
import { useIsMounted } from "./useIsMounted.js";
44
import { LoadingState, useLoadingValue } from "./useLoadingValue.js";
@@ -18,33 +18,43 @@ export function useGet<Value, Error, Reference>(
1818
);
1919

2020
const stableRef = useStableValue(reference ?? undefined, isEqual);
21+
const ongoingFetchRef = useRef<Reference>();
2122

2223
useEffect(() => {
23-
(async () => {
24-
if (stableRef === undefined) {
25-
setValue();
26-
} else {
27-
setLoading();
24+
if (stableRef === undefined) {
25+
setValue();
26+
} else {
27+
setLoading();
28+
ongoingFetchRef.current = stableRef;
2829

30+
(async () => {
2931
try {
3032
const data = await getData(stableRef);
3133

3234
if (!isMounted.current) {
3335
return;
3436
}
3537

38+
if (!isEqual(ongoingFetchRef.current, stableRef)) {
39+
return;
40+
}
41+
3642
setValue(data);
3743
} catch (e) {
3844
if (!isMounted.current) {
3945
return;
4046
}
4147

48+
if (!isEqual(ongoingFetchRef.current, stableRef)) {
49+
return;
50+
}
51+
4252
// We assume this is always a Error
4353
setError(e as Error);
4454
}
45-
}
46-
})();
47-
}, [stableRef, getData, isEqual, setValue, setLoading, isMounted, setError]);
55+
})();
56+
}
57+
}, [stableRef, getData, isEqual, setValue, setLoading, setError, isMounted]);
4858

4959
return useMemo(() => [value, loading, error], [value, loading, error]);
5060
}

0 commit comments

Comments
 (0)