From 0dd365c47654712a829a2d1cc5af83f05421a1a8 Mon Sep 17 00:00:00 2001 From: "samuel.gjabel" Date: Sat, 23 Nov 2024 19:29:49 +0700 Subject: [PATCH] feat: fixup selectors values to be always sync --- packages/core/__tests__/select.test.tsx | 42 +++++++++++++++++++++++-- packages/core/create-state.ts | 2 +- packages/core/select.ts | 32 ++++++++++++++++--- packages/core/types.ts | 2 +- packages/core/utils/common.ts | 2 +- 5 files changed, 70 insertions(+), 10 deletions(-) diff --git a/packages/core/__tests__/select.test.tsx b/packages/core/__tests__/select.test.tsx index 1701d7c..ca1933b 100644 --- a/packages/core/__tests__/select.test.tsx +++ b/packages/core/__tests__/select.test.tsx @@ -1,7 +1,8 @@ import { create } from '../create' import { select } from '../select' -import { waitFor } from '@testing-library/react' +import { renderHook, waitFor } from '@testing-library/react' import { longPromise } from './test-utils' +import { Suspense } from 'react' describe('select', () => { it('should derive state from a single dependency', async () => { @@ -101,7 +102,7 @@ describe('select', () => { await longPromise(100) return (await value) + 1 }) - const selectedState2 = selectedState.select(async (value) => (await value) + 1) + const selectedState2 = selectedState.select(async (value) => value + 1) const listener = jest.fn() selectedState2.listen(listener) await waitFor(() => { @@ -154,7 +155,7 @@ describe('select', () => { it('should select state from async initial state', async () => { const state = create(longPromise(100)) const selectedState = state.select(async (value) => { - return (await value) + 2 + return value + 2 }) await waitFor(() => { expect(selectedState.get()).toBe(2) @@ -169,4 +170,39 @@ describe('select', () => { expect(selectedState.get()).toBe(2) }) }) + + it('should select state from async state and do not change second time as it just boolean value', async () => { + const state = create(longPromise(100)) + const selectedState = state.select((value) => { + const result = value > 0 + expect(value).not.toBeUndefined() + return result + }) + const render = jest.fn() + + const { result } = renderHook( + () => { + render() + const value = selectedState() + return value + }, + { wrapper: ({ children }) => {children} }, + ) + + await waitFor(() => { + expect(result.current).toBe(false) + expect(selectedState.get()).toBe(false) + // re-render twice, as it hit suspense, because value is not resolved yet + expect(render).toHaveBeenCalledTimes(2) + }) + + state.set(1) + + await waitFor(() => { + expect(result.current).toBe(true) + expect(selectedState.get()).toBe(true) + // next time it re-render only once, as value is already resolved + expect(render).toHaveBeenCalledTimes(3) + }) + }) }) diff --git a/packages/core/create-state.ts b/packages/core/create-state.ts index 6f44efe..3996490 100644 --- a/packages/core/create-state.ts +++ b/packages/core/create-state.ts @@ -45,7 +45,7 @@ export function createState(options: GetStateOptions): FullState { return this } state.select = function (selector, isSelectorEqual = isEqualBase) { - return select([state], selector, isSelectorEqual) + return select([state as never], selector, isSelectorEqual) } state.get = get state.set = set as State['set'] diff --git a/packages/core/select.ts b/packages/core/select.ts index ce9e64f..9bc7c7d 100644 --- a/packages/core/select.ts +++ b/packages/core/select.ts @@ -2,8 +2,8 @@ import { stateScheduler } from './create' import { createState } from './create-state' import { subscribeToDevelopmentTools } from './debug/development-tools' import type { Cache, GetState, IsEqual } from './types' -import { canUpdate, handleAsyncUpdate } from './utils/common' -import { isUndefined } from './utils/is' +import { AbortError, canUpdate, handleAsyncUpdate } from './utils/common' +import { isPromise, isUndefined } from './utils/is' type StateDependencies> = { [K in keyof T]: GetState @@ -21,8 +21,32 @@ export function select = []>( const cache: Cache = {} function computedValue(): T { - const values = states.map((state) => state.get()) as S - return selector(...values) + // const values = states.map((state) => state.get()) as S + + const values: unknown[] = [] + let hasPromise = false + for (const state of states) { + const value = state.get() + if (isPromise(value)) { + hasPromise = true + } + values.push(value) + } + if (hasPromise) { + return new Promise((resolve, reject) => { + Promise.all(values).then((resolvedValues) => { + // check if some of value is undefined + // eslint-disable-next-line sonarjs/no-nested-functions + if (resolvedValues.some((element) => isUndefined(element))) { + return reject(new AbortError()) + } + const resolved = selector(...(resolvedValues as S)) + resolve(resolved) + }) + }) as T + } + const result = selector(...(values as S)) + return result } function getValue(): T { diff --git a/packages/core/types.ts b/packages/core/types.ts index b67d937..a4171a2 100644 --- a/packages/core/types.ts +++ b/packages/core/types.ts @@ -48,7 +48,7 @@ export interface GetState { * Select particular slice of the state. * It will create "another" state in read-only mode (without set). */ - select: (selector: (state: Awaited | T) => S, isEqual?: IsEqual) => GetState + select: (selector: (state: Awaited) => S, isEqual?: IsEqual) => GetState } export interface State extends GetState { diff --git a/packages/core/utils/common.ts b/packages/core/utils/common.ts index af00d06..c736f6d 100644 --- a/packages/core/utils/common.ts +++ b/packages/core/utils/common.ts @@ -12,7 +12,7 @@ export class AbortError extends Error { /** * Cancelable promise function, return promise and controller */ -function cancelablePromise(promise: Promise, previousController?: AbortController): CancelablePromise { +export function cancelablePromise(promise: Promise, previousController?: AbortController): CancelablePromise { if (previousController) { previousController.abort() }