Skip to content

Commit 828d872

Browse files
authored
fix: ReactAsyncIterables wrapping iters which yield non-nullable values are having the format function's result ignored if it returned undefined or null (#32)
1 parent 3f08461 commit 828d872

File tree

6 files changed

+170
-67
lines changed

6 files changed

+170
-67
lines changed

spec/tests/Iterate.spec.tsx

+58-16
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { it, describe, expect, afterEach } from 'vitest';
1+
import { it, describe, expect, afterEach, vi, type Mock } from 'vitest';
22
import { gray } from 'colorette';
33
import { render, cleanup as cleanupMountedReactTrees, act } from '@testing-library/react';
4-
import { Iterate, It, type IterationResult } from '../../src/index.js';
4+
import { Iterate, It, iterateFormatted, type IterationResult } from '../../src/index.js';
55
import { asyncIterOf } from '../utils/asyncIterOf.js';
66
import { IteratorChannelTestHelper } from '../utils/IteratorChannelTestHelper.js';
77

@@ -664,36 +664,78 @@ describe('`Iterate` component', () => {
664664
it(
665665
gray('When given iterable yields consecutive identical values the hook will not re-render'),
666666
async () => {
667-
let timesRerendered = 0;
668-
let lastRenderFnInput: undefined | IterationResult<string | undefined>;
669667
const channel = new IteratorChannelTestHelper<string>();
668+
const renderFn = vi.fn() as Mock<
669+
(next: IterationResult<AsyncIterable<string | undefined>>) => any
670+
>;
670671

671672
const rendered = render(
672673
<Iterate value={channel}>
673-
{next => {
674-
timesRerendered++;
675-
lastRenderFnInput = next;
676-
return <div id="test-created-elem">Render count: {timesRerendered}</div>;
677-
}}
674+
{renderFn.mockImplementation(() => (
675+
<div id="test-created-elem">Render count: {renderFn.mock.calls.length}</div>
676+
))}
678677
</Iterate>
679678
);
680679

681680
for (let i = 0; i < 3; ++i) {
682681
await act(() => channel.put('a'));
683682
}
684683

685-
expect(timesRerendered).toStrictEqual(2);
686-
expect(lastRenderFnInput).toStrictEqual({
687-
value: 'a',
688-
pendingFirst: false,
689-
done: false,
690-
error: undefined,
691-
});
684+
expect(renderFn.mock.calls).lengthOf(2);
685+
expect(renderFn.mock.lastCall).toStrictEqual([
686+
{ value: 'a', pendingFirst: false, done: false, error: undefined },
687+
]);
692688
expect(rendered.container.innerHTML).toStrictEqual(
693689
'<div id="test-created-elem">Render count: 2</div>'
694690
);
695691
}
696692
);
693+
694+
it(
695+
gray(
696+
'When given a `ReactAsyncIterable` yielding `undefined`s or `null`s that wraps an iter which originally yields non-nullable values, processes the `undefined`s and `null` values expected'
697+
),
698+
async () => {
699+
const channel = new IteratorChannelTestHelper<string>();
700+
const renderFn = vi.fn() as Mock<
701+
(next: IterationResult<AsyncIterable<string | null | undefined>>) => any
702+
>;
703+
704+
const buildContent = (iter: AsyncIterable<string>, formatInto: string | null | undefined) => {
705+
return (
706+
<Iterate value={iterateFormatted(iter, _ => formatInto)}>
707+
{renderFn.mockImplementation(next => (
708+
<div id="test-created-elem">{next.value + ''}</div>
709+
))}
710+
</Iterate>
711+
);
712+
};
713+
714+
const rendered = render(<></>);
715+
716+
rendered.rerender(buildContent(channel, ''));
717+
718+
await act(() => {
719+
channel.put('a');
720+
rendered.rerender(buildContent(channel, null));
721+
});
722+
expect(renderFn.mock.lastCall).toStrictEqual([
723+
{ value: null, pendingFirst: false, done: false, error: undefined },
724+
]);
725+
expect(rendered.container.innerHTML).toStrictEqual('<div id="test-created-elem">null</div>');
726+
727+
await act(() => {
728+
channel.put('b');
729+
rendered.rerender(buildContent(channel, undefined));
730+
});
731+
expect(renderFn.mock.lastCall).toStrictEqual([
732+
{ value: undefined, pendingFirst: false, done: false, error: undefined },
733+
]);
734+
expect(rendered.container.innerHTML).toStrictEqual(
735+
'<div id="test-created-elem">undefined</div>'
736+
);
737+
}
738+
);
697739
});
698740

699741
const simulatedError = new Error('🚨 Simulated Error 🚨');

spec/tests/useAsyncIter.spec.ts

+47-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { it, describe, expect, afterEach } from 'vitest';
22
import { gray } from 'colorette';
33
import { cleanup as cleanupMountedReactTrees, act, renderHook } from '@testing-library/react';
4-
import { useAsyncIter } from '../../src/index.js';
4+
import { useAsyncIter, iterateFormatted } from '../../src/index.js';
55
import { asyncIterOf } from '../utils/asyncIterOf.js';
66
import { IteratorChannelTestHelper } from '../utils/IteratorChannelTestHelper.js';
77

@@ -489,6 +489,52 @@ describe('`useAsyncIter` hook', () => {
489489
});
490490
}
491491
);
492+
493+
it(
494+
gray(
495+
'When given a `ReactAsyncIterable` yielding `undefined`s or `null`s that wraps an iter which originally yields non-nullable values, returns the `undefined`s and `null`s in the result as expected'
496+
),
497+
async () => {
498+
const channel = new IteratorChannelTestHelper<string>();
499+
let timesRerendered = 0;
500+
501+
const renderedHook = await act(() =>
502+
renderHook(
503+
({ formatInto }) => {
504+
timesRerendered++;
505+
return useAsyncIter(iterateFormatted(channel, _ => formatInto));
506+
},
507+
{
508+
initialProps: { formatInto: '' as string | null | undefined },
509+
}
510+
)
511+
);
512+
513+
await act(() => {
514+
channel.put('a');
515+
renderedHook.rerender({ formatInto: null });
516+
});
517+
expect(timesRerendered).toStrictEqual(3);
518+
expect(renderedHook.result.current).toStrictEqual({
519+
value: null,
520+
pendingFirst: false,
521+
done: false,
522+
error: undefined,
523+
});
524+
525+
await act(() => {
526+
channel.put('b');
527+
renderedHook.rerender({ formatInto: undefined });
528+
});
529+
expect(timesRerendered).toStrictEqual(5);
530+
expect(renderedHook.result.current).toStrictEqual({
531+
value: undefined,
532+
pendingFirst: false,
533+
done: false,
534+
error: undefined,
535+
});
536+
}
537+
);
492538
});
493539

494540
const simulatedError = new Error('🚨 Simulated Error 🚨');

spec/tests/useAsyncIterMulti.spec.ts

+49-1
Original file line numberDiff line numberDiff line change
@@ -652,7 +652,7 @@ describe('`useAsyncIterMulti` hook', () => {
652652

653653
it(
654654
gray(
655-
'When given "React Async Iterables", maintains the iteration states based on the original source iters they contain and applies the next given format functions correctly'
655+
'When given `ReactAsyncIterables`, maintains the iteration states based on the original source iters they contain and applies the next given format functions correctly'
656656
),
657657
async () => {
658658
const channel1 = new IteratorChannelTestHelper<string>();
@@ -722,6 +722,54 @@ describe('`useAsyncIterMulti` hook', () => {
722722
]);
723723
}
724724
);
725+
726+
it(
727+
gray(
728+
'When given `ReactAsyncIterable`s yielding `undefined`s or `null`s that wrap iters which originally yield non-nullable values, returns the `undefined`s and `null`s in the results as expected'
729+
),
730+
async () => {
731+
const channel1 = new IteratorChannelTestHelper<string>();
732+
const channel2 = new IteratorChannelTestHelper<string>();
733+
let timesRerendered = 0;
734+
735+
const renderedHook = await act(() =>
736+
renderHook(
737+
({ formatInto }) => {
738+
timesRerendered++;
739+
return useAsyncIterMulti([
740+
iterateFormatted(channel1, _ => formatInto),
741+
iterateFormatted(channel2, _ => formatInto),
742+
]);
743+
},
744+
{
745+
initialProps: { formatInto: '' as string | null | undefined },
746+
}
747+
)
748+
);
749+
750+
await act(() => {
751+
channel1.put('a');
752+
channel2.put('a');
753+
renderedHook.rerender({ formatInto: null });
754+
});
755+
expect(timesRerendered).toStrictEqual(3);
756+
expect(renderedHook.result.current).toStrictEqual([
757+
{ value: null, pendingFirst: false, done: false, error: undefined },
758+
{ value: null, pendingFirst: false, done: false, error: undefined },
759+
]);
760+
761+
await act(() => {
762+
channel1.put('b');
763+
channel2.put('b');
764+
renderedHook.rerender({ formatInto: undefined });
765+
});
766+
expect(timesRerendered).toStrictEqual(5);
767+
expect(renderedHook.result.current).toStrictEqual([
768+
{ value: undefined, pendingFirst: false, done: false, error: undefined },
769+
{ value: undefined, pendingFirst: false, done: false, error: undefined },
770+
]);
771+
}
772+
);
725773
});
726774

727775
const simulatedError1 = new Error('🚨 Simulated Error 1 🚨');

src/useAsyncIter/index.ts

+15-48
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
reactAsyncIterSpecialInfoSymbol,
88
type ReactAsyncIterSpecialInfo,
99
} from '../common/ReactAsyncIterable.js';
10+
import { iterateAsyncIterWithCallbacks } from '../common/iterateAsyncIterWithCallbacks.js';
1011
import { type Iterate } from '../Iterate/index.js'; // eslint-disable-line @typescript-eslint/no-unused-vars
1112
import { type iterateFormatted } from '../iterateFormatted/index.js'; // eslint-disable-line @typescript-eslint/no-unused-vars
1213

@@ -154,58 +155,24 @@ const useAsyncIter: {
154155
}, [iterSourceRefToUse]);
155156

156157
useEffect(() => {
157-
const iterator = iterSourceRefToUse[Symbol.asyncIterator]();
158-
let iteratorClosedByConsumer = false;
158+
let iterationIdx = 0;
159159

160-
(async () => {
161-
let iterationIdx = 0;
160+
return iterateAsyncIterWithCallbacks(iterSourceRefToUse, stateRef.current.value, next => {
161+
const possibleGivenFormatFn =
162+
latestInputRef.current?.[reactAsyncIterSpecialInfoSymbol]?.formatFn;
162163

163-
try {
164-
for await (const value of { [Symbol.asyncIterator]: () => iterator }) {
165-
if (!iteratorClosedByConsumer) {
166-
const formattedValue =
167-
latestInputRef.current?.[reactAsyncIterSpecialInfoSymbol]?.formatFn(
168-
value,
169-
iterationIdx++
170-
) ?? (value as ExtractAsyncIterValue<TVal>);
164+
const formattedValue = possibleGivenFormatFn
165+
? possibleGivenFormatFn(next.value, iterationIdx++)
166+
: (next.value as ExtractAsyncIterValue<TVal>);
171167

172-
if (!Object.is(formattedValue, stateRef.current.value)) {
173-
stateRef.current = {
174-
value: formattedValue,
175-
pendingFirst: false,
176-
done: false,
177-
error: undefined,
178-
};
179-
rerender();
180-
}
181-
}
182-
}
183-
if (!iteratorClosedByConsumer) {
184-
stateRef.current = {
185-
value: stateRef.current.value,
186-
pendingFirst: false,
187-
done: true,
188-
error: undefined,
189-
};
190-
rerender();
191-
}
192-
} catch (err) {
193-
if (!iteratorClosedByConsumer) {
194-
stateRef.current = {
195-
value: stateRef.current.value,
196-
pendingFirst: false,
197-
done: true,
198-
error: err,
199-
};
200-
rerender();
201-
}
202-
}
203-
})();
168+
stateRef.current = {
169+
...next,
170+
pendingFirst: false,
171+
value: formattedValue,
172+
};
204173

205-
return () => {
206-
iteratorClosedByConsumer = true;
207-
iterator.return?.();
208-
};
174+
rerender();
175+
});
209176
}, [iterSourceRefToUse]);
210177

211178
return stateRef.current;

src/useAsyncIterMulti/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { isAsyncIter } from '../common/isAsyncIter.js';
55
import { type IterationResult } from '../useAsyncIter/index.js';
66
import { asyncIterSyncMap } from '../common/asyncIterSyncMap.js';
77
import { parseReactAsyncIterable } from '../common/ReactAsyncIterable.js';
8-
import { iterateAsyncIterWithCallbacks } from './iterateAsyncIterWithCallbacks.js';
8+
import { iterateAsyncIterWithCallbacks } from '../common/iterateAsyncIterWithCallbacks.js';
99

1010
export { useAsyncIterMulti, type IterationResult, type IterationResultSet };
1111

0 commit comments

Comments
 (0)