@isograph/react-disposable-state 0.5.1 → 0.5.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/.turbo/turbo-compile-libs.log +5 -5
- package/.turbo/turbo-tsc.log +5 -0
- package/README.md +19 -0
- package/dist/{CacheItem.js → CacheItem.cjs} +11 -8
- package/dist/{CacheItem.d.ts → CacheItem.d.cts} +1 -1
- package/dist/CacheItem.d.cts.map +1 -0
- package/dist/CacheItem.d.mts.map +1 -1
- package/dist/CacheItem.mjs +11 -8
- package/dist/CacheItem.mjs.map +1 -1
- package/dist/{ParentCache.js → ParentCache.cjs} +4 -3
- package/dist/{ParentCache.d.ts → ParentCache.d.cts} +4 -4
- package/dist/ParentCache.d.cts.map +1 -0
- package/dist/ParentCache.d.mts +3 -3
- package/dist/ParentCache.d.mts.map +1 -1
- package/dist/ParentCache.mjs +3 -2
- package/dist/ParentCache.mjs.map +1 -1
- package/dist/{index.js → index.cjs} +14 -10
- package/dist/{index.d.ts → index.d.cts} +10 -8
- package/dist/index.d.mts +3 -1
- package/dist/index.mjs +3 -1
- package/dist/{useCachedResponsivePrecommitValue.js → useCachedResponsivePrecommitValue.cjs} +15 -9
- package/dist/{useCachedResponsivePrecommitValue.d.ts → useCachedResponsivePrecommitValue.d.cts} +2 -3
- package/dist/useCachedResponsivePrecommitValue.d.cts.map +1 -0
- package/dist/useCachedResponsivePrecommitValue.d.mts +0 -1
- package/dist/useCachedResponsivePrecommitValue.d.mts.map +1 -1
- package/dist/useCachedResponsivePrecommitValue.mjs +13 -7
- package/dist/useCachedResponsivePrecommitValue.mjs.map +1 -1
- package/dist/{useDisposableState.js → useDisposableState.cjs} +9 -10
- package/dist/{useDisposableState.d.ts → useDisposableState.d.cts} +3 -3
- package/dist/useDisposableState.d.cts.map +1 -0
- package/dist/useDisposableState.d.mts.map +1 -1
- package/dist/useDisposableState.mjs +7 -6
- package/dist/useDisposableState.mjs.map +1 -1
- package/dist/useEffectsRerunWithoutRender.cjs +27 -0
- package/dist/useEffectsRerunWithoutRender.d.cts +19 -0
- package/dist/useEffectsRerunWithoutRender.d.cts.map +1 -0
- package/dist/useEffectsRerunWithoutRender.d.mts +19 -0
- package/dist/useEffectsRerunWithoutRender.d.mts.map +1 -0
- package/dist/useEffectsRerunWithoutRender.mjs +28 -0
- package/dist/useEffectsRerunWithoutRender.mjs.map +1 -0
- package/dist/{useHasCommittedRef.js → useHasCommittedRef.cjs} +0 -2
- package/dist/{useHasCommittedRef.d.ts → useHasCommittedRef.d.cts} +1 -2
- package/dist/useHasCommittedRef.d.cts.map +1 -0
- package/dist/useHasCommittedRef.d.mts +0 -1
- package/dist/useHasCommittedRef.d.mts.map +1 -1
- package/dist/{useLazyDisposableState.js → useLazyDisposableState.cjs} +6 -6
- package/dist/{useLazyDisposableState.d.ts → useLazyDisposableState.d.cts} +3 -4
- package/dist/useLazyDisposableState.d.cts.map +1 -0
- package/dist/useLazyDisposableState.d.mts +0 -1
- package/dist/useLazyDisposableState.d.mts.map +1 -1
- package/dist/useLazyDisposableState.mjs +3 -3
- package/dist/useLazyDisposableState.mjs.map +1 -1
- package/dist/useUpdatableDisposableClearableState.cjs +36 -0
- package/dist/useUpdatableDisposableClearableState.d.cts +26 -0
- package/dist/useUpdatableDisposableClearableState.d.cts.map +1 -0
- package/dist/useUpdatableDisposableClearableState.d.mts +26 -0
- package/dist/useUpdatableDisposableClearableState.d.mts.map +1 -0
- package/dist/useUpdatableDisposableClearableState.mjs +37 -0
- package/dist/useUpdatableDisposableClearableState.mjs.map +1 -0
- package/dist/{useUpdatableDisposableState.js → useUpdatableDisposableState.cjs} +6 -4
- package/dist/{useUpdatableDisposableState.d.ts → useUpdatableDisposableState.d.cts} +1 -1
- package/dist/useUpdatableDisposableState.d.cts.map +1 -0
- package/dist/useUpdatableDisposableState.d.mts.map +1 -1
- package/dist/useUpdatableDisposableState.mjs +5 -1
- package/dist/useUpdatableDisposableState.mjs.map +1 -1
- package/package.json +9 -6
- package/src/CacheItem.test.ts +40 -2
- package/src/CacheItem.ts +1 -0
- package/src/ParentCache.ts +26 -21
- package/src/index.ts +2 -0
- package/src/useCachedResponsivePrecommitValue.test.tsx +8 -3
- package/src/useCachedResponsivePrecommitValue.ts +32 -20
- package/src/useDisposableState.ts +13 -15
- package/src/useEffectsRerunWithoutRender.test.tsx +54 -0
- package/src/useEffectsRerunWithoutRender.ts +23 -0
- package/src/useLazyDisposableState.test.tsx +73 -34
- package/src/useLazyDisposableState.ts +4 -5
- package/src/useUpdatableDisposableClearableState.ts +67 -0
- package/src/useUpdatableDisposableState.test.tsx +3 -2
- package/src/useUpdatableDisposableState.ts +2 -0
- package/tsconfig.json +2 -4
- package/vitest.config.ts +8 -0
- package/dist/CacheItem.d.ts.map +0 -1
- package/dist/ParentCache.d.ts.map +0 -1
- package/dist/_virtual/rolldown_runtime.js +0 -25
- package/dist/useCachedResponsivePrecommitValue.d.ts.map +0 -1
- package/dist/useDisposableState.d.ts.map +0 -1
- package/dist/useHasCommittedRef.d.ts.map +0 -1
- package/dist/useLazyDisposableState.d.ts.map +0 -1
- package/dist/useUpdatableDisposableState.d.ts.map +0 -1
- package/tsconfig.pkg.json +0 -9
package/package.json
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@isograph/react-disposable-state",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
4
4
|
"description": "Primitives for managing disposable state in React",
|
|
5
5
|
"homepage": "https://isograph.dev",
|
|
6
|
-
"main": "./dist/index.
|
|
7
|
-
"types": "./dist/index.d.
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"types": "./dist/index.d.cts",
|
|
8
8
|
"author": "Isograph Labs",
|
|
9
9
|
"license": "MIT",
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@isograph/disposable-types": "0.5.
|
|
11
|
+
"@isograph/disposable-types": "0.5.4"
|
|
12
12
|
},
|
|
13
13
|
"peerDependencies": {
|
|
14
14
|
"react": "^18.0.0 || ^19.0.0"
|
|
15
15
|
},
|
|
16
16
|
"devDependencies": {
|
|
17
|
+
"@testing-library/react": "16.3.2",
|
|
17
18
|
"@types/react": "18.3.1",
|
|
19
|
+
"@types/react-test-renderer": "^18.3.0",
|
|
20
|
+
"happy-dom": "20.4.0",
|
|
18
21
|
"react-test-renderer": "^18.2.0",
|
|
19
22
|
"typescript": "5.6.3"
|
|
20
23
|
},
|
|
@@ -27,8 +30,8 @@
|
|
|
27
30
|
"module": "./dist/index.mjs",
|
|
28
31
|
"exports": {
|
|
29
32
|
".": {
|
|
30
|
-
"
|
|
31
|
-
"
|
|
33
|
+
"require": "./dist/index.cjs",
|
|
34
|
+
"import": "./dist/index.mjs"
|
|
32
35
|
},
|
|
33
36
|
"./package.json": "./package.json"
|
|
34
37
|
},
|
package/src/CacheItem.test.ts
CHANGED
|
@@ -153,7 +153,45 @@ describe('CacheItem', () => {
|
|
|
153
153
|
|
|
154
154
|
expect(() => {
|
|
155
155
|
disposeTemporaryRetain();
|
|
156
|
-
}).toThrow();
|
|
156
|
+
}).toThrow('A temporary retain should only be cleared once.');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('Item that is temporarily retained twice', () => {
|
|
161
|
+
// A second clear of one temporary retain while the other still holds the
|
|
162
|
+
// item: without the status transition the count is decremented twice, the
|
|
163
|
+
// item leaves the parent cache, and the other holder's clear finds it in an
|
|
164
|
+
// invalid state.
|
|
165
|
+
test('Clearing the same temporary retain twice throws and leaves the other retain intact', () => {
|
|
166
|
+
const removeFromParentCache = vi.fn();
|
|
167
|
+
const disposeItem = vi.fn();
|
|
168
|
+
|
|
169
|
+
const factory = vi.fn(() => {
|
|
170
|
+
const ret: ItemCleanupPair<number> = [1, disposeItem];
|
|
171
|
+
return ret;
|
|
172
|
+
});
|
|
173
|
+
const [cacheItem, disposeTemporaryRetain1] =
|
|
174
|
+
createTemporarilyRetainedCacheItem<number>(
|
|
175
|
+
factory,
|
|
176
|
+
removeFromParentCache,
|
|
177
|
+
);
|
|
178
|
+
const disposeTemporaryRetain2 = cacheItem.temporaryRetain();
|
|
179
|
+
|
|
180
|
+
disposeTemporaryRetain1();
|
|
181
|
+
expect(() => {
|
|
182
|
+
disposeTemporaryRetain1();
|
|
183
|
+
}).toThrow('A temporary retain should only be cleared once.');
|
|
184
|
+
|
|
185
|
+
const state = getState(cacheItem);
|
|
186
|
+
assert(state.kind === 'InParentCacheAndNotDisposed');
|
|
187
|
+
expect(state.temporaryRetainCount).toEqual(1);
|
|
188
|
+
expect(removeFromParentCache).not.toHaveBeenCalled();
|
|
189
|
+
expect(disposeItem).not.toHaveBeenCalled();
|
|
190
|
+
|
|
191
|
+
disposeTemporaryRetain2();
|
|
192
|
+
expect(getState(cacheItem).kind).toEqual('NotInParentCacheAndDisposed');
|
|
193
|
+
expect(removeFromParentCache).toHaveBeenCalledOnce();
|
|
194
|
+
expect(disposeItem).toHaveBeenCalledOnce();
|
|
157
195
|
});
|
|
158
196
|
});
|
|
159
197
|
|
|
@@ -213,7 +251,7 @@ describe('CacheItem', () => {
|
|
|
213
251
|
|
|
214
252
|
expect(() => {
|
|
215
253
|
disposeTemporaryRetain();
|
|
216
|
-
}).toThrow();
|
|
254
|
+
}).toThrow('A temporary retain should only be cleared once.');
|
|
217
255
|
});
|
|
218
256
|
|
|
219
257
|
test('Item is disposed when the permanently retain is disposed', () => {
|
package/src/CacheItem.ts
CHANGED
|
@@ -209,6 +209,7 @@ export class CacheItem<T> {
|
|
|
209
209
|
'This indicates a bug in react-disposable-state.',
|
|
210
210
|
);
|
|
211
211
|
} else if (status === 'Uncleared') {
|
|
212
|
+
status = 'ClearedByCallback';
|
|
212
213
|
switch (this.__state.kind) {
|
|
213
214
|
case 'InParentCacheAndNotDisposed': {
|
|
214
215
|
this.__state.temporaryRetainCount--;
|
package/src/ParentCache.ts
CHANGED
|
@@ -3,11 +3,10 @@ import type {
|
|
|
3
3
|
Factory,
|
|
4
4
|
ItemCleanupPair,
|
|
5
5
|
} from '@isograph/disposable-types';
|
|
6
|
-
import type { CacheItem } from './CacheItem';
|
|
6
|
+
import type { CacheItem, CacheItemOptions } from './CacheItem';
|
|
7
7
|
import { createTemporarilyRetainedCacheItem } from './CacheItem';
|
|
8
8
|
|
|
9
9
|
// TODO convert cache impl to a getter and setter and free functions
|
|
10
|
-
// TODO accept options that get passed to CacheItem
|
|
11
10
|
|
|
12
11
|
/**
|
|
13
12
|
* ParentCache
|
|
@@ -28,11 +27,13 @@ import { createTemporarilyRetainedCacheItem } from './CacheItem';
|
|
|
28
27
|
export class ParentCache<T> {
|
|
29
28
|
private __cacheItem: CacheItem<T> | null = null;
|
|
30
29
|
private readonly __factory: Factory<T>;
|
|
30
|
+
private readonly __options: CacheItemOptions | void;
|
|
31
31
|
|
|
32
32
|
// TODO pass an onEmpty function, which can e.g. remove this ParentCache
|
|
33
33
|
// from some parent object.
|
|
34
|
-
constructor(factory: Factory<T
|
|
34
|
+
constructor(factory: Factory<T>, options?: CacheItemOptions) {
|
|
35
35
|
this.__factory = factory;
|
|
36
|
+
this.__options = options;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
/**
|
|
@@ -59,24 +60,28 @@ export class ParentCache<T> {
|
|
|
59
60
|
|
|
60
61
|
private __populateAndTemporaryRetain(): [CacheItem<T>, T, CleanupFn] {
|
|
61
62
|
const pair: ItemCleanupPair<CacheItem<T>> =
|
|
62
|
-
createTemporarilyRetainedCacheItem(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
63
|
+
createTemporarilyRetainedCacheItem(
|
|
64
|
+
this.__factory,
|
|
65
|
+
() => {
|
|
66
|
+
// We are doing this check because we don't want to remove the cache item
|
|
67
|
+
// if it is not the one that was created when the temporary retain was created.
|
|
68
|
+
//
|
|
69
|
+
// Consider the following scenario:
|
|
70
|
+
// - we populate the cache with CacheItem A,
|
|
71
|
+
// - then manually delete CacheItem A (e.g. to force a refetch)
|
|
72
|
+
// - then, we re-populate the parent cache with CacheItem B
|
|
73
|
+
// - then, the temporary retain of CacheItem A is disposed or expires.
|
|
74
|
+
//
|
|
75
|
+
// At this point, we don't want to delete CacheItem B from the cache.
|
|
76
|
+
//
|
|
77
|
+
// TODO consider what happens if items are === comparable to each other,
|
|
78
|
+
// e.g. the item is a number!
|
|
79
|
+
if (this.__cacheItem === pair[0]) {
|
|
80
|
+
this.empty();
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
this.__options,
|
|
84
|
+
);
|
|
80
85
|
|
|
81
86
|
// We deconstruct this here instead of at the definition site because otherwise,
|
|
82
87
|
// typescript thinks that cacheItem is any, because it's referenced in the closure.
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,8 @@ export * from './CacheItem';
|
|
|
4
4
|
export * from './ParentCache';
|
|
5
5
|
export * from './useCachedResponsivePrecommitValue';
|
|
6
6
|
export * from './useDisposableState';
|
|
7
|
+
export * from './useEffectsRerunWithoutRender';
|
|
7
8
|
export * from './useHasCommittedRef';
|
|
8
9
|
export * from './useLazyDisposableState';
|
|
10
|
+
export * from './useUpdatableDisposableClearableState';
|
|
9
11
|
export * from './useUpdatableDisposableState';
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { ItemCleanupPair } from '@isograph/disposable-types';
|
|
2
|
-
import React, {
|
|
2
|
+
import React, {
|
|
3
|
+
StrictMode,
|
|
4
|
+
type MutableRefObject,
|
|
5
|
+
type ReactElement,
|
|
6
|
+
} from 'react';
|
|
3
7
|
import { create } from 'react-test-renderer';
|
|
4
8
|
import { assert, describe, expect, test, vi } from 'vitest';
|
|
5
9
|
import type { CacheItem, CacheItemState } from './CacheItem';
|
|
@@ -52,9 +56,10 @@ function promiseAndResolver() {
|
|
|
52
56
|
|
|
53
57
|
// The fact that sometimes we need to render in concurrent mode and sometimes
|
|
54
58
|
// not is a bit worrisome.
|
|
55
|
-
async function awaitableCreate(Component, isConcurrent: boolean) {
|
|
59
|
+
async function awaitableCreate(Component: ReactElement, isConcurrent: boolean) {
|
|
56
60
|
const element = create(
|
|
57
|
-
Component
|
|
61
|
+
<StrictMode>{Component}</StrictMode>,
|
|
62
|
+
// @ts-expect-error
|
|
58
63
|
isConcurrent ? { unstable_isConcurrent: true } : undefined,
|
|
59
64
|
);
|
|
60
65
|
await shortPromise();
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import type { ItemCleanupPair } from '@isograph/disposable-types';
|
|
4
4
|
import { useEffect, useRef, useState } from 'react';
|
|
5
5
|
import type { ParentCache } from './ParentCache';
|
|
6
|
+
import { useEffectsRerunWithoutRenderRef } from './useEffectsRerunWithoutRender';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* useCachedResponsivePrecommitValue<T>
|
|
@@ -49,6 +50,8 @@ export function useCachedResponsivePrecommitValue<T>(
|
|
|
49
50
|
const [, rerender] = useState<{} | null>(null);
|
|
50
51
|
const lastCommittedParentCache = useRef<ParentCache<T> | null>(null);
|
|
51
52
|
|
|
53
|
+
const effectsRerunWithoutRenderRef = useEffectsRerunWithoutRenderRef();
|
|
54
|
+
|
|
52
55
|
useEffect(() => {
|
|
53
56
|
lastCommittedParentCache.current = parentCache;
|
|
54
57
|
// On commit, cacheItem may be disposed, because during the render phase,
|
|
@@ -67,28 +70,37 @@ export function useCachedResponsivePrecommitValue<T>(
|
|
|
67
70
|
//
|
|
68
71
|
// After the above, we have a non-disposed item and a cleanup function, which we
|
|
69
72
|
// can pass to onCommit.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
} else {
|
|
83
|
-
// We did not find an item in the parent cache, create a new one.
|
|
84
|
-
onCommit(parentCache.factory());
|
|
73
|
+
//
|
|
74
|
+
// When this effect runs again without a render (StrictMode's simulated
|
|
75
|
+
// remount, Fast Refresh, Activity), the render's temporary retain was
|
|
76
|
+
// already converted by the first run and released by the cleanup, so the
|
|
77
|
+
// item is re-acquired from the parent cache below instead.
|
|
78
|
+
if (!effectsRerunWithoutRenderRef.current) {
|
|
79
|
+
const undisposedPair = cacheItem.permanentRetainIfNotDisposed(
|
|
80
|
+
disposeOfTemporaryRetain,
|
|
81
|
+
);
|
|
82
|
+
if (undisposedPair != null) {
|
|
83
|
+
onCommit(undisposedPair);
|
|
84
|
+
return;
|
|
85
85
|
}
|
|
86
|
-
// TODO: Consider whether we always want to rerender if the committed item
|
|
87
|
-
// was not returned during the last render, or whether some callers will
|
|
88
|
-
// prefer opting out of this behavior (e.g. if every disposable item behaves
|
|
89
|
-
// identically, but must be loaded.)
|
|
90
|
-
rerender({});
|
|
91
86
|
}
|
|
87
|
+
effectsRerunWithoutRenderRef.current = false;
|
|
88
|
+
|
|
89
|
+
// The cache item we created during render has been disposed. Check if the parent
|
|
90
|
+
// cache is populated.
|
|
91
|
+
const existingCacheItemCleanupPair =
|
|
92
|
+
parentCache.getAndPermanentRetainIfPresent();
|
|
93
|
+
if (existingCacheItemCleanupPair != null) {
|
|
94
|
+
onCommit(existingCacheItemCleanupPair);
|
|
95
|
+
} else {
|
|
96
|
+
// We did not find an item in the parent cache, create a new one.
|
|
97
|
+
onCommit(parentCache.factory());
|
|
98
|
+
}
|
|
99
|
+
// TODO: Consider whether we always want to rerender if the committed item
|
|
100
|
+
// was not returned during the last render, or whether some callers will
|
|
101
|
+
// prefer opting out of this behavior (e.g. if every disposable item behaves
|
|
102
|
+
// identically, but must be loaded.)
|
|
103
|
+
rerender({});
|
|
92
104
|
}, [parentCache]);
|
|
93
105
|
|
|
94
106
|
if (lastCommittedParentCache.current === parentCache) {
|
|
@@ -21,7 +21,6 @@ export function useDisposableState<T = never>(
|
|
|
21
21
|
const preCommitItem = useCachedResponsivePrecommitValue(
|
|
22
22
|
parentCache,
|
|
23
23
|
(pair) => {
|
|
24
|
-
itemCleanupPairRef.current?.[1]();
|
|
25
24
|
itemCleanupPairRef.current = pair;
|
|
26
25
|
},
|
|
27
26
|
);
|
|
@@ -35,25 +34,25 @@ export function useDisposableState<T = never>(
|
|
|
35
34
|
if (itemCleanupPairRef.current != null) {
|
|
36
35
|
itemCleanupPairRef.current[1]();
|
|
37
36
|
itemCleanupPairRef.current = null;
|
|
38
|
-
} else {
|
|
39
|
-
throw new Error(
|
|
40
|
-
'itemCleanupPairRef.current is unexpectedly null. ' +
|
|
41
|
-
'This indicates a bug in react-disposable-state.',
|
|
42
|
-
);
|
|
43
37
|
}
|
|
44
38
|
}
|
|
45
39
|
},
|
|
46
40
|
[stateFromDisposableStateHook],
|
|
47
41
|
);
|
|
48
42
|
|
|
49
|
-
useEffect(
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
itemCleanupPairRef.current
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
43
|
+
useEffect(
|
|
44
|
+
function cleanupItemCleanupPairRefIfSetStateNotCalled() {
|
|
45
|
+
return () => {
|
|
46
|
+
if (itemCleanupPairRef.current != null) {
|
|
47
|
+
itemCleanupPairRef.current[1]();
|
|
48
|
+
// Forgotten so that nothing can release this pair a second time
|
|
49
|
+
// before an effect re-run (StrictMode, Fast Refresh) stores a new one.
|
|
50
|
+
itemCleanupPairRef.current = null;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
[parentCache],
|
|
55
|
+
);
|
|
57
56
|
const state: T | undefined =
|
|
58
57
|
(stateFromDisposableStateHook !== UNASSIGNED_STATE
|
|
59
58
|
? stateFromDisposableStateHook
|
|
@@ -67,7 +66,6 @@ export function useDisposableState<T = never>(
|
|
|
67
66
|
setState,
|
|
68
67
|
};
|
|
69
68
|
}
|
|
70
|
-
|
|
71
69
|
// Safety: we can be in one of three states. Pre-commit, in which case
|
|
72
70
|
// preCommitItem is assigned, post-commit but before setState has been
|
|
73
71
|
// called, in which case itemCleanupPairRef.current is assigned, or
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { render } from '@testing-library/react';
|
|
2
|
+
import React, { useEffect } from 'react';
|
|
3
|
+
import { describe, expect, test } from 'vitest';
|
|
4
|
+
import { useEffectsRerunWithoutRenderRef } from './useEffectsRerunWithoutRender';
|
|
5
|
+
|
|
6
|
+
function Probe({ dep, reports }: { dep: number; reports: boolean[] }) {
|
|
7
|
+
const effectsRerunWithoutRenderRef = useEffectsRerunWithoutRenderRef();
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
reports.push(effectsRerunWithoutRenderRef.current);
|
|
10
|
+
effectsRerunWithoutRenderRef.current = false;
|
|
11
|
+
}, [dep, effectsRerunWithoutRenderRef]);
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('useEffectsRerunWithoutRenderRef', () => {
|
|
16
|
+
test('under StrictMode, the first mount reads false and the simulated remount reads true', () => {
|
|
17
|
+
const reports: boolean[] = [];
|
|
18
|
+
render(<Probe dep={0} reports={reports} />, { reactStrictMode: true });
|
|
19
|
+
|
|
20
|
+
expect(reports).toEqual([false, true]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('without StrictMode, the mount reads false', () => {
|
|
24
|
+
const reports: boolean[] = [];
|
|
25
|
+
render(<Probe dep={0} reports={reports} />, { reactStrictMode: false });
|
|
26
|
+
|
|
27
|
+
expect(reports).toEqual([false]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('an effect run caused by a render reads false, once the remount has reset the ref', () => {
|
|
31
|
+
const reports: boolean[] = [];
|
|
32
|
+
const { rerender } = render(<Probe dep={0} reports={reports} />, {
|
|
33
|
+
reactStrictMode: true,
|
|
34
|
+
});
|
|
35
|
+
rerender(<Probe dep={1} reports={reports} />);
|
|
36
|
+
|
|
37
|
+
expect(reports).toEqual([false, true, false]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('the ref stays true until the caller resets it', () => {
|
|
41
|
+
const reads: boolean[] = [];
|
|
42
|
+
function NeverResets() {
|
|
43
|
+
const effectsRerunWithoutRenderRef = useEffectsRerunWithoutRenderRef();
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
reads.push(effectsRerunWithoutRenderRef.current);
|
|
46
|
+
});
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const { rerender } = render(<NeverResets />, { reactStrictMode: true });
|
|
50
|
+
rerender(<NeverResets />);
|
|
51
|
+
|
|
52
|
+
expect(reads).toEqual([false, true, true]);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type MutableRefObject, useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Detects that React is running this component's effects again without a
|
|
5
|
+
* render in between. That happens on StrictMode's simulated unmount and
|
|
6
|
+
* remount on the first commit, on Fast Refresh, and when an `Activity`
|
|
7
|
+
* boundary hides and then shows the component. In each case React runs every
|
|
8
|
+
* effect's cleanup and then its mount, so a cleanup that sets a flag is
|
|
9
|
+
* observable from the mounts that follow.
|
|
10
|
+
*
|
|
11
|
+
* Returns a ref whose `current` is `true` from the moment the cleanup ran until
|
|
12
|
+
* the caller resets it. The effect that must tell a re-run apart from a first
|
|
13
|
+
* mount reads it and sets it back to `false`; nothing else clears it.
|
|
14
|
+
*/
|
|
15
|
+
export function useEffectsRerunWithoutRenderRef(): MutableRefObject<boolean> {
|
|
16
|
+
const effectsRerunWithoutRenderRef = useRef(false);
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
return () => {
|
|
19
|
+
effectsRerunWithoutRenderRef.current = true;
|
|
20
|
+
};
|
|
21
|
+
}, []);
|
|
22
|
+
return effectsRerunWithoutRenderRef;
|
|
23
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { ItemCleanupPair } from '@isograph/disposable-types';
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import { describe, expect, test, vi } from 'vitest';
|
|
2
|
+
import { configure, renderHook } from '@testing-library/react';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
|
5
4
|
import { ParentCache } from './ParentCache';
|
|
6
5
|
import { useLazyDisposableState } from './useLazyDisposableState';
|
|
6
|
+
configure({ reactStrictMode: true });
|
|
7
7
|
|
|
8
8
|
function createCache<T>(value: T) {
|
|
9
9
|
const disposeItem = vi.fn();
|
|
@@ -26,46 +26,85 @@ function promiseWithResolvers() {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
describe('useLazyDisposableState', async () => {
|
|
29
|
-
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
vi.useFakeTimers();
|
|
31
|
+
});
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.restoreAllMocks();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('on render it should read the cache', async () => {
|
|
30
37
|
const cache1 = createCache(1);
|
|
31
|
-
const cache2 = createCache(2);
|
|
32
|
-
const renders = vi.fn();
|
|
33
38
|
|
|
34
|
-
|
|
35
|
-
|
|
39
|
+
const { result } = renderHook(
|
|
40
|
+
(props) => useLazyDisposableState(props.parentCache),
|
|
41
|
+
{
|
|
42
|
+
initialProps: {
|
|
43
|
+
parentCache: cache1.cache,
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
);
|
|
36
47
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
48
|
+
expect(result.current.state).toEqual(1);
|
|
49
|
+
expect(cache1.cache.factory).toHaveBeenCalledOnce();
|
|
50
|
+
});
|
|
40
51
|
|
|
41
|
-
|
|
42
|
-
|
|
52
|
+
test('on unmount it should dispose the cache', async () => {
|
|
53
|
+
const cache1 = createCache(1);
|
|
43
54
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
55
|
+
const { unmount } = renderHook(
|
|
56
|
+
(props) => useLazyDisposableState(props.parentCache),
|
|
57
|
+
{
|
|
58
|
+
initialProps: {
|
|
59
|
+
parentCache: cache1.cache,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
);
|
|
48
63
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
64
|
+
unmount();
|
|
65
|
+
vi.runAllTimers();
|
|
66
|
+
expect(cache1.disposeItem).toHaveBeenCalledOnce();
|
|
67
|
+
});
|
|
53
68
|
|
|
54
|
-
|
|
69
|
+
test('on cache change, it should read new cache', async () => {
|
|
70
|
+
const cache1 = createCache(1);
|
|
71
|
+
const cache2 = createCache(2);
|
|
55
72
|
|
|
56
|
-
|
|
57
|
-
|
|
73
|
+
const { result, rerender, unmount } = renderHook(
|
|
74
|
+
(props) => useLazyDisposableState(props.parentCache),
|
|
75
|
+
{
|
|
76
|
+
initialProps: {
|
|
77
|
+
parentCache: cache1.cache,
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
);
|
|
58
81
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
await unmounted.promise;
|
|
65
|
-
expect(cache2.disposeItem).toHaveBeenCalled();
|
|
82
|
+
rerender({
|
|
83
|
+
parentCache: cache2.cache,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(result.current.state).toEqual(2);
|
|
66
87
|
expect(cache2.cache.factory).toHaveBeenCalledOnce();
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('on cache change, it should dispose previous cache', async () => {
|
|
91
|
+
const cache1 = createCache(1);
|
|
92
|
+
const cache2 = createCache(2);
|
|
93
|
+
|
|
94
|
+
const { rerender } = renderHook(
|
|
95
|
+
(props) => useLazyDisposableState(props.parentCache),
|
|
96
|
+
{
|
|
97
|
+
initialProps: {
|
|
98
|
+
parentCache: cache1.cache,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
rerender({
|
|
104
|
+
parentCache: cache2.cache,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
vi.runAllTimers();
|
|
108
|
+
expect(cache1.disposeItem).toHaveBeenCalledOnce();
|
|
70
109
|
});
|
|
71
110
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
import type { ItemCleanupPair } from '@isograph/
|
|
3
|
+
import type { ItemCleanupPair } from '@isograph/disposable-types';
|
|
4
4
|
import { useEffect, useRef } from 'react';
|
|
5
5
|
import type { ParentCache } from './ParentCache';
|
|
6
6
|
import { useCachedResponsivePrecommitValue } from './useCachedResponsivePrecommitValue';
|
|
@@ -21,11 +21,9 @@ export function useLazyDisposableState<T>(
|
|
|
21
21
|
state: T;
|
|
22
22
|
} {
|
|
23
23
|
const itemCleanupPairRef = useRef<ItemCleanupPair<T> | null>(null);
|
|
24
|
-
|
|
25
24
|
const preCommitItem = useCachedResponsivePrecommitValue(
|
|
26
25
|
parentCache,
|
|
27
26
|
(pair) => {
|
|
28
|
-
itemCleanupPairRef.current?.[1]();
|
|
29
27
|
itemCleanupPairRef.current = pair;
|
|
30
28
|
},
|
|
31
29
|
);
|
|
@@ -39,9 +37,10 @@ export function useLazyDisposableState<T>(
|
|
|
39
37
|
'cleanupFn unexpectedly null. This indicates a bug in react-disposable-state.',
|
|
40
38
|
);
|
|
41
39
|
}
|
|
42
|
-
|
|
40
|
+
cleanupFn();
|
|
41
|
+
itemCleanupPairRef.current = null;
|
|
43
42
|
};
|
|
44
|
-
}, []);
|
|
43
|
+
}, [parentCache]);
|
|
45
44
|
|
|
46
45
|
const returnedItem = preCommitItem?.state ?? itemCleanupPairRef.current?.[0];
|
|
47
46
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ItemCleanupPair } from '@isograph/disposable-types';
|
|
2
|
+
import { useCallback } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
UNASSIGNED_STATE,
|
|
5
|
+
type UnassignedState,
|
|
6
|
+
useUpdatableDisposableState,
|
|
7
|
+
} from './useUpdatableDisposableState';
|
|
8
|
+
|
|
9
|
+
type UseUpdatableDisposableClearableStateReturnValue<T> = {
|
|
10
|
+
state: T | UnassignedState;
|
|
11
|
+
setState: (pair: ItemCleanupPair<Exclude<T, UnassignedState>>) => void;
|
|
12
|
+
clearState: () => void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The item that `clearState` puts in the underlying state. It is never returned
|
|
17
|
+
* from this hook; the hook reports UNASSIGNED_STATE while it is current.
|
|
18
|
+
*/
|
|
19
|
+
const CLEARED_STATE: unique symbol = Symbol();
|
|
20
|
+
type ClearedState = typeof CLEARED_STATE;
|
|
21
|
+
|
|
22
|
+
function noop() {}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* useUpdatableDisposableClearableState
|
|
26
|
+
* - useUpdatableDisposableState, plus clearState.
|
|
27
|
+
* - clearState returns the state to UNASSIGNED_STATE. The item that was in state
|
|
28
|
+
* is disposed on the next commit, exactly as if it had been superseded by
|
|
29
|
+
* setState.
|
|
30
|
+
* - Like setState, clearState throws if called before the initial commit.
|
|
31
|
+
*
|
|
32
|
+
* This is a wrapper around useUpdatableDisposableState: clearing sets a sentinel
|
|
33
|
+
* item with no cleanup, so the underlying hook's commit-time disposal of
|
|
34
|
+
* superseded items disposes the cleared item, and the sentinel is mapped back to
|
|
35
|
+
* UNASSIGNED_STATE on the way out.
|
|
36
|
+
*/
|
|
37
|
+
export function useUpdatableDisposableClearableState<
|
|
38
|
+
T = never,
|
|
39
|
+
>(): UseUpdatableDisposableClearableStateReturnValue<T> {
|
|
40
|
+
const { state, setState } = useUpdatableDisposableState<T | ClearedState>();
|
|
41
|
+
|
|
42
|
+
const clearState = useCallback(() => {
|
|
43
|
+
setState([CLEARED_STATE, noop]);
|
|
44
|
+
}, [setState]);
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
clearState,
|
|
48
|
+
setState,
|
|
49
|
+
state: state === CLEARED_STATE ? UNASSIGNED_STATE : state,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// @ts-ignore
|
|
54
|
+
function tsTests() {
|
|
55
|
+
const a = useUpdatableDisposableClearableState();
|
|
56
|
+
// @ts-expect-error
|
|
57
|
+
a.setState([UNASSIGNED_STATE, () => {}]);
|
|
58
|
+
// @ts-expect-error
|
|
59
|
+
a.setState(['asdf', () => {}]);
|
|
60
|
+
const b = useUpdatableDisposableClearableState<string | UnassignedState>();
|
|
61
|
+
// @ts-expect-error
|
|
62
|
+
b.setState([UNASSIGNED_STATE, () => {}]);
|
|
63
|
+
b.setState(['asdf', () => {}]);
|
|
64
|
+
// The cleared sentinel is not part of T, so it cannot be set from outside.
|
|
65
|
+
// @ts-expect-error
|
|
66
|
+
b.setState([CLEARED_STATE, () => {}]);
|
|
67
|
+
}
|