Skip to content

Commit 25e1ab5

Browse files
authored
fix: make iterators of the useAsyncIterState hook's iterable individually closable to prevent leaving around unsettled promises (#22)
1 parent 2a35f72 commit 25e1ab5

File tree

4 files changed

+79
-9
lines changed

4 files changed

+79
-9
lines changed

spec/tests/useAsyncIterState.spec.tsx

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { renderHook, cleanup as cleanupMountedReactTrees, act } from '@testing-l
55
import { useAsyncIterState } from '../../src/index.js';
66
import { asyncIterToArray } from '../utils/asyncIterToArray.js';
77
import { asyncIterTake } from '../utils/asyncIterTake.js';
8+
import { checkPromiseState } from '../utils/checkPromiseState.js';
89
import { pipe } from '../utils/pipe.js';
910

1011
afterEach(() => {
@@ -26,6 +27,44 @@ describe('`useAsyncIterState` hook', () => {
2627
expect(await collectPromise).toStrictEqual(['a', 'b', 'c']);
2728
});
2829

30+
it(
31+
gray(
32+
'Each iterator of the hook-returned iterable, upon getting manually closed, will immediately resolve all outstanding yieldings specifically pulled from it to "done'
33+
),
34+
async () => {
35+
const [values] = renderHook(() => useAsyncIterState<string>()).result.current;
36+
37+
const iterator1 = values[Symbol.asyncIterator]();
38+
const iterator2 = values[Symbol.asyncIterator]();
39+
const yieldPromise1 = iterator1.next();
40+
const yieldPromise2 = iterator2.next();
41+
42+
await iterator1.return!();
43+
44+
{
45+
const promiseStates = await Promise.all(
46+
[yieldPromise1, yieldPromise2].map(checkPromiseState)
47+
);
48+
expect(promiseStates).toStrictEqual([
49+
{ state: 'FULFILLED', value: { done: true, value: undefined } },
50+
{ state: 'PENDING', value: undefined },
51+
]);
52+
}
53+
54+
await iterator2.return!();
55+
56+
{
57+
const promiseStates = await Promise.all(
58+
[yieldPromise1, yieldPromise2].map(checkPromiseState)
59+
);
60+
expect(promiseStates).toStrictEqual([
61+
{ state: 'FULFILLED', value: { done: true, value: undefined } },
62+
{ state: 'FULFILLED', value: { done: true, value: undefined } },
63+
]);
64+
}
65+
}
66+
);
67+
2968
it(
3069
gray(
3170
'When hook is unmounted, all outstanding yieldings of the returned iterable resolve to "done"'
@@ -79,7 +118,7 @@ describe('`useAsyncIterState` hook', () => {
79118

80119
it(
81120
gray(
82-
"The returned iterable's values are each shared between all its parallel consumers so that each receives all the values from the start of consumption and onwards"
121+
"The returned iterable's values are each shared between all its parallel consumers so that each receives all the values that will yield after the start of its consumption"
83122
),
84123
async () => {
85124
const [values, setValue] = renderHook(() => useAsyncIterState<string>()).result.current;

spec/utils/checkPromiseState.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export { checkPromiseState, type PromiseCurrentState };
2+
3+
async function checkPromiseState<T>(p: Promise<T>): Promise<PromiseCurrentState<T>> {
4+
let result: PromiseCurrentState<T> = { state: 'PENDING', value: undefined };
5+
6+
p.then(
7+
val => (result = { state: 'FULFILLED', value: val }),
8+
reason => (result = { state: 'REJECTED', value: reason })
9+
);
10+
11+
await undefined;
12+
13+
return result;
14+
}
15+
16+
type PromiseCurrentState<T> =
17+
| { state: 'PENDING'; value: void }
18+
| { state: 'FULFILLED'; value: T }
19+
| { state: 'REJECTED'; value: unknown };

src/useAsyncIterState/IterableChannel.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,6 @@ export { IterableChannel };
55
class IterableChannel<TVal> {
66
#isClosed = false;
77
#nextIteration = promiseWithResolvers<IteratorResult<TVal, void>>();
8-
iterable = {
9-
[Symbol.asyncIterator]: () => ({
10-
next: () => this.#nextIteration.promise,
11-
}),
12-
};
138

149
put(value: TVal): void {
1510
if (!this.#isClosed) {
@@ -22,4 +17,21 @@ class IterableChannel<TVal> {
2217
this.#isClosed = true;
2318
this.#nextIteration.resolve({ done: true, value: undefined });
2419
}
20+
21+
iterable = {
22+
[Symbol.asyncIterator]: () => {
23+
const whenIteratorClosed = promiseWithResolvers<IteratorReturnResult<undefined>>();
24+
25+
return {
26+
next: () => {
27+
return Promise.race([this.#nextIteration.promise, whenIteratorClosed.promise]);
28+
},
29+
30+
return: async () => {
31+
whenIteratorClosed.resolve({ done: true, value: undefined });
32+
return { done: true as const, value: undefined };
33+
},
34+
};
35+
},
36+
} satisfies AsyncIterable<TVal, void, void>;
2537
}

src/useAsyncIterState/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,13 @@ function useAsyncIterState<TVal>(): AsyncIterStateResult<TVal> {
5454
result: AsyncIterStateResult<TVal>;
5555
}>();
5656

57-
if (!ref.current) {
57+
ref.current ??= (() => {
5858
const channel = new IterableChannel<TVal>();
59-
ref.current = {
59+
return {
6060
channel,
6161
result: [channel.iterable, newVal => channel.put(newVal)],
6262
};
63-
}
63+
})();
6464

6565
const { channel, result } = ref.current;
6666

0 commit comments

Comments
 (0)