Skip to content

Commit

Permalink
feat(react): function children of Delay (#1312)
Browse files Browse the repository at this point in the history
close #1300 

# Overview

<!--
    A clear and concise description of what this pr is about.
 -->

### Problem

```tsx
const Example = () => {
  const useState(false)

  return (
    <Delay ms={1000}>
      {/* just expose Modal ui after 1000ms. if we need to open with delay, we can't support it */}
      <Modal isOpen={!isDelayed} /> 
    </Delay>
  )
}
```

### Solution

```tsx
const Example = () => {
  const useState(false)

  return (
    <Delay ms={1000}>
      {({ isDelayed }) => (
        // We can support from now!
        <Modal isOpen={!isDelayed} />
      )}
    </Delay>
  )
}
```

But, I'm not confident for this feature. so I marked this feature as
experimental feature

## PR Checklist

- [x] I did below actions if need

1. I read the [Contributing
Guide](https://github.com/toss/suspensive/blob/main/CONTRIBUTING.md)
2. I added documents and tests.

---------

Co-authored-by: Juhyeok Kang <[email protected]>
Co-authored-by: 김석진(poki) <[email protected]>
  • Loading branch information
3 people authored Oct 14, 2024
1 parent f7a2ebe commit 643f8bd
Show file tree
Hide file tree
Showing 5 changed files with 95 additions and 16 deletions.
5 changes: 5 additions & 0 deletions .changeset/poor-poets-jam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@suspensive/react": minor
---

feat(react): function children of Delay
19 changes: 15 additions & 4 deletions packages/react/src/DefaultProps.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ describe('<DefaultPropsProvider/>', () => {
expect(screen.queryByText(TEXT)).toBeInTheDocument()
})

it('should accept defaultProps.suspense.fallback to setup default fallback of Suspense. If Suspense accepted no fallback, Suspense should use default fallback', () => {
it('should accept defaultProps.Suspense.fallback to setup default fallback of Suspense. If Suspense accepted no fallback, Suspense should use default fallback', () => {
render(
<DefaultPropsProvider defaultProps={new DefaultProps({ Suspense: { fallback: FALLBACK_GLOBAL } })}>
<Suspense>
Expand All @@ -55,7 +55,7 @@ describe('<DefaultPropsProvider/>', () => {
)
expect(screen.queryByText(FALLBACK_GLOBAL)).toBeInTheDocument()
})
it('should accept defaultProps.suspense.fallback to setup default fallback of Suspense. If Suspense accepted local fallback, Suspense should ignore default fallback and show it', () => {
it('should accept defaultProps.Suspense.fallback to setup default fallback of Suspense. If Suspense accepted local fallback, Suspense should ignore default fallback and show it', () => {
render(
<DefaultPropsProvider defaultProps={new DefaultProps({ Suspense: { fallback: FALLBACK_GLOBAL } })}>
<Suspense fallback={FALLBACK}>
Expand All @@ -66,7 +66,7 @@ describe('<DefaultPropsProvider/>', () => {
expect(screen.queryByText(FALLBACK_GLOBAL)).not.toBeInTheDocument()
expect(screen.queryByText(FALLBACK)).toBeInTheDocument()
})
it('should accept defaultProps.suspense.fallback to setup default fallback of Suspense. If Suspense accepted local fallback as null, Suspense should ignore default fallback. even though local fallback is nullish', () => {
it('should accept defaultProps.Suspense.fallback to setup default fallback of Suspense. If Suspense accepted local fallback as null, Suspense should ignore default fallback. even though local fallback is nullish', () => {
render(
<DefaultPropsProvider defaultProps={new DefaultProps({ Suspense: { fallback: FALLBACK_GLOBAL } })}>
<Suspense fallback={null}>
Expand All @@ -77,7 +77,7 @@ describe('<DefaultPropsProvider/>', () => {
expect(screen.queryByText(FALLBACK_GLOBAL)).not.toBeInTheDocument()
})

it('should accept defaultProps.suspense.clientOnly to setup default clientOnly prop of Suspense. If Suspense accept no clientOnly, Suspense should use default fallback', () => {
it('should accept defaultProps.Suspense.clientOnly to setup default clientOnly prop of Suspense. If Suspense accept no clientOnly, Suspense should use default fallback', () => {
let clientOnly1: SuspenseProps['clientOnly'] = undefined
render(
<DefaultPropsProvider defaultProps={new DefaultProps({ Suspense: { clientOnly: true } })}>
Expand Down Expand Up @@ -112,6 +112,17 @@ describe('<DefaultPropsProvider/>', () => {
expect(clientOnly3).toBeUndefined()
})

it('should accept defaultProps.Delay.fallback to setup default fallback of Delay. If Delay accepted local fallback as null, Delay should ignore default fallback. even though local fallback is nullish', () => {
render(
<DefaultPropsProvider defaultProps={new DefaultProps({ Delay: { fallback: FALLBACK_GLOBAL } })}>
<Delay fallback={null} ms={Infinity}>
{TEXT}
</Delay>
</DefaultPropsProvider>
)
expect(screen.queryByText(FALLBACK_GLOBAL)).not.toBeInTheDocument()
expect(screen.queryByText(TEXT)).not.toBeInTheDocument()
})
it('should accept defaultOptions.delay.ms only positive number', () => {
expect(() => new DefaultProps({ Delay: { ms: 0 } })).toThrow(Message_DefaultProp_delay_ms_should_be_greater_than_0)
try {
Expand Down
25 changes: 22 additions & 3 deletions packages/react/src/Delay.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,38 @@ describe('<Delay/>', () => {
})
it('should accept 0 for ms prop', () => {
render(<Delay ms={0}>{TEXT}</Delay>)

expect(screen.queryByText(TEXT)).toBeInTheDocument()
})
it('should accept function children', async () => {
render(<Delay ms={1000}>{({ isDelayed }) => <>{isDelayed ? TEXT : 'not delayed'}</>}</Delay>)
expect(screen.queryByText('not delayed')).toBeInTheDocument()
vi.advanceTimersByTime(ms('1s'))
await waitFor(() => expect(screen.queryByText(TEXT)).toBeInTheDocument())
})
it('should not rerender if ms is = 0', async () => {
const functionChildren = vi.fn(({ isDelayed }) => <>{isDelayed ? TEXT : 'not delayed'}</>)
render(<Delay ms={0}>{functionChildren}</Delay>)
expect(functionChildren).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(ms('1s'))
await waitFor(() => expect(functionChildren).toHaveBeenCalledTimes(1))
await waitFor(() => expect(functionChildren).not.toHaveBeenCalledTimes(2))
})
it('should not rerender if ms is > 0', async () => {
const functionChildren = vi.fn(({ isDelayed }) => <>{isDelayed ? TEXT : 'not delayed'}</>)
render(<Delay ms={100}>{functionChildren}</Delay>)
expect(functionChildren).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(ms('1s'))
await waitFor(() => expect(functionChildren).not.toHaveBeenCalledTimes(1))
await waitFor(() => expect(functionChildren).toHaveBeenCalledTimes(2))
})
it('should render fallback content initially and then the actual text after the delay', async () => {
render(
<Delay ms={ms('1s')} fallback={<p role="paragraph">fallback</p>}>
{TEXT}
</Delay>
)
expect(screen.queryByRole('paragraph')).toBeInTheDocument()

vi.advanceTimersByTime(ms('1s'))

await waitFor(() => expect(screen.queryByText(TEXT)).toBeInTheDocument())
})
it('should throw SuspensiveError if negative number is passed as ms prop', () => {
Expand Down
25 changes: 25 additions & 0 deletions packages/react/src/Delay.test-d.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,30 @@ describe('<Delay/>', () => {
<></>
</Delay>
).not.toEqualTypeOf<ReactNode>()
expectTypeOf(
<Delay>
{({ isDelayed }) => {
expectTypeOf(isDelayed).toEqualTypeOf<boolean>()
return <></>
}}
</Delay>
).toEqualTypeOf<JSX.Element>()
expectTypeOf(
<Delay>
{({ isDelayed }) => {
expectTypeOf(isDelayed).toEqualTypeOf<boolean>()
return <></>
}}
</Delay>
).not.toEqualTypeOf<ReactNode>()

expectTypeOf(
// @ts-expect-error no fallback prop with function children of Delay
<Delay fallback="delaying">
{({}) => {
return <></>
}}
</Delay>
).toEqualTypeOf<JSX.Element>()
})
})
37 changes: 28 additions & 9 deletions packages/react/src/Delay.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
import { type PropsWithChildren, type ReactNode, useContext, useState } from 'react'
import { type ReactNode, useContext, useState } from 'react'
import { DelayDefaultPropsContext } from './contexts'
import { useTimeout } from './hooks'
import { Message_Delay_ms_prop_should_be_greater_than_or_equal_to_0, SuspensiveError } from './models/SuspensiveError'

export interface DelayProps extends PropsWithChildren {
ms?: number
fallback?: ReactNode
}
export type DelayProps =
| {
ms?: number
fallback?: never
/**
* @experimental This is experimental feature.
*/
children?: ({ isDelayed }: { isDelayed: boolean }) => ReactNode
}
| {
ms?: number
fallback?: ReactNode
children?: ReactNode
}

export const Delay = (props: DelayProps) => {
if (process.env.NODE_ENV === 'development' && typeof props.ms === 'number') {
Expand All @@ -15,11 +25,20 @@ export const Delay = (props: DelayProps) => {
const defaultProps = useContext(DelayDefaultPropsContext)
const ms = props.ms ?? defaultProps.ms ?? 0

const [isDelaying, setIsDelaying] = useState(ms > 0)
useTimeout(() => setIsDelaying(false), ms)
const [isDelayed, setIsDelayed] = useState(ms <= 0)
useTimeout(() => setIsDelayed(true), ms)

if (typeof props.children === 'function') {
return <>{props.children({ isDelayed })}</>
}

const fallback = props.fallback === undefined ? defaultProps.fallback : props.fallback
return <>{isDelaying ? fallback : props.children}</>
if (isDelayed) {
return <>{props.children}</>
}
if (props.fallback === undefined) {
return <>{defaultProps.fallback}</>
}
return <>{props.fallback}</>
}
if (process.env.NODE_ENV === 'development') {
Delay.displayName = 'Delay'
Expand Down

0 comments on commit 643f8bd

Please sign in to comment.