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