@isograph/react-disposable-state 0.0.0-main-54f75b55 → 0.0.0-main-36b092bd

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.
@@ -1,11 +1,11 @@
1
1
  ../.. |  WARN  Unsupported engine: wanted: {"node":"24.12.0"} (current: {"node":"v24.21.0","pnpm":"10.15.0"})
2
2
 
3
- > @isograph/react-disposable-state@0.0.0-main-54f75b55 compile-libs /home/runner/work/isograph/isograph/libs/isograph-react-disposable-state
3
+ > @isograph/react-disposable-state@0.0.0-main-36b092bd compile-libs /home/runner/work/isograph/isograph/libs/isograph-react-disposable-state
4
4
  > tsdown
5
5
 
6
6
  ℹ tsdown v0.20.1 powered by rolldown v1.0.0-rc.1
7
7
  ℹ config file: /home/runner/work/isograph/isograph/tsdown.config.ts
8
- (node:2791) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///home/runner/work/isograph/isograph/tsdown.config.ts?no-cache=206d8011-1532-4f75-9b2e-174ab111541e is not specified and it doesn't parse as CommonJS.
8
+ (node:2909) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///home/runner/work/isograph/isograph/tsdown.config.ts?no-cache=f0580075-e3d4-4057-84f0-b8678cf6a2ba is not specified and it doesn't parse as CommonJS.
9
9
  Reparsing as ES module because module syntax was detected. This incurs a performance overhead.
10
10
  To eliminate this warning, add "type": "module" to /home/runner/work/isograph/isograph/package.json.
11
11
  (Use `node --trace-warnings ...` to show where the warning was created)
@@ -1,5 +1,5 @@
1
1
  ../.. |  WARN  Unsupported engine: wanted: {"node":"24.12.0"} (current: {"node":"v24.21.0","pnpm":"10.15.0"})
2
2
 
3
- > @isograph/react-disposable-state@0.0.0-main-54f75b55 tsc /home/runner/work/isograph/isograph/libs/isograph-react-disposable-state
3
+ > @isograph/react-disposable-state@0.0.0-main-36b092bd tsc /home/runner/work/isograph/isograph/libs/isograph-react-disposable-state
4
4
  > tsc
5
5
 
package/README.md CHANGED
@@ -129,6 +129,53 @@ const {
129
129
  } = useUpdatableDisposableClearableState<T>();
130
130
  ```
131
131
 
132
+ ### `useDisposableArray`
133
+
134
+ A hook that holds an array of disposable items, built on `useUpdatableDisposableState`.
135
+
136
+ - Returns an `{ entries, setEntries }` object. `entries` is the array of `ItemCleanupPair`s the current render shows. It is empty until the first `setEntries` call commits. The hook owns each pair's cleanup, so do not call it.
137
+ - `setEntries(nextEntries)` stores `nextEntries`. Older arrays are released when a newer array commits, and the rest on unmount. Releasing an array calls the cleanup of each of its pairs once.
138
+ - Every pair in `nextEntries` must be acquired by the caller for that call, and the hook owns it from then on. To keep an item of `entries`, take a new reference to it, for example with `cloneIfNotDisposed()` on a `ReferenceCountedPointer` from `@isograph/reference-counted-pointer`. Never pass a pair of `entries` itself.
139
+ - Build `nextEntries` from the `entries` of the same render. If that render is stale, because its `entries` were released after a newer array committed, `cloneIfNotDisposed()` returns `null`. Release what you acquired and do not call `setEntries`.
140
+ - Two `setEntries` calls before a commit both build on the same `entries`, and the second replaces the first.
141
+ - `setEntries` throws if called before the initial commit, as `setState` does. It stores nothing in that case, so the caller still owns `nextEntries`.
142
+
143
+ ```typescript
144
+ const {
145
+ entries,
146
+ setEntries,
147
+ }: {
148
+ entries: ReadonlyArray<ItemCleanupPair<T>>;
149
+ setEntries: (nextEntries: ReadonlyArray<ItemCleanupPair<T>>) => void;
150
+ } = useDisposableArray<T>();
151
+ ```
152
+
153
+ For example, to keep at most three items, store reference-counted pointers, take a new reference to each item that stays, and add the new one:
154
+
155
+ ```typescript
156
+ const { entries, setEntries } =
157
+ useDisposableArray<ReferenceCountedPointer<Item>>();
158
+
159
+ function addItem(pair: ItemCleanupPair<Item>) {
160
+ const kept: Array<ItemCleanupPair<ReferenceCountedPointer<Item>>> = [];
161
+ for (const entry of entries.slice(-2)) {
162
+ const clone = entry[0].cloneIfNotDisposed();
163
+ if (clone == null) {
164
+ // This render is stale.
165
+ for (const keptEntry of kept) {
166
+ keptEntry[1]();
167
+ }
168
+ pair[1]();
169
+ return;
170
+ }
171
+ kept.push(clone);
172
+ }
173
+ setEntries([...kept, createReferenceCountedPointer(pair)]);
174
+ }
175
+
176
+ // Read an item with entries[index][0].getItemIfNotDisposed().
177
+ ```
178
+
132
179
  ### `useDisposableState`
133
180
 
134
181
  > This could properly be called `useLazyUpdatableDisposableState`, but that's quite long!
package/dist/index.cjs CHANGED
@@ -4,6 +4,7 @@ const require_useEffectsRerunWithoutRender = require('./useEffectsRerunWithoutRe
4
4
  const require_useCachedResponsivePrecommitValue = require('./useCachedResponsivePrecommitValue.cjs');
5
5
  const require_useHasCommittedRef = require('./useHasCommittedRef.cjs');
6
6
  const require_useUpdatableDisposableState = require('./useUpdatableDisposableState.cjs');
7
+ const require_useDisposableArray = require('./useDisposableArray.cjs');
7
8
  const require_useDisposableState = require('./useDisposableState.cjs');
8
9
  const require_useLazyDisposableState = require('./useLazyDisposableState.cjs');
9
10
  const require_useUpdatableDisposableClearableState = require('./useUpdatableDisposableClearableState.cjs');
@@ -13,6 +14,7 @@ exports.ParentCache = require_ParentCache.ParentCache;
13
14
  exports.UNASSIGNED_STATE = require_useUpdatableDisposableState.UNASSIGNED_STATE;
14
15
  exports.createTemporarilyRetainedCacheItem = require_CacheItem.createTemporarilyRetainedCacheItem;
15
16
  exports.useCachedResponsivePrecommitValue = require_useCachedResponsivePrecommitValue.useCachedResponsivePrecommitValue;
17
+ exports.useDisposableArray = require_useDisposableArray.useDisposableArray;
16
18
  exports.useDisposableState = require_useDisposableState.useDisposableState;
17
19
  exports.useEffectsRerunWithoutRenderRef = require_useEffectsRerunWithoutRender.useEffectsRerunWithoutRenderRef;
18
20
  exports.useHasCommittedRef = require_useHasCommittedRef.useHasCommittedRef;
package/dist/index.d.cts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { CacheItem, CacheItemOptions, CacheItemState, InParentCacheAndNotDisposed, NotInParentCacheAndDisposed, NotInParentCacheAndNotDisposed, createTemporarilyRetainedCacheItem } from "./CacheItem.cjs";
2
2
  import { ParentCache } from "./ParentCache.cjs";
3
3
  import { useCachedResponsivePrecommitValue } from "./useCachedResponsivePrecommitValue.cjs";
4
+ import { UseDisposableArrayReturn, useDisposableArray } from "./useDisposableArray.cjs";
4
5
  import { UNASSIGNED_STATE, UnassignedState, useUpdatableDisposableState } from "./useUpdatableDisposableState.cjs";
5
6
  import { useDisposableState } from "./useDisposableState.cjs";
6
7
  import { useEffectsRerunWithoutRenderRef } from "./useEffectsRerunWithoutRender.cjs";
@@ -8,4 +9,4 @@ import { useHasCommittedRef } from "./useHasCommittedRef.cjs";
8
9
  import { useLazyDisposableState } from "./useLazyDisposableState.cjs";
9
10
  import { useUpdatableDisposableClearableState } from "./useUpdatableDisposableClearableState.cjs";
10
11
  export * from "@isograph/disposable-types";
11
- export { CacheItem, CacheItemOptions, CacheItemState, InParentCacheAndNotDisposed, NotInParentCacheAndDisposed, NotInParentCacheAndNotDisposed, ParentCache, UNASSIGNED_STATE, UnassignedState, createTemporarilyRetainedCacheItem, useCachedResponsivePrecommitValue, useDisposableState, useEffectsRerunWithoutRenderRef, useHasCommittedRef, useLazyDisposableState, useUpdatableDisposableClearableState, useUpdatableDisposableState };
12
+ export { CacheItem, CacheItemOptions, CacheItemState, InParentCacheAndNotDisposed, NotInParentCacheAndDisposed, NotInParentCacheAndNotDisposed, ParentCache, UNASSIGNED_STATE, UnassignedState, UseDisposableArrayReturn, createTemporarilyRetainedCacheItem, useCachedResponsivePrecommitValue, useDisposableArray, useDisposableState, useEffectsRerunWithoutRenderRef, useHasCommittedRef, useLazyDisposableState, useUpdatableDisposableClearableState, useUpdatableDisposableState };
package/dist/index.d.mts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { CacheItem, CacheItemOptions, CacheItemState, InParentCacheAndNotDisposed, NotInParentCacheAndDisposed, NotInParentCacheAndNotDisposed, createTemporarilyRetainedCacheItem } from "./CacheItem.mjs";
2
2
  import { ParentCache } from "./ParentCache.mjs";
3
3
  import { useCachedResponsivePrecommitValue } from "./useCachedResponsivePrecommitValue.mjs";
4
+ import { UseDisposableArrayReturn, useDisposableArray } from "./useDisposableArray.mjs";
4
5
  import { UNASSIGNED_STATE, UnassignedState, useUpdatableDisposableState } from "./useUpdatableDisposableState.mjs";
5
6
  import { useDisposableState } from "./useDisposableState.mjs";
6
7
  import { useEffectsRerunWithoutRenderRef } from "./useEffectsRerunWithoutRender.mjs";
@@ -8,4 +9,4 @@ import { useHasCommittedRef } from "./useHasCommittedRef.mjs";
8
9
  import { useLazyDisposableState } from "./useLazyDisposableState.mjs";
9
10
  import { useUpdatableDisposableClearableState } from "./useUpdatableDisposableClearableState.mjs";
10
11
  export * from "@isograph/disposable-types";
11
- export { CacheItem, CacheItemOptions, CacheItemState, InParentCacheAndNotDisposed, NotInParentCacheAndDisposed, NotInParentCacheAndNotDisposed, ParentCache, UNASSIGNED_STATE, UnassignedState, createTemporarilyRetainedCacheItem, useCachedResponsivePrecommitValue, useDisposableState, useEffectsRerunWithoutRenderRef, useHasCommittedRef, useLazyDisposableState, useUpdatableDisposableClearableState, useUpdatableDisposableState };
12
+ export { CacheItem, CacheItemOptions, CacheItemState, InParentCacheAndNotDisposed, NotInParentCacheAndDisposed, NotInParentCacheAndNotDisposed, ParentCache, UNASSIGNED_STATE, UnassignedState, UseDisposableArrayReturn, createTemporarilyRetainedCacheItem, useCachedResponsivePrecommitValue, useDisposableArray, useDisposableState, useEffectsRerunWithoutRenderRef, useHasCommittedRef, useLazyDisposableState, useUpdatableDisposableClearableState, useUpdatableDisposableState };
package/dist/index.mjs CHANGED
@@ -4,10 +4,11 @@ import { useEffectsRerunWithoutRenderRef } from "./useEffectsRerunWithoutRender.
4
4
  import { useCachedResponsivePrecommitValue } from "./useCachedResponsivePrecommitValue.mjs";
5
5
  import { useHasCommittedRef } from "./useHasCommittedRef.mjs";
6
6
  import { UNASSIGNED_STATE, useUpdatableDisposableState } from "./useUpdatableDisposableState.mjs";
7
+ import { useDisposableArray } from "./useDisposableArray.mjs";
7
8
  import { useDisposableState } from "./useDisposableState.mjs";
8
9
  import { useLazyDisposableState } from "./useLazyDisposableState.mjs";
9
10
  import { useUpdatableDisposableClearableState } from "./useUpdatableDisposableClearableState.mjs";
10
11
 
11
12
  export * from "@isograph/disposable-types"
12
13
 
13
- export { CacheItem, ParentCache, UNASSIGNED_STATE, createTemporarilyRetainedCacheItem, useCachedResponsivePrecommitValue, useDisposableState, useEffectsRerunWithoutRenderRef, useHasCommittedRef, useLazyDisposableState, useUpdatableDisposableClearableState, useUpdatableDisposableState };
14
+ export { CacheItem, ParentCache, UNASSIGNED_STATE, createTemporarilyRetainedCacheItem, useCachedResponsivePrecommitValue, useDisposableArray, useDisposableState, useEffectsRerunWithoutRenderRef, useHasCommittedRef, useLazyDisposableState, useUpdatableDisposableClearableState, useUpdatableDisposableState };
@@ -0,0 +1,20 @@
1
+ const require_useUpdatableDisposableState = require('./useUpdatableDisposableState.cjs');
2
+ let react = require("react");
3
+
4
+ //#region src/useDisposableArray.ts
5
+ const NO_ENTRIES = Object.freeze([]);
6
+ function useDisposableArray() {
7
+ const { state, setState } = require_useUpdatableDisposableState.useUpdatableDisposableState();
8
+ const setEntries = (0, react.useCallback)((nextEntries) => {
9
+ setState([nextEntries, () => {
10
+ for (const entry of nextEntries) entry[1]();
11
+ }]);
12
+ }, [setState]);
13
+ return {
14
+ entries: state === require_useUpdatableDisposableState.UNASSIGNED_STATE ? NO_ENTRIES : state,
15
+ setEntries
16
+ };
17
+ }
18
+
19
+ //#endregion
20
+ exports.useDisposableArray = useDisposableArray;
@@ -0,0 +1,11 @@
1
+ import { ItemCleanupPair } from "@isograph/disposable-types";
2
+
3
+ //#region src/useDisposableArray.d.ts
4
+ type UseDisposableArrayReturn<T> = {
5
+ readonly entries: ReadonlyArray<ItemCleanupPair<T>>;
6
+ readonly setEntries: (nextEntries: ReadonlyArray<ItemCleanupPair<T>>) => void;
7
+ };
8
+ declare function useDisposableArray<T>(): UseDisposableArrayReturn<T>;
9
+ //#endregion
10
+ export { UseDisposableArrayReturn, useDisposableArray };
11
+ //# sourceMappingURL=useDisposableArray.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useDisposableArray.d.cts","names":[],"sources":["../src/useDisposableArray.ts"],"mappings":";;;KAOY,wBAAA;EAAA,SAGD,OAAA,EAAS,aAAA,CAAc,eAAA,CAAgB,CAAA;EAAA,SAEvC,UAAA,GAAa,WAAA,EAAa,aAAA,CAAc,eAAA,CAAgB,CAAA;AAAA;AAAA,iBAoBnD,kBAAA,GAAA,CAAA,GAAyB,wBAAA,CAAyB,CAAA"}
@@ -0,0 +1,11 @@
1
+ import { ItemCleanupPair } from "@isograph/disposable-types";
2
+
3
+ //#region src/useDisposableArray.d.ts
4
+ type UseDisposableArrayReturn<T> = {
5
+ readonly entries: ReadonlyArray<ItemCleanupPair<T>>;
6
+ readonly setEntries: (nextEntries: ReadonlyArray<ItemCleanupPair<T>>) => void;
7
+ };
8
+ declare function useDisposableArray<T>(): UseDisposableArrayReturn<T>;
9
+ //#endregion
10
+ export { UseDisposableArrayReturn, useDisposableArray };
11
+ //# sourceMappingURL=useDisposableArray.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useDisposableArray.d.mts","names":[],"sources":["../src/useDisposableArray.ts"],"mappings":";;;KAOY,wBAAA;EAAA,SAGD,OAAA,EAAS,aAAA,CAAc,eAAA,CAAgB,CAAA;EAAA,SAEvC,UAAA,GAAa,WAAA,EAAa,aAAA,CAAc,eAAA,CAAgB,CAAA;AAAA;AAAA,iBAoBnD,kBAAA,GAAA,CAAA,GAAyB,wBAAA,CAAyB,CAAA"}
@@ -0,0 +1,21 @@
1
+ import { UNASSIGNED_STATE, useUpdatableDisposableState } from "./useUpdatableDisposableState.mjs";
2
+ import { useCallback } from "react";
3
+
4
+ //#region src/useDisposableArray.ts
5
+ const NO_ENTRIES = Object.freeze([]);
6
+ function useDisposableArray() {
7
+ const { state, setState } = useUpdatableDisposableState();
8
+ const setEntries = useCallback((nextEntries) => {
9
+ setState([nextEntries, () => {
10
+ for (const entry of nextEntries) entry[1]();
11
+ }]);
12
+ }, [setState]);
13
+ return {
14
+ entries: state === UNASSIGNED_STATE ? NO_ENTRIES : state,
15
+ setEntries
16
+ };
17
+ }
18
+
19
+ //#endregion
20
+ export { useDisposableArray };
21
+ //# sourceMappingURL=useDisposableArray.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useDisposableArray.mjs","names":[],"sources":["../src/useDisposableArray.ts"],"sourcesContent":["import type { ItemCleanupPair } from '@isograph/disposable-types';\nimport { useCallback } from 'react';\nimport {\n UNASSIGNED_STATE,\n useUpdatableDisposableState,\n} from './useUpdatableDisposableState';\n\nexport type UseDisposableArrayReturn<T> = {\n // The array this render shows. It is empty until the first setEntries call commits. The hook owns every pair in\n // it, so callers read the items and never call the cleanups.\n readonly entries: ReadonlyArray<ItemCleanupPair<T>>;\n // Replaces the array. See useDisposableArray.\n readonly setEntries: (nextEntries: ReadonlyArray<ItemCleanupPair<T>>) => void;\n};\n\nconst NO_ENTRIES: ReadonlyArray<never> = Object.freeze([]);\n\n// useDisposableArray\n// - Holds an array of disposable items in state, on top of useUpdatableDisposableState.\n// - setEntries(nextEntries) stores nextEntries. Releasing a stored array calls the cleanup of each of its pairs once.\n// useUpdatableDisposableState releases every array that is older than the committed one after the commit, and the\n// rest on unmount, including arrays that never committed.\n// - Every pair in nextEntries was acquired by the caller for this call, and the hook owns it from then on. To keep an\n// item of entries, the caller takes a new reference to it, for example with cloneIfNotDisposed() on a\n// ReferenceCountedPointer, and passes that new pair. The caller never passes a pair of entries itself and never\n// calls a cleanup the hook owns.\n// - The caller builds nextEntries from the entries of its render. If that render is stale, because its entries were\n// released after a newer array committed, taking a new reference fails (cloneIfNotDisposed() returns null). The\n// caller then releases what it acquired and does not call setEntries.\n// - Two setEntries calls before a commit both build on the same entries, and the second replaces the first.\n// - Like useUpdatableDisposableState's setState, setEntries throws if called before the initial commit. It throws\n// before storing anything, so the caller still owns nextEntries and must release them.\nexport function useDisposableArray<T>(): UseDisposableArrayReturn<T> {\n const { state, setState } =\n useUpdatableDisposableState<ReadonlyArray<ItemCleanupPair<T>>>();\n\n const setEntries = useCallback(\n (nextEntries: ReadonlyArray<ItemCleanupPair<T>>) => {\n setState([\n nextEntries,\n () => {\n for (const entry of nextEntries) {\n entry[1]();\n }\n },\n ]);\n },\n [setState],\n );\n\n return {\n entries: state === UNASSIGNED_STATE ? NO_ENTRIES : state,\n setEntries,\n };\n}\n"],"mappings":";;;;AAeA,MAAM,aAAmC,OAAO,OAAO,EAAE,CAAC;AAiB1D,SAAgB,qBAAqD;CACnE,MAAM,EAAE,OAAO,aACb,6BAAgE;CAElE,MAAM,aAAa,aAChB,gBAAmD;AAClD,WAAS,CACP,mBACM;AACJ,QAAK,MAAM,SAAS,YAClB,OAAM,IAAI;IAGf,CAAC;IAEJ,CAAC,SAAS,CACX;AAED,QAAO;EACL,SAAS,UAAU,mBAAmB,aAAa;EACnD;EACD"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isograph/react-disposable-state",
3
- "version": "0.0.0-main-54f75b55",
3
+ "version": "0.0.0-main-36b092bd",
4
4
  "description": "Primitives for managing disposable state in React",
5
5
  "homepage": "https://isograph.dev",
6
6
  "main": "./dist/index.cjs",
@@ -8,7 +8,7 @@
8
8
  "author": "Isograph Labs",
9
9
  "license": "MIT",
10
10
  "dependencies": {
11
- "@isograph/disposable-types": "0.0.0-main-54f75b55"
11
+ "@isograph/disposable-types": "0.0.0-main-36b092bd"
12
12
  },
13
13
  "peerDependencies": {
14
14
  "react": "^18.0.0 || ^19.0.0"
@@ -19,7 +19,8 @@
19
19
  "@types/react-test-renderer": "^18.3.0",
20
20
  "happy-dom": "20.4.0",
21
21
  "react-test-renderer": "^18.2.0",
22
- "typescript": "5.6.3"
22
+ "typescript": "5.6.3",
23
+ "@isograph/reference-counted-pointer": "0.0.0-main-36b092bd"
23
24
  },
24
25
  "repository": {
25
26
  "type": "git",
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ export * from '@isograph/disposable-types';
3
3
  export * from './CacheItem';
4
4
  export * from './ParentCache';
5
5
  export * from './useCachedResponsivePrecommitValue';
6
+ export * from './useDisposableArray';
6
7
  export * from './useDisposableState';
7
8
  export * from './useEffectsRerunWithoutRender';
8
9
  export * from './useHasCommittedRef';
@@ -0,0 +1,259 @@
1
+ import type { ItemCleanupPair } from '@isograph/disposable-types';
2
+ import {
3
+ createReferenceCountedPointer,
4
+ type ReferenceCountedPointer,
5
+ } from '@isograph/reference-counted-pointer';
6
+ import { act, render, screen } from '@testing-library/react';
7
+ import React, { startTransition, useLayoutEffect, useState } from 'react';
8
+ import { describe, expect, test, vi } from 'vitest';
9
+ import {
10
+ type UseDisposableArrayReturn,
11
+ useDisposableArray,
12
+ } from './useDisposableArray';
13
+
14
+ type Entry = ItemCleanupPair<ReferenceCountedPointer<string>>;
15
+
16
+ function createItem(label: string) {
17
+ const cleanup = vi.fn();
18
+ return {
19
+ cleanup,
20
+ acquire: (): Entry => createReferenceCountedPointer([label, cleanup]),
21
+ };
22
+ }
23
+
24
+ type CommittedRender = UseDisposableArrayReturn<
25
+ ReferenceCountedPointer<string>
26
+ >;
27
+
28
+ // What a caller does: builds the next array from its render's entries by taking a new reference to each kept entry
29
+ // and acquiring each added item, then calls setEntries. If a kept entry's reference was already released, the render
30
+ // is stale, so it releases everything it acquired and does not call setEntries. Returns whether it called setEntries.
31
+ function keepAndAdd(
32
+ committedRender: CommittedRender,
33
+ kept: ReadonlyArray<Entry>,
34
+ added: ReadonlyArray<ReturnType<typeof createItem>>,
35
+ ): boolean {
36
+ const addedEntries = added.map((item) => item.acquire());
37
+ const clones: Entry[] = [];
38
+ for (const entry of kept) {
39
+ const clone = entry[0].cloneIfNotDisposed();
40
+ if (clone == null) {
41
+ for (const acquired of [...clones, ...addedEntries]) {
42
+ acquired[1]();
43
+ }
44
+ return false;
45
+ }
46
+ clones.push(clone);
47
+ }
48
+ committedRender.setEntries([...clones, ...addedEntries]);
49
+ return true;
50
+ }
51
+
52
+ function labelsOf(entries: ReadonlyArray<Entry>) {
53
+ return entries.map((entry) => entry[0].getItemIfNotDisposed()).join(',');
54
+ }
55
+
56
+ // Renders, under StrictMode, a component that holds a useDisposableArray of reference-counted pointers to strings and
57
+ // shows their labels. Every commit of a new array records the render's entries and setEntries in `renders`. After the
58
+ // mount, it also appends the labels to `commits` and calls `onCommit`. Both run in a layout effect, which runs before
59
+ // the passive effect in which useUpdatableDisposableState releases older arrays. Renders are recorded per commit,
60
+ // because StrictMode discards the first of the two mount renders.
61
+ function renderDisposableArray(onCommit: (labels: string) => void = () => {}) {
62
+ const renders: CommittedRender[] = [];
63
+ const commits: string[] = [];
64
+ const setMountedRef: { current: (mounted: boolean) => void } = {
65
+ current: () => {},
66
+ };
67
+
68
+ function Owner() {
69
+ const { entries, setEntries } =
70
+ useDisposableArray<ReferenceCountedPointer<string>>();
71
+ useLayoutEffect(() => {
72
+ renders.push({ entries, setEntries });
73
+ commits.push(labelsOf(entries));
74
+ onCommit(labelsOf(entries));
75
+ }, [entries, setEntries]);
76
+ return <>{`entries: ${labelsOf(entries)}`}</>;
77
+ }
78
+
79
+ function Parent() {
80
+ const [mounted, setMounted] = useState(true);
81
+ setMountedRef.current = setMounted;
82
+ return mounted ? <Owner /> : null;
83
+ }
84
+
85
+ render(<Parent />, { reactStrictMode: true });
86
+ // StrictMode runs the mount's layout effects twice.
87
+ commits.length = 0;
88
+
89
+ return {
90
+ commits,
91
+ latestRender: () => renders[renders.length - 1]!,
92
+ setMounted: (mounted: boolean) => setMountedRef.current(mounted),
93
+ };
94
+ }
95
+
96
+ describe('useDisposableArray', () => {
97
+ test('dropping the first of three entries and adding a fourth releases the first after the new array commits', () => {
98
+ const a = createItem('a');
99
+ const b = createItem('b');
100
+ const c = createItem('c');
101
+ const d = createItem('d');
102
+ let aCleanupCallsAtCommit: number | null = null;
103
+ const owner = renderDisposableArray((labels) => {
104
+ if (labels === 'b,c,d') {
105
+ aCleanupCallsAtCommit = a.cleanup.mock.calls.length;
106
+ }
107
+ });
108
+
109
+ act(() => {
110
+ keepAndAdd(owner.latestRender(), [], [a, b, c]);
111
+ });
112
+ expect(screen.getByText('entries: a,b,c')).toBeTruthy();
113
+
114
+ act(() => {
115
+ const committedRender = owner.latestRender();
116
+ keepAndAdd(committedRender, committedRender.entries.slice(-2), [d]);
117
+ });
118
+ expect(screen.getByText('entries: b,c,d')).toBeTruthy();
119
+ expect(aCleanupCallsAtCommit).toBe(0);
120
+ expect(a.cleanup).toHaveBeenCalledOnce();
121
+ expect(b.cleanup).not.toHaveBeenCalled();
122
+ expect(c.cleanup).not.toHaveBeenCalled();
123
+ expect(d.cleanup).not.toHaveBeenCalled();
124
+
125
+ act(() => owner.setMounted(false));
126
+ for (const item of [a, b, c, d]) {
127
+ expect(item.cleanup).toHaveBeenCalledOnce();
128
+ }
129
+ });
130
+
131
+ test('of two calls before a commit, the second is rendered and the first is released once', () => {
132
+ const a = createItem('a');
133
+ const x = createItem('x');
134
+ const y = createItem('y');
135
+ const owner = renderDisposableArray();
136
+ act(() => {
137
+ keepAndAdd(owner.latestRender(), [], [a]);
138
+ });
139
+
140
+ act(() => {
141
+ const committedRender = owner.latestRender();
142
+ keepAndAdd(committedRender, committedRender.entries, [x]);
143
+ keepAndAdd(committedRender, committedRender.entries, [y]);
144
+ });
145
+
146
+ expect(screen.getByText('entries: a,y')).toBeTruthy();
147
+ expect(owner.commits).not.toContain('a,x');
148
+ expect(x.cleanup).toHaveBeenCalledOnce();
149
+ expect(a.cleanup).not.toHaveBeenCalled();
150
+ expect(y.cleanup).not.toHaveBeenCalled();
151
+
152
+ act(() => owner.setMounted(false));
153
+ for (const item of [a, x, y]) {
154
+ expect(item.cleanup).toHaveBeenCalledOnce();
155
+ }
156
+ });
157
+
158
+ test('a caller from an older committed render finds its clones fail and stores nothing', () => {
159
+ const a = createItem('a');
160
+ const b = createItem('b');
161
+ const c = createItem('c');
162
+ const owner = renderDisposableArray();
163
+ act(() => {
164
+ keepAndAdd(owner.latestRender(), [], [a]);
165
+ });
166
+ const olderRender = owner.latestRender();
167
+ act(() => {
168
+ keepAndAdd(olderRender, olderRender.entries, [b]);
169
+ });
170
+ expect(screen.getByText('entries: a,b')).toBeTruthy();
171
+
172
+ // The older render's array was released after 'a,b' committed. Item a is still held by the newer array, but the
173
+ // older render's reference to it is released, so cloning it fails.
174
+ let calledSetEntries: boolean | null = null;
175
+ act(() => {
176
+ calledSetEntries = keepAndAdd(olderRender, olderRender.entries, [c]);
177
+ });
178
+
179
+ expect(calledSetEntries).toBe(false);
180
+ expect(owner.commits).toEqual(['a', 'a,b']);
181
+ expect(screen.getByText('entries: a,b')).toBeTruthy();
182
+ expect(c.cleanup).toHaveBeenCalledOnce();
183
+ expect(a.cleanup).not.toHaveBeenCalled();
184
+ expect(b.cleanup).not.toHaveBeenCalled();
185
+
186
+ act(() => owner.setMounted(false));
187
+ for (const item of [a, b, c]) {
188
+ expect(item.cleanup).toHaveBeenCalledOnce();
189
+ }
190
+ });
191
+
192
+ test('an urgent call and a transition call from the same committed array commit in turn and release every item once', () => {
193
+ const a = createItem('a');
194
+ const b = createItem('b');
195
+ const c = createItem('c');
196
+ const d = createItem('d');
197
+ const owner = renderDisposableArray();
198
+ act(() => {
199
+ keepAndAdd(owner.latestRender(), [], [a, b]);
200
+ });
201
+
202
+ act(() => {
203
+ const committedRender = owner.latestRender();
204
+ keepAndAdd(committedRender, committedRender.entries.slice(0, 1), [c]);
205
+ startTransition(() => {
206
+ keepAndAdd(committedRender, committedRender.entries, [d]);
207
+ });
208
+ });
209
+
210
+ expect(owner.commits).toEqual(['a,b', 'a,c', 'a,b,d']);
211
+ expect(screen.getByText('entries: a,b,d')).toBeTruthy();
212
+ expect(c.cleanup).toHaveBeenCalledOnce();
213
+ expect(a.cleanup).not.toHaveBeenCalled();
214
+ expect(b.cleanup).not.toHaveBeenCalled();
215
+ expect(d.cleanup).not.toHaveBeenCalled();
216
+
217
+ act(() => owner.setMounted(false));
218
+ for (const item of [a, b, c, d]) {
219
+ expect(item.cleanup).toHaveBeenCalledOnce();
220
+ }
221
+ });
222
+
223
+ test('unmount releases every entry once, including an array that never committed', () => {
224
+ const a = createItem('a');
225
+ const b = createItem('b');
226
+ const c = createItem('c');
227
+ const e = createItem('e');
228
+ const owner = renderDisposableArray();
229
+ act(() => {
230
+ keepAndAdd(owner.latestRender(), [], [a, b]);
231
+ });
232
+
233
+ act(() => {
234
+ const committedRender = owner.latestRender();
235
+ keepAndAdd(committedRender, committedRender.entries, [c]);
236
+ owner.setMounted(false);
237
+ });
238
+
239
+ expect(owner.commits).toEqual(['a,b']);
240
+ for (const item of [a, b, c]) {
241
+ expect(item.cleanup).toHaveBeenCalledOnce();
242
+ }
243
+
244
+ // After unmount, the last render's entries are released, so the caller cannot keep them. It releases the item it
245
+ // acquired and does not call setEntries.
246
+ let calledSetEntries: boolean | null = null;
247
+ act(() => {
248
+ const committedRender = owner.latestRender();
249
+ calledSetEntries = keepAndAdd(committedRender, committedRender.entries, [
250
+ e,
251
+ ]);
252
+ });
253
+ expect(calledSetEntries).toBe(false);
254
+ expect(e.cleanup).toHaveBeenCalledOnce();
255
+ for (const item of [a, b, c]) {
256
+ expect(item.cleanup).toHaveBeenCalledOnce();
257
+ }
258
+ });
259
+ });
@@ -0,0 +1,55 @@
1
+ import type { ItemCleanupPair } from '@isograph/disposable-types';
2
+ import { useCallback } from 'react';
3
+ import {
4
+ UNASSIGNED_STATE,
5
+ useUpdatableDisposableState,
6
+ } from './useUpdatableDisposableState';
7
+
8
+ export type UseDisposableArrayReturn<T> = {
9
+ // The array this render shows. It is empty until the first setEntries call commits. The hook owns every pair in
10
+ // it, so callers read the items and never call the cleanups.
11
+ readonly entries: ReadonlyArray<ItemCleanupPair<T>>;
12
+ // Replaces the array. See useDisposableArray.
13
+ readonly setEntries: (nextEntries: ReadonlyArray<ItemCleanupPair<T>>) => void;
14
+ };
15
+
16
+ const NO_ENTRIES: ReadonlyArray<never> = Object.freeze([]);
17
+
18
+ // useDisposableArray
19
+ // - Holds an array of disposable items in state, on top of useUpdatableDisposableState.
20
+ // - setEntries(nextEntries) stores nextEntries. Releasing a stored array calls the cleanup of each of its pairs once.
21
+ // useUpdatableDisposableState releases every array that is older than the committed one after the commit, and the
22
+ // rest on unmount, including arrays that never committed.
23
+ // - Every pair in nextEntries was acquired by the caller for this call, and the hook owns it from then on. To keep an
24
+ // item of entries, the caller takes a new reference to it, for example with cloneIfNotDisposed() on a
25
+ // ReferenceCountedPointer, and passes that new pair. The caller never passes a pair of entries itself and never
26
+ // calls a cleanup the hook owns.
27
+ // - The caller builds nextEntries from the entries of its render. If that render is stale, because its entries were
28
+ // released after a newer array committed, taking a new reference fails (cloneIfNotDisposed() returns null). The
29
+ // caller then releases what it acquired and does not call setEntries.
30
+ // - Two setEntries calls before a commit both build on the same entries, and the second replaces the first.
31
+ // - Like useUpdatableDisposableState's setState, setEntries throws if called before the initial commit. It throws
32
+ // before storing anything, so the caller still owns nextEntries and must release them.
33
+ export function useDisposableArray<T>(): UseDisposableArrayReturn<T> {
34
+ const { state, setState } =
35
+ useUpdatableDisposableState<ReadonlyArray<ItemCleanupPair<T>>>();
36
+
37
+ const setEntries = useCallback(
38
+ (nextEntries: ReadonlyArray<ItemCleanupPair<T>>) => {
39
+ setState([
40
+ nextEntries,
41
+ () => {
42
+ for (const entry of nextEntries) {
43
+ entry[1]();
44
+ }
45
+ },
46
+ ]);
47
+ },
48
+ [setState],
49
+ );
50
+
51
+ return {
52
+ entries: state === UNASSIGNED_STATE ? NO_ENTRIES : state,
53
+ setEntries,
54
+ };
55
+ }