diff --git a/README.md b/README.md index 2028094..56b1452 100644 --- a/README.md +++ b/README.md @@ -759,10 +759,10 @@ It's similar to [``](#it), only it works with any changeable number of async An array of values to iterate over simultaneously, which may include any mix of async iterables or plain (non async iterable) values. Source values may be added, removed or changed at any time and new iterations will be close and started accordingly as per [Iteration lifecycle](#iteration-lifecycle). - `initialValues`: - An _optional_ array of initial values. The values here will be the starting points for all the async iterables from `values` (by corresponding array positions) while they are rendered by the `children` render function __for the first time__ and for each while it is __pending its first yield__. Async iterables from `values` that have no initial value corresponding to them will assume `undefined` as initial value. + An _optional_ array of initial values or functions that return initial values. The values here will be the starting points for all the async iterables from `values` (by corresponding array positions) while they are rendered by the `children` render function __for the first time__ and for each while it is __pending its first yield__. Async iterables from `values` that have no initial value corresponding to them will assume `undefined` as initial value. - `defaultInitialValue`: - An _optional_ default starting value for every new async iterable in `values` if there is no corresponding one for it in the `initialValues` prop, defaults to `undefined`. + An _optional_ default starting value for every new async iterable in `values` if there is no corresponding one for it in the `initialValues` prop, defaults to `undefined`. You can pass an actual value, or a function that returns a value (which the hook will call for every new iterable added). - `children`: A render function that is called on every progression in any of the running iterations, returning something to render for them. The function is called with an array of the combined iteration state objects of all sources currently given by the `values` prop (see [Iteration state properties breakdown](#iteration-state-properties-breakdown)). diff --git a/spec/tests/IterateMulti.spec.tsx b/spec/tests/IterateMulti.spec.tsx index a4c134a..fc9d972 100644 --- a/spec/tests/IterateMulti.spec.tsx +++ b/spec/tests/IterateMulti.spec.tsx @@ -226,7 +226,7 @@ describe('`IterateMulti` hook', () => { }); it( - gray("When given multiple iterables, some empty, reflects each's states correctly"), + gray("When given multiple iterables, some empty, renders each's state correctly"), async () => { const renderFn = vi.fn() as Mock< IterateMultiProps<[AsyncIterable<'a'>, AsyncIterable]>['children'] @@ -257,6 +257,123 @@ describe('`IterateMulti` hook', () => { } ); + it( + gray( + 'When given multiple iterables with a default initial value as a function, calls it once whenever a new iterable is added' + ), + async () => { + const channels = [ + new IteratorChannelTestHelper(), + new IteratorChannelTestHelper(), + ]; + const initialValueFn = vi.fn(() => '___'); + const renderFn = vi.fn() as Mock< + (nexts: IterationResultSet[], [], '___'>) => any + >; + + const Component = ({ values }: { values: AsyncIterable[] }) => ( + + {renderFn.mockImplementation(() => ( +
Render count: {renderFn.mock.calls.length}
+ ))} +
+ ); + + const rendered = render(<>); + + await act(() => rendered.rerender()); + expect(renderFn.mock.calls).lengthOf(1); + expect(renderFn.mock.lastCall?.flat()).toStrictEqual([ + { value: '___', pendingFirst: true, done: false, error: undefined }, + { value: '___', pendingFirst: true, done: false, error: undefined }, + ]); + expect(rendered.container.innerHTML).toStrictEqual( + `
Render count: 1
` + ); + + await act(() => { + channels[0].put('a'); + channels[1].put('b'); + }); + expect(renderFn.mock.calls).lengthOf(2); + expect(renderFn.mock.lastCall?.flat()).toStrictEqual([ + { value: 'a', pendingFirst: false, done: false, error: undefined }, + { value: 'b', pendingFirst: false, done: false, error: undefined }, + ]); + expect(rendered.container.innerHTML).toStrictEqual( + `
Render count: 2
` + ); + + await act(() => { + channels.push(new IteratorChannelTestHelper()); + rendered.rerender(); + }); + expect(renderFn.mock.calls).lengthOf(3); + expect(renderFn.mock.lastCall?.flat()).toStrictEqual([ + { value: 'a', pendingFirst: false, done: false, error: undefined }, + { value: 'b', pendingFirst: false, done: false, error: undefined }, + { value: '___', pendingFirst: true, done: false, error: undefined }, + ]); + expect(rendered.container.innerHTML).toStrictEqual( + `
Render count: 3
` + ); + expect(initialValueFn).toHaveBeenCalledTimes(3); + } + ); + + it( + gray( + 'When given multiple iterables with initial values as a functions, calls each once whenever a corresponding iterable is added' + ), + async () => { + const channels = [new IteratorChannelTestHelper()]; + const [initialValueFn1, initialValueFn2] = [vi.fn(), vi.fn()]; + const renderFn = vi.fn() as Mock< + (nexts: IterationResultSet[], ['_1_', '_2_']>) => any + >; + + const Component = ({ values }: { values: AsyncIterable[] }) => ( + '_1_'), + initialValueFn2.mockImplementation(() => '_2_'), + ]} + > + {renderFn.mockImplementation(() => ( +
Render count: {renderFn.mock.calls.length}
+ ))} +
+ ); + + const rendered = render(<>); + + await act(() => rendered.rerender()); + expect(renderFn.mock.calls).lengthOf(1); + expect(renderFn.mock.lastCall?.flat()).toStrictEqual([ + { value: '_1_', pendingFirst: true, done: false, error: undefined }, + ]); + + await act(() => channels[0].put('a')); + expect(renderFn.mock.calls).lengthOf(2); + expect(renderFn.mock.lastCall?.flat()).toStrictEqual([ + { value: 'a', pendingFirst: false, done: false, error: undefined }, + ]); + + await act(() => { + channels.push(new IteratorChannelTestHelper()); + rendered.rerender(); + }); + expect(renderFn.mock.calls).lengthOf(3); + expect(renderFn.mock.lastCall?.flat()).toStrictEqual([ + { value: 'a', pendingFirst: false, done: false, error: undefined }, + { value: '_2_', pendingFirst: true, done: false, error: undefined }, + ]); + expect(initialValueFn1).toHaveBeenCalledOnce(); + expect(initialValueFn2).toHaveBeenCalledOnce(); + } + ); + it( gray( "When given multiple iterables with corresponding initial values for some and a default initial value, correctly renders each's state and corresponding initial value or the default initial value if not present" diff --git a/spec/tests/useAsyncIterMulti.spec.ts b/spec/tests/useAsyncIterMulti.spec.ts index e94538c..61036b5 100644 --- a/spec/tests/useAsyncIterMulti.spec.ts +++ b/spec/tests/useAsyncIterMulti.spec.ts @@ -167,7 +167,7 @@ describe('`useAsyncIterMulti` hook', () => { it( gray( - 'When given multiple iterables with a default initial value as a function, calls it once on every added source iterable' + 'When given multiple iterables with a default initial value as a function, calls it once whenever a new iterable is added' ), async () => { const channels = [ @@ -215,7 +215,7 @@ describe('`useAsyncIterMulti` hook', () => { it( gray( - 'When given multiple iterables with initial values as a functions, calls each once when a corresponding iterable is added' + 'When given multiple iterables with initial values as a functions, calls each once whenever a corresponding iterable is added' ), async () => { const channels = [new IteratorChannelTestHelper()]; @@ -277,8 +277,6 @@ describe('`useAsyncIterMulti` hook', () => { }); }); - renderedHook.result.current[0].value; - await act(() => {}); expect(timesRerendered).toStrictEqual(1); expect(renderedHook.result.current).toStrictEqual([ diff --git a/src/IterateMulti/index.tsx b/src/IterateMulti/index.tsx index 692e994..6881b00 100644 --- a/src/IterateMulti/index.tsx +++ b/src/IterateMulti/index.tsx @@ -1,6 +1,7 @@ import { type ReactNode } from 'react'; import { type Writeable } from '../common/Writeable.js'; import { useAsyncIterMulti, type IterationResultSet } from '../useAsyncIterMulti/index.js'; +import { type MaybeFunction } from '../common/MaybeFunction.js'; import { type iterateFormatted } from '../iterateFormatted/index.js'; // eslint-disable-line @typescript-eslint/no-unused-vars export { IterateMulti, type IterateMultiProps }; @@ -154,7 +155,7 @@ function IterateMulti< const TVals extends readonly unknown[], const TInitVals extends readonly unknown[] = readonly [], const TDefaultInitVal = undefined, ->(props: IterateMultiProps): ReactNode { +>(props: IterateMultiProps, TDefaultInitVal>): ReactNode { const nexts = useAsyncIterMulti(props.values, { initialValues: props.initialValues, defaultInitialValue: props.defaultInitialValue, @@ -181,18 +182,20 @@ type IterateMultiProps< values: TVals; /** - * An optional array of initial values. The values here will be the starting points for all the - * async iterables from `values` (correspondingly by matching array positions) when they are - * rendered by the `children` render function for the first time and for each while it is pending - * its first yield. Async iterables from `values` that have no corresponding item in this array, - * will fall back to the {@link IterateMultiProps.defaultInitialValue `defaultInitialValue`} prop - * as the initial value. + * An _optional_ array of initial values or functions that return initial values. These values + * will be the starting points for all the async iterables from `values` (correspondingly by + * matching array positions) when they are rendered by the `children` render function for the + * first time and for each while it is pending its first yield. Async iterables from `values` + * that have no corresponding item in this array, will fall back to the + * {@link IterateMultiProps.defaultInitialValue `defaultInitialValue`} prop as the initial value. */ initialValues?: TInitVals; /** * An _optional_ default starting value for every new async iterable in `values` if there is no - * corresponding one for it in the `initialValues` prop, defaults to `undefined`. + * corresponding one for it in the `initialValues` prop, defaults to `undefined`. You can pass + * an actual value, or a function that returns a value (which the hook will call for every new + * iterable added). */ defaultInitialValue?: TDefaultInitVal; @@ -210,3 +213,7 @@ type IterateMultiProps< iterationStates: IterationResultSet, Writeable, TDefaultInitVal> ) => ReactNode; }; + +type MaybeFunctions = { + [I in keyof T]: T[I] extends MaybeFunction ? J : T[I]; +}; diff --git a/src/useAsyncIterMulti/index.ts b/src/useAsyncIterMulti/index.ts index 5e96953..aa1e1d9 100644 --- a/src/useAsyncIterMulti/index.ts +++ b/src/useAsyncIterMulti/index.ts @@ -156,7 +156,7 @@ export { useAsyncIterMulti, type IterationResult, type IterationResultSet }; * function DynamicInputsComponent() { * const [inputs, setInputs] = useState[]>([]); * - * const states = useAsyncIterMulti(inputs); + * const states = useAsyncIterMulti(inputs, { defaultInitialValue: '' }); * * const addAsyncIterValue = () => { * const iterableValue = (async function* () {