From 769cf03badea7ecd142ba6776379e7861c97d160 Mon Sep 17 00:00:00 2001 From: Dor Shtaif Date: Tue, 28 Jan 2025 12:27:02 +0200 Subject: [PATCH 1/2] support for `IterateMulti` to handle getting initial values as initializer functions --- README.md | 4 +- spec/tests/IterateMulti.spec.tsx | 119 ++++++++++++++++++++++++++- spec/tests/useAsyncIterMulti.spec.ts | 6 +- src/IterateMulti/index.tsx | 23 ++++-- src/useAsyncIterMulti/index.ts | 2 +- 5 files changed, 138 insertions(+), 16 deletions(-) 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* () { From a65209e7a52d48eaa0bb51f93e841240ec71ca5d Mon Sep 17 00:00:00 2001 From: Dor Shtaif Date: Tue, 28 Jan 2025 12:29:11 +0200 Subject: [PATCH 2/2] various docs edits --- src/Iterate/index.tsx | 14 +++++++------- src/IterateMulti/index.tsx | 2 +- src/iterateFormatted/index.ts | 22 ++++++++++++---------- src/useAsyncIterState/index.ts | 20 ++++++++++---------- 4 files changed, 30 insertions(+), 28 deletions(-) diff --git a/src/Iterate/index.tsx b/src/Iterate/index.tsx index 3d79ac0..5ac3ef0 100644 --- a/src/Iterate/index.tsx +++ b/src/Iterate/index.tsx @@ -6,7 +6,7 @@ import { useAsyncIter, type IterationResult } from '../useAsyncIter/index.js'; export { Iterate, type IterateProps }; /** - * The `` helper component (also exported as ``) is used to format and render an async + * The `` component (also exported as ``) is used to format and render an async * iterable (or a plain non-iterable value) directly onto a piece of UI. * * Essentially, can be seen as a {@link useAsyncIter `useAsyncIter`} hook in a component form, @@ -46,15 +46,15 @@ export { Iterate, type IterateProps }; * {@link useAsyncIter `useAsyncIter`} being a hook it has to re-render an entire * component's output for every new value. * - * Given an async iterable as the `value` prop, this component will iterate it and render each new + * Given an async iterable as the `value` prop, this component will iterate it and render every new * value that becomes available together with any possible completion or error it may run into. - * If `value` is a plain (non async iterable) value, it will simply be rendered over as-is. + * If `value` is a plain (non async iterable) value, it will simply be rendered as-is. * * Whenever given `value` is changed from the previous one seen, `` will close the previous * if it was async iterable before proceeding to iterate the new `value`. Care should be taken to - * avoid passing a constantly recreated iterable object across re-renders, e.g; by declaring it outside the component body or control __when__ it - * should be recreated with React's [`useMemo`](https://react.dev/reference/react/useMemo). - * `` will automatically close its iterated iterable as soon as it gets unmounted. + * avoid passing a constantly recreated iterable object across re-renders, e.g; by declaring it outside + * the component body or by controlling __when__ it should be recreated with React's + * [`useMemo`](https://react.dev/reference/react/useMemo). `` will automatically close its iterated iterable as soon as it gets unmounted. * * --- * @@ -141,7 +141,7 @@ type IterateProps = type IteratePropsWithRenderFunction = { /** - * The source value to iterate over, an async iterable or a plain (non async iterable) value. + * The source value to iterate over - an async iterable or a plain (non async iterable) value. */ value: TVal; /** diff --git a/src/IterateMulti/index.tsx b/src/IterateMulti/index.tsx index 6881b00..fa2725c 100644 --- a/src/IterateMulti/index.tsx +++ b/src/IterateMulti/index.tsx @@ -9,7 +9,7 @@ export { IterateMulti, type IterateMultiProps }; // TODO: The initial values should be able to be given in function/s form, with consideration for iterable sources that could be added in dynamically. /** - * The `` helper component (also exported as ``) is used to combine and render + * The `` component (also exported as ``) is used to combine and render * any number of async iterables (or plain non-iterable values) directly onto a piece of UI. * * It's similar to ``, only it works with any changeable number of async iterables or plain values diff --git a/src/iterateFormatted/index.ts b/src/iterateFormatted/index.ts index ad8b8d0..ab93016 100644 --- a/src/iterateFormatted/index.ts +++ b/src/iterateFormatted/index.ts @@ -14,8 +14,11 @@ import { type Iterate } from '../Iterate/index.js'; // eslint-disable-line @type export { iterateFormatted }; /** - * An optional utility to format an async iterable's values inline where its passed into - * an other consuming component. + * A utility to inline-format an async iterable's values before passed into an other + * consuming component. + * + * Can be thought of as mapping an async iterable before being rendered/passed over in the same way + * you would commonly `.map(...)` an array before rendering/passing it over. * * @example * ```tsx @@ -55,27 +58,26 @@ export { iterateFormatted }; * This utility should come handy in places when you need a formatted (or _"mapped"_) version of * some existing async iterable before passing it as prop into an other component which consumes it * and you rather have the formatting logic right at the place in code of passing the prop instead - * of far from it, having to perform the transformation using some `useMemo` call at the top of the - * component. + * of having to perform the transformation using some `useMemo` call at the top of the component. * * The utility's method of operation is to be given a `source` and return from it a new transformed * async iterable object, attaching it with some special metadata that tells library tools like - * {@link Iterate ``} and {@link useAsyncIter `useAsyncIter`} to bind the iteration process - * to the same original base object. This way, the outer "formatted" iterable may be recreated repeatedly - * without concerns of restarting the iteration process (as long as the `source` arg is consistently - * passed the same base object). + * {@link Iterate ``} and {@link useAsyncIter `useAsyncIter`} that the original base object + * is what the iteration process should be bound to instead of the given object. This way, the + * resulting formatted iterable may be recreated repeatedly without concerns of restarting the + * iteration process (as long as `source` is passed the same base iterable consistently). * * `source` may have a current value property (at `.value.current` - per the `AsyncIterableSubject` * interface), in which case it will be formatted via `formatFn` in the same way like yielded values. * - * If `source` is a plain value and not an async iterable, it will be passed to the given `formatFn` + * If `source` is a plain value and not an async iterable, it will be passed into the given `formatFn` * and returned on the spot. * * @template TIn The full type of the source input. * @template TRes The type of values resulting after formatting. * * @param source Any async iterable or plain value. - * @param formatFn Function that performs formatting/mapping logic for each value of `source` + * @param formatFn Function that performs formatting/mapping logic for each value of `source`. * * @returns a transformed async iterable emitting every value of `source` after formatting. */ diff --git a/src/useAsyncIterState/index.ts b/src/useAsyncIterState/index.ts index 9ed22fe..15848b2 100644 --- a/src/useAsyncIterState/index.ts +++ b/src/useAsyncIterState/index.ts @@ -12,11 +12,13 @@ export { useAsyncIterState, type AsyncIterStateResult, type AsyncIterableChannel /** * Basically like {@link https://react.dev/reference/react/useState `React.useState`}, only that the value - * is provided back __wrapped as an async iterable__. + * is provided back __wrapped in an async iterable__. * - * This hook allows a component to declare and manage a piece of state while easily letting you control - * what specifically area(s) within the UI should be bound to it (should re-render in reaction to changes - * in it) - for example, if combined with one or more {@link Iterate ``}s. + * This hook allows a component to declare and manage a piece of state as an async iterable thus + * letting you easily control what specific places in the app UI tree should be bound to it, + * re-rendering in reaction to its changes (if used in conjunction with {@link Iterate ``} + * for example). + * * @example * ```tsx @@ -44,9 +46,9 @@ export { useAsyncIterState, type AsyncIterStateResult, type AsyncIterableChannel * * The returned async iterable can be passed over to any level down the component tree and rendered * using ``, `useAsyncIter`, and others. It also contains a `.value.current` property which shows - * the current up to date state value at all times. Use this any time you need to read the immediate + * the current up to date state value at any time. Use this any time you need to read the immediate * current state (for example as part of side effect logic) rather than directly rendering it, since - * for rendering you may simply iterate values as part of an ``. + * for rendering you may simply iterate the values as part of an ``. * * Returned also alongside the async iterable is a function for updating the state. Calling it with a new * value will cause the paired iterable to yield the updated state value as well as immediately set the @@ -84,9 +86,7 @@ export { useAsyncIterState, type AsyncIterStateResult, type AsyncIterableChannel * } * ``` * - * The returned async iterable is a shared iterable - can be iterated by multiple consumers simultaneously - * (e.g multiple {@link Iterate ``}s) and each would pick up the same yielded values and at the - * same time. + * The returned async iterable is a shared iterable so that if iterated by multiple consumers simultaneously (e.g multiple {@link Iterate ``}s) then all would pick up the same yields at the same time. * * The returned async iterable is automatically closed on host component unmount. * @@ -97,7 +97,7 @@ export { useAsyncIterState, type AsyncIterStateResult, type AsyncIterableChannel * * @param initialValue Any optional starting value for the state iterable's `.value.current` property, defaults to `undefined`. You can pass an actual value, or a function that returns a value (which the hook will call once during mounting). * - * @returns a stateful async iterable and a function with which to yield an update, both maintain stable references across re-renders. + * @returns a stateful async iterable and a function for yielding an update. Both maintain stable references across re-renders. * * @see {@link Iterate ``} */