From 1db7dd45fb352db8b63196e0b456f2003a85b7a2 Mon Sep 17 00:00:00 2001 From: Dor Shtaif Date: Thu, 9 Jan 2025 13:26:14 +0200 Subject: [PATCH 1/4] feat(useAsyncIter): allow initial value to be a function, called once on mount --- spec/tests/useAsyncIter.spec.ts | 28 +++++++++++++++++++++++++++- src/common/callOrReturn.ts | 5 +++++ src/useAsyncIter/index.ts | 23 +++++++++++++---------- 3 files changed, 45 insertions(+), 11 deletions(-) create mode 100644 src/common/callOrReturn.ts diff --git a/spec/tests/useAsyncIter.spec.ts b/spec/tests/useAsyncIter.spec.ts index e9c5e7e..2127d96 100644 --- a/spec/tests/useAsyncIter.spec.ts +++ b/spec/tests/useAsyncIter.spec.ts @@ -1,4 +1,4 @@ -import { it, describe, expect, afterEach } from 'vitest'; +import { it, describe, expect, afterEach, vi } from 'vitest'; import { gray } from 'colorette'; import { cleanup as cleanupMountedReactTrees, act, renderHook } from '@testing-library/react'; import { useAsyncIter, iterateFormatted } from '../../src/index.js'; @@ -404,6 +404,32 @@ describe('`useAsyncIter` hook', () => { } ); + it( + gray( + 'When given an initial value as a function, calls it once on mount and uses its result as the initial value correctly' + ), + async () => { + const channel = new IteratorChannelTestHelper(); + const initValFn = vi.fn(() => '_'); + + const renderedHook = await act(() => renderHook(() => useAsyncIter(channel, initValFn))); + const results = [renderedHook.result.current]; + + await act(() => renderedHook.rerender()); + results.push(renderedHook.result.current); + + await act(() => channel.put('a')); + results.push(renderedHook.result.current); + + expect(initValFn).toHaveBeenCalledOnce(); + expect(results).toStrictEqual([ + { value: '_', pendingFirst: true, done: false, error: undefined }, + { value: '_', pendingFirst: true, done: false, error: undefined }, + { value: 'a', pendingFirst: false, done: false, error: undefined }, + ]); + } + ); + it(gray('When unmounted will close the last active iterator it held'), async () => { const channel = new IteratorChannelTestHelper(); diff --git a/src/common/callOrReturn.ts b/src/common/callOrReturn.ts new file mode 100644 index 0000000..0f31993 --- /dev/null +++ b/src/common/callOrReturn.ts @@ -0,0 +1,5 @@ +export { callOrReturn }; + +function callOrReturn(value: T | (() => T)): T { + return typeof value !== 'function' ? value : (value as () => T)(); +} diff --git a/src/useAsyncIter/index.ts b/src/useAsyncIter/index.ts index 91042bd..27d9238 100644 --- a/src/useAsyncIter/index.ts +++ b/src/useAsyncIter/index.ts @@ -1,20 +1,20 @@ -import { useRef, useMemo, useEffect } from 'react'; +import { useMemo, useEffect } from 'react'; import { useLatest } from '../common/hooks/useLatest.js'; import { isAsyncIter } from '../common/isAsyncIter.js'; import { useSimpleRerender } from '../common/hooks/useSimpleRerender.js'; +import { useRefWithInitialValue } from '../common/hooks/useRefWithInitialValue.js'; import { type ExtractAsyncIterValue } from '../common/ExtractAsyncIterValue.js'; import { reactAsyncIterSpecialInfoSymbol, type ReactAsyncIterSpecialInfo, } from '../common/ReactAsyncIterable.js'; import { iterateAsyncIterWithCallbacks } from '../common/iterateAsyncIterWithCallbacks.js'; +import { callOrReturn } from '../common/callOrReturn.js'; import { type Iterate } from '../Iterate/index.js'; // eslint-disable-line @typescript-eslint/no-unused-vars import { type iterateFormatted } from '../iterateFormatted/index.js'; // eslint-disable-line @typescript-eslint/no-unused-vars export { useAsyncIter, type IterationResult }; -// TODO: The initial values should be able to be given as functions, having them called once on mount - /** * `useAsyncIter` hooks up a single async iterable value to your component and its lifecycle. * @@ -62,7 +62,7 @@ export { useAsyncIter, type IterationResult }; * @template TInitVal The type of the initial value, defaults to `undefined`. * * @param input Any async iterable or plain value. - * @param initialVal Any initial value for the hook to return prior to resolving the ___first emission___ of the ___first given___ async iterable, defaults to `undefined`. + * @param initialVal Any optional starting value for the hook to return prior to the ___first yield___ of the ___first given___ async iterable, 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 An object with properties reflecting the current state of the iterated async iterable or plain value provided via `input` (see {@link IterationResult `IterationResult`}). * @@ -100,7 +100,10 @@ export { useAsyncIter, type IterationResult }; */ const useAsyncIter: { (input: TVal, initialVal?: undefined): IterationResult; - (input: TVal, initialVal: TInitVal): IterationResult; + ( + input: TVal, + initialVal: TInitVal | (() => TInitVal) + ): IterationResult; } = < TVal extends | undefined @@ -112,19 +115,19 @@ const useAsyncIter: { ExtractAsyncIterValue >; }, - TInitVal = undefined, + TInitVal, >( input: TVal, - initialVal: TInitVal + initialVal: TInitVal | (() => TInitVal) ): IterationResult => { const rerender = useSimpleRerender(); - const stateRef = useRef>({ - value: initialVal as any, + const stateRef = useRefWithInitialValue>(() => ({ + value: callOrReturn(initialVal) as any, pendingFirst: true, done: false, error: undefined, - }); + })); const latestInputRef = useLatest(input); From b185189d8c22fc7245d5bdc5fb318894410ca3b1 Mon Sep 17 00:00:00 2001 From: Dor Shtaif Date: Thu, 9 Jan 2025 13:54:47 +0200 Subject: [PATCH 2/4] feat: introduce `MaybeFunction` type to clean up places accepting a value-or-function types of inputs --- src/common/MaybeFunction.ts | 3 +++ src/common/callOrReturn.ts | 4 +++- src/useAsyncIter/index.ts | 5 +++-- 3 files changed, 9 insertions(+), 3 deletions(-) create mode 100644 src/common/MaybeFunction.ts diff --git a/src/common/MaybeFunction.ts b/src/common/MaybeFunction.ts new file mode 100644 index 0000000..273732c --- /dev/null +++ b/src/common/MaybeFunction.ts @@ -0,0 +1,3 @@ +export { MaybeFunction }; + +type MaybeFunction = T | ((...args: TPossibleArgs) => T); diff --git a/src/common/callOrReturn.ts b/src/common/callOrReturn.ts index 0f31993..520707d 100644 --- a/src/common/callOrReturn.ts +++ b/src/common/callOrReturn.ts @@ -1,5 +1,7 @@ +import { type MaybeFunction } from './MaybeFunction.js'; + export { callOrReturn }; -function callOrReturn(value: T | (() => T)): T { +function callOrReturn(value: MaybeFunction): T { return typeof value !== 'function' ? value : (value as () => T)(); } diff --git a/src/useAsyncIter/index.ts b/src/useAsyncIter/index.ts index 27d9238..d29f813 100644 --- a/src/useAsyncIter/index.ts +++ b/src/useAsyncIter/index.ts @@ -3,6 +3,7 @@ import { useLatest } from '../common/hooks/useLatest.js'; import { isAsyncIter } from '../common/isAsyncIter.js'; import { useSimpleRerender } from '../common/hooks/useSimpleRerender.js'; import { useRefWithInitialValue } from '../common/hooks/useRefWithInitialValue.js'; +import { type MaybeFunction } from '../common/MaybeFunction.js'; import { type ExtractAsyncIterValue } from '../common/ExtractAsyncIterValue.js'; import { reactAsyncIterSpecialInfoSymbol, @@ -102,7 +103,7 @@ const useAsyncIter: { (input: TVal, initialVal?: undefined): IterationResult; ( input: TVal, - initialVal: TInitVal | (() => TInitVal) + initialVal: MaybeFunction ): IterationResult; } = < TVal extends @@ -118,7 +119,7 @@ const useAsyncIter: { TInitVal, >( input: TVal, - initialVal: TInitVal | (() => TInitVal) + initialVal: MaybeFunction ): IterationResult => { const rerender = useSimpleRerender(); From 9f015a078b13e1e84859321dee9d2371119acbe1 Mon Sep 17 00:00:00 2001 From: Dor Shtaif Date: Thu, 9 Jan 2025 16:11:08 +0200 Subject: [PATCH 3/4] feat(Iterate): support initial value in function form and update type definitions --- spec/tests/Iterate.spec.tsx | 44 +++++++++++++++++++++++++++++++++++++ src/Iterate/index.tsx | 22 ++++++++++++------- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/spec/tests/Iterate.spec.tsx b/spec/tests/Iterate.spec.tsx index 95aad83..c2b9db8 100644 --- a/spec/tests/Iterate.spec.tsx +++ b/spec/tests/Iterate.spec.tsx @@ -580,6 +580,50 @@ describe('`Iterate` component', () => { } ); + it( + gray( + 'When given an initial value as a function, calls it once on mount and uses its result as the initial value correctly' + ), + async () => { + const channel = new IteratorChannelTestHelper(); + const initValFn = vi.fn(() => '_'); + const renderFn = vi.fn() as Mock< + (next: IterationResult, string>) => any + >; + + const Component = (props: { value: AsyncIterable }) => ( + + {renderFn.mockImplementation(() => ( +
Render count: {renderFn.mock.calls.length}
+ ))} +
+ ); + + const rendered = render(<>); + + await act(() => rendered.rerender()); + const renderedHtmls = [rendered.container.innerHTML]; + + await act(() => rendered.rerender()); + renderedHtmls.push(rendered.container.innerHTML); + + await act(() => channel.put('a')); + renderedHtmls.push(rendered.container.innerHTML); + + expect(initValFn).toHaveBeenCalledOnce(); + expect(renderFn.mock.calls).toStrictEqual([ + [{ value: '_', pendingFirst: true, done: false, error: undefined }], + [{ value: '_', pendingFirst: true, done: false, error: undefined }], + [{ value: 'a', pendingFirst: false, done: false, error: undefined }], + ]); + expect(renderedHtmls).toStrictEqual([ + '
Render count: 1
', + '
Render count: 2
', + '
Render count: 3
', + ]); + } + ); + it(gray('When unmounted will close the last active iterator it held'), async () => { let lastRenderFnInput: undefined | IterationResult; diff --git a/src/Iterate/index.tsx b/src/Iterate/index.tsx index e466313..3d79ac0 100644 --- a/src/Iterate/index.tsx +++ b/src/Iterate/index.tsx @@ -1,4 +1,6 @@ import { type ReactNode } from 'react'; +import { type MaybeFunction } from '../common/MaybeFunction.js'; +import { type MaybeAsyncIterable } from '../MaybeAsyncIterable/index.js'; import { useAsyncIter, type IterationResult } from '../useAsyncIter/index.js'; export { Iterate, type IterateProps }; @@ -110,7 +112,7 @@ function Iterate(props: IterateProps; const next = useAsyncIter( propsBetterTyped.value, - propsBetterTyped.initialValue as TInitialVal + propsBetterTyped.initialValue as NonNullable ); return propsBetterTyped.children(next); })() @@ -143,16 +145,18 @@ type IteratePropsWithRenderFunction = { */ value: TVal; /** - * An optional initial value, defaults to `undefined`. Will be the value provided inside the child - * render function when `` first renders on being mounted and while it's pending its first - * value to be yielded. + * An optional starting value, defaults to `undefined`. Will be the value inserted into the child render + * function when `` first renders during mount and while it's pending its first value to be + * yielded. + * + * You can pass an actual value, or a function that returns a value (which `` will call once during mounting). */ - initialValue?: TInitialVal; + initialValue?: MaybeFunction; /** * A render function that is called for each step of the iteration, returning something to render * out of it. * - * @param nextIterationState - The current state of the iteration, including the yielded value, whether iteration is complete, any associated error, etc. (see {@link IterationResult `IterationResult`}) + * @param nextIterationState - The current state of the iteration, including the yielded value, whether iteration is complete, any associated error, etc. (see {@link IterationResult `IterationResult`}). * @returns The content to render for the current iteration state. * * @see {@link IterateProps `IterateProps`} @@ -169,10 +173,12 @@ type IteratePropsWithNoRenderFunction = { value?: undefined; /** * An optional initial value, defaults to `undefined`. + * + * You can pass an actual value, or a function that returns a value (which `` will call once during mounting). */ - initialValue?: ReactNode; + initialValue?: MaybeFunction; /** * The source value to render from, either an async iterable to iterate over of a plain value. */ - children: ReactNode | AsyncIterable; + children: MaybeAsyncIterable; }; From 55cf8f24b7e317f0812148e054c6491ad1ad9c89 Mon Sep 17 00:00:00 2001 From: Dor Shtaif Date: Thu, 9 Jan 2025 16:19:25 +0200 Subject: [PATCH 4/4] improve TODO comments to implement corresponding behaviors for `useAsyncIterMulti` and `IterateMulti` as well --- src/IterateMulti/index.tsx | 2 ++ src/useAsyncIterMulti/index.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/IterateMulti/index.tsx b/src/IterateMulti/index.tsx index 79ec504..e9396ee 100644 --- a/src/IterateMulti/index.tsx +++ b/src/IterateMulti/index.tsx @@ -5,6 +5,8 @@ import { type iterateFormatted } from '../iterateFormatted/index.js'; // eslint- 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 * any number of async iterables (or plain non-iterable values) directly onto a piece of UI. diff --git a/src/useAsyncIterMulti/index.ts b/src/useAsyncIterMulti/index.ts index 50bc744..9313d62 100644 --- a/src/useAsyncIterMulti/index.ts +++ b/src/useAsyncIterMulti/index.ts @@ -9,7 +9,7 @@ import { iterateAsyncIterWithCallbacks } from '../common/iterateAsyncIterWithCal export { useAsyncIterMulti, type IterationResult, type IterationResultSet }; -// TODO: The initial values should be able to be given as functions, having them called once on mount if so +// 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. /** * `useAsyncIterMulti` hooks up multiple async iterables to your component and its lifecycle, letting