From 544d1b41429b1e47317e3daba8a7bcf70c4585c0 Mon Sep 17 00:00:00 2001 From: Dor Shtaif Date: Mon, 23 Dec 2024 13:23:05 +0200 Subject: [PATCH] implement `iterateFormatted` async iter value formatting helper --- spec/tests/Iterate.spec.tsx | 22 +-- spec/tests/iterateFormatted.spec.tsx | 166 ++++++++++++++++++ spec/tests/useAsyncIter.spec.ts | 22 +-- spec/utils/IterableChannelTestHelper.ts | 28 +-- spec/utils/asyncIterToArray.ts | 9 + spec/utils/pipe.ts | 98 +++++++++++ src/common/asyncIterSyncMap.ts | 30 ++++ src/common/isAsyncIter.ts | 4 +- src/common/reactAsyncIterSpecialInfoSymbol.ts | 8 + src/index.ts | 10 +- src/iterateFormatted/index.ts | 113 ++++++++++++ src/useAsyncIter/index.ts | 115 +++++++----- 12 files changed, 542 insertions(+), 83 deletions(-) create mode 100644 spec/tests/iterateFormatted.spec.tsx create mode 100644 spec/utils/asyncIterToArray.ts create mode 100644 spec/utils/pipe.ts create mode 100644 src/common/asyncIterSyncMap.ts create mode 100644 src/common/reactAsyncIterSpecialInfoSymbol.ts create mode 100644 src/iterateFormatted/index.ts diff --git a/spec/tests/Iterate.spec.tsx b/spec/tests/Iterate.spec.tsx index 800df67..dce5d6b 100644 --- a/spec/tests/Iterate.spec.tsx +++ b/spec/tests/Iterate.spec.tsx @@ -488,7 +488,7 @@ describe('`Iterate` component', () => { it( gray( - "When consequtively updated with new iterables will close the previous one's iterator every time and render accordingly" + "When consecutively updated with new iterables will close the previous one's iterator every time and render accordingly" ), async () => { let lastRenderFnInput: undefined | IterationResult; @@ -498,7 +498,7 @@ describe('`Iterate` component', () => { new IterableChannelTestHelper(), ]; - const [channel1IterCloseSpy, channel2IterCloseSpy] = [ + const [channelReturnSpy1, channelReturnSpy2] = [ vi.spyOn(channel1, 'return'), vi.spyOn(channel2, 'return'), ]; @@ -519,8 +519,8 @@ describe('`Iterate` component', () => { { rendered.rerender(buildTestContent(channel1)); - expect(channel1IterCloseSpy).not.toHaveBeenCalled(); - expect(channel2IterCloseSpy).not.toHaveBeenCalled(); + expect(channelReturnSpy1).not.toHaveBeenCalled(); + expect(channelReturnSpy2).not.toHaveBeenCalled(); expect(lastRenderFnInput).toStrictEqual({ value: undefined, pendingFirst: true, @@ -543,8 +543,8 @@ describe('`Iterate` component', () => { { rendered.rerender(buildTestContent(channel2)); - expect(channel1IterCloseSpy).toHaveBeenCalledOnce(); - expect(channel2IterCloseSpy).not.toHaveBeenCalled(); + expect(channelReturnSpy1).toHaveBeenCalledOnce(); + expect(channelReturnSpy2).not.toHaveBeenCalled(); expect(lastRenderFnInput).toStrictEqual({ value: 'a', pendingFirst: true, @@ -567,8 +567,8 @@ describe('`Iterate` component', () => { { rendered.rerender(buildTestContent((async function* () {})())); - expect(channel1IterCloseSpy).toHaveBeenCalledOnce(); - expect(channel2IterCloseSpy).toHaveBeenCalledOnce(); + expect(channelReturnSpy1).toHaveBeenCalledOnce(); + expect(channelReturnSpy2).toHaveBeenCalledOnce(); expect(lastRenderFnInput).toStrictEqual({ value: 'b', pendingFirst: true, @@ -584,7 +584,7 @@ describe('`Iterate` component', () => { let lastRenderFnInput: undefined | IterationResult; const channel = new IterableChannelTestHelper(); - const channelIterCloseSpy = vi.spyOn(channel, 'return'); + const channelReturnSpy = vi.spyOn(channel, 'return'); const buildTestContent = (value: AsyncIterable) => { return ( @@ -602,7 +602,7 @@ describe('`Iterate` component', () => { { rendered.rerender(buildTestContent(channel)); - expect(channelIterCloseSpy).not.toHaveBeenCalled(); + expect(channelReturnSpy).not.toHaveBeenCalled(); expect(lastRenderFnInput).toStrictEqual({ value: undefined, pendingFirst: true, @@ -624,7 +624,7 @@ describe('`Iterate` component', () => { { rendered.unmount(); - expect(channelIterCloseSpy).toHaveBeenCalledOnce(); + expect(channelReturnSpy).toHaveBeenCalledOnce(); } }); }); diff --git a/spec/tests/iterateFormatted.spec.tsx b/spec/tests/iterateFormatted.spec.tsx new file mode 100644 index 0000000..4d0966e --- /dev/null +++ b/spec/tests/iterateFormatted.spec.tsx @@ -0,0 +1,166 @@ +import { it, describe, expect, afterEach, vi } from 'vitest'; +import { gray } from 'colorette'; +import { render, cleanup as cleanupMountedReactTrees, act } from '@testing-library/react'; +import { iterateFormatted, Iterate } from '../../src/index.js'; +import { pipe } from '../utils/pipe.js'; +import { asyncIterToArray } from '../utils/asyncIterToArray.js'; +import { IterableChannelTestHelper } from '../utils/IterableChannelTestHelper.js'; + +afterEach(() => { + cleanupMountedReactTrees(); +}); + +describe('`iterateFormatted` function', () => { + it(gray('When called on some plain value it formats and returns that on the spot'), () => { + const multiFormattedPlainValue = pipe( + 'a', + $ => iterateFormatted($, (value, i) => `${value} formatted once (idx: ${i})`), + $ => iterateFormatted($, (value, i) => `${value} and formatted twice (idx: ${i})`) + ); + expect(multiFormattedPlainValue).toStrictEqual( + 'a formatted once (idx: 0) and formatted twice (idx: 0)' + ); + }); + + it( + gray( + 'When the resulting object is iterated manually (without the library tools) it still has the provided formatting applied' + ), + async () => { + const multiFormattedIter = pipe( + (async function* () { + yield* ['a', 'b', 'c']; + })(), + $ => iterateFormatted($, (value, i) => `${value} formatted once (idx: ${i})`), + $ => iterateFormatted($, (value, i) => `${value} and formatted twice (idx: ${i})`) + ); + + const yielded = await asyncIterToArray(multiFormattedIter); + + expect(yielded).toStrictEqual([ + 'a formatted once (idx: 0) and formatted twice (idx: 0)', + 'b formatted once (idx: 1) and formatted twice (idx: 1)', + 'c formatted once (idx: 2) and formatted twice (idx: 2)', + ]); + } + ); + + it( + gray( + 'When the wrapped source is used normally with library tools it is rendered and formatted correctly' + ), + async () => { + const channel = new IterableChannelTestHelper(); + + const rendered = render( + iterateFormatted($, (value, i) => `${value} formatted once (idx: ${i})`), + $ => iterateFormatted($, (value, i) => `${value} and formatted twice (idx: ${i})`) + )} + > + {next =>

Rendered: {next.value}

} +
+ ); + + expect(rendered.container.innerHTML).toStrictEqual('

Rendered:

'); + + for (const [i, value] of ['a', 'b', 'c'].entries()) { + await act(() => channel.put(value)); + expect(rendered.container.innerHTML).toStrictEqual( + `

Rendered: ${value} formatted once (idx: ${i}) and formatted twice (idx: ${i})

` + ); + } + } + ); + + it( + gray( + 'When re-rendering with a new wrapped iterable each time, as long as they wrap the same source iterable, the same source iteration process will persist across these re-renderings' + ), + async () => { + const [channel1, channel2] = [ + new IterableChannelTestHelper(), + new IterableChannelTestHelper(), + ]; + + const [channelReturnSpy1, channelReturnSpy2] = [ + vi.spyOn(channel1, 'return'), + vi.spyOn(channel2, 'return'), + ]; + + const rebuildTestContent = (it: AsyncIterable) => ( + iterateFormatted($, (value, i) => `${value} formatted once (idx: ${i})`), + $ => iterateFormatted($, (value, i) => `${value} and formatted twice (idx: ${i})`) + )} + > + {next =>

Rendered: {next.value}

} +
+ ); + + const rendered = render(<>); + + rendered.rerender(rebuildTestContent(channel1)); + expect(channelReturnSpy1).not.toHaveBeenCalled(); + + rendered.rerender(rebuildTestContent(channel1)); + expect(channelReturnSpy1).not.toHaveBeenCalled(); + + rendered.rerender(rebuildTestContent(channel2)); + expect(channelReturnSpy1).toHaveBeenCalledOnce(); + expect(channelReturnSpy2).not.toHaveBeenCalled(); + + rendered.rerender(rebuildTestContent(channel2)); + expect(channelReturnSpy2).not.toHaveBeenCalled(); + } + ); + + it( + gray( + 'Always the latest closure passed in as the format function will be the one to format the next-arriving source value' + ), + async () => { + const channel = new IterableChannelTestHelper(); + + const Wrapper = (props: { outerValue: string }) => ( + + iterateFormatted( + $, + (value, i) => `${value} formatted once (idx: ${i}, outer val: ${props.outerValue})` + ), + $ => + iterateFormatted( + $, + (value, i) => + `${value} and formatted twice (idx: ${i}, outer val: ${props.outerValue})` + ) + )} + > + {next =>

Rendered: {next.value}

} +
+ ); + + const rendered = render(<>); + + for (const [i, [nextYield, nextProp]] of [ + ['yield_a', 'prop_a'], + ['yield_b', 'prop_b'], + ['yield_c', 'prop_c'], + ].entries()) { + rendered.rerender(); + await act(() => channel.put(nextYield)); + + expect(rendered.container.innerHTML).toStrictEqual( + `

Rendered: ${nextYield} formatted once (idx: ${i}, outer val: ${nextProp}) and formatted twice (idx: ${i}, outer val: ${nextProp})

` + ); + } + } + ); +}); diff --git a/spec/tests/useAsyncIter.spec.ts b/spec/tests/useAsyncIter.spec.ts index 762c2d7..6d6471c 100644 --- a/spec/tests/useAsyncIter.spec.ts +++ b/spec/tests/useAsyncIter.spec.ts @@ -330,7 +330,7 @@ describe('`useAsyncIter` hook', () => { it( gray( - "When consequtively updated with new iterables will close the previous one's iterator every time and render accordingly" + "When consecutively updated with new iterables will close the previous one's iterator every time and render accordingly" ), async () => { const [channel1, channel2] = [ @@ -338,7 +338,7 @@ describe('`useAsyncIter` hook', () => { new IterableChannelTestHelper(), ]; - const [channel1IterCloseSpy, channel2IterCloseSpy] = [ + const [channelReturnSpy1, channelReturnSpy2] = [ vi.spyOn(channel1, 'return'), vi.spyOn(channel2, 'return'), ]; @@ -352,8 +352,8 @@ describe('`useAsyncIter` hook', () => { { renderedHook.rerender({ value: channel1 }); - expect(channel1IterCloseSpy).not.toHaveBeenCalled(); - expect(channel2IterCloseSpy).not.toHaveBeenCalled(); + expect(channelReturnSpy1).not.toHaveBeenCalled(); + expect(channelReturnSpy2).not.toHaveBeenCalled(); expect(renderedHook.result.current).toStrictEqual({ value: undefined, pendingFirst: true, @@ -374,8 +374,8 @@ describe('`useAsyncIter` hook', () => { { renderedHook.rerender({ value: channel2 }); - expect(channel1IterCloseSpy).toHaveBeenCalledOnce(); - expect(channel2IterCloseSpy).not.toHaveBeenCalled(); + expect(channelReturnSpy1).toHaveBeenCalledOnce(); + expect(channelReturnSpy2).not.toHaveBeenCalled(); expect(renderedHook.result.current).toStrictEqual({ value: 'a', pendingFirst: true, @@ -396,8 +396,8 @@ describe('`useAsyncIter` hook', () => { { renderedHook.rerender({ value: (async function* () {})() }); - expect(channel1IterCloseSpy).toHaveBeenCalledOnce(); - expect(channel2IterCloseSpy).toHaveBeenCalledOnce(); + expect(channelReturnSpy1).toHaveBeenCalledOnce(); + expect(channelReturnSpy2).toHaveBeenCalledOnce(); expect(renderedHook.result.current).toStrictEqual({ value: 'b', pendingFirst: true, @@ -410,7 +410,7 @@ describe('`useAsyncIter` hook', () => { it(gray('When unmounted will close the last active iterator it held'), async () => { const channel = new IterableChannelTestHelper(); - const channelIterCloseSpy = vi.spyOn(channel, 'return'); + const channelReturnSpy = vi.spyOn(channel, 'return'); const renderedHook = renderHook(({ value }) => useAsyncIter(value), { initialProps: { @@ -421,7 +421,7 @@ describe('`useAsyncIter` hook', () => { { renderedHook.rerender({ value: channel }); - expect(channelIterCloseSpy).not.toHaveBeenCalled(); + expect(channelReturnSpy).not.toHaveBeenCalled(); expect(renderedHook.result.current).toStrictEqual({ value: undefined, pendingFirst: true, @@ -441,7 +441,7 @@ describe('`useAsyncIter` hook', () => { { renderedHook.unmount(); - expect(channelIterCloseSpy).toHaveBeenCalledOnce(); + expect(channelReturnSpy).toHaveBeenCalledOnce(); } }); }); diff --git a/spec/utils/IterableChannelTestHelper.ts b/spec/utils/IterableChannelTestHelper.ts index 13ca27c..0e8d980 100644 --- a/spec/utils/IterableChannelTestHelper.ts +++ b/spec/utils/IterableChannelTestHelper.ts @@ -1,8 +1,8 @@ export { IterableChannelTestHelper }; class IterableChannelTestHelper implements AsyncIterableIterator, AsyncDisposable { - isChannelClosed = false; - nextIteration = Promise.withResolvers>(); + #isChannelClosed = false; + #nextIteration = Promise.withResolvers>(); [Symbol.asyncIterator]() { return this; @@ -13,36 +13,36 @@ class IterableChannelTestHelper implements AsyncIterableIterator, AsyncDis } get isClosed(): boolean { - return this.isChannelClosed; + return this.#isChannelClosed; } put(value: T): void { - if (this.isChannelClosed) { + if (this.#isChannelClosed) { return; } - this.nextIteration.resolve({ done: false, value }); - this.nextIteration = Promise.withResolvers(); + this.#nextIteration.resolve({ done: false, value }); + this.#nextIteration = Promise.withResolvers(); } complete(): void { - this.isChannelClosed = true; - this.nextIteration.resolve({ done: true, value: undefined }); + this.#isChannelClosed = true; + this.#nextIteration.resolve({ done: true, value: undefined }); } error(errValue?: unknown): void { - this.isChannelClosed = true; - this.nextIteration.reject(errValue); - this.nextIteration = Promise.withResolvers(); - this.nextIteration.resolve({ done: true, value: undefined }); + this.#isChannelClosed = true; + this.#nextIteration.reject(errValue); + this.#nextIteration = Promise.withResolvers(); + this.#nextIteration.resolve({ done: true, value: undefined }); } async next(): Promise> { - return this.nextIteration.promise; + return this.#nextIteration.promise; } async return(): Promise> { this.complete(); - const res = await this.nextIteration.promise; + const res = await this.#nextIteration.promise; return res as typeof res & { done: true }; } } diff --git a/spec/utils/asyncIterToArray.ts b/spec/utils/asyncIterToArray.ts new file mode 100644 index 0000000..d554cc7 --- /dev/null +++ b/spec/utils/asyncIterToArray.ts @@ -0,0 +1,9 @@ +export { asyncIterToArray }; + +async function asyncIterToArray(source: AsyncIterable): Promise { + const values: T[] = []; + for await (const value of source) { + values.push(value); + } + return values; +} diff --git a/spec/utils/pipe.ts b/spec/utils/pipe.ts new file mode 100644 index 0000000..8dccdad --- /dev/null +++ b/spec/utils/pipe.ts @@ -0,0 +1,98 @@ +export { pipe }; + +const pipe: PipeFunction = (initVal: unknown, ...funcs: ((...args: any[]) => any)[]) => { + return funcs.reduce((currVal, nextFunc) => nextFunc(currVal), initVal); +}; + +interface PipeFunction { + (initVal: TInitVal): TInitVal; + + (initVal: TInitVal, ...funcs: [(arg: TInitVal) => A]): A; + + (initVal: TInitVal, ...funcs: [(arg: TInitVal) => A, (arg: A) => B]): B; + + ( + initVal: TInitVal, + ...funcs: [(arg: TInitVal) => A, (arg: A) => B, (arg: B) => C] + ): C; + + ( + initVal: TInitVal, + ...funcs: [(arg: TInitVal) => A, (arg: A) => B, (arg: B) => C, (arg: C) => D] + ): D; + + ( + initVal: TInitVal, + ...funcs: [(arg: TInitVal) => A, (arg: A) => B, (arg: B) => C, (arg: C) => D, (arg: D) => E] + ): E; + + ( + initVal: TInitVal, + ...funcs: [ + (arg: TInitVal) => A, + (arg: A) => B, + (arg: B) => C, + (arg: C) => D, + (arg: D) => E, + (arg: E) => F, + ] + ): F; + + ( + initVal: TInitVal, + ...funcs: [ + (arg: TInitVal) => A, + (arg: A) => B, + (arg: B) => C, + (arg: C) => D, + (arg: D) => E, + (arg: E) => F, + (arg: F) => G, + ] + ): G; + + ( + initVal: TInitVal, + ...funcs: [ + (arg: TInitVal) => A, + (arg: A) => B, + (arg: B) => C, + (arg: C) => D, + (arg: D) => E, + (arg: E) => F, + (arg: F) => G, + (arg: G) => H, + ] + ): H; + + ( + initVal: TInitVal, + ...funcs: [ + (arg: TInitVal) => A, + (arg: A) => B, + (arg: B) => C, + (arg: C) => D, + (arg: D) => E, + (arg: E) => F, + (arg: F) => G, + (arg: G) => H, + (arg: H) => I, + ] + ): I; + + ( + initVal: TInitVal, + ...funcs: [ + (arg: TInitVal) => A, + (arg: A) => B, + (arg: B) => C, + (arg: C) => D, + (arg: D) => E, + (arg: E) => F, + (arg: F) => G, + (arg: G) => H, + (arg: H) => I, + (arg: I) => J, + ] + ): J; +} diff --git a/src/common/asyncIterSyncMap.ts b/src/common/asyncIterSyncMap.ts new file mode 100644 index 0000000..c96892e --- /dev/null +++ b/src/common/asyncIterSyncMap.ts @@ -0,0 +1,30 @@ +export { asyncIterSyncMap }; + +function asyncIterSyncMap( + source: AsyncIterable, + mapFn: (val: TIn, i: number) => TOut +): AsyncIterable { + return { + [Symbol.asyncIterator]: () => { + let iterator: AsyncIterator; + let iterationIdx = 0; + + return { + next: async () => { + iterator ??= source[Symbol.asyncIterator](); + const next = await iterator.next(); + if (next.done) { + return next; + } + const mappedValue = mapFn(next.value, iterationIdx++); + return { done: false, value: mappedValue }; + }, + + return: async () => { + await iterator?.return?.(); + return { done: true, value: undefined }; + }, + }; + }, + }; +} diff --git a/src/common/isAsyncIter.ts b/src/common/isAsyncIter.ts index c303dea..270ba4e 100644 --- a/src/common/isAsyncIter.ts +++ b/src/common/isAsyncIter.ts @@ -1,5 +1,7 @@ +import { type ExtractAsyncIterValue } from './ExtractAsyncIterValue.js'; + export { isAsyncIter }; -function isAsyncIter(input: T): input is T & AsyncIterable { +function isAsyncIter(input: T): input is T & AsyncIterable> { return typeof (input as any)?.[Symbol.asyncIterator] === 'function'; } diff --git a/src/common/reactAsyncIterSpecialInfoSymbol.ts b/src/common/reactAsyncIterSpecialInfoSymbol.ts new file mode 100644 index 0000000..fec26b3 --- /dev/null +++ b/src/common/reactAsyncIterSpecialInfoSymbol.ts @@ -0,0 +1,8 @@ +export { reactAsyncIterSpecialInfoSymbol, type ReactAsyncIterSpecialInfo }; + +const reactAsyncIterSpecialInfoSymbol = Symbol('reactAsyncIterSpecialInfoSymbol'); + +type ReactAsyncIterSpecialInfo = { + origSource: AsyncIterable; + formatFn(value: TOrigVal, i: number): TFormattedVal; +}; diff --git a/src/index.ts b/src/index.ts index b10f5a9..33d69c2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,12 @@ import { useAsyncIter, type IterationResult } from './useAsyncIter/index.js'; import { Iterate, type IterateProps } from './Iterate/index.js'; +import { iterateFormatted, type FixedRefFormattedIterable } from './iterateFormatted//index.js'; -export { useAsyncIter, type IterationResult, Iterate, type IterateProps }; +export { + useAsyncIter, + type IterationResult, + Iterate, + type IterateProps, + iterateFormatted, + type FixedRefFormattedIterable, +}; diff --git a/src/iterateFormatted/index.ts b/src/iterateFormatted/index.ts new file mode 100644 index 0000000..83ffb0b --- /dev/null +++ b/src/iterateFormatted/index.ts @@ -0,0 +1,113 @@ +import { + reactAsyncIterSpecialInfoSymbol, + type ReactAsyncIterSpecialInfo, +} from '../common/reactAsyncIterSpecialInfoSymbol.js'; +import { asyncIterSyncMap } from '../common/asyncIterSyncMap.js'; +import { isAsyncIter } from '../common/isAsyncIter.js'; +import { type ExtractAsyncIterValue } from '../common/ExtractAsyncIterValue.js'; +import { type useAsyncIter } from '../useAsyncIter/index.js'; // eslint-disable-line @typescript-eslint/no-unused-vars +import { type Iterate } from '../Iterate/index.js'; // eslint-disable-line @typescript-eslint/no-unused-vars + +export { iterateFormatted, type FixedRefFormattedIterable }; + +/** + * An optional utility to format an async iterable's values inline right where its passing into + * an other consuming component. + * + * @example + * ```tsx + * // Allows this: + * + * import { iterateFormatted } from 'react-async-iterators'; + * + * function MyComponent(props) { + * return ( + * ({ + * value: id, + * label: name, + * }))} + * /> + * ); + * } + * + * // ...instead of this: + * + * import { useMemo } from 'react'; + * + * function MyComponent(props) { + * const dropdownOpts = useMemo( // `useMemo` with some `mapAsyncIter` third-party mapping helper: + * () => + * mapAsyncIter(props.iter, ({ id, name }) => ({ + * value: id, + * label: name, + * })), + * [props.iter] + * ); + * + * return ; + * } + * ``` + * + * 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 transformation written right next to the place instead of far from it + * in the top as some `useMemo` hook call. + * + * The utility's method of operation is it will take `source` and return from it a new transformed + * async iterable object with some special metadata attached that tells library tools like + * {@link Iterate ``} and {@link useAsyncIter `useAsyncIter`} the actual source object + * to base the iteration process on instead of on the root object itself. This way, the root object + * may be repeatedly recreated without any effect of restarting the iteration process - as long + * as the `source` is repeatedly passed the same base object. + * + * If `source` is a plain value and not an async iterable, it will be passed to the given `formatFn` + * and returned on the spot. + * + * @template TIn The type of values yielded by the passed iterable or of a plain value passed otherwise. + * @template TOut 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` + * + * @returns a transformed async iterable emitting every value of `source` after formatting. + */ +function iterateFormatted( + source: TIn, + formatFn: (value: ExtractAsyncIterValue, i: number) => TOut +): TIn extends AsyncIterable + ? FixedRefFormattedIterable, TOut> + : TOut; + +function iterateFormatted( + source: unknown, + formatFn: (value: unknown, i: number) => unknown +): unknown { + if (!isAsyncIter(source)) { + return formatFn(source, 0); + } + + const sourceSpecialInfo = (source as any)?.[reactAsyncIterSpecialInfoSymbol] as + | undefined + | ReactAsyncIterSpecialInfo; + + return { + [Symbol.asyncIterator]: () => asyncIterSyncMap(source, formatFn)[Symbol.asyncIterator](), + [reactAsyncIterSpecialInfoSymbol]: !sourceSpecialInfo + ? { + origSource: source, + formatFn, + } + : { + origSource: sourceSpecialInfo.origSource, + formatFn: (value: unknown, i: number) => { + const prevMapResult = sourceSpecialInfo.formatFn(value, i); + return formatFn(prevMapResult, i); + }, + }, + }; +} + +type FixedRefFormattedIterable = AsyncIterable & { + [reactAsyncIterSpecialInfoSymbol]: ReactAsyncIterSpecialInfo; +}; diff --git a/src/useAsyncIter/index.ts b/src/useAsyncIter/index.ts index d3928fa..a93fcb1 100644 --- a/src/useAsyncIter/index.ts +++ b/src/useAsyncIter/index.ts @@ -3,6 +3,12 @@ import { useLatest } from '../common/hooks/useLatest.js'; import { isAsyncIter } from '../common/isAsyncIter.js'; import { useSimpleRerender } from '../common/hooks/useSimpleRerender.js'; import { type ExtractAsyncIterValue } from '../common/ExtractAsyncIterValue.js'; +import { + reactAsyncIterSpecialInfoSymbol, + type ReactAsyncIterSpecialInfo, +} from '../common/reactAsyncIterSpecialInfoSymbol.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 }; @@ -33,11 +39,13 @@ export { useAsyncIter, type IterationResult }; * If `input` is a plain (non async iterable) value, it will simply be used to render once and * immediately. * - * The hook inits and maintains its current iteration process across re-renders as long as its - * `input` is passed the same object reference each time (similar to the behavior of a - * `useEffect(() => {...}, [input])`), therefore care should be taken to avoid constantly recreating - * the iterable every render, 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). + * The hook inits and maintains its current iteration process with its given `input` async iterable + * across re-renders as long as `input` is passed the same object reference each time (similar to + * the behavior of a `useEffect(() => {...}, [input])`), therefore care should be taken to avoid + * constantly recreating the iterable every render, e.g; by declaring it outside the component body, + * control __when__ it should be recreated with React's + * [`useMemo`](https://react.dev/reference/react/useMemo) or alternatively the library's + * {@link iterateFormatted `iterateFormatted`} util for only formatting the values. * Whenever `useAsyncIter` detects a different `input` value, it automatically closes a previous * `input` async iterable before proceeding to iterate any new `input` async iterable. The hook will * also ensure closing a currently iterated `input` on component unmount. @@ -48,11 +56,11 @@ export { useAsyncIter, type IterationResult }; * In case `input` is given a plain value, it will be delivered as-is within the returned * result object's `value` property. * - * @template TValue The type of values yielded by the passed iterable or otherwise type of the passed plain value itself. - * @template TInitValue The type of the initial value, defaults to `undefined`. + * @template TVal The type of values yielded by the passed iterable or of a plain value passed otherwise. + * @template TInitVal The type of the initial value, defaults to `undefined`. * * @param input Any async iterable or plain value - * @param initialValue Any initial value for the hook to return prior to resolving the ___first + * @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`. * * @returns An object with properties reflecting the current state of the iterated async iterable @@ -62,10 +70,10 @@ export { useAsyncIter, type IterationResult }; * * @example * ```tsx - * // With an `initialValue` and showing usage of all properties of the returned iteration object: + * // With an `initialVal` and showing usage of all properties of the returned iteration object: * * import { useAsyncIter } from 'react-async-iterators'; - * + * function SelfUpdatingTodoList(props) { * const todosNext = useAsyncIter(props.todosAsyncIter, []); * return ( @@ -91,23 +99,28 @@ export { useAsyncIter, type IterationResult }; * ``` */ const useAsyncIter: { - ( - input: AsyncIterable, - initialValue?: undefined - ): IterationResult; - - ( - input: TValue, - initialValue?: TInitValue - ): IterationResult; -} = ( - input: TValue, - initialValue: TInitValue -): IterationResult => { + (input: TVal, initialVal?: undefined): IterationResult; + (input: TVal, initialVal?: TInitVal): IterationResult; +} = < + TVal extends + | undefined + | null + | { + [Symbol.asyncIterator]?: () => AsyncIterator, unknown, unknown>; + [reactAsyncIterSpecialInfoSymbol]?: ReactAsyncIterSpecialInfo< + unknown, + ExtractAsyncIterValue + >; + }, + TInitVal = undefined, +>( + input: TVal, + initialVal: TInitVal +): IterationResult => { const rerender = useSimpleRerender(); - const stateRef = useRef>({ - value: initialValue, + const stateRef = useRef>({ + value: initialVal, pendingFirst: true, done: false, error: undefined, @@ -119,34 +132,45 @@ const useAsyncIter: { useMemo(() => {}, [undefined]); useEffect(() => {}, [undefined]); - return (stateRef.current = { - value: latestInputRef.current as ExtractAsyncIterValue, + stateRef.current = { + value: latestInputRef.current as ExtractAsyncIterValue, pendingFirst: false, done: false, error: undefined, - }); + }; + + return stateRef.current; } else { - useMemo(() => { + const iterSourceRefToUse = + latestInputRef.current[reactAsyncIterSpecialInfoSymbol]?.origSource ?? latestInputRef.current; + + useMemo((): void => { stateRef.current = { value: stateRef.current.value, pendingFirst: true, done: false, error: undefined, }; - }, [latestInputRef.current]); + }, [iterSourceRefToUse]); useEffect(() => { - const iterator = (latestInputRef.current as AsyncIterable>)[ - Symbol.asyncIterator - ](); - let iteratorClosedAbruptly = false; + const iterator = iterSourceRefToUse[Symbol.asyncIterator](); + let iteratorClosedByConsumer = false; (async () => { + let iterationIdx = 0; + try { for await (const value of { [Symbol.asyncIterator]: () => iterator }) { - if (!iteratorClosedAbruptly) { + if (!iteratorClosedByConsumer) { + const formattedValue = + latestInputRef.current?.[reactAsyncIterSpecialInfoSymbol]?.formatFn( + value, + iterationIdx++ + ) ?? (value as ExtractAsyncIterValue); + stateRef.current = { - value, + value: formattedValue, pendingFirst: false, done: false, error: undefined, @@ -154,7 +178,7 @@ const useAsyncIter: { rerender(); } } - if (!iteratorClosedAbruptly) { + if (!iteratorClosedByConsumer) { stateRef.current = { value: stateRef.current.value, pendingFirst: false, @@ -164,7 +188,7 @@ const useAsyncIter: { rerender(); } } catch (err) { - if (!iteratorClosedAbruptly) { + if (!iteratorClosedByConsumer) { stateRef.current = { value: stateRef.current.value, pendingFirst: false, @@ -177,10 +201,10 @@ const useAsyncIter: { })(); return () => { - iteratorClosedAbruptly = true; + iteratorClosedByConsumer = true; iterator.return?.(); }; - }, [latestInputRef.current]); + }, [iterSourceRefToUse]); return stateRef.current; } @@ -190,15 +214,16 @@ const useAsyncIter: { * The `iterationResult` object holds all the state from the most recent iteration of a currently * hooked async iterable object (or plain value). * - * Returned from the {@link useAsyncIter} hook and also injected into `` component's render function. + * Returned from the {@link useAsyncIter `useAsyncIter`} hook and also injected into + * {@link Iterate ``} component's render function. * - * @see {@link useAsyncIter} - * @see {@link Iterate} + * @see {@link useAsyncIter `useAsyncIter`} + * @see {@link Iterate ``} */ type IterationResult = { /** - * The most recent value received from iterating an async iterable, starting as {@link TInitVal}. - * If iterating a plain value, it will simply be it. + * The most recent value received from the async iterable iteration, starting as {@link TInitVal}. + * If the source was instead a plain value, it will simply be it. * * Starting to iterate a new async iterable at any future point on itself doesn't reset this; * only some newly resolved next value will.