Skip to content
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
45 changes: 37 additions & 8 deletions spec/tests/useAsyncIterState.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,43 @@ afterEach(() => {
});

describe('`useAsyncIterState` hook', () => {
it(gray("The state iterable's `.current.value` property is read-only"), async () => {
const [values] = renderHook(() => useAsyncIterState<string>()).result.current;

expect(() => {
(values.value as any).current = '...';
}).toThrow(TypeError);
});

describe(
gray(
"When a non-`undefined` initial value is given, it's set as the starting value for the iterable's `.value.current` property"
),
() => {
for (const [desc, initVal] of [
['As a plain value', { initial: true } as const],
['As a function', () => ({ initial: true }) as const],
] as const) {
it(gray(desc), async () => {
const [values, setValue] = renderHook(() =>
useAsyncIterState<string, { initial: true }>(initVal)
).result.current;

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

await act(() => {
setValue('a');
currentValues.push(values.value.current);
});

expect(await yieldPromise).toStrictEqual('a');
expect(currentValues).toStrictEqual([{ initial: true }, 'a']);
});
}
}
);

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

Expand Down Expand Up @@ -243,12 +280,4 @@ describe('`useAsyncIterState` hook', () => {
expect(currentValues).toStrictEqual([undefined, 'a', 'b', 'c']);
}
);

it(gray("The state iterable's `.current.value` property is read-only"), async () => {
const [values] = renderHook(() => useAsyncIterState<number>()).result.current;

expect(() => {
(values.value as any).current = `CAN'T DO THIS...`;
}).toThrow(TypeError);
});
});
18 changes: 11 additions & 7 deletions src/useAsyncIterState/IterableChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,22 @@ import { promiseWithResolvers } from '../common/promiseWithResolvers.js';

export { IterableChannel, type AsyncIterableSubject };

class IterableChannel<T> {
class IterableChannel<T, TInit = T> {
#isClosed = false;
#nextIteration = promiseWithResolvers<IteratorResult<T, void>>();
#currentValue: T | undefined;
#currentValue: T | TInit;

put(update: T | ((prevState: T | undefined) => T)): void {
constructor(initialValue: TInit) {
this.#currentValue = initialValue;
}

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

Expand All @@ -32,7 +36,7 @@ class IterableChannel<T> {
this.#nextIteration.resolve({ done: true, value: undefined });
}

values: AsyncIterableSubject<T> = {
values: AsyncIterableSubject<T, TInit> = {
value: (() => {
const self = this;
return {
Expand Down Expand Up @@ -65,11 +69,11 @@ class IterableChannel<T> {
* meaning that multiple iterators can be consumed (iterated) simultaneously and each one would pick up
* the same values as others the moment they were generated through state updates.
*/
type AsyncIterableSubject<T> = {
type AsyncIterableSubject<T, TInit> = {
/**
* A React Ref-like object whose inner `current` property shows the most up to date state value.
*/
value: Readonly<MutableRefObject<T | undefined>>;
value: Readonly<MutableRefObject<T | TInit>>;

/**
* Returns an async iterator to iterate over. All iterators returned by this share the same source
Expand Down
35 changes: 26 additions & 9 deletions src/useAsyncIterState/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,6 @@ export { useAsyncIterState, type AsyncIterStateResult, type AsyncIterableSubject
*
* ---
*
*
*
* 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
Expand Down Expand Up @@ -88,19 +86,38 @@ export { useAsyncIterState, type AsyncIterStateResult, type AsyncIterableSubject
* ---
*
* @template TVal the type of state to be set and yielded by returned iterable.
* @template TInitVal The type of the starting value for the state iterable's `.current.value` property.
*
* @param initialValue Any optional starting value for the state iterable's `.current.value` property, defaults to `undefined`.
*
* @returns a stateful async iterable and a function with which to yield an update, both maintain stable references across re-renders.
*
* @see {@link Iterate `<Iterate>`}
*/
function useAsyncIterState<TVal>(): AsyncIterStateResult<TVal> {
function useAsyncIterState<TVal>(): AsyncIterStateResult<TVal, undefined>;

function useAsyncIterState<TVal>(
initialValue: TVal | (() => TVal)
): AsyncIterStateResult<TVal, TVal>;

function useAsyncIterState<TVal, TInitVal = undefined>(
initialValue: TInitVal | (() => TInitVal)
): AsyncIterStateResult<TVal, TInitVal>;

function useAsyncIterState<TVal, TInitVal>(
initialValue?: TInitVal | (() => TInitVal)
): AsyncIterStateResult<TVal, TInitVal> {
const ref = useRef<{
channel: IterableChannel<TVal>;
result: AsyncIterStateResult<TVal>;
channel: IterableChannel<TVal, TInitVal>;
result: AsyncIterStateResult<TVal, TInitVal>;
}>();

ref.current ??= (() => {
const channel = new IterableChannel<TVal>();
const initialValueDetermined =
typeof initialValue !== 'function' ? initialValue : (initialValue as () => TInitVal)();

const channel = new IterableChannel<TVal, TInitVal>(initialValueDetermined as TInitVal);

return {
channel,
result: [channel.values, newVal => channel.put(newVal)],
Expand All @@ -123,7 +140,7 @@ function useAsyncIterState<TVal>(): AsyncIterStateResult<TVal> {
*
* @see {@link useAsyncIterState `useAsyncIterState`}
*/
type AsyncIterStateResult<TVal> = [
type AsyncIterStateResult<TVal, TInitVal> = [
/**
* A stateful async iterable which yields every updated value following a state update.
*
Expand All @@ -133,11 +150,11 @@ type AsyncIterStateResult<TVal> = [
* meaning multiple iterators can be consumed (iterated) simultaneously, each one picking up the
* same values as others the moment they were generated through state updates.
*/
values: AsyncIterableSubject<TVal>,
values: AsyncIterableSubject<TVal, TInitVal>,

/**
* 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: (update: TVal | ((prevState: TVal | undefined) => TVal)) => void,
setValue: (update: TVal | ((prevState: TVal | TInitVal) => TVal)) => void,
];
Loading