Skip to content

Commit 61b4ab9

Browse files
committed
implementations and tests for a new useAsyncIterMemo hook
1 parent 20af9b0 commit 61b4ab9

File tree

6 files changed

+342
-0
lines changed

6 files changed

+342
-0
lines changed

spec/tests/useAsyncIterMemo.spec.ts

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import { nextTick } from 'node:process';
2+
import { it, describe, expect, afterEach } from 'vitest';
3+
import { gray } from 'colorette';
4+
import { renderHook, cleanup as cleanupMountedReactTrees } from '@testing-library/react';
5+
import { useAsyncIterMemo, iterateFormatted } from '../../src/index.js';
6+
import { pipe } from '../utils/pipe.js';
7+
import { asyncIterToArray } from '../utils/asyncIterToArray.js';
8+
import { asyncIterTake } from '../utils/asyncIterTake.js';
9+
import { asyncIterOf } from '../utils/asyncIterOf.js';
10+
import { asyncIterTickSeparatedOf } from '../utils/asyncIterTickSeparatedOf.js';
11+
import { IterableChannelTestHelper } from '../utils/IterableChannelTestHelper.js';
12+
13+
afterEach(() => {
14+
cleanupMountedReactTrees();
15+
});
16+
17+
describe('`useAsyncIterMemo` hook', () => {
18+
it(gray('___ ___ ___ 1'), async () => {
19+
const renderedHook = renderHook(
20+
({ val1, val2, iter1, iter2 }) =>
21+
useAsyncIterMemo((...deps) => deps, [val1, val2, iter1, iter2]),
22+
{
23+
initialProps: {
24+
val1: 'a',
25+
val2: 'b',
26+
iter1: asyncIterOf('a', 'b', 'c'),
27+
iter2: asyncIterOf('d', 'e', 'f'),
28+
},
29+
}
30+
);
31+
32+
const [resVal1, resVal2, resIter1, resIter2] = renderedHook.result.current;
33+
34+
expect(resVal1).toStrictEqual('a');
35+
expect(resVal2).toStrictEqual('b');
36+
expect(await asyncIterToArray(resIter1)).toStrictEqual(['a', 'b', 'c']);
37+
expect(await asyncIterToArray(resIter2)).toStrictEqual(['d', 'e', 'f']);
38+
});
39+
40+
it(gray('___ ___ ___ 2'), async () => {
41+
const channel1 = new IterableChannelTestHelper<string>();
42+
const channel2 = new IterableChannelTestHelper<string>();
43+
let timesRerun = 0;
44+
45+
const renderedHook = renderHook(
46+
({ val1, val2, iter1, iter2 }) =>
47+
useAsyncIterMemo(
48+
(...deps) => {
49+
timesRerun++;
50+
return deps;
51+
},
52+
[val1, val2, iter1, iter2]
53+
),
54+
{
55+
initialProps: {
56+
val1: 'a',
57+
val2: 'b',
58+
iter1: iterateFormatted(channel1, v => `${v}_formatted_1st_time`),
59+
iter2: iterateFormatted(channel2, v => `${v}_formatted_1st_time`),
60+
},
61+
}
62+
);
63+
64+
const hookFirstResult = renderedHook.result.current;
65+
66+
{
67+
expect(timesRerun).toStrictEqual(1);
68+
69+
const [, , resIter1, resIter2] = hookFirstResult;
70+
71+
feedChannelAcrossTicks(channel1, ['a', 'b', 'c']);
72+
const resIter1Values = await pipe(resIter1, asyncIterTake(3), asyncIterToArray);
73+
expect(resIter1Values).toStrictEqual([
74+
'a_formatted_1st_time',
75+
'b_formatted_1st_time',
76+
'c_formatted_1st_time',
77+
]);
78+
79+
feedChannelAcrossTicks(channel2, ['d', 'e', 'f']);
80+
const resIter2Values = await pipe(resIter2, asyncIterTake(3), asyncIterToArray);
81+
82+
expect(resIter2Values).toStrictEqual([
83+
'd_formatted_1st_time',
84+
'e_formatted_1st_time',
85+
'f_formatted_1st_time',
86+
]);
87+
}
88+
89+
renderedHook.rerender({
90+
val1: 'a',
91+
val2: 'b',
92+
iter1: iterateFormatted(channel1, v => `${v}_formatted_2nd_time`),
93+
iter2: iterateFormatted(channel2, v => `${v}_formatted_2nd_time`),
94+
});
95+
96+
const hookSecondResult = renderedHook.result.current;
97+
98+
{
99+
expect(timesRerun).toStrictEqual(1);
100+
expect(hookFirstResult).toStrictEqual(hookSecondResult);
101+
102+
const [, , resIter1, resIter2] = hookSecondResult;
103+
104+
feedChannelAcrossTicks(channel1, ['a', 'b', 'c']);
105+
const resIter1Values = await pipe(resIter1, asyncIterTake(3), asyncIterToArray);
106+
expect(resIter1Values).toStrictEqual([
107+
'a_formatted_2nd_time',
108+
'b_formatted_2nd_time',
109+
'c_formatted_2nd_time',
110+
]);
111+
112+
feedChannelAcrossTicks(channel2, ['d', 'e', 'f']);
113+
const resIter2Values = await pipe(resIter2, asyncIterTake(3), asyncIterToArray);
114+
expect(resIter2Values).toStrictEqual([
115+
'd_formatted_2nd_time',
116+
'e_formatted_2nd_time',
117+
'f_formatted_2nd_time',
118+
]);
119+
}
120+
});
121+
122+
it(gray('___ ___ ___ 3'), async () => {
123+
const iter1 = asyncIterTickSeparatedOf('a', 'b', 'c');
124+
const iter2 = asyncIterTickSeparatedOf('d', 'e', 'f');
125+
let timesRerun = 0;
126+
127+
const renderedHook = renderHook(
128+
({ val1, val2, iter1, iter2 }) =>
129+
useAsyncIterMemo(
130+
(...deps) => {
131+
timesRerun++;
132+
return deps;
133+
},
134+
[val1, val2, iter1, iter2]
135+
),
136+
{
137+
initialProps: {
138+
val1: 'a',
139+
val2: 'b',
140+
iter1: iterateFormatted(iter1, v => `${v}_formatted_1st_time`),
141+
iter2: iterateFormatted(iter2, v => `${v}_formatted_1st_time`),
142+
},
143+
}
144+
);
145+
146+
const hookFirstResult = renderedHook.result.current;
147+
148+
{
149+
expect(timesRerun).toStrictEqual(1);
150+
151+
const [, , resIter1, resIter2] = hookFirstResult;
152+
153+
const resIter1Values = await pipe(resIter1, asyncIterTake(3), asyncIterToArray);
154+
expect(resIter1Values).toStrictEqual([
155+
'a_formatted_1st_time',
156+
'b_formatted_1st_time',
157+
'c_formatted_1st_time',
158+
]);
159+
160+
const resIter2Values = await pipe(resIter2, asyncIterTake(3), asyncIterToArray);
161+
expect(resIter2Values).toStrictEqual([
162+
'd_formatted_1st_time',
163+
'e_formatted_1st_time',
164+
'f_formatted_1st_time',
165+
]);
166+
}
167+
168+
const differentIter1 = asyncIterTickSeparatedOf('a', 'b', 'c');
169+
const differentIter2 = asyncIterTickSeparatedOf('d', 'e', 'f');
170+
171+
renderedHook.rerender({
172+
val1: 'a',
173+
val2: 'b',
174+
iter1: iterateFormatted(differentIter1, v => `${v}_formatted_2nd_time`),
175+
iter2: iterateFormatted(differentIter2, v => `${v}_formatted_2nd_time`),
176+
});
177+
178+
const hookSecondResult = renderedHook.result.current;
179+
180+
{
181+
expect(timesRerun).toStrictEqual(2);
182+
183+
expect(hookFirstResult[0]).toStrictEqual(hookSecondResult[0]);
184+
expect(hookFirstResult[1]).toStrictEqual(hookSecondResult[1]);
185+
expect(hookFirstResult[2]).not.toStrictEqual(hookSecondResult[2]);
186+
expect(hookFirstResult[3]).not.toStrictEqual(hookSecondResult[3]);
187+
188+
const resIter1Values = await pipe(hookSecondResult[2], asyncIterTake(3), asyncIterToArray);
189+
expect(resIter1Values).toStrictEqual([
190+
'a_formatted_2nd_time',
191+
'b_formatted_2nd_time',
192+
'c_formatted_2nd_time',
193+
]);
194+
195+
const resIter2Values = await pipe(hookSecondResult[3], asyncIterTake(3), asyncIterToArray);
196+
expect(resIter2Values).toStrictEqual([
197+
'd_formatted_2nd_time',
198+
'e_formatted_2nd_time',
199+
'f_formatted_2nd_time',
200+
]);
201+
}
202+
});
203+
});
204+
205+
async function feedChannelAcrossTicks<const T>(
206+
channel: IterableChannelTestHelper<T>,
207+
values: T[]
208+
): Promise<void> {
209+
for (const value of values) {
210+
await new Promise(resolve => nextTick(resolve));
211+
channel.put(value);
212+
}
213+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
export { IterableChannelTestHelper };
2+
3+
class IterableChannelTestHelper<T> implements AsyncIterable<T, void, void> {
4+
#isClosed = false;
5+
#nextIteration = Promise.withResolvers<IteratorResult<T, void>>();
6+
7+
put(value: T): void {
8+
if (!this.#isClosed) {
9+
this.#nextIteration.resolve({ done: false, value });
10+
this.#nextIteration = Promise.withResolvers();
11+
}
12+
}
13+
14+
close(): void {
15+
this.#isClosed = true;
16+
this.#nextIteration.resolve({ done: true, value: undefined });
17+
}
18+
19+
[Symbol.asyncIterator]() {
20+
const whenIteratorClosed = Promise.withResolvers<IteratorReturnResult<undefined>>();
21+
22+
return {
23+
next: (): Promise<IteratorResult<T, void>> => {
24+
return Promise.race([this.#nextIteration.promise, whenIteratorClosed.promise]);
25+
},
26+
27+
return: async (): Promise<IteratorReturnResult<void>> => {
28+
whenIteratorClosed.resolve({ done: true, value: undefined });
29+
return { done: true, value: undefined };
30+
},
31+
};
32+
}
33+
}

spec/utils/asyncIterOf.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export { asyncIterOf };
2+
3+
function asyncIterOf<const T>(...values: T[]) {
4+
return {
5+
async *[Symbol.asyncIterator]() {
6+
yield* values;
7+
},
8+
};
9+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { nextTick } from 'node:process';
2+
3+
export { asyncIterTickSeparatedOf };
4+
5+
function asyncIterTickSeparatedOf<const T>(...values: T[]): {
6+
[Symbol.asyncIterator](): AsyncGenerator<T, void, void>;
7+
} {
8+
return {
9+
async *[Symbol.asyncIterator]() {
10+
await new Promise(resolve => nextTick(resolve));
11+
yield* values;
12+
},
13+
};
14+
}

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ 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';
44
import { useAsyncIterState, type AsyncIterStateResult } from './useAsyncIterState/index.js';
5+
import { useAsyncIterMemo } from './useAsyncIterMemo/index.js';
56
import { type MaybeAsyncIterable } from './MaybeAsyncIterable/index.js';
67

78
export {
@@ -15,4 +16,5 @@ export {
1516
useAsyncIterState,
1617
type AsyncIterStateResult,
1718
type MaybeAsyncIterable,
19+
useAsyncIterMemo,
1820
};

src/useAsyncIterMemo/index.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { useMemo } from 'react';
2+
import { type FixedRefFormattedIterable } from '../iterateFormatted/index.js';
3+
import {
4+
reactAsyncIterSpecialInfoSymbol,
5+
type ReactAsyncIterSpecialInfo,
6+
} from '../common/reactAsyncIterSpecialInfoSymbol.js';
7+
import { useLatest } from '../common/hooks/useLatest.js';
8+
import { asyncIterSyncMap } from '../common/asyncIterSyncMap.js';
9+
import { type ExtractAsyncIterValue } from '../common/ExtractAsyncIterValue.js';
10+
11+
export { useAsyncIterMemo };
12+
13+
const useAsyncIterMemo: {
14+
<TRes, const TDeps extends React.DependencyList>(
15+
factory: (...depsWithWrappedAsyncIters: DepsWithReactAsyncItersWrapped<TDeps>) => TRes,
16+
deps: TDeps
17+
): TRes;
18+
19+
<TRes>(factory: () => TRes, deps: []): TRes;
20+
} = <TRes, const TDeps extends React.DependencyList>(
21+
factory: (...depsWithWrappedAsyncIters: DepsWithReactAsyncItersWrapped<TDeps>) => TRes,
22+
deps: TDeps
23+
) => {
24+
const latestDepsRef = useLatest(deps);
25+
26+
const depsWithFormattedItersAccountedFor = latestDepsRef.current.map(dep =>
27+
isReactAsyncIterable(dep) ? dep[reactAsyncIterSpecialInfoSymbol].origSource : dep
28+
);
29+
30+
const result = useMemo(() => {
31+
const depsWithWrappedFormattedIters = latestDepsRef.current.map((dep, i) => {
32+
const specialInfo = isReactAsyncIterable(dep)
33+
? dep[reactAsyncIterSpecialInfoSymbol]
34+
: undefined;
35+
36+
return !specialInfo
37+
? dep
38+
: (() => {
39+
let iterationIdx = 0;
40+
41+
return asyncIterSyncMap(
42+
specialInfo.origSource,
43+
value =>
44+
(latestDepsRef.current[i] as FixedRefFormattedIterable<unknown, unknown>)[
45+
reactAsyncIterSpecialInfoSymbol
46+
].formatFn(value, iterationIdx++) // TODO: Any change there won't be a `.formatFn` here if its possible that this might be called somehow at the moment the deps were changed completely?
47+
);
48+
})();
49+
}) as DepsWithReactAsyncItersWrapped<TDeps>;
50+
51+
return factory(...depsWithWrappedFormattedIters);
52+
}, depsWithFormattedItersAccountedFor);
53+
54+
return result;
55+
};
56+
57+
type DepsWithReactAsyncItersWrapped<TDeps extends React.DependencyList> = {
58+
[I in keyof TDeps]: TDeps[I] extends {
59+
[Symbol.asyncIterator](): AsyncIterator<unknown>;
60+
[reactAsyncIterSpecialInfoSymbol]: ReactAsyncIterSpecialInfo<unknown, unknown>;
61+
}
62+
? AsyncIterable<ExtractAsyncIterValue<TDeps[I]>>
63+
: TDeps[I];
64+
};
65+
66+
function isReactAsyncIterable<T>(
67+
input: T
68+
): input is T & FixedRefFormattedIterable<unknown, unknown> {
69+
const inputAsAny = input as any;
70+
return !!inputAsAny?.[reactAsyncIterSpecialInfoSymbol];
71+
}

0 commit comments

Comments
 (0)