@isograph/react-disposable-state 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +144 -0
- package/dist/CacheItem.d.ts +54 -0
- package/dist/CacheItem.js +265 -0
- package/dist/ParentCache.d.ts +39 -0
- package/dist/ParentCache.js +86 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +24 -0
- package/dist/useCachedPrecommitValue.d.ts +36 -0
- package/dist/useCachedPrecommitValue.js +93 -0
- package/dist/useDisposableState.d.ts +9 -0
- package/dist/useDisposableState.js +68 -0
- package/dist/useHasCommittedRef.d.ts +5 -0
- package/dist/useHasCommittedRef.js +15 -0
- package/dist/useLazyDisposableState.d.ts +13 -0
- package/dist/useLazyDisposableState.js +39 -0
- package/dist/useUpdatableDisposableState.d.ts +39 -0
- package/dist/useUpdatableDisposableState.js +92 -0
- package/docs/managing-complex-state.md +151 -0
- package/package.json +28 -0
- package/src/CacheItem.test.ts +788 -0
- package/src/CacheItem.ts +364 -0
- package/src/ParentCache.test.ts +70 -0
- package/src/ParentCache.ts +100 -0
- package/src/index.ts +9 -0
- package/src/useCachedPrecommitValue.test.tsx +587 -0
- package/src/useCachedPrecommitValue.ts +104 -0
- package/src/useDisposableState.ts +92 -0
- package/src/useHasCommittedRef.ts +12 -0
- package/src/useLazyDisposableState.ts +48 -0
- package/src/useUpdatableDisposableState.test.tsx +482 -0
- package/src/useUpdatableDisposableState.ts +134 -0
- package/tsconfig.pkg.json +9 -0
@@ -0,0 +1,92 @@
|
|
1
|
+
import { useEffect, useRef } from "react";
|
2
|
+
import { ParentCache } from "./ParentCache";
|
3
|
+
import { ItemCleanupPair } from "@isograph/disposable-types";
|
4
|
+
import { useCachedPrecommitValue } from "./useCachedPrecommitValue";
|
5
|
+
import {
|
6
|
+
UNASSIGNED_STATE,
|
7
|
+
UnassignedState,
|
8
|
+
useUpdatableDisposableState,
|
9
|
+
} from "./useUpdatableDisposableState";
|
10
|
+
|
11
|
+
type UseUpdatableDisposableStateReturnValue<T> = {
|
12
|
+
state: T;
|
13
|
+
setState: (pair: ItemCleanupPair<Exclude<T, UnassignedState>>) => void;
|
14
|
+
};
|
15
|
+
|
16
|
+
export function useDisposableState<T = never>(
|
17
|
+
parentCache: ParentCache<T>
|
18
|
+
): UseUpdatableDisposableStateReturnValue<T> {
|
19
|
+
const itemCleanupPairRef = useRef<ItemCleanupPair<T> | null>(null);
|
20
|
+
|
21
|
+
const preCommitItem = useCachedPrecommitValue(parentCache, (pair) => {
|
22
|
+
itemCleanupPairRef.current = pair;
|
23
|
+
});
|
24
|
+
|
25
|
+
const { state: stateFromDisposableStateHook, setState } =
|
26
|
+
useUpdatableDisposableState<T>();
|
27
|
+
|
28
|
+
useEffect(
|
29
|
+
function cleanupItemCleanupPairRefAfterSetState() {
|
30
|
+
if (stateFromDisposableStateHook !== UNASSIGNED_STATE) {
|
31
|
+
if (itemCleanupPairRef.current !== null) {
|
32
|
+
itemCleanupPairRef.current[1]();
|
33
|
+
itemCleanupPairRef.current = null;
|
34
|
+
} else {
|
35
|
+
throw new Error(
|
36
|
+
"itemCleanupPairRef.current is unexpectedly null. " +
|
37
|
+
"This indicates a bug in react-disposable-state."
|
38
|
+
);
|
39
|
+
}
|
40
|
+
}
|
41
|
+
},
|
42
|
+
[stateFromDisposableStateHook]
|
43
|
+
);
|
44
|
+
|
45
|
+
useEffect(function cleanupItemCleanupPairRefIfSetStateNotCalled() {
|
46
|
+
return () => {
|
47
|
+
if (itemCleanupPairRef.current !== null) {
|
48
|
+
itemCleanupPairRef.current[1]();
|
49
|
+
itemCleanupPairRef.current = null;
|
50
|
+
}
|
51
|
+
};
|
52
|
+
}, []);
|
53
|
+
|
54
|
+
// Safety: we can be in one of three states. Pre-commit, in which case
|
55
|
+
// preCommitItem is assigned, post-commit but before setState has been
|
56
|
+
// called, in which case itemCleanupPairRef.current is assigned, or
|
57
|
+
// after setState has been called, in which case
|
58
|
+
// stateFromDisposableStateHook is assigned.
|
59
|
+
//
|
60
|
+
// Therefore, the type of state is T, not T | undefined. But the fact
|
61
|
+
// that we are in one of the three states is not reflected in the types.
|
62
|
+
// So we have to cast to T.
|
63
|
+
//
|
64
|
+
// Note that in the post-commit post-setState state, itemCleanupPairRef
|
65
|
+
// can still be assigned, during the render before the
|
66
|
+
// cleanupItemCleanupPairRefAfterSetState effect is called.
|
67
|
+
const state: T | undefined =
|
68
|
+
(stateFromDisposableStateHook != UNASSIGNED_STATE
|
69
|
+
? stateFromDisposableStateHook
|
70
|
+
: null) ??
|
71
|
+
itemCleanupPairRef.current?.[0] ??
|
72
|
+
preCommitItem?.state;
|
73
|
+
|
74
|
+
return {
|
75
|
+
state: state!,
|
76
|
+
setState,
|
77
|
+
};
|
78
|
+
}
|
79
|
+
|
80
|
+
// @ts-ignore
|
81
|
+
function tsTests() {
|
82
|
+
let x: any;
|
83
|
+
const a = useDisposableState(x);
|
84
|
+
// @ts-expect-error
|
85
|
+
a.setState(["asdf", () => {}]);
|
86
|
+
// @ts-expect-error
|
87
|
+
a.setState([UNASSIGNED_STATE, () => {}]);
|
88
|
+
const b = useDisposableState<string | UnassignedState>(x);
|
89
|
+
// @ts-expect-error
|
90
|
+
b.setState([UNASSIGNED_STATE, () => {}]);
|
91
|
+
b.setState(["asdf", () => {}]);
|
92
|
+
}
|
@@ -0,0 +1,12 @@
|
|
1
|
+
import { MutableRefObject, useEffect, useRef } from "react";
|
2
|
+
|
3
|
+
/**
|
4
|
+
* Returns true if the component has committed, false otherwise.
|
5
|
+
*/
|
6
|
+
export function useHasCommittedRef(): MutableRefObject<boolean> {
|
7
|
+
const hasCommittedRef = useRef(false);
|
8
|
+
useEffect(() => {
|
9
|
+
hasCommittedRef.current = true;
|
10
|
+
}, []);
|
11
|
+
return hasCommittedRef;
|
12
|
+
}
|
@@ -0,0 +1,48 @@
|
|
1
|
+
"use strict";
|
2
|
+
|
3
|
+
import { useEffect, useRef } from "react";
|
4
|
+
import type { ItemCleanupPair } from "@isograph/disposable-types";
|
5
|
+
import { ParentCache } from "./ParentCache";
|
6
|
+
import { useCachedPrecommitValue } from "./useCachedPrecommitValue";
|
7
|
+
|
8
|
+
/**
|
9
|
+
* useLazyDisposableState<T>
|
10
|
+
* - Takes a mutable parent cache and a factory function
|
11
|
+
* - Returns { state: T }
|
12
|
+
*
|
13
|
+
* This lazily loads the disposable item using useCachedPrecommitValue, then
|
14
|
+
* (on commit) sets it in state. The item continues to be returned after
|
15
|
+
* commit and is disposed when the hook unmounts.
|
16
|
+
*/
|
17
|
+
export function useLazyDisposableState<T>(parentCache: ParentCache<T>): {
|
18
|
+
state: T;
|
19
|
+
} {
|
20
|
+
const itemCleanupPairRef = useRef<ItemCleanupPair<T> | null>(null);
|
21
|
+
|
22
|
+
const preCommitItem = useCachedPrecommitValue(parentCache, (pair) => {
|
23
|
+
itemCleanupPairRef.current = pair;
|
24
|
+
});
|
25
|
+
|
26
|
+
useEffect(() => {
|
27
|
+
const cleanupFn = itemCleanupPairRef.current?.[1];
|
28
|
+
// TODO confirm useEffect is called in order.
|
29
|
+
if (cleanupFn == null) {
|
30
|
+
throw new Error(
|
31
|
+
"cleanupFn unexpectedly null. This indicates a bug in react-disposable-state."
|
32
|
+
);
|
33
|
+
}
|
34
|
+
return cleanupFn;
|
35
|
+
}, []);
|
36
|
+
|
37
|
+
const returnedItem = preCommitItem?.state ?? itemCleanupPairRef.current?.[0];
|
38
|
+
if (returnedItem != null) {
|
39
|
+
return { state: returnedItem };
|
40
|
+
}
|
41
|
+
|
42
|
+
// Safety: This can't happen. For renders before the initial commit, preCommitItem
|
43
|
+
// is non-null. During the initial commit, we assign itemCleanupPairRef.current,
|
44
|
+
// so during subsequent renders, itemCleanupPairRef.current is non-null.
|
45
|
+
throw new Error(
|
46
|
+
"returnedItem was unexpectedly null. This indicates a bug in react-disposable-state."
|
47
|
+
);
|
48
|
+
}
|
@@ -0,0 +1,482 @@
|
|
1
|
+
import { describe, test, vi, expect } from "vitest";
|
2
|
+
import React from "react";
|
3
|
+
import { create } from "react-test-renderer";
|
4
|
+
import {
|
5
|
+
useUpdatableDisposableState,
|
6
|
+
UNASSIGNED_STATE,
|
7
|
+
} from "./useUpdatableDisposableState";
|
8
|
+
|
9
|
+
function Suspender({ promise, isResolvedRef }) {
|
10
|
+
if (!isResolvedRef.current) {
|
11
|
+
throw promise;
|
12
|
+
}
|
13
|
+
return null;
|
14
|
+
}
|
15
|
+
|
16
|
+
function shortPromise() {
|
17
|
+
let resolve;
|
18
|
+
const promise = new Promise((_resolve) => {
|
19
|
+
resolve = _resolve;
|
20
|
+
});
|
21
|
+
|
22
|
+
setTimeout(resolve, 1);
|
23
|
+
return promise;
|
24
|
+
}
|
25
|
+
|
26
|
+
function promiseAndResolver() {
|
27
|
+
let resolve;
|
28
|
+
const isResolvedRef = {
|
29
|
+
current: false,
|
30
|
+
};
|
31
|
+
const promise = new Promise((r) => {
|
32
|
+
resolve = r;
|
33
|
+
});
|
34
|
+
return {
|
35
|
+
promise,
|
36
|
+
resolve: () => {
|
37
|
+
isResolvedRef.current = true;
|
38
|
+
resolve();
|
39
|
+
},
|
40
|
+
isResolvedRef,
|
41
|
+
};
|
42
|
+
}
|
43
|
+
|
44
|
+
// The fact that sometimes we need to render in concurrent mode and sometimes
|
45
|
+
// not is a bit worrisome.
|
46
|
+
async function awaitableCreate(Component, isConcurrent) {
|
47
|
+
const element = create(
|
48
|
+
Component,
|
49
|
+
isConcurrent ? { unstable_isConcurrent: true } : undefined
|
50
|
+
);
|
51
|
+
await shortPromise();
|
52
|
+
return element;
|
53
|
+
}
|
54
|
+
|
55
|
+
describe("useUpdatableDisposableState", () => {
|
56
|
+
test("it should return a sentinel value initially and a setter", async () => {
|
57
|
+
const render = vi.fn();
|
58
|
+
function TestComponent() {
|
59
|
+
render();
|
60
|
+
const value = useUpdatableDisposableState();
|
61
|
+
expect(value.state).toBe(UNASSIGNED_STATE);
|
62
|
+
expect(typeof value.setState).toBe("function");
|
63
|
+
return null;
|
64
|
+
}
|
65
|
+
await awaitableCreate(<TestComponent />, false);
|
66
|
+
expect(render).toHaveBeenCalledTimes(1);
|
67
|
+
});
|
68
|
+
|
69
|
+
test("it should allow you to update the value in state", async () => {
|
70
|
+
const render = vi.fn();
|
71
|
+
let value;
|
72
|
+
function TestComponent() {
|
73
|
+
render();
|
74
|
+
value = useUpdatableDisposableState();
|
75
|
+
return null;
|
76
|
+
}
|
77
|
+
await awaitableCreate(<TestComponent />, false);
|
78
|
+
expect(render).toHaveBeenCalledTimes(1);
|
79
|
+
|
80
|
+
value.setState([1, () => {}]);
|
81
|
+
|
82
|
+
await shortPromise();
|
83
|
+
|
84
|
+
expect(render).toHaveBeenCalledTimes(2);
|
85
|
+
expect(value.state).toEqual(1);
|
86
|
+
});
|
87
|
+
|
88
|
+
test("it should dispose previous values on commit", async () => {
|
89
|
+
const render = vi.fn();
|
90
|
+
const componentCommits = vi.fn();
|
91
|
+
let value;
|
92
|
+
function TestComponent() {
|
93
|
+
render();
|
94
|
+
value = useUpdatableDisposableState();
|
95
|
+
|
96
|
+
React.useEffect(() => {
|
97
|
+
if (value.state === 2) {
|
98
|
+
componentCommits();
|
99
|
+
expect(disposeInitialState).toHaveBeenCalledTimes(1);
|
100
|
+
}
|
101
|
+
});
|
102
|
+
return null;
|
103
|
+
}
|
104
|
+
await awaitableCreate(<TestComponent />, false);
|
105
|
+
expect(render).toHaveBeenCalledTimes(1);
|
106
|
+
|
107
|
+
const disposeInitialState = vi.fn(() => {});
|
108
|
+
value.setState([1, disposeInitialState]);
|
109
|
+
|
110
|
+
await shortPromise();
|
111
|
+
|
112
|
+
expect(render).toHaveBeenCalledTimes(2);
|
113
|
+
expect(value.state).toEqual(1);
|
114
|
+
|
115
|
+
value.setState([2, () => {}]);
|
116
|
+
expect(disposeInitialState).not.toHaveBeenCalled();
|
117
|
+
|
118
|
+
expect(componentCommits).not.toHaveBeenCalled();
|
119
|
+
await shortPromise();
|
120
|
+
expect(componentCommits).toHaveBeenCalled();
|
121
|
+
});
|
122
|
+
|
123
|
+
test("it should dispose identical previous values on commit", async () => {
|
124
|
+
const render = vi.fn();
|
125
|
+
const componentCommits = vi.fn();
|
126
|
+
let value;
|
127
|
+
let hasSetStateASecondTime = false;
|
128
|
+
function TestComponent() {
|
129
|
+
render();
|
130
|
+
value = useUpdatableDisposableState();
|
131
|
+
|
132
|
+
React.useEffect(() => {
|
133
|
+
if (hasSetStateASecondTime) {
|
134
|
+
componentCommits();
|
135
|
+
expect(disposeInitialState).toHaveBeenCalledTimes(1);
|
136
|
+
}
|
137
|
+
});
|
138
|
+
return null;
|
139
|
+
}
|
140
|
+
await awaitableCreate(<TestComponent />, false);
|
141
|
+
expect(render).toHaveBeenCalledTimes(1);
|
142
|
+
|
143
|
+
const disposeInitialState = vi.fn(() => {});
|
144
|
+
value.setState([1, disposeInitialState]);
|
145
|
+
|
146
|
+
await shortPromise();
|
147
|
+
|
148
|
+
expect(render).toHaveBeenCalledTimes(2);
|
149
|
+
expect(value.state).toEqual(1);
|
150
|
+
|
151
|
+
value.setState([1, () => {}]);
|
152
|
+
hasSetStateASecondTime = true;
|
153
|
+
|
154
|
+
expect(disposeInitialState).not.toHaveBeenCalled();
|
155
|
+
|
156
|
+
expect(componentCommits).not.toHaveBeenCalled();
|
157
|
+
await shortPromise();
|
158
|
+
expect(componentCommits).toHaveBeenCalled();
|
159
|
+
});
|
160
|
+
|
161
|
+
test("it should dispose multiple previous values on commit", async () => {
|
162
|
+
const render = vi.fn();
|
163
|
+
const componentCommits = vi.fn();
|
164
|
+
let value;
|
165
|
+
let hasSetState = false;
|
166
|
+
function TestComponent() {
|
167
|
+
render();
|
168
|
+
value = useUpdatableDisposableState();
|
169
|
+
|
170
|
+
React.useEffect(() => {
|
171
|
+
if (hasSetState) {
|
172
|
+
componentCommits();
|
173
|
+
expect(dispose1).toHaveBeenCalledTimes(1);
|
174
|
+
expect(dispose2).toHaveBeenCalledTimes(1);
|
175
|
+
}
|
176
|
+
});
|
177
|
+
return null;
|
178
|
+
}
|
179
|
+
// incremental mode => false leads to an immediate (synchronous) commit
|
180
|
+
// after the second state update.
|
181
|
+
await awaitableCreate(<TestComponent />, true);
|
182
|
+
expect(render).toHaveBeenCalledTimes(1);
|
183
|
+
|
184
|
+
const dispose1 = vi.fn(() => {});
|
185
|
+
value.setState([1, dispose1]);
|
186
|
+
|
187
|
+
await shortPromise();
|
188
|
+
|
189
|
+
expect(render).toHaveBeenCalledTimes(2);
|
190
|
+
expect(value.state).toEqual(1);
|
191
|
+
|
192
|
+
expect(dispose1).not.toHaveBeenCalled();
|
193
|
+
const dispose2 = vi.fn(() => {});
|
194
|
+
value.setState([2, dispose2]);
|
195
|
+
value.setState([2, () => {}]);
|
196
|
+
hasSetState = true;
|
197
|
+
|
198
|
+
expect(dispose1).not.toHaveBeenCalled();
|
199
|
+
|
200
|
+
expect(componentCommits).not.toHaveBeenCalled();
|
201
|
+
await shortPromise();
|
202
|
+
expect(componentCommits).toHaveBeenCalled();
|
203
|
+
});
|
204
|
+
|
205
|
+
test("it should throw if setState is called during a render before commit", async () => {
|
206
|
+
let didCatch;
|
207
|
+
function TestComponent() {
|
208
|
+
const value = useUpdatableDisposableState<number>();
|
209
|
+
try {
|
210
|
+
value.setState([0, () => {}]);
|
211
|
+
} catch {
|
212
|
+
didCatch = true;
|
213
|
+
}
|
214
|
+
return null;
|
215
|
+
}
|
216
|
+
|
217
|
+
await awaitableCreate(<TestComponent />, false);
|
218
|
+
|
219
|
+
expect(didCatch).toBe(true);
|
220
|
+
});
|
221
|
+
|
222
|
+
test("it should not throw if setState is called during render after commit", async () => {
|
223
|
+
let value;
|
224
|
+
const cleanupFn = vi.fn();
|
225
|
+
const sawCorrectValue = vi.fn();
|
226
|
+
let shouldSetHookState = false;
|
227
|
+
let setState;
|
228
|
+
function TestComponent() {
|
229
|
+
value = useUpdatableDisposableState<number>();
|
230
|
+
const [, _setState] = React.useState();
|
231
|
+
setState = _setState;
|
232
|
+
|
233
|
+
if (shouldSetHookState) {
|
234
|
+
value.setState([1, cleanupFn]);
|
235
|
+
shouldSetHookState = false;
|
236
|
+
}
|
237
|
+
|
238
|
+
React.useEffect(() => {
|
239
|
+
if (value.state === 1) {
|
240
|
+
sawCorrectValue();
|
241
|
+
}
|
242
|
+
});
|
243
|
+
return null;
|
244
|
+
}
|
245
|
+
|
246
|
+
await awaitableCreate(<TestComponent />, true);
|
247
|
+
|
248
|
+
shouldSetHookState = true;
|
249
|
+
setState({});
|
250
|
+
|
251
|
+
await shortPromise();
|
252
|
+
|
253
|
+
expect(sawCorrectValue).toHaveBeenCalledTimes(1);
|
254
|
+
expect(value.state).toBe(1);
|
255
|
+
});
|
256
|
+
|
257
|
+
test("it should throw if setState is called after a render before commit", async () => {
|
258
|
+
let value;
|
259
|
+
const componentCommits = vi.fn();
|
260
|
+
function TestComponent() {
|
261
|
+
value = useUpdatableDisposableState<number>();
|
262
|
+
React.useEffect(() => {
|
263
|
+
componentCommits();
|
264
|
+
});
|
265
|
+
return null;
|
266
|
+
}
|
267
|
+
|
268
|
+
const { promise, isResolvedRef, resolve } = promiseAndResolver();
|
269
|
+
await awaitableCreate(
|
270
|
+
<React.Suspense fallback="fallback">
|
271
|
+
<TestComponent />
|
272
|
+
<Suspender promise={promise} isResolvedRef={isResolvedRef} />
|
273
|
+
</React.Suspense>,
|
274
|
+
true
|
275
|
+
);
|
276
|
+
|
277
|
+
expect(componentCommits).not.toHaveBeenCalled();
|
278
|
+
|
279
|
+
expect(() => {
|
280
|
+
value.setState([1, () => {}]);
|
281
|
+
}).toThrow();
|
282
|
+
});
|
283
|
+
|
284
|
+
test(
|
285
|
+
"it should dispose items that were set during " +
|
286
|
+
"suspense when the component commits due to unsuspense",
|
287
|
+
async () => {
|
288
|
+
// Note that "during suspense" implies that there is no commit, so this
|
289
|
+
// follows from the descriptions of the previous tests. Nonetheless, we
|
290
|
+
// should test this scenario.
|
291
|
+
|
292
|
+
let value;
|
293
|
+
const componentCommits = vi.fn();
|
294
|
+
const render = vi.fn();
|
295
|
+
function TestComponent() {
|
296
|
+
render();
|
297
|
+
value = useUpdatableDisposableState<number>();
|
298
|
+
React.useEffect(() => {
|
299
|
+
componentCommits();
|
300
|
+
});
|
301
|
+
return null;
|
302
|
+
}
|
303
|
+
|
304
|
+
let setState;
|
305
|
+
function ParentComponent() {
|
306
|
+
const [, _setState] = React.useState();
|
307
|
+
setState = _setState;
|
308
|
+
return (
|
309
|
+
<>
|
310
|
+
<TestComponent />
|
311
|
+
<Suspender promise={promise} isResolvedRef={isResolvedRef} />
|
312
|
+
</>
|
313
|
+
);
|
314
|
+
}
|
315
|
+
|
316
|
+
const { promise, isResolvedRef, resolve } = promiseAndResolver();
|
317
|
+
// Do not suspend initially
|
318
|
+
isResolvedRef.current = true;
|
319
|
+
await awaitableCreate(
|
320
|
+
<React.Suspense fallback="fallback">
|
321
|
+
<ParentComponent />
|
322
|
+
</React.Suspense>,
|
323
|
+
true
|
324
|
+
);
|
325
|
+
|
326
|
+
expect(render).toHaveBeenCalledTimes(1);
|
327
|
+
expect(componentCommits).toHaveBeenCalledTimes(1);
|
328
|
+
|
329
|
+
// We need to also re-render the suspending component, in this case we do so
|
330
|
+
// by triggering a state change on the parent
|
331
|
+
isResolvedRef.current = false;
|
332
|
+
setState({});
|
333
|
+
|
334
|
+
const cleanup1 = vi.fn();
|
335
|
+
value.setState([1, cleanup1]);
|
336
|
+
const cleanup2 = vi.fn();
|
337
|
+
value.setState([2, cleanup2]);
|
338
|
+
|
339
|
+
await shortPromise();
|
340
|
+
|
341
|
+
// Assert that the state changes were batched due to concurrent mode
|
342
|
+
// by noting that only one render occurred.
|
343
|
+
expect(render).toHaveBeenCalledTimes(2);
|
344
|
+
// Also assert another commit hasn't occurred
|
345
|
+
expect(componentCommits).toHaveBeenCalledTimes(1);
|
346
|
+
expect(cleanup1).not.toHaveBeenCalled();
|
347
|
+
expect(cleanup2).not.toHaveBeenCalled();
|
348
|
+
|
349
|
+
// Now, unsuspend
|
350
|
+
isResolvedRef.current = true;
|
351
|
+
resolve();
|
352
|
+
await shortPromise();
|
353
|
+
|
354
|
+
expect(cleanup1).toHaveBeenCalledTimes(1);
|
355
|
+
expect(render).toHaveBeenCalledTimes(3);
|
356
|
+
expect(componentCommits).toHaveBeenCalledTimes(2);
|
357
|
+
}
|
358
|
+
);
|
359
|
+
|
360
|
+
test("it should properly clean up all items passed to setState during suspense on unmount", async () => {
|
361
|
+
let value;
|
362
|
+
const componentCommits = vi.fn();
|
363
|
+
const render = vi.fn();
|
364
|
+
function TestComponent() {
|
365
|
+
render();
|
366
|
+
value = useUpdatableDisposableState<number>();
|
367
|
+
React.useEffect(() => {
|
368
|
+
componentCommits();
|
369
|
+
});
|
370
|
+
return null;
|
371
|
+
}
|
372
|
+
|
373
|
+
let setState;
|
374
|
+
function ParentComponent({ shouldMountRef }) {
|
375
|
+
const [, _setState] = React.useState();
|
376
|
+
setState = _setState;
|
377
|
+
return shouldMountRef.current ? (
|
378
|
+
<>
|
379
|
+
<TestComponent />
|
380
|
+
<Suspender promise={promise} isResolvedRef={isResolvedRef} />
|
381
|
+
</>
|
382
|
+
) : null;
|
383
|
+
}
|
384
|
+
|
385
|
+
const { promise, isResolvedRef } = promiseAndResolver();
|
386
|
+
// Do not suspend initially
|
387
|
+
isResolvedRef.current = true;
|
388
|
+
const shouldMountRef = { current: true };
|
389
|
+
|
390
|
+
await awaitableCreate(
|
391
|
+
<React.Suspense fallback="fallback">
|
392
|
+
<ParentComponent shouldMountRef={shouldMountRef} />
|
393
|
+
</React.Suspense>,
|
394
|
+
true
|
395
|
+
);
|
396
|
+
|
397
|
+
expect(render).toHaveBeenCalledTimes(1);
|
398
|
+
expect(componentCommits).toHaveBeenCalledTimes(1);
|
399
|
+
|
400
|
+
// We need to also re-render the suspending component, in this case we do so
|
401
|
+
// by triggering a state change on the parent
|
402
|
+
isResolvedRef.current = false;
|
403
|
+
setState({});
|
404
|
+
|
405
|
+
// For thoroughness, we might want to test awaiting a shortPromise() here, so
|
406
|
+
// as not to batch these state changes.
|
407
|
+
|
408
|
+
const cleanup1 = vi.fn();
|
409
|
+
value.setState([1, cleanup1]);
|
410
|
+
const cleanup2 = vi.fn();
|
411
|
+
value.setState([2, cleanup2]);
|
412
|
+
|
413
|
+
await shortPromise();
|
414
|
+
|
415
|
+
// Assert that the state changes were batched due to concurrent mode
|
416
|
+
// by noting that only one render occurred.
|
417
|
+
expect(render).toHaveBeenCalledTimes(2);
|
418
|
+
// Also assert another commit hasn't occurred
|
419
|
+
expect(componentCommits).toHaveBeenCalledTimes(1);
|
420
|
+
expect(cleanup1).not.toHaveBeenCalled();
|
421
|
+
expect(cleanup2).not.toHaveBeenCalled();
|
422
|
+
|
423
|
+
// Now, unmount
|
424
|
+
shouldMountRef.current = false;
|
425
|
+
setState({});
|
426
|
+
|
427
|
+
await shortPromise();
|
428
|
+
|
429
|
+
expect(cleanup1).toHaveBeenCalled();
|
430
|
+
expect(cleanup2).toHaveBeenCalled();
|
431
|
+
});
|
432
|
+
|
433
|
+
test("it should clean up the item currently in state on unmount", async () => {
|
434
|
+
let value;
|
435
|
+
const componentCommits = vi.fn();
|
436
|
+
const render = vi.fn();
|
437
|
+
function TestComponent() {
|
438
|
+
render();
|
439
|
+
value = useUpdatableDisposableState<number>();
|
440
|
+
React.useEffect(() => {
|
441
|
+
componentCommits();
|
442
|
+
});
|
443
|
+
return null;
|
444
|
+
}
|
445
|
+
|
446
|
+
let setState;
|
447
|
+
function ParentComponent({ shouldMountRef }) {
|
448
|
+
const [, _setState] = React.useState();
|
449
|
+
setState = _setState;
|
450
|
+
return shouldMountRef.current ? <TestComponent /> : null;
|
451
|
+
}
|
452
|
+
|
453
|
+
const shouldMountRef = { current: true };
|
454
|
+
|
455
|
+
await awaitableCreate(
|
456
|
+
<ParentComponent shouldMountRef={shouldMountRef} />,
|
457
|
+
true
|
458
|
+
);
|
459
|
+
|
460
|
+
expect(render).toHaveBeenCalledTimes(1);
|
461
|
+
expect(componentCommits).toHaveBeenCalledTimes(1);
|
462
|
+
|
463
|
+
const cleanup1 = vi.fn();
|
464
|
+
value.setState([1, cleanup1]);
|
465
|
+
|
466
|
+
await shortPromise();
|
467
|
+
expect(componentCommits).toHaveBeenCalledTimes(2);
|
468
|
+
expect(value.state).toBe(1);
|
469
|
+
|
470
|
+
expect(render).toHaveBeenCalledTimes(2);
|
471
|
+
expect(cleanup1).not.toHaveBeenCalled();
|
472
|
+
|
473
|
+
// Now, unmount
|
474
|
+
shouldMountRef.current = false;
|
475
|
+
setState({});
|
476
|
+
|
477
|
+
await shortPromise();
|
478
|
+
|
479
|
+
expect(cleanup1).toHaveBeenCalled();
|
480
|
+
expect(render).toHaveBeenCalledTimes(2);
|
481
|
+
});
|
482
|
+
});
|