@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.
@@ -0,0 +1,134 @@
1
+ import { ItemCleanupPair } from "@isograph/disposable-types";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { useHasCommittedRef } from "./useHasCommittedRef";
4
+
5
+ export const UNASSIGNED_STATE = Symbol();
6
+ export type UnassignedState = typeof UNASSIGNED_STATE;
7
+
8
+ type UseUpdatableDisposableStateReturnValue<T> = {
9
+ state: T | UnassignedState;
10
+ setState: (pair: ItemCleanupPair<Exclude<T, UnassignedState>>) => void;
11
+ };
12
+
13
+ /**
14
+ * ICI stands for ItemCleanupIndex. Use a short name because it shows up a lot.
15
+ *
16
+ * Like an ItemCleanupPair<T>, but with an additional index and as a struct
17
+ * for readability.
18
+ *
19
+ * Interally, we must keep track of the index of the state change. The disposable
20
+ * items that are set in state are not guaranteed to be distinct according to ===,
21
+ * as in `setState([1, dispose1]); setState([1, dispose2])`. Following this, we
22
+ * would expect dispose1 to be called on commit. If state items were compared for
23
+ * ===, then dispose1 would not be called in such a situation.
24
+ *
25
+ * Note that this comes at a runtime cost, and we may want to expose APIs that do
26
+ * not incur this runtime cost, for example in cases where every disposable item is
27
+ * distinguishable via ===.
28
+ */
29
+ type ICI<T> = { item: T; cleanup: () => void; index: number };
30
+
31
+ /**
32
+ * useUpdatableDisposableState
33
+ * - Returns a { state, setItem } object.
34
+ * - setItem accepts an ItemCleanupPair<T>, and throws if called before commit.
35
+ * - setItem sets the T in state and adds it to a set.
36
+ * - React's behavior is that when the hook commits, whatever item is currently
37
+ * returned from the useState hook is the oldest item which will ever be returned
38
+ * from that useState hook. (More newly created ones can later be returned with
39
+ * concurrent mode.)
40
+ * - When this hook commits, all items up to, but not including, the item currently
41
+ * returned from the useState hook are disposed and removed from the set.
42
+ *
43
+ * Calling setState before the hook commits:
44
+ * - Calling setState before the hook commits is disallowed because until the hook
45
+ * commits, React will not schedule any unmount callbacks, meaning that if this
46
+ * hook never commits, any disposable items passed to setState will never be
47
+ * disposed.
48
+ * - We also cannot store them in some cache, because multiple components can share
49
+ * the same cache location (for example, if they are loading the same query from
50
+ * multiple components), so updating the cache with the disposable item will cause
51
+ * both components to show the updated data, which is almost certainly a bug.
52
+ * - Note that calling setState before commit is probably an anti-pattern! Consider
53
+ * not doing it.
54
+ * - If you must, the workaround is to lazily load the disposable item with
55
+ * useDisposableState or useLazyDisposableState, and update the cache location
56
+ * instead of calling setState before commit. One can update the cache location
57
+ * by calling setState on some parent component that has already mounted, and
58
+ * therefore passing in different props.
59
+ * - This may only work in concurrent mode, though.
60
+ */
61
+ export function useUpdatableDisposableState<
62
+ T = never
63
+ >(): UseUpdatableDisposableStateReturnValue<T> {
64
+ const hasCommittedRef = useHasCommittedRef();
65
+
66
+ const undisposedICIs = useRef(new Set<ICI<T>>());
67
+ const setStateCountRef = useRef(0);
68
+
69
+ const [stateICI, setStateICI] = useState<ICI<T> | UnassignedState>(
70
+ UNASSIGNED_STATE
71
+ );
72
+
73
+ const setStateAfterCommit = useCallback(
74
+ (itemCleanupPair: ItemCleanupPair<T>) => {
75
+ if (!hasCommittedRef.current) {
76
+ throw new Error(
77
+ "Calling setState before the component has committed is unsafe and disallowed."
78
+ );
79
+ }
80
+
81
+ const ici: ICI<T> = {
82
+ item: itemCleanupPair[0],
83
+ cleanup: itemCleanupPair[1],
84
+ index: setStateCountRef.current,
85
+ };
86
+ setStateCountRef.current++;
87
+ undisposedICIs.current.add(ici);
88
+ setStateICI(ici);
89
+ },
90
+ [setStateICI]
91
+ );
92
+
93
+ useEffect(function cleanupUnreachableItems() {
94
+ const indexInState = stateICI !== UNASSIGNED_STATE ? stateICI.index : 0;
95
+
96
+ if (indexInState === 0) {
97
+ return;
98
+ }
99
+
100
+ for (const undisposedICI of undisposedICIs.current) {
101
+ if (undisposedICI.index === indexInState) {
102
+ break;
103
+ }
104
+ undisposedICIs.current.delete(undisposedICI);
105
+ undisposedICI.cleanup();
106
+ }
107
+ });
108
+
109
+ useEffect(() => {
110
+ return function disposeAllRemainingItems() {
111
+ for (const undisposedICI of undisposedICIs.current) {
112
+ undisposedICI.cleanup();
113
+ }
114
+ };
115
+ }, []);
116
+
117
+ return {
118
+ setState: setStateAfterCommit,
119
+ state: stateICI !== UNASSIGNED_STATE ? stateICI.item : UNASSIGNED_STATE,
120
+ };
121
+ }
122
+
123
+ // @ts-ignore
124
+ function tsTests() {
125
+ const a = useUpdatableDisposableState();
126
+ // @ts-expect-error
127
+ a.setState([UNASSIGNED_STATE, () => {}]);
128
+ // @ts-expect-error
129
+ a.setState(["asdf", () => {}]);
130
+ const b = useUpdatableDisposableState<string | UnassignedState>();
131
+ // @ts-expect-error
132
+ b.setState([UNASSIGNED_STATE, () => {}]);
133
+ b.setState(["asdf", () => {}]);
134
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.build.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist/",
5
+ "rootDir": "./src/",
6
+ "declaration": true
7
+ },
8
+ "include": ["./**/*.ts"]
9
+ }