@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,587 @@
1
+ import { describe, test, vi, expect, assert } from "vitest";
2
+ import { ParentCache } from "./ParentCache";
3
+ import { ItemCleanupPair } from "@isograph/disposable-types";
4
+ import { useCachedPrecommitValue } from "./useCachedPrecommitValue";
5
+ import React from "react";
6
+ import { create } from "react-test-renderer";
7
+ import { CacheItem, CacheItemState } from "./CacheItem";
8
+
9
+ function getItem<T>(cache: ParentCache<T>): CacheItem<T> | null {
10
+ return (cache as any).__item;
11
+ }
12
+
13
+ function getState<T>(cacheItem: CacheItem<T>): CacheItemState<T> {
14
+ return (cacheItem as any).__state;
15
+ }
16
+
17
+ function Suspender({ promise, isResolvedRef }) {
18
+ if (!isResolvedRef.current) {
19
+ throw promise;
20
+ }
21
+ return null;
22
+ }
23
+
24
+ function shortPromise() {
25
+ let resolve;
26
+ const promise = new Promise((_resolve) => {
27
+ resolve = _resolve;
28
+ });
29
+
30
+ setTimeout(resolve, 1);
31
+ return promise;
32
+ }
33
+
34
+ function promiseAndResolver() {
35
+ let resolve;
36
+ const isResolvedRef = {
37
+ current: false,
38
+ };
39
+ const promise = new Promise((r) => {
40
+ resolve = r;
41
+ });
42
+ return {
43
+ promise,
44
+ resolve: () => {
45
+ isResolvedRef.current = true;
46
+ resolve();
47
+ },
48
+ isResolvedRef,
49
+ };
50
+ }
51
+
52
+ // The fact that sometimes we need to render in concurrent mode and sometimes
53
+ // not is a bit worrisome.
54
+ async function awaitableCreate(Component, isConcurrent) {
55
+ const element = create(
56
+ Component,
57
+ isConcurrent ? { unstable_isConcurrent: true } : undefined
58
+ );
59
+ await shortPromise();
60
+ return element;
61
+ }
62
+
63
+ describe("useCachedPrecommitValue", () => {
64
+ test("on initial render, it should call getOrPopulateAndTemporaryRetain", async () => {
65
+ const disposeItem = vi.fn();
66
+ const factory = vi.fn(() => {
67
+ const pair: ItemCleanupPair<number> = [1, disposeItem];
68
+ return pair;
69
+ });
70
+ const cache = new ParentCache(factory);
71
+ const getOrPopulateAndTemporaryRetain = vi.spyOn(
72
+ cache,
73
+ "getOrPopulateAndTemporaryRetain"
74
+ );
75
+
76
+ const componentCommits = vi.fn();
77
+ const hookOnCommit = vi.fn();
78
+ const render = vi.fn();
79
+ function TestComponent() {
80
+ render();
81
+ React.useEffect(componentCommits);
82
+
83
+ const data = useCachedPrecommitValue(cache, hookOnCommit);
84
+
85
+ expect(render).toBeCalledTimes(1);
86
+ expect(componentCommits).not.toBeCalled();
87
+ expect(hookOnCommit).not.toBeCalled();
88
+ expect(data).toEqual({ state: 1 });
89
+ expect(factory).toBeCalledTimes(1);
90
+ expect(disposeItem).not.toBeCalled();
91
+ expect(getOrPopulateAndTemporaryRetain).toBeCalledTimes(1);
92
+
93
+ // TODO we should assert that permanentRetainIfNotDisposed was called
94
+ // on the cache item.
95
+
96
+ return <div />;
97
+ }
98
+
99
+ await awaitableCreate(<TestComponent />, false);
100
+
101
+ expect(componentCommits).toBeCalledTimes(1);
102
+ expect(hookOnCommit).toBeCalledTimes(1);
103
+ expect(render).toHaveBeenCalledTimes(1);
104
+ });
105
+
106
+ test("on commit, it should call the provided callback and empty the parent cache", async () => {
107
+ const disposeItem = vi.fn();
108
+ const factory = vi.fn(() => {
109
+ const pair: ItemCleanupPair<number> = [1, disposeItem];
110
+ return pair;
111
+ });
112
+ const cache = new ParentCache(factory);
113
+
114
+ const componentCommits = vi.fn();
115
+ const hookOnCommit = vi.fn();
116
+ const render = vi.fn();
117
+ function TestComponent() {
118
+ render();
119
+ expect(render).toHaveBeenCalledTimes(1);
120
+ const data = useCachedPrecommitValue(cache, hookOnCommit);
121
+
122
+ React.useEffect(() => {
123
+ componentCommits();
124
+ expect(componentCommits).toHaveBeenCalledTimes(1);
125
+ expect(hookOnCommit).toBeCalledTimes(1);
126
+ expect(hookOnCommit.mock.calls[0][0][0]).toBe(1);
127
+ expect(typeof hookOnCommit.mock.calls[0][0][1]).toBe("function");
128
+ expect(factory).toBeCalledTimes(1);
129
+ expect(disposeItem).not.toBeCalled();
130
+ expect(cache.isEmpty()).toBe(true);
131
+ }, []);
132
+
133
+ expect(factory).toBeCalledTimes(1);
134
+ expect(disposeItem).not.toBeCalled();
135
+ return <div />;
136
+ }
137
+
138
+ await awaitableCreate(<TestComponent />, false);
139
+ expect(componentCommits).toBeCalledTimes(1);
140
+ expect(render).toHaveBeenCalledTimes(1);
141
+ });
142
+
143
+ test("after commit, on subsequent renders it should return null", async () => {
144
+ const disposeItem = vi.fn();
145
+ const factory = vi.fn(() => {
146
+ const pair: ItemCleanupPair<number> = [1, disposeItem];
147
+ return pair;
148
+ });
149
+ const cache = new ParentCache(factory);
150
+
151
+ const componentCommits = vi.fn();
152
+ const hookOnCommit = vi.fn();
153
+ let setState;
154
+ let initialRender = true;
155
+ function TestComponent() {
156
+ const [, _setState] = React.useState(null);
157
+ setState = _setState;
158
+ const value = useCachedPrecommitValue(cache, hookOnCommit);
159
+
160
+ if (initialRender && value !== null) {
161
+ initialRender = false;
162
+ expect(value).toEqual({ state: 1 });
163
+ } else {
164
+ expect(value).toEqual(null);
165
+ }
166
+
167
+ React.useEffect(() => {
168
+ componentCommits();
169
+ expect(componentCommits).toHaveBeenCalledTimes(1);
170
+ expect(hookOnCommit).toBeCalledTimes(1);
171
+ expect(factory).toBeCalledTimes(1);
172
+ expect(disposeItem).not.toBeCalled();
173
+ }, []);
174
+
175
+ return <div />;
176
+ }
177
+
178
+ await awaitableCreate(<TestComponent />, false);
179
+
180
+ expect(componentCommits).toHaveBeenCalledTimes(1);
181
+
182
+ // Trigger a re-render
183
+ setState({});
184
+ await shortPromise();
185
+ expect(initialRender).toBe(false);
186
+ });
187
+
188
+ test(
189
+ "on repeated pre-commit renders, if the temporary retain is not disposed, " +
190
+ "it should re-call getOrPopulateAndTemporaryRetain but not call factory again",
191
+ async () => {
192
+ const disposeItem = vi.fn();
193
+ const factory = vi.fn(() => {
194
+ const pair: ItemCleanupPair<number> = [1, disposeItem];
195
+ return pair;
196
+ });
197
+ const cache = new ParentCache(factory);
198
+ const getOrPopulateAndTemporaryRetain = vi.spyOn(
199
+ cache,
200
+ "getOrPopulateAndTemporaryRetain"
201
+ );
202
+
203
+ const componentCommits = vi.fn();
204
+ const hookOnCommit = vi.fn();
205
+ const render = vi.fn();
206
+ let renderCount = 0;
207
+ function TestComponent() {
208
+ render();
209
+ const value = useCachedPrecommitValue(cache, hookOnCommit);
210
+
211
+ expect(value).toEqual({ state: 1 });
212
+ expect(factory).toHaveBeenCalledTimes(1);
213
+
214
+ renderCount++;
215
+ expect(getOrPopulateAndTemporaryRetain).toHaveBeenCalledTimes(
216
+ renderCount
217
+ );
218
+
219
+ React.useEffect(() => {
220
+ componentCommits();
221
+ expect(componentCommits).toHaveBeenCalledTimes(1);
222
+ expect(hookOnCommit).toBeCalledTimes(1);
223
+ expect(factory).toBeCalledTimes(1);
224
+ expect(disposeItem).not.toBeCalled();
225
+ }, []);
226
+
227
+ return <div />;
228
+ }
229
+
230
+ const { promise, isResolvedRef, resolve } = promiseAndResolver();
231
+ await awaitableCreate(
232
+ <React.Suspense fallback={<div />}>
233
+ <TestComponent />
234
+ <Suspender promise={promise} isResolvedRef={isResolvedRef} />
235
+ </React.Suspense>,
236
+ true
237
+ );
238
+
239
+ expect(componentCommits).toHaveBeenCalledTimes(0);
240
+ expect(render).toHaveBeenCalledTimes(1);
241
+
242
+ resolve();
243
+ await shortPromise();
244
+
245
+ expect(componentCommits).toHaveBeenCalledTimes(1);
246
+ expect(render).toHaveBeenCalledTimes(2);
247
+ }
248
+ );
249
+
250
+ test(
251
+ "on repeated pre-commit renders, if the temporary retain is disposed, " +
252
+ "it should re-call getOrPopulateAndTemporaryRetain and factory",
253
+ async () => {
254
+ const disposeItem = vi.fn();
255
+ let factoryValue = 0;
256
+ const factory = vi.fn(() => {
257
+ factoryValue++;
258
+ const pair: ItemCleanupPair<number> = [factoryValue, disposeItem];
259
+ return pair;
260
+ });
261
+ const cache = new ParentCache(factory);
262
+
263
+ const getOrPopulateAndTemporaryRetain = vi.spyOn(
264
+ cache,
265
+ "getOrPopulateAndTemporaryRetain"
266
+ );
267
+
268
+ const componentCommits = vi.fn();
269
+ const hookOnCommit = vi.fn();
270
+ const render = vi.fn();
271
+ function TestComponent() {
272
+ render();
273
+ const value = useCachedPrecommitValue(cache, hookOnCommit);
274
+
275
+ expect(value).toEqual({ state: factoryValue });
276
+
277
+ React.useEffect(() => {
278
+ componentCommits();
279
+ expect(cache.isEmpty()).toBe(true);
280
+ expect(componentCommits).toHaveBeenCalledTimes(1);
281
+ expect(hookOnCommit).toBeCalledTimes(1);
282
+ expect(hookOnCommit.mock.calls[0][0][0]).toBe(2);
283
+ expect(factory).toBeCalledTimes(2);
284
+ expect(disposeItem).toBeCalledTimes(1);
285
+ }, []);
286
+
287
+ if (render.mock.calls.length === 1) {
288
+ expect(factory).toHaveBeenCalledTimes(1);
289
+ // First render, dispose the temporary retain
290
+ expect(disposeItem).toBeCalledTimes(0);
291
+ getOrPopulateAndTemporaryRetain.mock.results[0].value[2]();
292
+ expect(disposeItem).toBeCalledTimes(1);
293
+ expect(getOrPopulateAndTemporaryRetain).toHaveBeenCalledTimes(1);
294
+ } else {
295
+ expect(factory).toHaveBeenCalledTimes(2);
296
+ expect(getOrPopulateAndTemporaryRetain).toHaveBeenCalledTimes(2);
297
+ }
298
+
299
+ return <div />;
300
+ }
301
+
302
+ const { promise, isResolvedRef, resolve } = promiseAndResolver();
303
+ await awaitableCreate(
304
+ <React.Suspense fallback={<div />}>
305
+ <TestComponent />
306
+ <Suspender promise={promise} isResolvedRef={isResolvedRef} />
307
+ </React.Suspense>,
308
+ true
309
+ );
310
+
311
+ expect(componentCommits).toHaveBeenCalledTimes(0);
312
+ expect(render).toHaveBeenCalledTimes(1);
313
+
314
+ resolve();
315
+ await shortPromise();
316
+
317
+ expect(componentCommits).toHaveBeenCalledTimes(1);
318
+ expect(render).toHaveBeenCalledTimes(2);
319
+ }
320
+ );
321
+
322
+ test(
323
+ "if the item has been disposed between the render and the commit, " +
324
+ "and the parent cache is empty, it will call factory again, re-render an " +
325
+ "additional time and called onCommit with the newly generated item",
326
+ async () => {
327
+ const disposeItem = vi.fn();
328
+ let factoryCount = 0;
329
+ const factory = vi.fn(() => {
330
+ factoryCount++;
331
+ const pair: ItemCleanupPair<number> = [factoryCount, disposeItem];
332
+ return pair;
333
+ });
334
+ const cache = new ParentCache(factory);
335
+ const getOrPopulateAndTemporaryRetain = vi.spyOn(
336
+ cache,
337
+ "getOrPopulateAndTemporaryRetain"
338
+ );
339
+ const getAndPermanentRetainIfPresent = vi.spyOn(
340
+ cache,
341
+ "getAndPermanentRetainIfPresent"
342
+ );
343
+
344
+ const componentCommits = vi.fn();
345
+ const hookOnCommit = vi.fn();
346
+ const render = vi.fn();
347
+ function TestComponent() {
348
+ render();
349
+
350
+ useCachedPrecommitValue(cache, hookOnCommit);
351
+
352
+ React.useEffect(() => {
353
+ componentCommits();
354
+ expect(getOrPopulateAndTemporaryRetain).toHaveBeenCalledTimes(1);
355
+ expect(getAndPermanentRetainIfPresent).toHaveBeenCalledTimes(1);
356
+ expect(getAndPermanentRetainIfPresent.mock.results[0].value).toBe(
357
+ null
358
+ );
359
+ expect(factory).toHaveBeenCalledTimes(2);
360
+ expect(cache.isEmpty()).toBe(true);
361
+ expect(hookOnCommit).toHaveBeenCalledTimes(1);
362
+ expect(hookOnCommit.mock.calls[0][0][0]).toBe(2);
363
+ }, []);
364
+
365
+ return <div />;
366
+ }
367
+
368
+ // wat is going on?
369
+ //
370
+ // We want to test a scenario where the item is disposed between the render and
371
+ // the commit.
372
+ //
373
+ // The subcomponents are rendered in order: TestComponent followed by CodeExecutor.
374
+ //
375
+ // - During TestComponent's render, it will populate the cache.
376
+ // - Then, CodeExecutor will render, and dispose the temporary retain,
377
+ // disposing the cache item. The parent cache will be empty as well.
378
+ // - Then, TestComponent commits.
379
+ let initialRender = true;
380
+ function CodeExecutor() {
381
+ if (initialRender) {
382
+ // This code executes after the initial render of TestComponent, but before
383
+ // it commits.
384
+ expect(disposeItem).not.toHaveBeenCalled();
385
+ getOrPopulateAndTemporaryRetain.mock.results[0].value[2]();
386
+ expect(disposeItem).toHaveBeenCalledTimes(1);
387
+ expect(cache.isEmpty()).toBe(true);
388
+
389
+ expect(render).toHaveBeenCalledTimes(1);
390
+ expect(hookOnCommit).toBeCalledTimes(0);
391
+ expect(componentCommits).toBeCalledTimes(0);
392
+ expect(factory).toHaveBeenCalledTimes(1);
393
+
394
+ initialRender = false;
395
+ }
396
+
397
+ return null;
398
+ }
399
+
400
+ const element = await awaitableCreate(
401
+ <>
402
+ <TestComponent />
403
+ <CodeExecutor />
404
+ </>,
405
+ false
406
+ );
407
+
408
+ // This code executes after the commit and re-render of TestComponent.
409
+ // The commit triggers a re-render, because the item was disposed.
410
+ expect(render).toHaveBeenCalledTimes(2);
411
+ expect(factory).toBeCalledTimes(2);
412
+ }
413
+ );
414
+
415
+ test(
416
+ "if, between the render and the commit, the item has been disposed, " +
417
+ "and the parent cache is not empty, it will not call factory again, will re-render " +
418
+ "an additional time and will call onCommit with the value in the parent cache",
419
+ async () => {
420
+ const disposeItem = vi.fn();
421
+ let factoryCount = 0;
422
+ const factory = vi.fn(() => {
423
+ factoryCount++;
424
+ const pair: ItemCleanupPair<number> = [factoryCount, disposeItem];
425
+ return pair;
426
+ });
427
+ const cache = new ParentCache(factory);
428
+ const getAndPermanentRetainIfPresent = vi.spyOn(
429
+ cache,
430
+ "getAndPermanentRetainIfPresent"
431
+ );
432
+
433
+ const componentCommits = vi.fn();
434
+ const hookOnCommit = vi.fn();
435
+ const render = vi.fn();
436
+ function TestComponent() {
437
+ render();
438
+ useCachedPrecommitValue(cache, hookOnCommit);
439
+
440
+ React.useEffect(() => {
441
+ componentCommits();
442
+ // Note that we called getOrPopulateAndTemporaryRetain during CodeExecutor, hence 2
443
+ expect(getOrPopulateAndTemporaryRetain).toHaveBeenCalledTimes(2);
444
+ expect(getAndPermanentRetainIfPresent).toHaveBeenCalledTimes(1);
445
+ expect(getAndPermanentRetainIfPresent.mock.results[0].value[0]).toBe(
446
+ 2
447
+ );
448
+ expect(factory).toHaveBeenCalledTimes(2);
449
+ expect(hookOnCommit).toHaveBeenCalledTimes(1);
450
+ expect(hookOnCommit.mock.calls[0][0][0]).toBe(2);
451
+ }, []);
452
+
453
+ return <div />;
454
+ }
455
+
456
+ const getOrPopulateAndTemporaryRetain = vi.spyOn(
457
+ cache,
458
+ "getOrPopulateAndTemporaryRetain"
459
+ );
460
+
461
+ // wat is going on?
462
+ //
463
+ // We want to test a scenario where the item is disposed between the render and
464
+ // the commit.
465
+ //
466
+ // The subcomponents are rendered in order: TestComponent followed by CodeExecutor.
467
+ //
468
+ // - During TestComponent's render, it will populate the cache.
469
+ // - Then, CodeExecutor will render, and dispose the temporary retain,
470
+ // disposing the cache item. It will then repopulate the parent cache.
471
+ // - Then, TestComponent commits.
472
+ let initialRender = true;
473
+ function CodeExecutor() {
474
+ if (initialRender) {
475
+ // This code executes after the initial render of TestComponent, but before
476
+ // it commits.
477
+ expect(disposeItem).not.toHaveBeenCalled();
478
+ getOrPopulateAndTemporaryRetain.mock.results[0].value[2]();
479
+ expect(disposeItem).toHaveBeenCalledTimes(1);
480
+ expect(cache.isEmpty()).toBe(true);
481
+
482
+ cache.getOrPopulateAndTemporaryRetain();
483
+ expect(cache.isEmpty()).toBe(false);
484
+ // The factory function was called when we called getOrPopulateAndTemporaryRetain
485
+ expect(factory).toHaveBeenCalledTimes(2);
486
+
487
+ expect(render).toHaveBeenCalledTimes(1);
488
+ expect(hookOnCommit).toBeCalledTimes(0);
489
+ expect(componentCommits).toBeCalledTimes(0);
490
+
491
+ initialRender = false;
492
+ }
493
+
494
+ return null;
495
+ }
496
+
497
+ const element = await awaitableCreate(
498
+ <React.Suspense fallback="fallback">
499
+ <TestComponent />
500
+ <CodeExecutor />
501
+ </React.Suspense>,
502
+ false
503
+ );
504
+
505
+ // This code executes after the commit and re-render of TestComponent.
506
+ // The commit triggers a re-render, because the item was disposed.
507
+ expect(render).toHaveBeenCalledTimes(2);
508
+ // Note that this is the same number of calls as inside of CodeExecutor,
509
+ // implying that the factory function was not called again.
510
+ expect(factory).toBeCalledTimes(2);
511
+ }
512
+ );
513
+
514
+ test(
515
+ "If the component unmounts before committing, " +
516
+ "the item will remain in the parent cache, " +
517
+ "temporarily retained",
518
+ async () => {
519
+ const disposeItem = vi.fn();
520
+ const factory = vi.fn(() => {
521
+ const pair: ItemCleanupPair<number> = [1, disposeItem];
522
+ return pair;
523
+ });
524
+ const cache = new ParentCache(factory);
525
+
526
+ const componentCommits = vi.fn();
527
+ const hookOnCommit = vi.fn();
528
+ const render = vi.fn();
529
+ function TestComponent() {
530
+ render();
531
+
532
+ useCachedPrecommitValue(cache, hookOnCommit);
533
+
534
+ React.useEffect(() => {
535
+ componentCommits();
536
+ }, []);
537
+
538
+ return <div />;
539
+ }
540
+
541
+ // wat is going on?
542
+ //
543
+ // We want to test a scenario where the component unmounts before committing.
544
+ //
545
+ // The subcomponents are rendered in order: TestComponent followed by CodeExecutor.
546
+ // So, during CodeExecutor, we trigger a state update that causes the ParentComponent
547
+ // to not render the children.
548
+ function CodeExecutor() {
549
+ setShowChildren(false);
550
+ return null;
551
+ }
552
+
553
+ let setShowChildren;
554
+ function ParentComponent({ children }) {
555
+ const [showChildren, _setShowChildren] = React.useState(true);
556
+ setShowChildren = _setShowChildren;
557
+
558
+ if (showChildren) {
559
+ return children;
560
+ } else {
561
+ return null;
562
+ }
563
+ }
564
+
565
+ const element = await awaitableCreate(
566
+ <ParentComponent>
567
+ <TestComponent />
568
+ <CodeExecutor />
569
+ </ParentComponent>,
570
+ // If we're not in concurrent mode, TestComponent will mount before
571
+ // unmounting. This perhaps is a bug in react-test-renderer. Regardless,
572
+ // we're not interested in that scenario.
573
+ true
574
+ );
575
+
576
+ // This code executes after the commit and re-render of TestComponent.
577
+ // The commit triggers a re-render, because the item was disposed.
578
+ expect(render).toHaveBeenCalledTimes(1);
579
+ expect(componentCommits).toHaveBeenCalledTimes(0);
580
+ const item = getItem(cache)!;
581
+ const state = getState(item);
582
+ assert(state.kind === "InParentCacheAndNotDisposed");
583
+ expect(state.permanentRetainCount).toBe(0);
584
+ expect(state.temporaryRetainCount).toBe(1);
585
+ }
586
+ );
587
+ });
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { ParentCache } from "./ParentCache";
5
+ import { useHasCommittedRef } from "./useHasCommittedRef";
6
+ import { ItemCleanupPair } from "@isograph/isograph-disposable-types/dist";
7
+
8
+ /**
9
+ * usePrecommitValue<T>
10
+ * - Takes a mutable parent cache, a factory function, and an onCommit callback.
11
+ * - Returns T before the initial commit, and null afterward.
12
+ * - Calls onCommit with the ItemCleanupPair during the first commit.
13
+ * - The T from the render phase is only temporarily retained. It may have been
14
+ * disposed by the time of the commit. If so, this hook checks the parent cache
15
+ * for another T or creates one, and passes this T to onCommit.
16
+ * - If the T returned during the last render is not the same as the one that
17
+ * is passed to onCommit, during the commit phase, will schedule another render.
18
+ *
19
+ * Invariant: the returned T has not been disposed during the tick of the render.
20
+ * The T passed to the onCommit callback has not been disposed when the onCommit
21
+ * callback is called.
22
+ *
23
+ * Passing a different parentCache:
24
+ * - Pre-commit, passing a different parentCache has the effect of "resetting" this
25
+ * hook's state to the new cache's state. For example, if you have a cache associated
26
+ * with a set of variables (e.g. {name: "Matthew"}), and pass in another cache
27
+ * (e.g. associated with {name: "James"}), which is empty, the hook will fill that
28
+ * new cache with the factory function.
29
+ *
30
+ * Passing a different factory:
31
+ * - Passing a different factory has no effect, except when factory is called,
32
+ * which is when the parent cache is being filled, or during the initial commit.
33
+ *
34
+ * Passing a different onCommit:
35
+ * - Passing a different onCommit has no effect, except for during the initial commit.
36
+ *
37
+ * Post-commit, all parameters are ignored and the hook returns null.
38
+ */
39
+ export function useCachedPrecommitValue<T>(
40
+ parentCache: ParentCache<T>,
41
+ onCommit: (pair: ItemCleanupPair<T>) => void
42
+ ): { state: T } | null {
43
+ // TODO: there should be two APIs. One in which we always re-render if the
44
+ // committed item was not returned during the last render, and one in which
45
+ // we do not. The latter is useful for cases where every disposable item
46
+ // behaves identically, but must be loaded.
47
+ //
48
+ // This hook is the former, i.e. re-renders if the committed item has changed.
49
+ const [, rerender] = useState<{} | null>(null);
50
+
51
+ useEffect(() => {
52
+ // On first commit, cacheItem may be disposed, because during the render phase,
53
+ // we only temporarily retained the item, and the temporary retain could have
54
+ // expired by the time of the commit.
55
+ //
56
+ // So, we can be in one of two states:
57
+ // - the item is not disposed. In that case, permanently retain and use that item.
58
+ // - the item is disposed. In that case, we can be in two states:
59
+ // - the parent cache is not empty (due to another component rendering, or
60
+ // another render of the same component.) In that case, permanently retain and
61
+ // use the item from the parent cache. (Note: any item present in the parent
62
+ // cache is not disposed.)
63
+ // - the parent cache is empty. In that case, call factory, getting a new item
64
+ // and a cleanup function.
65
+ //
66
+ // After the above, we have a non-disposed item and a cleanup function, which we
67
+ // can pass to onCommit.
68
+ const undisposedPair = cacheItem.permanentRetainIfNotDisposed(
69
+ disposeOfTemporaryRetain
70
+ );
71
+ if (undisposedPair !== null) {
72
+ onCommit(undisposedPair);
73
+ } else {
74
+ // The cache item we created during render has been disposed. Check if the parent
75
+ // cache is populated.
76
+ const existingCacheItemCleanupPair =
77
+ parentCache.getAndPermanentRetainIfPresent();
78
+ if (existingCacheItemCleanupPair !== null) {
79
+ onCommit(existingCacheItemCleanupPair);
80
+ } else {
81
+ // We did not find an item in the parent cache, create a new one.
82
+ onCommit(parentCache.factory());
83
+ }
84
+
85
+ // TODO: Consider whether we always want to rerender if the committed item
86
+ // was not returned during the last render, or whether some callers will
87
+ // prefer opting out of this behavior (e.g. if every disposable item behaves
88
+ // identically, but must be loaded.)
89
+ rerender({});
90
+ }
91
+ }, []);
92
+
93
+ const hasCommittedRef = useHasCommittedRef();
94
+ if (hasCommittedRef.current) {
95
+ return null;
96
+ }
97
+
98
+ // Safety: item is only safe to use (i.e. guaranteed not to have disposed)
99
+ // during this tick.
100
+ const [cacheItem, item, disposeOfTemporaryRetain] =
101
+ parentCache.getOrPopulateAndTemporaryRetain();
102
+
103
+ return { state: item };
104
+ }