@isograph/react-disposable-state 0.5.2 → 0.5.5

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.
Files changed (96) hide show
  1. package/.turbo/turbo-compile-libs.log +5 -5
  2. package/.turbo/turbo-tsc.log +5 -0
  3. package/README.md +19 -0
  4. package/dist/{CacheItem.js → CacheItem.cjs} +11 -8
  5. package/dist/{CacheItem.d.ts → CacheItem.d.cts} +1 -1
  6. package/dist/CacheItem.d.cts.map +1 -0
  7. package/dist/CacheItem.d.mts.map +1 -1
  8. package/dist/CacheItem.mjs +11 -8
  9. package/dist/CacheItem.mjs.map +1 -1
  10. package/dist/{ParentCache.js → ParentCache.cjs} +4 -3
  11. package/dist/{ParentCache.d.ts → ParentCache.d.cts} +4 -4
  12. package/dist/ParentCache.d.cts.map +1 -0
  13. package/dist/ParentCache.d.mts +3 -3
  14. package/dist/ParentCache.d.mts.map +1 -1
  15. package/dist/ParentCache.mjs +3 -2
  16. package/dist/ParentCache.mjs.map +1 -1
  17. package/dist/{index.js → index.cjs} +14 -10
  18. package/dist/{index.d.ts → index.d.cts} +10 -8
  19. package/dist/index.d.mts +3 -1
  20. package/dist/index.mjs +3 -1
  21. package/dist/{useCachedResponsivePrecommitValue.js → useCachedResponsivePrecommitValue.cjs} +15 -9
  22. package/dist/{useCachedResponsivePrecommitValue.d.ts → useCachedResponsivePrecommitValue.d.cts} +2 -3
  23. package/dist/useCachedResponsivePrecommitValue.d.cts.map +1 -0
  24. package/dist/useCachedResponsivePrecommitValue.d.mts +0 -1
  25. package/dist/useCachedResponsivePrecommitValue.d.mts.map +1 -1
  26. package/dist/useCachedResponsivePrecommitValue.mjs +13 -7
  27. package/dist/useCachedResponsivePrecommitValue.mjs.map +1 -1
  28. package/dist/{useDisposableState.js → useDisposableState.cjs} +9 -10
  29. package/dist/{useDisposableState.d.ts → useDisposableState.d.cts} +3 -3
  30. package/dist/useDisposableState.d.cts.map +1 -0
  31. package/dist/useDisposableState.d.mts.map +1 -1
  32. package/dist/useDisposableState.mjs +7 -6
  33. package/dist/useDisposableState.mjs.map +1 -1
  34. package/dist/useEffectsRerunWithoutRender.cjs +27 -0
  35. package/dist/useEffectsRerunWithoutRender.d.cts +19 -0
  36. package/dist/useEffectsRerunWithoutRender.d.cts.map +1 -0
  37. package/dist/useEffectsRerunWithoutRender.d.mts +19 -0
  38. package/dist/useEffectsRerunWithoutRender.d.mts.map +1 -0
  39. package/dist/useEffectsRerunWithoutRender.mjs +28 -0
  40. package/dist/useEffectsRerunWithoutRender.mjs.map +1 -0
  41. package/dist/useHasCommittedRef.cjs +20 -0
  42. package/dist/useHasCommittedRef.d.cts +14 -0
  43. package/dist/useHasCommittedRef.d.cts.map +1 -0
  44. package/dist/useHasCommittedRef.d.mts +4 -1
  45. package/dist/useHasCommittedRef.d.mts.map +1 -1
  46. package/dist/useHasCommittedRef.mjs +6 -2
  47. package/dist/useHasCommittedRef.mjs.map +1 -1
  48. package/dist/{useLazyDisposableState.js → useLazyDisposableState.cjs} +6 -6
  49. package/dist/{useLazyDisposableState.d.ts → useLazyDisposableState.d.cts} +3 -4
  50. package/dist/useLazyDisposableState.d.cts.map +1 -0
  51. package/dist/useLazyDisposableState.d.mts +0 -1
  52. package/dist/useLazyDisposableState.d.mts.map +1 -1
  53. package/dist/useLazyDisposableState.mjs +3 -3
  54. package/dist/useLazyDisposableState.mjs.map +1 -1
  55. package/dist/useUpdatableDisposableClearableState.cjs +36 -0
  56. package/dist/useUpdatableDisposableClearableState.d.cts +26 -0
  57. package/dist/useUpdatableDisposableClearableState.d.cts.map +1 -0
  58. package/dist/useUpdatableDisposableClearableState.d.mts +26 -0
  59. package/dist/useUpdatableDisposableClearableState.d.mts.map +1 -0
  60. package/dist/useUpdatableDisposableClearableState.mjs +37 -0
  61. package/dist/useUpdatableDisposableClearableState.mjs.map +1 -0
  62. package/dist/{useUpdatableDisposableState.js → useUpdatableDisposableState.cjs} +6 -4
  63. package/dist/{useUpdatableDisposableState.d.ts → useUpdatableDisposableState.d.cts} +1 -1
  64. package/dist/useUpdatableDisposableState.d.cts.map +1 -0
  65. package/dist/useUpdatableDisposableState.d.mts.map +1 -1
  66. package/dist/useUpdatableDisposableState.mjs +5 -1
  67. package/dist/useUpdatableDisposableState.mjs.map +1 -1
  68. package/package.json +9 -6
  69. package/src/CacheItem.test.ts +40 -2
  70. package/src/CacheItem.ts +1 -0
  71. package/src/ParentCache.ts +26 -21
  72. package/src/index.ts +2 -0
  73. package/src/useCachedResponsivePrecommitValue.test.tsx +8 -3
  74. package/src/useCachedResponsivePrecommitValue.ts +32 -20
  75. package/src/useDisposableState.ts +13 -15
  76. package/src/useEffectsRerunWithoutRender.test.tsx +54 -0
  77. package/src/useEffectsRerunWithoutRender.ts +23 -0
  78. package/src/useHasCommittedRef.ts +6 -2
  79. package/src/useLazyDisposableState.test.tsx +73 -34
  80. package/src/useLazyDisposableState.ts +4 -5
  81. package/src/useUpdatableDisposableClearableState.ts +67 -0
  82. package/src/useUpdatableDisposableState.test.tsx +55 -4
  83. package/src/useUpdatableDisposableState.ts +2 -0
  84. package/tsconfig.json +2 -4
  85. package/vitest.config.ts +8 -0
  86. package/dist/CacheItem.d.ts.map +0 -1
  87. package/dist/ParentCache.d.ts.map +0 -1
  88. package/dist/_virtual/rolldown_runtime.js +0 -25
  89. package/dist/useCachedResponsivePrecommitValue.d.ts.map +0 -1
  90. package/dist/useDisposableState.d.ts.map +0 -1
  91. package/dist/useHasCommittedRef.d.ts +0 -11
  92. package/dist/useHasCommittedRef.d.ts.map +0 -1
  93. package/dist/useHasCommittedRef.js +0 -18
  94. package/dist/useLazyDisposableState.d.ts.map +0 -1
  95. package/dist/useUpdatableDisposableState.d.ts.map +0 -1
  96. package/tsconfig.pkg.json +0 -9
@@ -60,7 +60,11 @@ function useUpdatableDisposableState() {
60
60
  });
61
61
  useEffect(() => {
62
62
  return function disposeAllRemainingItems() {
63
- for (const undisposedICI of undisposedICIs.current) undisposedICI.cleanup();
63
+ for (const undisposedICI of undisposedICIs.current) {
64
+ undisposedICIs.current.delete(undisposedICI);
65
+ undisposedICI.cleanup();
66
+ }
67
+ setStateICI(UNASSIGNED_STATE);
64
68
  };
65
69
  }, []);
66
70
  return {
@@ -1 +1 @@
1
- {"version":3,"file":"useUpdatableDisposableState.mjs","names":["UNASSIGNED_STATE: unique symbol","ici: ICI<T>"],"sources":["../src/useUpdatableDisposableState.ts"],"sourcesContent":["import type { ItemCleanupPair } from '@isograph/disposable-types';\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport { useHasCommittedRef } from './useHasCommittedRef';\n\nexport const UNASSIGNED_STATE: unique symbol = Symbol();\nexport type UnassignedState = typeof UNASSIGNED_STATE;\n\ntype UseUpdatableDisposableStateReturnValue<T> = {\n state: T | UnassignedState;\n setState: (pair: ItemCleanupPair<Exclude<T, UnassignedState>>) => void;\n};\n\n/**\n * ICI stands for ItemCleanupIndex. Use a short name because it shows up a lot.\n *\n * Like an ItemCleanupPair<T>, but with an additional index and as a struct\n * for readability.\n *\n * Interally, we must keep track of the index of the state change. The disposable\n * items that are set in state are not guaranteed to be distinct according to ===,\n * as in `setState([1, dispose1]); setState([1, dispose2])`. Following this, we\n * would expect dispose1 to be called on commit. If state items were compared for\n * ===, then dispose1 would not be called in such a situation.\n *\n * Note that this comes at a runtime cost, and we may want to expose APIs that do\n * not incur this runtime cost, for example in cases where every disposable item is\n * distinguishable via ===.\n */\ntype ICI<T> = { item: T; cleanup: () => void; index: number };\n\n/**\n * useUpdatableDisposableState\n * - Returns a { state, setItem } object.\n * - setItem accepts an ItemCleanupPair<T>, and throws if called before commit.\n * - setItem sets the T in state and adds it to a set.\n * - React's behavior is that when the hook commits, whatever item is currently\n * returned from the useState hook is the oldest item which will ever be returned\n * from that useState hook. (More newly created ones can later be returned with\n * concurrent mode.)\n * - When this hook commits, all items up to, but not including, the item currently\n * returned from the useState hook are disposed and removed from the set.\n *\n * Calling setState before the hook commits:\n * - Calling setState before the hook commits is disallowed because until the hook\n * commits, React will not schedule any unmount callbacks, meaning that if this\n * hook never commits, any disposable items passed to setState will never be\n * disposed.\n * - We also cannot store them in some cache, because multiple components can share\n * the same cache location (for example, if they are loading the same query from\n * multiple components), so updating the cache with the disposable item will cause\n * both components to show the updated data, which is almost certainly a bug.\n * - Note that calling setState before commit is probably an anti-pattern! Consider\n * not doing it.\n * - If you must, the workaround is to lazily load the disposable item with\n * useDisposableState or useLazyDisposableState, and update the cache location\n * instead of calling setState before commit. One can update the cache location\n * by calling setState on some parent component that has already mounted, and\n * therefore passing in different props.\n * - This may only work in concurrent mode, though.\n */\nexport function useUpdatableDisposableState<\n T = never,\n>(): UseUpdatableDisposableStateReturnValue<T> {\n const hasCommittedRef = useHasCommittedRef();\n\n const undisposedICIs = useRef(new Set<ICI<T>>());\n const setStateCountRef = useRef(0);\n\n const [stateICI, setStateICI] = useState<ICI<T> | UnassignedState>(\n UNASSIGNED_STATE,\n );\n\n const setStateAfterCommit = useCallback(\n (itemCleanupPair: ItemCleanupPair<T>) => {\n if (!hasCommittedRef.current) {\n throw new Error(\n 'Calling setState before the component has committed is unsafe and disallowed.',\n );\n }\n\n const ici: ICI<T> = {\n item: itemCleanupPair[0],\n cleanup: itemCleanupPair[1],\n index: setStateCountRef.current,\n };\n setStateCountRef.current++;\n undisposedICIs.current.add(ici);\n setStateICI(ici);\n },\n [setStateICI],\n );\n\n useEffect(function cleanupUnreachableItems() {\n const indexInState = stateICI !== UNASSIGNED_STATE ? stateICI.index : 0;\n\n if (indexInState === 0) {\n return;\n }\n\n for (const undisposedICI of undisposedICIs.current) {\n if (undisposedICI.index === indexInState) {\n break;\n }\n undisposedICIs.current.delete(undisposedICI);\n undisposedICI.cleanup();\n }\n });\n\n useEffect(() => {\n return function disposeAllRemainingItems() {\n for (const undisposedICI of undisposedICIs.current) {\n undisposedICI.cleanup();\n }\n };\n }, []);\n\n return {\n setState: setStateAfterCommit,\n state: stateICI !== UNASSIGNED_STATE ? stateICI.item : UNASSIGNED_STATE,\n };\n}\n\n// @ts-ignore\nfunction tsTests() {\n const a = useUpdatableDisposableState();\n // @ts-expect-error\n a.setState([UNASSIGNED_STATE, () => {}]);\n // @ts-expect-error\n a.setState(['asdf', () => {}]);\n const b = useUpdatableDisposableState<string | UnassignedState>();\n // @ts-expect-error\n b.setState([UNASSIGNED_STATE, () => {}]);\n b.setState(['asdf', () => {}]);\n}\n"],"mappings":";;;;AAIA,MAAaA,mBAAkC,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwDvD,SAAgB,8BAE+B;CAC7C,MAAM,kBAAkB,oBAAoB;CAE5C,MAAM,iBAAiB,uBAAO,IAAI,KAAa,CAAC;CAChD,MAAM,mBAAmB,OAAO,EAAE;CAElC,MAAM,CAAC,UAAU,eAAe,SAC9B,iBACD;CAED,MAAM,sBAAsB,aACzB,oBAAwC;AACvC,MAAI,CAAC,gBAAgB,QACnB,OAAM,IAAI,MACR,gFACD;EAGH,MAAMC,MAAc;GAClB,MAAM,gBAAgB;GACtB,SAAS,gBAAgB;GACzB,OAAO,iBAAiB;GACzB;AACD,mBAAiB;AACjB,iBAAe,QAAQ,IAAI,IAAI;AAC/B,cAAY,IAAI;IAElB,CAAC,YAAY,CACd;AAED,WAAU,SAAS,0BAA0B;EAC3C,MAAM,eAAe,aAAa,mBAAmB,SAAS,QAAQ;AAEtE,MAAI,iBAAiB,EACnB;AAGF,OAAK,MAAM,iBAAiB,eAAe,SAAS;AAClD,OAAI,cAAc,UAAU,aAC1B;AAEF,kBAAe,QAAQ,OAAO,cAAc;AAC5C,iBAAc,SAAS;;GAEzB;AAEF,iBAAgB;AACd,SAAO,SAAS,2BAA2B;AACzC,QAAK,MAAM,iBAAiB,eAAe,QACzC,eAAc,SAAS;;IAG1B,EAAE,CAAC;AAEN,QAAO;EACL,UAAU;EACV,OAAO,aAAa,mBAAmB,SAAS,OAAO;EACxD"}
1
+ {"version":3,"file":"useUpdatableDisposableState.mjs","names":[],"sources":["../src/useUpdatableDisposableState.ts"],"sourcesContent":["import type { ItemCleanupPair } from '@isograph/disposable-types';\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport { useHasCommittedRef } from './useHasCommittedRef';\n\nexport const UNASSIGNED_STATE: unique symbol = Symbol();\nexport type UnassignedState = typeof UNASSIGNED_STATE;\n\ntype UseUpdatableDisposableStateReturnValue<T> = {\n state: T | UnassignedState;\n setState: (pair: ItemCleanupPair<Exclude<T, UnassignedState>>) => void;\n};\n\n/**\n * ICI stands for ItemCleanupIndex. Use a short name because it shows up a lot.\n *\n * Like an ItemCleanupPair<T>, but with an additional index and as a struct\n * for readability.\n *\n * Interally, we must keep track of the index of the state change. The disposable\n * items that are set in state are not guaranteed to be distinct according to ===,\n * as in `setState([1, dispose1]); setState([1, dispose2])`. Following this, we\n * would expect dispose1 to be called on commit. If state items were compared for\n * ===, then dispose1 would not be called in such a situation.\n *\n * Note that this comes at a runtime cost, and we may want to expose APIs that do\n * not incur this runtime cost, for example in cases where every disposable item is\n * distinguishable via ===.\n */\ntype ICI<T> = { item: T; cleanup: () => void; index: number };\n\n/**\n * useUpdatableDisposableState\n * - Returns a { state, setItem } object.\n * - setItem accepts an ItemCleanupPair<T>, and throws if called before commit.\n * - setItem sets the T in state and adds it to a set.\n * - React's behavior is that when the hook commits, whatever item is currently\n * returned from the useState hook is the oldest item which will ever be returned\n * from that useState hook. (More newly created ones can later be returned with\n * concurrent mode.)\n * - When this hook commits, all items up to, but not including, the item currently\n * returned from the useState hook are disposed and removed from the set.\n *\n * Calling setState before the hook commits:\n * - Calling setState before the hook commits is disallowed because until the hook\n * commits, React will not schedule any unmount callbacks, meaning that if this\n * hook never commits, any disposable items passed to setState will never be\n * disposed.\n * - We also cannot store them in some cache, because multiple components can share\n * the same cache location (for example, if they are loading the same query from\n * multiple components), so updating the cache with the disposable item will cause\n * both components to show the updated data, which is almost certainly a bug.\n * - Note that calling setState before commit is probably an anti-pattern! Consider\n * not doing it.\n * - If you must, the workaround is to lazily load the disposable item with\n * useDisposableState or useLazyDisposableState, and update the cache location\n * instead of calling setState before commit. One can update the cache location\n * by calling setState on some parent component that has already mounted, and\n * therefore passing in different props.\n * - This may only work in concurrent mode, though.\n */\nexport function useUpdatableDisposableState<\n T = never,\n>(): UseUpdatableDisposableStateReturnValue<T> {\n const hasCommittedRef = useHasCommittedRef();\n\n const undisposedICIs = useRef(new Set<ICI<T>>());\n const setStateCountRef = useRef(0);\n\n const [stateICI, setStateICI] = useState<ICI<T> | UnassignedState>(\n UNASSIGNED_STATE,\n );\n\n const setStateAfterCommit = useCallback(\n (itemCleanupPair: ItemCleanupPair<T>) => {\n if (!hasCommittedRef.current) {\n throw new Error(\n 'Calling setState before the component has committed is unsafe and disallowed.',\n );\n }\n\n const ici: ICI<T> = {\n item: itemCleanupPair[0],\n cleanup: itemCleanupPair[1],\n index: setStateCountRef.current,\n };\n setStateCountRef.current++;\n undisposedICIs.current.add(ici);\n setStateICI(ici);\n },\n [setStateICI],\n );\n\n useEffect(function cleanupUnreachableItems() {\n const indexInState = stateICI !== UNASSIGNED_STATE ? stateICI.index : 0;\n\n if (indexInState === 0) {\n return;\n }\n\n for (const undisposedICI of undisposedICIs.current) {\n if (undisposedICI.index === indexInState) {\n break;\n }\n undisposedICIs.current.delete(undisposedICI);\n undisposedICI.cleanup();\n }\n });\n\n useEffect(() => {\n return function disposeAllRemainingItems() {\n for (const undisposedICI of undisposedICIs.current) {\n undisposedICIs.current.delete(undisposedICI);\n undisposedICI.cleanup();\n }\n setStateICI(UNASSIGNED_STATE);\n };\n }, []);\n\n return {\n setState: setStateAfterCommit,\n state: stateICI !== UNASSIGNED_STATE ? stateICI.item : UNASSIGNED_STATE,\n };\n}\n\n// @ts-ignore\nfunction tsTests() {\n const a = useUpdatableDisposableState();\n // @ts-expect-error\n a.setState([UNASSIGNED_STATE, () => {}]);\n // @ts-expect-error\n a.setState(['asdf', () => {}]);\n const b = useUpdatableDisposableState<string | UnassignedState>();\n // @ts-expect-error\n b.setState([UNASSIGNED_STATE, () => {}]);\n b.setState(['asdf', () => {}]);\n}\n"],"mappings":";;;;AAIA,MAAa,mBAAkC,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwDvD,SAAgB,8BAE+B;CAC7C,MAAM,kBAAkB,oBAAoB;CAE5C,MAAM,iBAAiB,uBAAO,IAAI,KAAa,CAAC;CAChD,MAAM,mBAAmB,OAAO,EAAE;CAElC,MAAM,CAAC,UAAU,eAAe,SAC9B,iBACD;CAED,MAAM,sBAAsB,aACzB,oBAAwC;AACvC,MAAI,CAAC,gBAAgB,QACnB,OAAM,IAAI,MACR,gFACD;EAGH,MAAM,MAAc;GAClB,MAAM,gBAAgB;GACtB,SAAS,gBAAgB;GACzB,OAAO,iBAAiB;GACzB;AACD,mBAAiB;AACjB,iBAAe,QAAQ,IAAI,IAAI;AAC/B,cAAY,IAAI;IAElB,CAAC,YAAY,CACd;AAED,WAAU,SAAS,0BAA0B;EAC3C,MAAM,eAAe,aAAa,mBAAmB,SAAS,QAAQ;AAEtE,MAAI,iBAAiB,EACnB;AAGF,OAAK,MAAM,iBAAiB,eAAe,SAAS;AAClD,OAAI,cAAc,UAAU,aAC1B;AAEF,kBAAe,QAAQ,OAAO,cAAc;AAC5C,iBAAc,SAAS;;GAEzB;AAEF,iBAAgB;AACd,SAAO,SAAS,2BAA2B;AACzC,QAAK,MAAM,iBAAiB,eAAe,SAAS;AAClD,mBAAe,QAAQ,OAAO,cAAc;AAC5C,kBAAc,SAAS;;AAEzB,eAAY,iBAAiB;;IAE9B,EAAE,CAAC;AAEN,QAAO;EACL,UAAU;EACV,OAAO,aAAa,mBAAmB,SAAS,OAAO;EACxD"}
package/package.json CHANGED
@@ -1,20 +1,23 @@
1
1
  {
2
2
  "name": "@isograph/react-disposable-state",
3
- "version": "0.5.2",
3
+ "version": "0.5.5",
4
4
  "description": "Primitives for managing disposable state in React",
5
5
  "homepage": "https://isograph.dev",
6
- "main": "./dist/index.js",
7
- "types": "./dist/index.d.ts",
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.2"
11
+ "@isograph/disposable-types": "0.5.5"
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
- "import": "./dist/index.mjs",
31
- "require": "./dist/index.js"
33
+ "require": "./dist/index.cjs",
34
+ "import": "./dist/index.mjs"
32
35
  },
33
36
  "./package.json": "./package.json"
34
37
  },
@@ -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--;
@@ -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(this.__factory, () => {
63
- // We are doing this check because we don't want to remove the cache item
64
- // if it is not the one that was created when the temporary retain was created.
65
- //
66
- // Consider the following scenario:
67
- // - we populate the cache with CacheItem A,
68
- // - then manually delete CacheItem A (e.g. to force a refetch)
69
- // - then, we re-populate the parent cache with CacheItem B
70
- // - then, the temporary retain of CacheItem A is disposed or expires.
71
- //
72
- // At this point, we don't want to delete CacheItem B from the cache.
73
- //
74
- // TODO consider what happens if items are === comparable to each other,
75
- // e.g. the item is a number!
76
- if (this.__cacheItem === pair[0]) {
77
- this.empty();
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, { type MutableRefObject } from '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
- const undisposedPair = cacheItem.permanentRetainIfNotDisposed(
71
- disposeOfTemporaryRetain,
72
- );
73
- if (undisposedPair != null) {
74
- onCommit(undisposedPair);
75
- } else {
76
- // The cache item we created during render has been disposed. Check if the parent
77
- // cache is populated.
78
- const existingCacheItemCleanupPair =
79
- parentCache.getAndPermanentRetainIfPresent();
80
- if (existingCacheItemCleanupPair != null) {
81
- onCommit(existingCacheItemCleanupPair);
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(function cleanupItemCleanupPairRefIfSetStateNotCalled() {
50
- return () => {
51
- if (itemCleanupPairRef.current != null) {
52
- itemCleanupPairRef.current[1]();
53
- itemCleanupPairRef.current = null;
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,11 +1,15 @@
1
- import { type MutableRefObject, useEffect, useRef } from 'react';
1
+ import { type MutableRefObject, useInsertionEffect, useRef } from 'react';
2
2
 
3
3
  /**
4
4
  * Returns true if the component has committed, false otherwise.
5
+ *
6
+ * Set in an insertion effect, which React runs in the mutation phase of the commit, before any layout or passive
7
+ * effect of the commit. Layout and passive effects of the component's children, and DOM event handlers that run
8
+ * before the passive effects are flushed, therefore read true.
5
9
  */
6
10
  export function useHasCommittedRef(): MutableRefObject<boolean> {
7
11
  const hasCommittedRef = useRef(false);
8
- useEffect(() => {
12
+ useInsertionEffect(() => {
9
13
  hasCommittedRef.current = true;
10
14
  }, []);
11
15
  return hasCommittedRef;
@@ -1,9 +1,9 @@
1
1
  import type { ItemCleanupPair } from '@isograph/disposable-types';
2
- import React, { useEffect, useState } from 'react';
3
- import { create } from 'react-test-renderer';
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
- test('on cache change, it should dispose previous cache', async () => {
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
- let unmounted = promiseWithResolvers();
35
- let committed = promiseWithResolvers();
39
+ const { result } = renderHook(
40
+ (props) => useLazyDisposableState(props.parentCache),
41
+ {
42
+ initialProps: {
43
+ parentCache: cache1.cache,
44
+ },
45
+ },
46
+ );
36
47
 
37
- function TestComponent() {
38
- const [cache, setCache] = useState(cache1.cache);
39
- const { state } = useLazyDisposableState(cache);
48
+ expect(result.current.state).toEqual(1);
49
+ expect(cache1.cache.factory).toHaveBeenCalledOnce();
50
+ });
40
51
 
41
- useEffect(() => {
42
- setCache(cache2.cache);
52
+ test('on unmount it should dispose the cache', async () => {
53
+ const cache1 = createCache(1);
43
54
 
44
- return () => {
45
- unmounted.resolve();
46
- };
47
- }, []);
55
+ const { unmount } = renderHook(
56
+ (props) => useLazyDisposableState(props.parentCache),
57
+ {
58
+ initialProps: {
59
+ parentCache: cache1.cache,
60
+ },
61
+ },
62
+ );
48
63
 
49
- useEffect(() => {
50
- if (state === 1) return;
51
- committed.resolve();
52
- }, [state]);
64
+ unmount();
65
+ vi.runAllTimers();
66
+ expect(cache1.disposeItem).toHaveBeenCalledOnce();
67
+ });
53
68
 
54
- renders(state);
69
+ test('on cache change, it should read new cache', async () => {
70
+ const cache1 = createCache(1);
71
+ const cache2 = createCache(2);
55
72
 
56
- return null;
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
- const root = create(<TestComponent />, { unstable_isConcurrent: true });
60
- await committed.promise;
61
- expect(cache1.disposeItem).toHaveBeenCalled();
62
- expect(cache1.cache.factory).toHaveBeenCalledOnce();
63
- root.unmount();
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
- expect(renders).toHaveBeenNthCalledWith(1, 1);
68
- expect(renders).toHaveBeenNthCalledWith(2, 2);
69
- expect(renders).toHaveBeenCalledTimes(2);
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/isograph-disposable-types';
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
- return cleanupFn();
40
+ cleanupFn();
41
+ itemCleanupPairRef.current = null;
43
42
  };
44
- }, []);
43
+ }, [parentCache]);
45
44
 
46
45
  const returnedItem = preCommitItem?.state ?? itemCleanupPairRef.current?.[0];
47
46