Skip to content

Make useAsyncIterState's setter accept a function as to calculate next state #45

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jan 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 89 additions & 39 deletions spec/tests/useAsyncIterState.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { it, describe, expect, afterEach } from 'vitest';
import { it, describe, expect, afterEach, vi } from 'vitest';
import { gray } from 'colorette';
import { range } from 'lodash-es';
import { renderHook, cleanup as cleanupMountedReactTrees, act } from '@testing-library/react';
Expand All @@ -14,43 +14,6 @@ afterEach(() => {
});

describe('`useAsyncIterState` hook', () => {
it(gray('Updating states iteratively with the returned setter works correctly'), async () => {
const [values, setValue] = renderHook(() => useAsyncIterState<number>()).result.current;

const rounds = 3;

const yieldsPromise = pipe(values, asyncIterTake(rounds), asyncIterToArray);
const currentValues = [values.value.current];

for (let i = 0; i < rounds; ++i) {
await act(() => {
setValue(i);
currentValues.push(values.value.current);
});
}

expect(currentValues).toStrictEqual([undefined, 0, 1, 2]);
expect(await yieldsPromise).toStrictEqual([0, 1, 2]);
});

it(
gray('Updating states as rapidly as possible with the returned setter works correctly'),
async () => {
const [values, setValue] = renderHook(() => useAsyncIterState<number>()).result.current;

const yieldPromise = pipe(values, asyncIterTakeFirst());
const currentValues = [values.value.current];

for (let i = 0; i < 3; ++i) {
setValue(i);
currentValues.push(values.value.current);
}

expect(currentValues).toStrictEqual([undefined, 0, 1, 2]);
expect(await yieldPromise).toStrictEqual(2);
}
);

it(gray('The returned iterable can be async-iterated upon successfully'), async () => {
const [values, setValue] = renderHook(() => useAsyncIterState<string>()).result.current;

Expand Down Expand Up @@ -108,6 +71,93 @@ describe('`useAsyncIterState` hook', () => {
}
);

it(gray('Updating states iteratively with the returned setter works correctly'), async () => {
const [values, setValue] = renderHook(() => useAsyncIterState<number>()).result.current;

const rounds = 3;

const yieldsPromise = pipe(values, asyncIterTake(rounds), asyncIterToArray);
const currentValues = [values.value.current];

for (let i = 0; i < rounds; ++i) {
await act(() => {
setValue(i);
currentValues.push(values.value.current);
});
}

expect(currentValues).toStrictEqual([undefined, 0, 1, 2]);
expect(await yieldsPromise).toStrictEqual([0, 1, 2]);
});

it(
gray('Updating states as rapidly as possible with the returned setter works correctly'),
async () => {
const [values, setValue] = renderHook(() => useAsyncIterState<number>()).result.current;

const yieldPromise = pipe(values, asyncIterTakeFirst());
const currentValues = [values.value.current];

for (let i = 0; i < 3; ++i) {
setValue(i);
currentValues.push(values.value.current);
}

expect(currentValues).toStrictEqual([undefined, 0, 1, 2]);
expect(await yieldPromise).toStrictEqual(2);
}
);

it(
gray(
'Updating states iteratively with the returned setter *in the functional form* works correctly'
),
async () => {
const renderFn = vi.fn<(prevState: number | undefined) => number>();
const [values, setValue] = renderHook(() => useAsyncIterState<number>()).result.current;

const rounds = 3;

const yieldsPromise = pipe(values, asyncIterTake(rounds), asyncIterToArray);
const currentValues = [values.value.current];

for (let i = 0; i < rounds; ++i) {
await act(() => {
setValue(renderFn.mockImplementation(_prev => i));
currentValues.push(values.value.current);
});
}

expect(renderFn.mock.calls).toStrictEqual([[undefined], [0], [1]]);
expect(currentValues).toStrictEqual([undefined, 0, 1, 2]);
expect(await yieldsPromise).toStrictEqual([0, 1, 2]);
}
);

it(
gray(
'Updating states as rapidly as possible with the returned setter *in the functional form* works correctly'
),
async () => {
const renderFn = vi.fn<(prevState: number | undefined) => number>();

const [values, setValue] = renderHook(() => useAsyncIterState<number>()).result.current;

const yieldPromise = pipe(values, asyncIterTakeFirst());

const currentValues = [values.value.current];

for (let i = 0; i < 3; ++i) {
setValue(renderFn.mockImplementation(_prev => i));
currentValues.push(values.value.current);
}

expect(renderFn.mock.calls).toStrictEqual([[undefined], [0], [1]]);
expect(currentValues).toStrictEqual([undefined, 0, 1, 2]);
expect(await yieldPromise).toStrictEqual(2);
}
);

it(
gray(
'When hook is unmounted, all outstanding yieldings of the returned iterable resolve to "done"'
Expand Down Expand Up @@ -198,7 +248,7 @@ describe('`useAsyncIterState` hook', () => {
const [values] = renderHook(() => useAsyncIterState<number>()).result.current;

expect(() => {
(values.value as any).current = "can't do this...";
(values.value as any).current = `CAN'T DO THIS...`;
}).toThrow(TypeError);
});
});
10 changes: 9 additions & 1 deletion src/useAsyncIterState/IterableChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,16 @@ class IterableChannel<T> {
#nextIteration = promiseWithResolvers<IteratorResult<T, void>>();
#currentValue: T | undefined;

put(value: T): void {
put(update: T | ((prevState: T | undefined) => T)): void {
if (!this.#isClosed) {
const value =
typeof update !== 'function'
? update
: (() => {
const updateFnTypePatched = update as (prevState: T | undefined) => T;
return updateFnTypePatched(this.#currentValue);
})();

(async () => {
this.#currentValue = value;
await undefined; // Deferring to the next microtick so that an attempt to pull the a value before making multiple rapid synchronous calls to `put()` will make that pull ultimately yield only the last value that was put - instead of the first one as were if this otherwise wasn't deferred.
Expand Down
37 changes: 24 additions & 13 deletions src/useAsyncIterState/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ export { useAsyncIterState, type AsyncIterStateResult, type AsyncIterableSubject
* Basically like {@link https://react.dev/reference/react/useState `React.useState`}, only that the value
* is provided back __wrapped as an async iterable__.
*
* This hook allows a component to declare and manage a piece of state while easily letting it control
* what area(s) specifically within the UI should be bound to it (should re-render in reaction to changes
* in it) - combined for example with one or more {@link Iterate `<Iterate>`}s.
* 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 `<Iterate>`}s.
*
* @example
* ```tsx
Expand All @@ -36,14 +36,24 @@ export { useAsyncIterState, type AsyncIterStateResult, type AsyncIterableSubject
*
* ---
*
* This is unlike vanila `React.useState` which simply re-renders the entire component. Instead,
* `useAsyncIterState` helps confine UI updates as well as facilitate layers of sub-components that pass
* actual async iterables across one another as props, skipping typical cascading re-renderings down to
* __only the inner-most leafs__ of the UI tree.
*
* The returned async iterable contains a `.current.value` property which shows the current up to date
* state value at all times. Use this any case you just need to read the immediate current state rather
* than directly rendering it, since for rendering you may simply async-iterate it.
*
* The returned async iterable can be passed over to any level down the component tree and rendered
* using `<Iterate>`, `useAsyncIter`, and so on. It also contains a `.current.value` property which shows
* the current up to date state value at all times. Use this any case you just need to read the immediate
* current state rather than directly rendering it, since for rendering you may simply async-iterate it.
*
* 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
* iterable's `.current.value` property to that new state. Just like
* [`React.useState`'s setter](https://react.dev/reference/react/useState#setstate), you can pass it
* the next state directly, or a function that calculates it from the previous state.
*
* Unlike vanila `React.useState`, which simply re-renders the entire component - `useAsyncIterState`
* helps confine UI updates by handing you an iterable which choose how and where in the component tree
* to render it. This work method can facilitate layers of sub-components that pass actual async iterables
* across one another as props, skipping typical cascading re-renderings down to __only the inner-most
* leafs__ of the UI tree.
*
* @example
* ```tsx
Expand Down Expand Up @@ -107,7 +117,8 @@ function useAsyncIterState<TVal>(): AsyncIterStateResult<TVal> {
}

/**
* A pair of stateful async iterable and a function which modifies the state and yields the updated value.
* A pair of stateful async iterable and a function which updates the state and making the paired
* async iterable yield the new value.
* Returned from the {@link useAsyncIterState `useAsyncIterState`} hook.
*
* @see {@link useAsyncIterState `useAsyncIterState`}
Expand All @@ -125,8 +136,8 @@ type AsyncIterStateResult<TVal> = [
values: AsyncIterableSubject<TVal>,

/**
* A function which modifies the state, causing the paired async iterable to yield the updated state
* A function which updates the state, causing the paired async iterable to yield the updated state
* value and immediately sets its `.current.value` property to the latest state.
*/
setValue: (newValue: TVal) => void,
setValue: (update: TVal | ((prevState: TVal | undefined) => TVal)) => void,
];
Loading