Skip to content

Commit 5f729d0

Browse files
committed
first code for new useAsyncIterState hook
1 parent dfc7ab7 commit 5f729d0

File tree

9 files changed

+257
-3
lines changed

9 files changed

+257
-3
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"@eslint/js": "^9.17.0",
5757
"@testing-library/jest-dom": "^6.6.3",
5858
"@testing-library/react": "^16.1.0",
59+
"@types/lodash-es": "^4.17.12",
5960
"@types/node": "^22.10.2",
6061
"@types/react": "^18.2.47",
6162
"@types/react-dom": "^18.0.11",
@@ -66,6 +67,7 @@
6667
"eslint-config-prettier": "^9.1.0",
6768
"globals": "^15.13.0",
6869
"jsdom": "^25.0.1",
70+
"lodash-es": "^4.17.21",
6971
"prettier": "^3.4.2",
7072
"typescript": "^5.7.2",
7173
"typescript-eslint": "^8.18.0",

pnpm-lock.yaml

Lines changed: 23 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

spec/tests/useAsyncIterState.spec.tsx

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { it, describe, expect, afterEach } from 'vitest';
2+
import { gray } from 'colorette';
3+
import { range } from 'lodash-es';
4+
import { renderHook, cleanup as cleanupMountedReactTrees, act } from '@testing-library/react';
5+
import { useAsyncIterState } from '../../src/index.js';
6+
import { asyncIterToArray } from '../utils/asyncIterToArray.js';
7+
import { asyncIterTake } from '../utils/asyncIterTake.js';
8+
import { pipe } from '../utils/pipe.js';
9+
10+
afterEach(() => {
11+
cleanupMountedReactTrees();
12+
});
13+
14+
describe('`useAsyncIterState` hook', () => {
15+
it(gray('The returned iterable can be async-iterated upon successfully'), async () => {
16+
const [values, setValue] = renderHook(() => useAsyncIterState<string>()).result.current;
17+
18+
const valuesToSet = ['a', 'b', 'c'];
19+
20+
const collectPromise = pipe(values, asyncIterTake(valuesToSet.length), asyncIterToArray);
21+
22+
for (const value of valuesToSet) {
23+
await act(() => setValue(value));
24+
}
25+
26+
expect(await collectPromise).toStrictEqual(['a', 'b', 'c']);
27+
});
28+
29+
it(
30+
gray(
31+
'When hook is unmounted, all outstanding yieldings of the returned iterable resolve to "done"'
32+
),
33+
async () => {
34+
const renderedHook = renderHook(() => useAsyncIterState<string>());
35+
const [values] = renderedHook.result.current;
36+
37+
const [collectPromise1, collectPromise2] = range(2).map(() => asyncIterToArray(values));
38+
39+
renderedHook.unmount();
40+
41+
const collections = await Promise.all([collectPromise1, collectPromise2]);
42+
expect(collections).toStrictEqual([[], []]);
43+
}
44+
);
45+
46+
it(
47+
gray(
48+
'After setting some values followed by unmounting the hook, the pre-unmounting values go through while further values pulled from the returned iterable are always "done"'
49+
),
50+
async () => {
51+
const renderedHook = renderHook(() => useAsyncIterState<string>());
52+
const [values, setValue] = renderedHook.result.current;
53+
54+
const [collectPromise1, collectPromise2] = range(2).map(() => asyncIterToArray(values));
55+
56+
await act(() => setValue('a'));
57+
58+
renderedHook.unmount();
59+
60+
const collections = await Promise.all([collectPromise1, collectPromise2]);
61+
expect(collections).toStrictEqual([['a'], ['a']]);
62+
}
63+
);
64+
65+
it(
66+
gray(
67+
'After the hook is unmounted, any further values pulled from the returned iterable are always "done"'
68+
),
69+
async () => {
70+
const renderedHook = renderHook(() => useAsyncIterState<string>());
71+
const [values] = renderedHook.result.current;
72+
73+
renderedHook.unmount();
74+
75+
const collections = await Promise.all(range(2).map(() => asyncIterToArray(values)));
76+
expect(collections).toStrictEqual([[], []]);
77+
}
78+
);
79+
80+
it(
81+
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"
83+
),
84+
async () => {
85+
const [values, setValue] = renderHook(() => useAsyncIterState<string>()).result.current;
86+
87+
const consumeStacks: string[][] = [];
88+
89+
for (const [i, value] of ['a', 'b', 'c'].entries()) {
90+
consumeStacks[i] = [];
91+
(async () => {
92+
for await (const v of values) consumeStacks[i].push(v);
93+
})();
94+
await act(() => setValue(value));
95+
}
96+
97+
expect(consumeStacks).toStrictEqual([['a', 'b', 'c'], ['b', 'c'], ['c']]);
98+
}
99+
);
100+
});

spec/utils/asyncIterTake.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
export { asyncIterTake };
2+
3+
function asyncIterTake<T>(count: number): (src: AsyncIterable<T>) => AsyncIterable<T> {
4+
return sourceIter => {
5+
let iterator: AsyncIterator<T>;
6+
let remainingCount = count;
7+
let closed = false;
8+
9+
return {
10+
[Symbol.asyncIterator]: () => ({
11+
async next() {
12+
if (closed) {
13+
return { done: true, value: undefined };
14+
}
15+
16+
iterator ??= sourceIter[Symbol.asyncIterator]();
17+
18+
if (remainingCount === 0) {
19+
closed = true;
20+
await iterator.return?.();
21+
return { done: true, value: undefined };
22+
}
23+
24+
remainingCount--;
25+
const next = await iterator.next();
26+
27+
if (next.done) {
28+
closed = true;
29+
return { done: true, value: undefined };
30+
}
31+
32+
return next;
33+
},
34+
35+
async return() {
36+
if (!closed) {
37+
closed = true;
38+
await iterator?.return?.();
39+
}
40+
return { done: true, value: undefined };
41+
},
42+
}),
43+
};
44+
};
45+
}

spec/utils/asyncIterToArray.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
export { asyncIterToArray };
22

33
async function asyncIterToArray<T>(source: AsyncIterable<T>): Promise<T[]> {
4-
const values: T[] = [];
4+
const collected: T[] = [];
55
for await (const value of source) {
6-
values.push(value);
6+
collected.push(value);
77
}
8-
return values;
8+
return collected;
99
}

src/common/promiseWithResolvers.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export { promiseWithResolvers, type PromiseWithResolvers };
2+
3+
const promiseWithResolvers: <T>() => PromiseWithResolvers<T> =
4+
'withResolvers' in Promise
5+
? () => Promise.withResolvers()
6+
: /**
7+
* A ponyfill for the [`Promise.withResolvers`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers) helper
8+
* @returns A pending {@link PromiseWithResolvers} instance for use
9+
*/
10+
function promiseWithResolversPonyfill<T>(): PromiseWithResolvers<T> {
11+
let resolve!: PromiseWithResolvers<T>['resolve'];
12+
let reject!: PromiseWithResolvers<T>['reject'];
13+
const promise = new Promise<T>((res, rej) => {
14+
resolve = res;
15+
reject = rej;
16+
});
17+
return { promise, resolve, reject };
18+
};
19+
20+
type PromiseWithResolvers<T> = {
21+
promise: Promise<T>;
22+
resolve(value: T): void;
23+
reject(reason?: unknown): void;
24+
};

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useAsyncIter, type IterationResult } from './useAsyncIter/index.js';
22
import { Iterate, type IterateProps } from './Iterate/index.js';
33
import { iterateFormatted, type FixedRefFormattedIterable } from './iterateFormatted//index.js';
4+
import { useAsyncIterState, type AsyncIterStateResult } from './useAsyncIterState/index.js';
45

56
export {
67
useAsyncIter,
@@ -9,4 +10,6 @@ export {
910
type IterateProps,
1011
iterateFormatted,
1112
type FixedRefFormattedIterable,
13+
useAsyncIterState,
14+
type AsyncIterStateResult,
1215
};
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { promiseWithResolvers } from '../common/promiseWithResolvers.js';
2+
3+
export { IterableChannel };
4+
5+
class IterableChannel<TVal> {
6+
#isClosed = false;
7+
#nextIteration = promiseWithResolvers<IteratorResult<TVal, void>>();
8+
iterable = {
9+
[Symbol.asyncIterator]: () => ({
10+
next: () => this.#nextIteration.promise,
11+
}),
12+
};
13+
14+
put(value: TVal): void {
15+
if (!this.#isClosed) {
16+
this.#nextIteration.resolve({ done: false, value });
17+
this.#nextIteration = promiseWithResolvers();
18+
}
19+
}
20+
21+
close(): void {
22+
this.#isClosed = true;
23+
this.#nextIteration.resolve({ done: true, value: undefined });
24+
}
25+
}

src/useAsyncIterState/index.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { useEffect, useRef } from 'react';
2+
import { IterableChannel } from './IterableChannel.js';
3+
4+
export { useAsyncIterState, type AsyncIterStateResult };
5+
6+
/**
7+
* ... ... ...
8+
*/
9+
function useAsyncIterState<TVal>(): AsyncIterStateResult<TVal> {
10+
const ref = useRef<{
11+
channel: IterableChannel<TVal>;
12+
result: AsyncIterStateResult<TVal>;
13+
}>();
14+
15+
if (!ref.current) {
16+
const channel = new IterableChannel<TVal>();
17+
ref.current = {
18+
channel,
19+
result: [channel.iterable, newVal => channel.put(newVal)],
20+
};
21+
}
22+
23+
const { channel, result } = ref.current;
24+
25+
useEffect(() => {
26+
return () => channel.close();
27+
}, []);
28+
29+
return result;
30+
}
31+
32+
type AsyncIterStateResult<TVal> = [AsyncIterable<TVal, void, void>, (newValue: TVal) => void];

0 commit comments

Comments
 (0)