@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 ADDED
@@ -0,0 +1,144 @@
1
+ # `@isograph/react-disposable-state`
2
+
3
+ > Primitives for managing disposable items in React state.
4
+
5
+ This library's purpose is to enable safely storing disposable items in React state. These hooks seek to guarantee that **each disposable item is eventually destroyed when it is no longer used** and that **no disposable item is returned from a library hook after it has been disposed**.
6
+
7
+ This library's goals **do not include** being ergonomic. A library built on top of `react-disposable-state` should expose easier-to-use hooks for common cases. Application developers can use the hooks exposed in `react-disposable-state` when more complicated cases arise.
8
+
9
+ This is unstable, alpha software. The API is likely to change.
10
+
11
+ ## Conceptual overview
12
+
13
+ ### What is a disposable item?
14
+
15
+ A disposable item is anything that is either explicitly created or must be explicitly cleaned up. That is, it is an item with a lifecycle.
16
+
17
+ A disposable item is safe to use as long as its destructor has not been called.
18
+
19
+ Code that manages disposable items (such as the `useDisposableState` hook) should also ensure that each destructor is eventually called, and should not provide access to the underlying item once the destructor has been called.
20
+
21
+ Disposable items are allowed to have side effects when created or when destroyed.
22
+
23
+ ### What is disposable state?
24
+
25
+ Disposable state is React state that contains a disposable item.
26
+
27
+ ### Examples of disposable items
28
+
29
+ - A subscription that periodically updates a displayed stock price. When the component where the stock price is displayed is unmounted, the subscription should be disposed, so as to avoid doing unproductive work.
30
+ - References to items that are stored externally. For example, consider a centralized store of profile photos. Photos are stored centrally to ensure consistency, meaning that every component displaying a given profile photo displays the same photo. In order to avoid the situation where no profile photo is ever garbage collected, individual components' "claims" to profile photos must be explicitly created and disposed.
31
+ - Items which you want to create **exactly once** when a functional React component is first rendered, such as a network request.
32
+ - Due to how React behaves, this state must be stored externally. Hence, this can be thought of as an example of the previous bullet point.
33
+ - Other frameworks make different choices. For example, a SolidJS component function is called exactly once. In these cases, the network request can easily be executed once, without being stored in external state.
34
+
35
+ ### How does disposable state differ from regular React state?
36
+
37
+ Disposable state stands in contrast to "regular" React state (e.g. if `{isVisible: boolean, currentlySelectedItem: Item}` was stored in state), where
38
+
39
+ - creating the JavaScript object is the only work done when creating the regular state, and therefore it is okay to create the state multiple times; and
40
+ - the only necessary cleanup work is [garbage collection](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_Management) of the underlying memory.
41
+
42
+ In particular, it is unobservable to the outside world if a piece of "regular" state is created multiple times.
43
+
44
+ ### Can React primitives handle disposable state?
45
+
46
+ The primitives provided by React are a poor fit for storing disposable items in state. An upcoming blog post will explore this in more detail.
47
+
48
+ ## This library
49
+
50
+ ### Guarantees
51
+
52
+ This library guarantees that:
53
+
54
+ - First, each disposable item that is created is eventually disposed.
55
+
56
+ > React and suspense prevent this library from ensuring that each disposable item is disposed immediately when the hook unmounts. Instead, the best we can do if a component suspends is often dispose after a configurable timeout.
57
+
58
+ - Second, no disposable item is returned from a library hook after it has been disposed.
59
+ - Third, if a component has committed, no disposable item returned from a library hook will be disposed while it is accessible from a mounted component.
60
+
61
+ > Colloquially, this means that disposable items returned from library hooks are safe to use in event callbacks.
62
+
63
+ > This guarantee is not upheld if an item returned from a library hook is re-stored in another state hook. So, don't do that!
64
+
65
+ ### Supported behaviors
66
+
67
+ The hooks in this library enable the following behavior:
68
+
69
+ - Lazily creating a disposable item. In this context, "lazily" means creating the item during the render phase of a component, before that component has committed. The item is then available in the functional component.
70
+
71
+ > Note that this is how [Relay](relay.dev) uses the term lazy. Libraries like [react-query](...) use the word lazy differently.
72
+
73
+ - Creating a disposable item outside of the render phase and after a hook's initial commit and storing the item in React state, making it available during the next render of that functional component.
74
+
75
+ ## API Overview
76
+
77
+ ### `useLazyDisposableState`
78
+
79
+ A hook that:
80
+
81
+ - Takes a mutable parent cache and a loader function, and returns a `{ state: T }`.
82
+ - The returned `T` is guaranteed to not be disposed during the tick of the render.
83
+ - If this hook commits, the returned `T` will not be disposed until the component unmounts.
84
+
85
+ ```typescript
86
+ const { state }: { state: T } = useLazyDisposableState<T>(
87
+ parentCache: ParentCache<T>,
88
+ factory: Loader<T>,
89
+ options: ?Options,
90
+ );
91
+ ```
92
+
93
+ ### `useUpdatableDisposableState`
94
+
95
+ A hook that:
96
+
97
+ - Returns a `{ state, setState }` object.
98
+ - `setState` throws if called before the initial commit.
99
+ - The `state` (a disposable item) is guaranteed to be undisposed during the tick in which it is returned from the hook. It will not be disposed until after it can no longer be returned from this hook, even in the presence of concurrent rendering.
100
+ - Every time the hook commits, a given disposable item is currently exposed in the state. All items previously passed to `setState` are guaranteed to never be returned from the hook, so they are disposed at that time.
101
+ - When the hook unmounts, all disposable items passed to `setState` are disposed.
102
+
103
+ ```typescript
104
+ const {
105
+ state,
106
+ setState,
107
+ }: {
108
+ state: T | null,
109
+ setState: (ItemCleanupPair<T>) => void,
110
+ } = useUpdatableDisposableState<T>(
111
+ options: ?Options,
112
+ );
113
+ ```
114
+
115
+ ### `useDisposableState`
116
+
117
+ > This could properly be called `useLazyUpdatableDisposableState`, but that's quite long!
118
+
119
+ A hook that combines the behavior of the previous two hooks:
120
+
121
+ ```typescript
122
+ const {
123
+ state,
124
+ setState,
125
+ }: {
126
+ state: T,
127
+ setState: (ItemCleanupPair<T>) => void,
128
+ } = useDisposableState<T>(
129
+ parentCache: ParentCache<T>,
130
+ factory: Loader<T>,
131
+ options: ?Options,
132
+ );
133
+ ```
134
+
135
+ ## Miscellaneous notes
136
+
137
+ ### Runtime overhead
138
+
139
+ - The hooks in this library are generic, and the type of the disposable items `T` is mostly as unconstrained as possible.
140
+ - The only constraint we impose on `T` is to disallow `T` from including the value `UNASSIGNED_STATE`. This is for primarily for ergonomic purposes. However, it does prevent some runtime overhead.
141
+ - This incurs some runtime overhead. In particular, it means we need to keep track of an index (and create a new short-lived object) to distinguish items that can overthise be `===` to each other. Consider, a component that uses `useDisposableState` or `useUpdatableDiposableState`. If we execute `setState([1, cleanup1])` followed by `setState([1, cleanup2])`, we would expect `cleanup1` to be called when the hook commits. This index is required to distinguish those two, otherwise indistinguishable items.
142
+ - This problem also occurs if disposable items are re-used, but their cleanup functions are distinct. That can occur if items are shared references held in a reference counted wrapper!
143
+ - However, client libraries may not require this flexbility! For example, if every disposable item is a newly-created object, then all disposable items are `!==` to each other!
144
+ - A future version of this library should expose alternative hooks that disallow `null` and do away with the above check. They may be
@@ -0,0 +1,54 @@
1
+ import { CleanupFn, Factory, ItemCleanupPair } from "@isograph/disposable-types";
2
+ export type NotInParentCacheAndDisposed = {
3
+ kind: "NotInParentCacheAndDisposed";
4
+ };
5
+ export type NotInParentCacheAndNotDisposed<T> = {
6
+ kind: "NotInParentCacheAndNotDisposed";
7
+ value: T;
8
+ disposeValue: () => void;
9
+ permanentRetainCount: number;
10
+ };
11
+ export type InParentCacheAndNotDisposed<T> = {
12
+ kind: "InParentCacheAndNotDisposed";
13
+ value: T;
14
+ disposeValue: () => void;
15
+ removeFromParentCache: () => void;
16
+ temporaryRetainCount: number;
17
+ permanentRetainCount: number;
18
+ };
19
+ export type CacheItemState<T> = InParentCacheAndNotDisposed<T> | NotInParentCacheAndNotDisposed<T> | NotInParentCacheAndDisposed;
20
+ export type CacheItemOptions = {
21
+ temporaryRetainTime: number;
22
+ };
23
+ /**
24
+ * - TRC = Temporary Retain Count
25
+ * - PRC = Permanent Retain Count
26
+ *
27
+ * Rules:
28
+ * - In parent cache <=> TRC > 0
29
+ * - Removed from parent cache <=> TRC === 0
30
+ * - In parent cache => not disposed
31
+ * - Disposed => removed from parent cache + PRC === 0
32
+ *
33
+ * A CacheItem<T> can be in three states:
34
+ * - Removed from the parent cache, item disposed, TRC === 0, PRC === 0
35
+ * - Removed from the parent cache, item not disposed, PRC > 0, TRC === 0
36
+ * - In parent cache, item not disposed, TRC > 0, PRC >= 0
37
+ *
38
+ * Valid transitions are:
39
+ * - InParentCacheAndNotDisposed => NotInParentCacheAndNotDisposed
40
+ * - InParentCacheAndNotDisposed => NotInParentCacheAndDisposed
41
+ * - NotInParentCacheAndNotDisposed => NotInParentCacheAndDisposed
42
+ */
43
+ export declare class CacheItem<T> {
44
+ private __state;
45
+ private __options;
46
+ constructor(factory: Factory<T>, removeFromParentCache: CleanupFn, options: CacheItemOptions | void);
47
+ getValue(): T;
48
+ permanentRetainIfNotDisposed(disposeOfTemporaryRetain: CleanupFn): ItemCleanupPair<T> | null;
49
+ temporaryRetain(): CleanupFn;
50
+ permanentRetain(): CleanupFn;
51
+ private __maybeExitInParentCacheAndNotDisposedState;
52
+ private __maybeExitNotInParentCacheAndNotDisposedState;
53
+ }
54
+ export declare function createTemporarilyRetainedCacheItem<T>(factory: Factory<T>, removeFromParentCache: CleanupFn, options: CacheItemOptions | void): [CacheItem<T>, CleanupFn];
@@ -0,0 +1,265 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createTemporarilyRetainedCacheItem = exports.CacheItem = void 0;
4
+ const DEFAULT_TEMPORARY_RETAIN_TIME = 5000;
5
+ // TODO don't export this class, only export type (interface) instead
6
+ // TODO convert cacheitem impl to a getter and setter and free functions
7
+ /**
8
+ * - TRC = Temporary Retain Count
9
+ * - PRC = Permanent Retain Count
10
+ *
11
+ * Rules:
12
+ * - In parent cache <=> TRC > 0
13
+ * - Removed from parent cache <=> TRC === 0
14
+ * - In parent cache => not disposed
15
+ * - Disposed => removed from parent cache + PRC === 0
16
+ *
17
+ * A CacheItem<T> can be in three states:
18
+ * - Removed from the parent cache, item disposed, TRC === 0, PRC === 0
19
+ * - Removed from the parent cache, item not disposed, PRC > 0, TRC === 0
20
+ * - In parent cache, item not disposed, TRC > 0, PRC >= 0
21
+ *
22
+ * Valid transitions are:
23
+ * - InParentCacheAndNotDisposed => NotInParentCacheAndNotDisposed
24
+ * - InParentCacheAndNotDisposed => NotInParentCacheAndDisposed
25
+ * - NotInParentCacheAndNotDisposed => NotInParentCacheAndDisposed
26
+ */
27
+ class CacheItem {
28
+ // Private. Do not call this constructor directly. Use
29
+ // createTemporarilyRetainedCacheItem instead. This is because this
30
+ // constructor creates a CacheItem in an invalid state. It must be
31
+ // temporarily retained to enter a valid state, and JavaScript doesn't
32
+ // let you return a tuple from a constructor.
33
+ constructor(factory, removeFromParentCache, options) {
34
+ this.__options = options !== null && options !== void 0 ? options : null;
35
+ const [value, disposeValue] = factory();
36
+ this.__state = {
37
+ kind: "InParentCacheAndNotDisposed",
38
+ value,
39
+ disposeValue,
40
+ removeFromParentCache,
41
+ // NOTE: we are creating the CacheItem in an invalid state. This is okay, because
42
+ // we are immediately calling .temporaryRetain.
43
+ temporaryRetainCount: 0,
44
+ permanentRetainCount: 0,
45
+ };
46
+ }
47
+ getValue() {
48
+ switch (this.__state.kind) {
49
+ case "InParentCacheAndNotDisposed": {
50
+ return this.__state.value;
51
+ }
52
+ case "NotInParentCacheAndNotDisposed": {
53
+ return this.__state.value;
54
+ }
55
+ default: {
56
+ throw new Error("Attempted to access disposed value from CacheItem. " +
57
+ "This indicates a bug in react-disposable-state.");
58
+ }
59
+ }
60
+ }
61
+ permanentRetainIfNotDisposed(disposeOfTemporaryRetain) {
62
+ switch (this.__state.kind) {
63
+ case "InParentCacheAndNotDisposed": {
64
+ let cleared = false;
65
+ this.__state.permanentRetainCount++;
66
+ disposeOfTemporaryRetain();
67
+ return [
68
+ this.__state.value,
69
+ () => {
70
+ if (cleared) {
71
+ throw new Error("A permanent retain should only be cleared once. " +
72
+ "This indicates a bug in react-disposable-state.");
73
+ }
74
+ cleared = true;
75
+ switch (this.__state.kind) {
76
+ case "InParentCacheAndNotDisposed": {
77
+ this.__state.permanentRetainCount--;
78
+ this.__maybeExitInParentCacheAndNotDisposedState(this.__state);
79
+ return;
80
+ }
81
+ case "NotInParentCacheAndNotDisposed": {
82
+ this.__state.permanentRetainCount--;
83
+ this.__maybeExitNotInParentCacheAndNotDisposedState(this.__state);
84
+ return;
85
+ }
86
+ default: {
87
+ throw new Error("CacheItem was in a disposed state, but there existed a permanent retain. " +
88
+ "This indicates a bug in react-disposable-state.");
89
+ }
90
+ }
91
+ },
92
+ ];
93
+ }
94
+ case "NotInParentCacheAndNotDisposed": {
95
+ let cleared = false;
96
+ this.__state.permanentRetainCount++;
97
+ disposeOfTemporaryRetain();
98
+ return [
99
+ this.__state.value,
100
+ () => {
101
+ if (cleared) {
102
+ throw new Error("A permanent retain should only be cleared once. " +
103
+ "This indicates a bug in react-disposable-state.");
104
+ }
105
+ cleared = true;
106
+ switch (this.__state.kind) {
107
+ case "NotInParentCacheAndNotDisposed": {
108
+ this.__state.permanentRetainCount--;
109
+ this.__maybeExitNotInParentCacheAndNotDisposedState(this.__state);
110
+ return;
111
+ }
112
+ default: {
113
+ throw new Error("CacheItem was in an unexpected state. " +
114
+ "This indicates a bug in react-disposable-state.");
115
+ }
116
+ }
117
+ },
118
+ ];
119
+ }
120
+ default: {
121
+ // The CacheItem is disposed, so disposeOfTemporaryRetain is a no-op
122
+ return null;
123
+ }
124
+ }
125
+ }
126
+ temporaryRetain() {
127
+ var _a, _b;
128
+ switch (this.__state.kind) {
129
+ case "InParentCacheAndNotDisposed": {
130
+ let status = "Uncleared";
131
+ this.__state.temporaryRetainCount++;
132
+ const clearTemporaryRetainByCallack = () => {
133
+ if (status === "ClearedByCallback") {
134
+ throw new Error("A temporary retain should only be cleared once. " +
135
+ "This indicates a bug in react-disposable-state.");
136
+ }
137
+ else if (status === "Uncleared") {
138
+ switch (this.__state.kind) {
139
+ case "InParentCacheAndNotDisposed": {
140
+ this.__state.temporaryRetainCount--;
141
+ this.__maybeExitInParentCacheAndNotDisposedState(this.__state);
142
+ clearTimeout(timeoutId);
143
+ return;
144
+ }
145
+ default: {
146
+ throw new Error("A temporary retain was cleared, for which the CacheItem is in an invalid state. " +
147
+ "This indicates a bug in react-disposable-state.");
148
+ }
149
+ }
150
+ }
151
+ };
152
+ const clearTemporaryRetainByTimeout = () => {
153
+ status = "ClearedByTimeout";
154
+ switch (this.__state.kind) {
155
+ case "InParentCacheAndNotDisposed": {
156
+ this.__state.temporaryRetainCount--;
157
+ this.__maybeExitInParentCacheAndNotDisposedState(this.__state);
158
+ return;
159
+ }
160
+ default: {
161
+ throw new Error("A temporary retain was cleared, for which the CacheItem is in an invalid state. " +
162
+ "This indicates a bug in react-disposable-state.");
163
+ }
164
+ }
165
+ };
166
+ const timeoutId = setTimeout(clearTemporaryRetainByTimeout, (_b = (_a = this.__options) === null || _a === void 0 ? void 0 : _a.temporaryRetainTime) !== null && _b !== void 0 ? _b : DEFAULT_TEMPORARY_RETAIN_TIME);
167
+ return clearTemporaryRetainByCallack;
168
+ }
169
+ default: {
170
+ throw new Error("temporaryRetain was called, for which the CacheItem is in an invalid state. " +
171
+ "This indicates a bug in react-disposable-state.");
172
+ }
173
+ }
174
+ }
175
+ permanentRetain() {
176
+ switch (this.__state.kind) {
177
+ case "InParentCacheAndNotDisposed": {
178
+ let cleared = false;
179
+ this.__state.permanentRetainCount++;
180
+ return () => {
181
+ if (cleared) {
182
+ throw new Error("A permanent retain should only be cleared once. " +
183
+ "This indicates a bug in react-disposable-state.");
184
+ }
185
+ cleared = true;
186
+ switch (this.__state.kind) {
187
+ case "InParentCacheAndNotDisposed": {
188
+ this.__state.permanentRetainCount--;
189
+ this.__maybeExitInParentCacheAndNotDisposedState(this.__state);
190
+ return;
191
+ }
192
+ case "NotInParentCacheAndNotDisposed": {
193
+ this.__state.permanentRetainCount--;
194
+ this.__maybeExitNotInParentCacheAndNotDisposedState(this.__state);
195
+ return;
196
+ }
197
+ default: {
198
+ throw new Error("CacheItem was in a disposed state, but there existed a permanent retain. " +
199
+ "This indicates a bug in react-disposable-state.");
200
+ }
201
+ }
202
+ };
203
+ }
204
+ case "NotInParentCacheAndNotDisposed": {
205
+ let cleared = false;
206
+ this.__state.permanentRetainCount++;
207
+ return () => {
208
+ if (cleared) {
209
+ throw new Error("A permanent retain should only be cleared once. " +
210
+ "This indicates a bug in react-disposable-state.");
211
+ }
212
+ cleared = true;
213
+ switch (this.__state.kind) {
214
+ case "NotInParentCacheAndNotDisposed": {
215
+ this.__state.permanentRetainCount--;
216
+ this.__maybeExitNotInParentCacheAndNotDisposedState(this.__state);
217
+ return;
218
+ }
219
+ default: {
220
+ throw new Error("CacheItem was in an unexpected state. " +
221
+ "This indicates a bug in react-disposable-state.");
222
+ }
223
+ }
224
+ };
225
+ }
226
+ default: {
227
+ throw new Error("permanentRetain was called, but the CacheItem is in an invalid state. " +
228
+ "This indicates a bug in react-disposable-state.");
229
+ }
230
+ }
231
+ }
232
+ __maybeExitInParentCacheAndNotDisposedState(state) {
233
+ if (state.temporaryRetainCount === 0 && state.permanentRetainCount === 0) {
234
+ state.removeFromParentCache();
235
+ state.disposeValue();
236
+ this.__state = {
237
+ kind: "NotInParentCacheAndDisposed",
238
+ };
239
+ }
240
+ else if (state.temporaryRetainCount === 0) {
241
+ state.removeFromParentCache();
242
+ this.__state = {
243
+ kind: "NotInParentCacheAndNotDisposed",
244
+ value: state.value,
245
+ disposeValue: state.disposeValue,
246
+ permanentRetainCount: state.permanentRetainCount,
247
+ };
248
+ }
249
+ }
250
+ __maybeExitNotInParentCacheAndNotDisposedState(state) {
251
+ if (state.permanentRetainCount === 0) {
252
+ state.disposeValue();
253
+ this.__state = {
254
+ kind: "NotInParentCacheAndDisposed",
255
+ };
256
+ }
257
+ }
258
+ }
259
+ exports.CacheItem = CacheItem;
260
+ function createTemporarilyRetainedCacheItem(factory, removeFromParentCache, options) {
261
+ const cacheItem = new CacheItem(factory, removeFromParentCache, options);
262
+ const disposeTemporaryRetain = cacheItem.temporaryRetain();
263
+ return [cacheItem, disposeTemporaryRetain];
264
+ }
265
+ exports.createTemporarilyRetainedCacheItem = createTemporarilyRetainedCacheItem;
@@ -0,0 +1,39 @@
1
+ import { CacheItem } from "./CacheItem";
2
+ import { CleanupFn, Factory, ItemCleanupPair } from "@isograph/disposable-types";
3
+ /**
4
+ * ParentCache
5
+ * - A ParentCache can be in two states: populated and unpopulated.
6
+ * - A ParentCache holds a CacheItem, which can choose to remove itself from
7
+ * the parent ParentCache.
8
+ * - If the ParentCache is populated, the CacheItem (i.e. this.__value) must be
9
+ * in the InParentCacheAndNotDisposed state, i.e. not disposed, so after we
10
+ * null-check this.__value, this.__value.getValue(), this.__value.temporaryRetain()
11
+ * and this.__value.permanentRetain() are safe to be called.
12
+ *
13
+ * - Though we do not do so, it is always safe to call parentCache.delete().
14
+ *
15
+ * Invariant:
16
+ * - A parent cache at a given "location" (conceptually, an ID) should always
17
+ * be called
18
+ */
19
+ export declare class ParentCache<T> {
20
+ private __cacheItem;
21
+ private readonly __factory;
22
+ constructor(factory: Factory<T>);
23
+ /**
24
+ * This is called from useCachedPrecommitValue, when the parent cache is populated
25
+ * and a previous temporary retain has been disposed. This can occur in scenarios like:
26
+ * - temporary retain A is created by component B rendering
27
+ * - temporary retain A expires, emptying the parent cache
28
+ * - another component renders, sharing the same parent cache, filling
29
+ * by calling getOrPopulateAndTemporaryRetain
30
+ * - component B commits. We see that temporary retain A has been disposed,
31
+ * and re-check the parent cache by calling this method.
32
+ */
33
+ getAndPermanentRetainIfPresent(): ItemCleanupPair<T> | null;
34
+ getOrPopulateAndTemporaryRetain(): [CacheItem<T>, T, CleanupFn];
35
+ private __populateAndTemporaryRetain;
36
+ empty(): void;
37
+ get factory(): Factory<T>;
38
+ isEmpty(): boolean;
39
+ }
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ParentCache = void 0;
4
+ const CacheItem_1 = require("./CacheItem");
5
+ // TODO convert cache impl to a getter and setter and free functions
6
+ // TODO accept options that get passed to CacheItem
7
+ /**
8
+ * ParentCache
9
+ * - A ParentCache can be in two states: populated and unpopulated.
10
+ * - A ParentCache holds a CacheItem, which can choose to remove itself from
11
+ * the parent ParentCache.
12
+ * - If the ParentCache is populated, the CacheItem (i.e. this.__value) must be
13
+ * in the InParentCacheAndNotDisposed state, i.e. not disposed, so after we
14
+ * null-check this.__value, this.__value.getValue(), this.__value.temporaryRetain()
15
+ * and this.__value.permanentRetain() are safe to be called.
16
+ *
17
+ * - Though we do not do so, it is always safe to call parentCache.delete().
18
+ *
19
+ * Invariant:
20
+ * - A parent cache at a given "location" (conceptually, an ID) should always
21
+ * be called
22
+ */
23
+ class ParentCache {
24
+ constructor(factory) {
25
+ this.__cacheItem = null;
26
+ this.__factory = factory;
27
+ }
28
+ /**
29
+ * This is called from useCachedPrecommitValue, when the parent cache is populated
30
+ * and a previous temporary retain has been disposed. This can occur in scenarios like:
31
+ * - temporary retain A is created by component B rendering
32
+ * - temporary retain A expires, emptying the parent cache
33
+ * - another component renders, sharing the same parent cache, filling
34
+ * by calling getOrPopulateAndTemporaryRetain
35
+ * - component B commits. We see that temporary retain A has been disposed,
36
+ * and re-check the parent cache by calling this method.
37
+ */
38
+ getAndPermanentRetainIfPresent() {
39
+ return this.__cacheItem != null
40
+ ? [this.__cacheItem.getValue(), this.__cacheItem.permanentRetain()]
41
+ : null;
42
+ }
43
+ getOrPopulateAndTemporaryRetain() {
44
+ return this.__cacheItem === null
45
+ ? this.__populateAndTemporaryRetain()
46
+ : temporaryRetain(this.__cacheItem);
47
+ }
48
+ __populateAndTemporaryRetain() {
49
+ const pair = (0, CacheItem_1.createTemporarilyRetainedCacheItem)(this.__factory, () => {
50
+ // We are doing this check because we don't want to remove the cache item
51
+ // if it is not the one that was created when the temporary retain was created.
52
+ //
53
+ // Consider the following scenario:
54
+ // - we populate the cache with CacheItem A,
55
+ // - then manually delete CacheItem A (e.g. to force a refetch)
56
+ // - then, we re-populate the parent cache with CacheItem B
57
+ // - then, the temporary retain of CacheItem A is disposed or expires.
58
+ //
59
+ // At this point, we don't want to delete CacheItem B from the cache.
60
+ //
61
+ // TODO consider what happens if items are === comparable to each other,
62
+ // e.g. the item is a number!
63
+ if (this.__cacheItem === pair[0]) {
64
+ this.empty();
65
+ }
66
+ });
67
+ // We deconstruct this here instead of at the definition site because otherwise,
68
+ // typescript thinks that cacheItem is any, because it's referenced in the closure.
69
+ const [cacheItem, disposeTemporaryRetain] = pair;
70
+ this.__cacheItem = cacheItem;
71
+ return [cacheItem, this.__cacheItem.getValue(), disposeTemporaryRetain];
72
+ }
73
+ empty() {
74
+ this.__cacheItem = null;
75
+ }
76
+ get factory() {
77
+ return this.__factory;
78
+ }
79
+ isEmpty() {
80
+ return this.__cacheItem === null;
81
+ }
82
+ }
83
+ exports.ParentCache = ParentCache;
84
+ function temporaryRetain(value) {
85
+ return [value, value.getValue(), value.temporaryRetain()];
86
+ }
@@ -0,0 +1,8 @@
1
+ export * from "@isograph/disposable-types";
2
+ export * from "./CacheItem";
3
+ export * from "./ParentCache";
4
+ export * from "./useCachedPrecommitValue";
5
+ export * from "./useDisposableState";
6
+ export * from "./useHasCommittedRef";
7
+ export * from "./useLazyDisposableState";
8
+ export * from "./useUpdatableDisposableState";
package/dist/index.js ADDED
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("@isograph/disposable-types"), exports);
18
+ __exportStar(require("./CacheItem"), exports);
19
+ __exportStar(require("./ParentCache"), exports);
20
+ __exportStar(require("./useCachedPrecommitValue"), exports);
21
+ __exportStar(require("./useDisposableState"), exports);
22
+ __exportStar(require("./useHasCommittedRef"), exports);
23
+ __exportStar(require("./useLazyDisposableState"), exports);
24
+ __exportStar(require("./useUpdatableDisposableState"), exports);
@@ -0,0 +1,36 @@
1
+ import { ParentCache } from "./ParentCache";
2
+ import { ItemCleanupPair } from "@isograph/isograph-disposable-types/dist";
3
+ /**
4
+ * usePrecommitValue<T>
5
+ * - Takes a mutable parent cache, a factory function, and an onCommit callback.
6
+ * - Returns T before the initial commit, and null afterward.
7
+ * - Calls onCommit with the ItemCleanupPair during the first commit.
8
+ * - The T from the render phase is only temporarily retained. It may have been
9
+ * disposed by the time of the commit. If so, this hook checks the parent cache
10
+ * for another T or creates one, and passes this T to onCommit.
11
+ * - If the T returned during the last render is not the same as the one that
12
+ * is passed to onCommit, during the commit phase, will schedule another render.
13
+ *
14
+ * Invariant: the returned T has not been disposed during the tick of the render.
15
+ * The T passed to the onCommit callback has not been disposed when the onCommit
16
+ * callback is called.
17
+ *
18
+ * Passing a different parentCache:
19
+ * - Pre-commit, passing a different parentCache has the effect of "resetting" this
20
+ * hook's state to the new cache's state. For example, if you have a cache associated
21
+ * with a set of variables (e.g. {name: "Matthew"}), and pass in another cache
22
+ * (e.g. associated with {name: "James"}), which is empty, the hook will fill that
23
+ * new cache with the factory function.
24
+ *
25
+ * Passing a different factory:
26
+ * - Passing a different factory has no effect, except when factory is called,
27
+ * which is when the parent cache is being filled, or during the initial commit.
28
+ *
29
+ * Passing a different onCommit:
30
+ * - Passing a different onCommit has no effect, except for during the initial commit.
31
+ *
32
+ * Post-commit, all parameters are ignored and the hook returns null.
33
+ */
34
+ export declare function useCachedPrecommitValue<T>(parentCache: ParentCache<T>, onCommit: (pair: ItemCleanupPair<T>) => void): {
35
+ state: T;
36
+ } | null;