@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,364 @@
1
+ import {
2
+ CleanupFn,
3
+ Factory,
4
+ ItemCleanupPair,
5
+ } from "@isograph/disposable-types";
6
+
7
+ const DEFAULT_TEMPORARY_RETAIN_TIME = 5000;
8
+
9
+ export type NotInParentCacheAndDisposed = {
10
+ kind: "NotInParentCacheAndDisposed";
11
+ };
12
+ export type NotInParentCacheAndNotDisposed<T> = {
13
+ kind: "NotInParentCacheAndNotDisposed";
14
+ value: T;
15
+ disposeValue: () => void;
16
+
17
+ // Invariant: >0
18
+ permanentRetainCount: number;
19
+ };
20
+ export type InParentCacheAndNotDisposed<T> = {
21
+ kind: "InParentCacheAndNotDisposed";
22
+ value: T;
23
+ disposeValue: () => void;
24
+ removeFromParentCache: () => void;
25
+
26
+ // Invariant: >0
27
+ temporaryRetainCount: number;
28
+
29
+ // Invariant: >= 0
30
+ permanentRetainCount: number;
31
+ };
32
+
33
+ export type CacheItemState<T> =
34
+ | InParentCacheAndNotDisposed<T>
35
+ | NotInParentCacheAndNotDisposed<T>
36
+ | NotInParentCacheAndDisposed;
37
+
38
+ export type CacheItemOptions = {
39
+ temporaryRetainTime: number;
40
+ };
41
+
42
+ // TODO don't export this class, only export type (interface) instead
43
+ // TODO convert cacheitem impl to a getter and setter and free functions
44
+
45
+ /**
46
+ * - TRC = Temporary Retain Count
47
+ * - PRC = Permanent Retain Count
48
+ *
49
+ * Rules:
50
+ * - In parent cache <=> TRC > 0
51
+ * - Removed from parent cache <=> TRC === 0
52
+ * - In parent cache => not disposed
53
+ * - Disposed => removed from parent cache + PRC === 0
54
+ *
55
+ * A CacheItem<T> can be in three states:
56
+ * - Removed from the parent cache, item disposed, TRC === 0, PRC === 0
57
+ * - Removed from the parent cache, item not disposed, PRC > 0, TRC === 0
58
+ * - In parent cache, item not disposed, TRC > 0, PRC >= 0
59
+ *
60
+ * Valid transitions are:
61
+ * - InParentCacheAndNotDisposed => NotInParentCacheAndNotDisposed
62
+ * - InParentCacheAndNotDisposed => NotInParentCacheAndDisposed
63
+ * - NotInParentCacheAndNotDisposed => NotInParentCacheAndDisposed
64
+ */
65
+ export class CacheItem<T> {
66
+ private __state: CacheItemState<T>;
67
+ private __options: CacheItemOptions | null;
68
+
69
+ // Private. Do not call this constructor directly. Use
70
+ // createTemporarilyRetainedCacheItem instead. This is because this
71
+ // constructor creates a CacheItem in an invalid state. It must be
72
+ // temporarily retained to enter a valid state, and JavaScript doesn't
73
+ // let you return a tuple from a constructor.
74
+ constructor(
75
+ factory: Factory<T>,
76
+ removeFromParentCache: CleanupFn,
77
+ options: CacheItemOptions | void
78
+ ) {
79
+ this.__options = options ?? null;
80
+ const [value, disposeValue] = factory();
81
+ this.__state = {
82
+ kind: "InParentCacheAndNotDisposed",
83
+ value,
84
+ disposeValue,
85
+ removeFromParentCache,
86
+ // NOTE: we are creating the CacheItem in an invalid state. This is okay, because
87
+ // we are immediately calling .temporaryRetain.
88
+ temporaryRetainCount: 0,
89
+ permanentRetainCount: 0,
90
+ };
91
+ }
92
+
93
+ getValue(): T {
94
+ switch (this.__state.kind) {
95
+ case "InParentCacheAndNotDisposed": {
96
+ return this.__state.value;
97
+ }
98
+ case "NotInParentCacheAndNotDisposed": {
99
+ return this.__state.value;
100
+ }
101
+ default: {
102
+ throw new Error(
103
+ "Attempted to access disposed value from CacheItem. " +
104
+ "This indicates a bug in react-disposable-state."
105
+ );
106
+ }
107
+ }
108
+ }
109
+
110
+ permanentRetainIfNotDisposed(
111
+ disposeOfTemporaryRetain: CleanupFn
112
+ ): ItemCleanupPair<T> | null {
113
+ switch (this.__state.kind) {
114
+ case "InParentCacheAndNotDisposed": {
115
+ let cleared = false;
116
+ this.__state.permanentRetainCount++;
117
+ disposeOfTemporaryRetain();
118
+ return [
119
+ this.__state.value,
120
+ () => {
121
+ if (cleared) {
122
+ throw new Error(
123
+ "A permanent retain should only be cleared once. " +
124
+ "This indicates a bug in react-disposable-state."
125
+ );
126
+ }
127
+ cleared = true;
128
+ switch (this.__state.kind) {
129
+ case "InParentCacheAndNotDisposed": {
130
+ this.__state.permanentRetainCount--;
131
+ this.__maybeExitInParentCacheAndNotDisposedState(this.__state);
132
+ return;
133
+ }
134
+ case "NotInParentCacheAndNotDisposed": {
135
+ this.__state.permanentRetainCount--;
136
+ this.__maybeExitNotInParentCacheAndNotDisposedState(
137
+ this.__state
138
+ );
139
+ return;
140
+ }
141
+ default: {
142
+ throw new Error(
143
+ "CacheItem was in a disposed state, but there existed a permanent retain. " +
144
+ "This indicates a bug in react-disposable-state."
145
+ );
146
+ }
147
+ }
148
+ },
149
+ ];
150
+ }
151
+ case "NotInParentCacheAndNotDisposed": {
152
+ let cleared = false;
153
+ this.__state.permanentRetainCount++;
154
+ disposeOfTemporaryRetain();
155
+ return [
156
+ this.__state.value,
157
+ () => {
158
+ if (cleared) {
159
+ throw new Error(
160
+ "A permanent retain should only be cleared once. " +
161
+ "This indicates a bug in react-disposable-state."
162
+ );
163
+ }
164
+ cleared = true;
165
+ switch (this.__state.kind) {
166
+ case "NotInParentCacheAndNotDisposed": {
167
+ this.__state.permanentRetainCount--;
168
+ this.__maybeExitNotInParentCacheAndNotDisposedState(
169
+ this.__state
170
+ );
171
+ return;
172
+ }
173
+ default: {
174
+ throw new Error(
175
+ "CacheItem was in an unexpected state. " +
176
+ "This indicates a bug in react-disposable-state."
177
+ );
178
+ }
179
+ }
180
+ },
181
+ ];
182
+ }
183
+ default: {
184
+ // The CacheItem is disposed, so disposeOfTemporaryRetain is a no-op
185
+ return null;
186
+ }
187
+ }
188
+ }
189
+
190
+ temporaryRetain(): CleanupFn {
191
+ type TemporaryRetainStatus =
192
+ | "Uncleared"
193
+ | "ClearedByCallback"
194
+ | "ClearedByTimeout";
195
+
196
+ switch (this.__state.kind) {
197
+ case "InParentCacheAndNotDisposed": {
198
+ let status: TemporaryRetainStatus = "Uncleared";
199
+ this.__state.temporaryRetainCount++;
200
+ const clearTemporaryRetainByCallack: CleanupFn = () => {
201
+ if (status === "ClearedByCallback") {
202
+ throw new Error(
203
+ "A temporary retain should only be cleared once. " +
204
+ "This indicates a bug in react-disposable-state."
205
+ );
206
+ } else if (status === "Uncleared") {
207
+ switch (this.__state.kind) {
208
+ case "InParentCacheAndNotDisposed": {
209
+ this.__state.temporaryRetainCount--;
210
+ this.__maybeExitInParentCacheAndNotDisposedState(this.__state);
211
+ clearTimeout(timeoutId);
212
+ return;
213
+ }
214
+ default: {
215
+ throw new Error(
216
+ "A temporary retain was cleared, for which the CacheItem is in an invalid state. " +
217
+ "This indicates a bug in react-disposable-state."
218
+ );
219
+ }
220
+ }
221
+ }
222
+ };
223
+
224
+ const clearTemporaryRetainByTimeout = () => {
225
+ status = "ClearedByTimeout";
226
+ switch (this.__state.kind) {
227
+ case "InParentCacheAndNotDisposed": {
228
+ this.__state.temporaryRetainCount--;
229
+ this.__maybeExitInParentCacheAndNotDisposedState(this.__state);
230
+ return;
231
+ }
232
+ default: {
233
+ throw new Error(
234
+ "A temporary retain was cleared, for which the CacheItem is in an invalid state. " +
235
+ "This indicates a bug in react-disposable-state."
236
+ );
237
+ }
238
+ }
239
+ };
240
+
241
+ const timeoutId = setTimeout(
242
+ clearTemporaryRetainByTimeout,
243
+ this.__options?.temporaryRetainTime ?? DEFAULT_TEMPORARY_RETAIN_TIME
244
+ );
245
+ return clearTemporaryRetainByCallack;
246
+ }
247
+ default: {
248
+ throw new Error(
249
+ "temporaryRetain was called, for which the CacheItem is in an invalid state. " +
250
+ "This indicates a bug in react-disposable-state."
251
+ );
252
+ }
253
+ }
254
+ }
255
+
256
+ permanentRetain(): CleanupFn {
257
+ switch (this.__state.kind) {
258
+ case "InParentCacheAndNotDisposed": {
259
+ let cleared = false;
260
+ this.__state.permanentRetainCount++;
261
+ return () => {
262
+ if (cleared) {
263
+ throw new Error(
264
+ "A permanent retain should only be cleared once. " +
265
+ "This indicates a bug in react-disposable-state."
266
+ );
267
+ }
268
+ cleared = true;
269
+ switch (this.__state.kind) {
270
+ case "InParentCacheAndNotDisposed": {
271
+ this.__state.permanentRetainCount--;
272
+ this.__maybeExitInParentCacheAndNotDisposedState(this.__state);
273
+ return;
274
+ }
275
+ case "NotInParentCacheAndNotDisposed": {
276
+ this.__state.permanentRetainCount--;
277
+ this.__maybeExitNotInParentCacheAndNotDisposedState(this.__state);
278
+ return;
279
+ }
280
+ default: {
281
+ throw new Error(
282
+ "CacheItem was in a disposed state, but there existed a permanent retain. " +
283
+ "This indicates a bug in react-disposable-state."
284
+ );
285
+ }
286
+ }
287
+ };
288
+ }
289
+ case "NotInParentCacheAndNotDisposed": {
290
+ let cleared = false;
291
+ this.__state.permanentRetainCount++;
292
+ return () => {
293
+ if (cleared) {
294
+ throw new Error(
295
+ "A permanent retain should only be cleared once. " +
296
+ "This indicates a bug in react-disposable-state."
297
+ );
298
+ }
299
+ cleared = true;
300
+ switch (this.__state.kind) {
301
+ case "NotInParentCacheAndNotDisposed": {
302
+ this.__state.permanentRetainCount--;
303
+ this.__maybeExitNotInParentCacheAndNotDisposedState(this.__state);
304
+ return;
305
+ }
306
+ default: {
307
+ throw new Error(
308
+ "CacheItem was in an unexpected state. " +
309
+ "This indicates a bug in react-disposable-state."
310
+ );
311
+ }
312
+ }
313
+ };
314
+ }
315
+ default: {
316
+ throw new Error(
317
+ "permanentRetain was called, but the CacheItem is in an invalid state. " +
318
+ "This indicates a bug in react-disposable-state."
319
+ );
320
+ }
321
+ }
322
+ }
323
+
324
+ private __maybeExitInParentCacheAndNotDisposedState(
325
+ state: InParentCacheAndNotDisposed<T>
326
+ ) {
327
+ if (state.temporaryRetainCount === 0 && state.permanentRetainCount === 0) {
328
+ state.removeFromParentCache();
329
+ state.disposeValue();
330
+ this.__state = {
331
+ kind: "NotInParentCacheAndDisposed",
332
+ };
333
+ } else if (state.temporaryRetainCount === 0) {
334
+ state.removeFromParentCache();
335
+ this.__state = {
336
+ kind: "NotInParentCacheAndNotDisposed",
337
+ value: state.value,
338
+ disposeValue: state.disposeValue,
339
+ permanentRetainCount: state.permanentRetainCount,
340
+ };
341
+ }
342
+ }
343
+
344
+ private __maybeExitNotInParentCacheAndNotDisposedState(
345
+ state: NotInParentCacheAndNotDisposed<T>
346
+ ) {
347
+ if (state.permanentRetainCount === 0) {
348
+ state.disposeValue();
349
+ this.__state = {
350
+ kind: "NotInParentCacheAndDisposed",
351
+ };
352
+ }
353
+ }
354
+ }
355
+
356
+ export function createTemporarilyRetainedCacheItem<T>(
357
+ factory: Factory<T>,
358
+ removeFromParentCache: CleanupFn,
359
+ options: CacheItemOptions | void
360
+ ): [CacheItem<T>, CleanupFn] {
361
+ const cacheItem = new CacheItem(factory, removeFromParentCache, options);
362
+ const disposeTemporaryRetain = cacheItem.temporaryRetain();
363
+ return [cacheItem, disposeTemporaryRetain];
364
+ }
@@ -0,0 +1,70 @@
1
+ import { describe, assert, test, vi, expect } from "vitest";
2
+ import { ParentCache } from "./ParentCache";
3
+ import { ItemCleanupPair } from "@isograph/disposable-types";
4
+ import { CacheItem } from "./CacheItem";
5
+
6
+ function getValue<T>(cache: ParentCache<T>): CacheItem<T> | null {
7
+ return (cache as any).__item as CacheItem<T> | null;
8
+ }
9
+
10
+ describe("ParentCache", () => {
11
+ test("Populated, emptied, repopulated cache is not re-emptied by original temporary retain being disposed", () => {
12
+ const factory = vi.fn(() => {
13
+ const pair: ItemCleanupPair<number> = [1, vi.fn()];
14
+ return pair;
15
+ });
16
+ const parentCache = new ParentCache<number>(factory);
17
+
18
+ const [_cacheItem, value, clearTemporaryRetain] =
19
+ parentCache.getOrPopulateAndTemporaryRetain();
20
+
21
+ expect(factory.mock.calls.length).toBe(1);
22
+ assert(value === 1);
23
+ assert(getValue(parentCache) != null);
24
+
25
+ parentCache.empty();
26
+ assert(getValue(parentCache) === null);
27
+
28
+ parentCache.getOrPopulateAndTemporaryRetain();
29
+ expect(factory.mock.calls.length).toBe(2);
30
+
31
+ assert(getValue(parentCache) != null);
32
+
33
+ clearTemporaryRetain();
34
+
35
+ assert(getValue(parentCache) != null);
36
+ });
37
+
38
+ test("Clearing the only temporary retain removes the item from the parent cache", () => {
39
+ const factory = vi.fn(() => {
40
+ const pair: ItemCleanupPair<number> = [1, vi.fn()];
41
+ return pair;
42
+ });
43
+ const parentCache = new ParentCache<number>(factory);
44
+
45
+ const [_cacheItem, _value, clearTemporaryRetain] =
46
+ parentCache.getOrPopulateAndTemporaryRetain();
47
+ clearTemporaryRetain();
48
+
49
+ assert(getValue(parentCache) === null);
50
+ });
51
+
52
+ test("Clearing one of two temporary retains does not remove the item from the parent cache", () => {
53
+ const factory = vi.fn(() => {
54
+ const pair: ItemCleanupPair<number> = [1, vi.fn()];
55
+ return pair;
56
+ });
57
+ const parentCache = new ParentCache<number>(factory);
58
+
59
+ const [_cacheItem, _value, clearTemporaryRetain] =
60
+ parentCache.getOrPopulateAndTemporaryRetain();
61
+ const [_cacheItem2, _value2, clearTemporaryRetain2] =
62
+ parentCache.getOrPopulateAndTemporaryRetain();
63
+
64
+ clearTemporaryRetain();
65
+ assert(getValue(parentCache) != null);
66
+
67
+ clearTemporaryRetain2();
68
+ assert(getValue(parentCache) === null);
69
+ });
70
+ });
@@ -0,0 +1,100 @@
1
+ import { CacheItem, createTemporarilyRetainedCacheItem } from "./CacheItem";
2
+ import {
3
+ CleanupFn,
4
+ Factory,
5
+ ItemCleanupPair,
6
+ } from "@isograph/disposable-types";
7
+
8
+ // TODO convert cache impl to a getter and setter and free functions
9
+ // TODO accept options that get passed to CacheItem
10
+
11
+ /**
12
+ * ParentCache
13
+ * - A ParentCache can be in two states: populated and unpopulated.
14
+ * - A ParentCache holds a CacheItem, which can choose to remove itself from
15
+ * the parent ParentCache.
16
+ * - If the ParentCache is populated, the CacheItem (i.e. this.__value) must be
17
+ * in the InParentCacheAndNotDisposed state, i.e. not disposed, so after we
18
+ * null-check this.__value, this.__value.getValue(), this.__value.temporaryRetain()
19
+ * and this.__value.permanentRetain() are safe to be called.
20
+ *
21
+ * - Though we do not do so, it is always safe to call parentCache.delete().
22
+ *
23
+ * Invariant:
24
+ * - A parent cache at a given "location" (conceptually, an ID) should always
25
+ * be called
26
+ */
27
+ export class ParentCache<T> {
28
+ private __cacheItem: CacheItem<T> | null = null;
29
+ private readonly __factory: Factory<T>;
30
+
31
+ constructor(factory: Factory<T>) {
32
+ this.__factory = factory;
33
+ }
34
+
35
+ /**
36
+ * This is called from useCachedPrecommitValue, when the parent cache is populated
37
+ * and a previous temporary retain has been disposed. This can occur in scenarios like:
38
+ * - temporary retain A is created by component B rendering
39
+ * - temporary retain A expires, emptying the parent cache
40
+ * - another component renders, sharing the same parent cache, filling
41
+ * by calling getOrPopulateAndTemporaryRetain
42
+ * - component B commits. We see that temporary retain A has been disposed,
43
+ * and re-check the parent cache by calling this method.
44
+ */
45
+ getAndPermanentRetainIfPresent(): ItemCleanupPair<T> | null {
46
+ return this.__cacheItem != null
47
+ ? [this.__cacheItem.getValue(), this.__cacheItem.permanentRetain()]
48
+ : null;
49
+ }
50
+
51
+ getOrPopulateAndTemporaryRetain(): [CacheItem<T>, T, CleanupFn] {
52
+ return this.__cacheItem === null
53
+ ? this.__populateAndTemporaryRetain()
54
+ : temporaryRetain(this.__cacheItem);
55
+ }
56
+
57
+ private __populateAndTemporaryRetain(): [CacheItem<T>, T, CleanupFn] {
58
+ const pair: ItemCleanupPair<CacheItem<T>> =
59
+ createTemporarilyRetainedCacheItem(this.__factory, () => {
60
+ // We are doing this check because we don't want to remove the cache item
61
+ // if it is not the one that was created when the temporary retain was created.
62
+ //
63
+ // Consider the following scenario:
64
+ // - we populate the cache with CacheItem A,
65
+ // - then manually delete CacheItem A (e.g. to force a refetch)
66
+ // - then, we re-populate the parent cache with CacheItem B
67
+ // - then, the temporary retain of CacheItem A is disposed or expires.
68
+ //
69
+ // At this point, we don't want to delete CacheItem B from the cache.
70
+ //
71
+ // TODO consider what happens if items are === comparable to each other,
72
+ // e.g. the item is a number!
73
+ if (this.__cacheItem === pair[0]) {
74
+ this.empty();
75
+ }
76
+ });
77
+
78
+ // We deconstruct this here instead of at the definition site because otherwise,
79
+ // typescript thinks that cacheItem is any, because it's referenced in the closure.
80
+ const [cacheItem, disposeTemporaryRetain] = pair;
81
+ this.__cacheItem = cacheItem;
82
+ return [cacheItem, this.__cacheItem.getValue(), disposeTemporaryRetain];
83
+ }
84
+
85
+ empty() {
86
+ this.__cacheItem = null;
87
+ }
88
+
89
+ get factory(): Factory<T> {
90
+ return this.__factory;
91
+ }
92
+
93
+ isEmpty(): boolean {
94
+ return this.__cacheItem === null;
95
+ }
96
+ }
97
+
98
+ function temporaryRetain<T>(value: CacheItem<T>): [CacheItem<T>, T, CleanupFn] {
99
+ return [value, value.getValue(), value.temporaryRetain()];
100
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export * from "@isograph/disposable-types";
2
+
3
+ export * from "./CacheItem";
4
+ export * from "./ParentCache";
5
+ export * from "./useCachedPrecommitValue";
6
+ export * from "./useDisposableState";
7
+ export * from "./useHasCommittedRef";
8
+ export * from "./useLazyDisposableState";
9
+ export * from "./useUpdatableDisposableState";