Skip to content
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

feat(promise): add SuspensePromise, useSuspensePromise, promiseOptions, PromiseCache, PromiseCacheProvider, usePromiseCache #1074

Merged
merged 8 commits into from
Jul 13, 2024
113 changes: 113 additions & 0 deletions packages/promise/src/PromiseCache.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { ERROR_MESSAGE, FALLBACK, TEXT, sleep } from '@suspensive/test-utils'
import { render, screen, waitFor } from '@testing-library/react'
import ms from 'ms'
import { Suspense } from 'react'
import { PromiseCache } from './PromiseCache'
import { PromiseCacheProvider } from './PromiseCacheProvider'
import { SuspensePromise } from './SuspensePromise'
import { hashKey } from './utils'

const key = (id: number) => ['key', id] as const

// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
const asyncErrorFn = () => new Promise((_, reject) => reject(ERROR_MESSAGE))
describe('promiseCache', () => {
let promiseCache: PromiseCache

beforeEach(() => {
promiseCache = new PromiseCache()
promiseCache.reset()
})

it("have clearError method without key should clear promise & error for all key's promiseCacheState", async () => {
expect(promiseCache.getError(key(1))).toBeUndefined()
expect(promiseCache.getError(key(2))).toBeUndefined()
try {
promiseCache.suspend({ promiseKey: key(1), promiseFn: asyncErrorFn })
} catch (promiseToSuspense) {
expect(await promiseToSuspense).toBeUndefined()
}
try {
promiseCache.suspend({ promiseKey: key(1), promiseFn: asyncErrorFn })
} catch (error) {
expect(error).toBe(ERROR_MESSAGE)
}
try {
promiseCache.suspend({ promiseKey: key(2), promiseFn: asyncErrorFn })
} catch (promiseToSuspense) {
expect(await promiseToSuspense).toBeUndefined()
}
try {
promiseCache.suspend({ promiseKey: key(2), promiseFn: asyncErrorFn })
} catch (error) {
expect(error).toBe(ERROR_MESSAGE)
}
expect(promiseCache.getError(key(1))).toBe(ERROR_MESSAGE)
expect(promiseCache.getError(key(2))).toBe(ERROR_MESSAGE)

promiseCache.clearError()
expect(promiseCache.getError(key(1))).toBeUndefined()
expect(promiseCache.getError(key(2))).toBeUndefined()
})

it("have clearError method with key should clear promise & error for key's promiseCacheState", async () => {
expect(promiseCache.getError(key(1))).toBeUndefined()
expect(promiseCache.getError(key(2))).toBeUndefined()
try {
promiseCache.suspend({ promiseKey: key(1), promiseFn: asyncErrorFn })
} catch (promiseToSuspense) {
expect(await promiseToSuspense).toBeUndefined()
}
try {
promiseCache.suspend({ promiseKey: key(1), promiseFn: asyncErrorFn })
} catch (error) {
expect(error).toBe(ERROR_MESSAGE)
}
try {
promiseCache.suspend({ promiseKey: key(2), promiseFn: asyncErrorFn })
} catch (promiseToSuspense) {
expect(await promiseToSuspense).toBeUndefined()
}
try {
promiseCache.suspend({ promiseKey: key(2), promiseFn: asyncErrorFn })
} catch (error) {
expect(error).toBe(ERROR_MESSAGE)
}
expect(promiseCache.getError(key(1))).toBe(ERROR_MESSAGE)
expect(promiseCache.getError(key(2))).toBe(ERROR_MESSAGE)

promiseCache.clearError(key(1))
expect(promiseCache.getError(key(1))).toBeUndefined()
expect(promiseCache.getError(key(2))).toBe(ERROR_MESSAGE)
promiseCache.clearError(key(2))
expect(promiseCache.getError(key(1))).toBeUndefined()
expect(promiseCache.getError(key(2))).toBeUndefined()
})

it("have getData method with key should get data of key's promiseCacheState", async () => {
render(
<PromiseCacheProvider cache={promiseCache}>
<Suspense fallback={FALLBACK}>
<SuspensePromise options={{ promiseKey: key(1), promiseFn: () => sleep(ms('0.1s')).then(() => TEXT) }}>
{(resolvedData) => <>{resolvedData.data}</>}
</SuspensePromise>
</Suspense>
</PromiseCacheProvider>
)

expect(screen.queryByText(FALLBACK)).toBeInTheDocument()
expect(screen.queryByText(TEXT)).not.toBeInTheDocument()
expect(promiseCache.getData(key(1))).toBeUndefined()
await waitFor(() => expect(screen.queryByText(TEXT)).toBeInTheDocument())
expect(screen.queryByText(FALLBACK)).not.toBeInTheDocument()
expect(promiseCache.getData(key(1))).toBe(TEXT)
})

it('should handle unsubscribe gracefully when no subscribers exist', () => {
const mockSync = vi.fn()
const key = ['nonexistent', 'key'] as const
promiseCache.unsubscribe(key, mockSync)

expect(promiseCache['syncsMap'].get(hashKey(key))).toBeUndefined()
})
})
125 changes: 125 additions & 0 deletions packages/promise/src/PromiseCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import type { Key, SuspensePromiseOptions } from './types'
import { hashKey } from './utils'

type Sync = (...args: unknown[]) => unknown

type PromiseCacheState<TKey extends Key = Key> = {
promise?: Promise<unknown>
promiseKey: TKey
hashedKey: ReturnType<typeof hashKey>
error?: unknown
data?: unknown
}

/**
* @experimental This is experimental feature.
*/
export class PromiseCache {
private cache = new Map<ReturnType<typeof hashKey>, PromiseCacheState>()
private syncsMap = new Map<ReturnType<typeof hashKey>, Sync[]>()

public reset = (promiseKey?: Key) => {
if (promiseKey === undefined || promiseKey.length === 0) {
this.cache.clear()
this.syncSubscribers()
return
}

const hashedKey = hashKey(promiseKey)

if (this.cache.has(hashedKey)) {
// TODO: reset with key index hierarchy
this.cache.delete(hashedKey)
}

this.syncSubscribers(promiseKey)
}

public clearError = (promiseKey?: Key) => {
if (promiseKey === undefined || promiseKey.length === 0) {
this.cache.forEach((value, key, map) => {
map.set(key, { ...value, promise: undefined, error: undefined })
})
return
}

const hashedKey = hashKey(promiseKey)
const promiseCacheState = this.cache.get(hashedKey)
if (promiseCacheState) {
// TODO: clearError with key index hierarchy
this.cache.set(hashedKey, { ...promiseCacheState, promise: undefined, error: undefined })
}
}

public suspend = <TData, TKey extends Key = Key>({
promiseKey,
promiseFn,
}: SuspensePromiseOptions<TData, TKey>): TData => {
const hashedKey = hashKey(promiseKey)
const promiseCacheState = this.cache.get(hashedKey)

if (promiseCacheState) {
if (promiseCacheState.error) {
// eslint-disable-next-line @typescript-eslint/no-throw-literal
throw promiseCacheState.error
}
if (promiseCacheState.data) {
return promiseCacheState.data as TData
}

if (promiseCacheState.promise) {
// eslint-disable-next-line @typescript-eslint/no-throw-literal
throw promiseCacheState.promise
}
}
const newPromiseCache: PromiseCacheState<TKey> = {
promiseKey,
hashedKey,
promise: promiseFn({ promiseKey })
.then((data) => {
newPromiseCache.data = data
})
.catch((error: unknown) => {
newPromiseCache.error = error
}),
}

this.cache.set(hashedKey, newPromiseCache)
// eslint-disable-next-line @typescript-eslint/no-throw-literal
throw newPromiseCache.promise
}

public getData = (promiseKey: Key) => this.cache.get(hashKey(promiseKey))?.data
public getError = (promiseKey: Key) => this.cache.get(hashKey(promiseKey))?.error

public subscribe(promiseKey: Key, syncSubscriber: Sync) {
const hashedKey = hashKey(promiseKey)
const syncs = this.syncsMap.get(hashedKey)
this.syncsMap.set(hashedKey, [...(syncs ?? []), syncSubscriber])

const subscribed = {
unsubscribe: () => this.unsubscribe(promiseKey, syncSubscriber),
}
return subscribed
}

public unsubscribe(promiseKey: Key, syncSubscriber: Sync) {
const hashedKey = hashKey(promiseKey)
const syncs = this.syncsMap.get(hashedKey)

if (syncs) {
this.syncsMap.set(
hashedKey,
syncs.filter((sync) => sync !== syncSubscriber)
)
}
}

private syncSubscribers = (promiseKey?: Key) => {
const hashedKey = promiseKey ? hashKey(promiseKey) : undefined

return hashedKey
? this.syncsMap.get(hashedKey)?.forEach((sync) => sync())
: this.syncsMap.forEach((syncs) => syncs.forEach((sync) => sync()))
}
}
12 changes: 12 additions & 0 deletions packages/promise/src/PromiseCacheProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { type PropsWithChildren } from 'react'
import { PromiseCacheContext } from './contexts'
import type { PromiseCache } from './PromiseCache'

type PromiseCacheProviderProps = PropsWithChildren<{ cache: PromiseCache }>

/**
* @experimental This is experimental feature.
*/
export const PromiseCacheProvider = ({ cache, children }: PromiseCacheProviderProps) => (
<PromiseCacheContext.Provider value={cache}>{children}</PromiseCacheContext.Provider>
)
Comment on lines +1 to +12
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is the implementation reflecting the review from #1066 (comment)

30 changes: 30 additions & 0 deletions packages/promise/src/SuspensePromise.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Suspense } from '@suspensive/react'
import { TEXT } from '@suspensive/test-utils'
import { render, screen } from '@testing-library/react'
import { PromiseCache } from './PromiseCache'
import { PromiseCacheProvider } from './PromiseCacheProvider'
import { SuspensePromise } from './SuspensePromise'

const key = (id: number) => ['key', id] as const

describe('<SuspensePromise />', () => {
let promiseCache: PromiseCache

beforeEach(() => {
promiseCache = new PromiseCache()
})

it('should render child component with data from useSuspensePromise hook', async () => {
render(
<PromiseCacheProvider cache={promiseCache}>
<Suspense fallback="Loading...">
<SuspensePromise options={{ promiseKey: key(1), promiseFn: () => Promise.resolve(TEXT) }}>
{({ data }) => <>{data}</>}
</SuspensePromise>
</Suspense>
</PromiseCacheProvider>
)

expect(await screen.findByText(TEXT)).toBeInTheDocument()
})
})
19 changes: 19 additions & 0 deletions packages/promise/src/SuspensePromise.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { type FunctionComponent } from 'react'
import type { Key, ResolvedData, SuspensePromiseOptions } from './types'
import { useSuspensePromise } from './useSuspensePromise'

/**
* @experimental This is experimental feature.
*/
export type SuspensePromiseProps<TData, TKey extends Key> = {
options: SuspensePromiseOptions<TData, TKey>
children: FunctionComponent<ResolvedData<TData>>
}

/**
* @experimental This is experimental feature.
*/
export const SuspensePromise = <TData, TKey extends Key>({
children: Children,
options,
}: SuspensePromiseProps<TData, TKey>) => <Children {...useSuspensePromise<TData, TKey>(options)} />
4 changes: 4 additions & 0 deletions packages/promise/src/contexts/PromiseCacheContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { createContext } from 'react'
import type { PromiseCache } from '../PromiseCache'

export const PromiseCacheContext = createContext<PromiseCache | null>(null)
1 change: 1 addition & 0 deletions packages/promise/src/contexts/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { PromiseCacheContext } from './PromiseCacheContext'
7 changes: 0 additions & 7 deletions packages/promise/src/index.spec.ts

This file was deleted.

10 changes: 9 additions & 1 deletion packages/promise/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,9 @@
export const test = 'test'
export { SuspensePromise } from './SuspensePromise'
export { useSuspensePromise } from './useSuspensePromise'
export { promiseOptions } from './promiseOptions'
export { PromiseCache } from './PromiseCache'
export { PromiseCacheProvider } from './PromiseCacheProvider'
export { usePromiseCache } from './usePromiseCache'

export type { SuspensePromiseProps } from './SuspensePromise'
export type { SuspensePromiseOptions } from './types'
50 changes: 50 additions & 0 deletions packages/promise/src/promiseOptions.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { FALLBACK, TEXT } from '@suspensive/test-utils'
import { render, screen } from '@testing-library/react'
import { Suspense } from 'react'
import { PromiseCache } from './PromiseCache'
import { PromiseCacheProvider } from './PromiseCacheProvider'
import { promiseOptions } from './promiseOptions'
import { SuspensePromise } from './SuspensePromise'
import { useSuspensePromise } from './useSuspensePromise'

const key = (id: number) => ['key', id] as const

const options = promiseOptions({ promiseKey: key(1), promiseFn: () => Promise.resolve(TEXT) })

describe('promiseOptions', () => {
let promiseCache: PromiseCache

beforeEach(() => {
promiseCache = new PromiseCache()
})

it('should be used with SuspensePromise', async () => {
render(
<PromiseCacheProvider cache={promiseCache}>
<Suspense fallback={FALLBACK}>
<SuspensePromise options={options}>{({ data }) => <>{data}</>}</SuspensePromise>
</Suspense>
</PromiseCacheProvider>
)

expect(await screen.findByText(TEXT)).toBeInTheDocument()
})

it('should be used with useSuspensePromise', async () => {
const SuspensePromiseComponent = () => {
const resolvedData = useSuspensePromise(options)

return <>{resolvedData.data}</>
}

render(
<PromiseCacheProvider cache={promiseCache}>
<Suspense fallback={FALLBACK}>
<SuspensePromiseComponent />
</Suspense>
</PromiseCacheProvider>
)

expect(await screen.findByText(TEXT)).toBeInTheDocument()
})
})
8 changes: 8 additions & 0 deletions packages/promise/src/promiseOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { Key, SuspensePromiseOptions } from './types'

/**
* @experimental This is experimental feature.
*/
export const promiseOptions = <TData, TKey extends Key>(options: SuspensePromiseOptions<TData, TKey>) => {
return options
}
Loading
Loading