@isograph/react-disposable-state 0.0.0-main-4ef7c123

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.
@@ -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
+ });