Skip to content

Commit

Permalink
feat: fixup selectors values to be always sync
Browse files Browse the repository at this point in the history
  • Loading branch information
samuelgja committed Nov 23, 2024
1 parent 6656357 commit 0dd365c
Show file tree
Hide file tree
Showing 5 changed files with 70 additions and 10 deletions.
42 changes: 39 additions & 3 deletions packages/core/__tests__/select.test.tsx
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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)
Expand All @@ -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 }) => <Suspense fallback="loading">{children}</Suspense> },
)

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)
})
})
})
2 changes: 1 addition & 1 deletion packages/core/create-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export function createState<T>(options: GetStateOptions<T>): FullState<T> {
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<T>['set']
Expand Down
32 changes: 28 additions & 4 deletions packages/core/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends Array<unknown>> = {
[K in keyof T]: GetState<T[K]>
Expand All @@ -21,8 +21,32 @@ export function select<T = unknown, S extends Array<unknown> = []>(
const cache: Cache<T> = {}

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 {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export interface GetState<T> {
* Select particular slice of the state.
* It will create "another" state in read-only mode (without set).
*/
select: <S>(selector: (state: Awaited<T> | T) => S, isEqual?: IsEqual<S>) => GetState<S>
select: <S>(selector: (state: Awaited<T>) => S, isEqual?: IsEqual<S>) => GetState<S>
}

export interface State<T> extends GetState<T> {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/utils/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export class AbortError extends Error {
/**
* Cancelable promise function, return promise and controller
*/
function cancelablePromise<T>(promise: Promise<T>, previousController?: AbortController): CancelablePromise<T> {
export function cancelablePromise<T>(promise: Promise<T>, previousController?: AbortController): CancelablePromise<T> {
if (previousController) {
previousController.abort()
}
Expand Down

0 comments on commit 0dd365c

Please sign in to comment.