Skip to content

Commit

Permalink
perf: Reduce the number of useSwitchTransition renderings to enhanc…
Browse files Browse the repository at this point in the history
…e response speed
  • Loading branch information
Daydreamer-riri committed May 11, 2024
1 parent 25cb686 commit 610de95
Show file tree
Hide file tree
Showing 7 changed files with 114 additions and 106 deletions.
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ Awesome documentation station is under construction!
### useTransition

```tsx
import { useState } from 'react'
import { useTransition } from 'transition-hooks'

function Demo() {
const [show, setShow] = useState(false)
const { status, shouldMount } = useTransition(show)
Expand All @@ -51,6 +54,9 @@ function Demo() {
### useSwitchTransition

```tsx
import { useState } from 'react'
import { useSwitchTransition } from 'transition-hooks'

function Demo() {
const [count, setCount] = useState(0)
const { transition } = useSwitchTransition(count, { mode: 'default' })
Expand Down Expand Up @@ -79,8 +85,11 @@ function Demo() {
### useListTransition

```tsx
import { useState } from 'react'
import { useListTransition } from 'transition-hooks'

function Demo() {
const [list, setList] = useState(numbers)
const [list, setList] = useState([0, 1, 2])
const { transitionList } = useListTransition(list)

return (
Expand Down
3 changes: 3 additions & 0 deletions docs/components/BasicUseSwitchTransition/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export function BasicUseSwitchTransition() {
</label>
))}
</div>
<div>
<Button onClick={() => setCount(count + 1)}>click</Button>
</div>
</div>
)
}
4 changes: 4 additions & 0 deletions src/helpers/setAnimationFrameTimeout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,7 @@ export function nextTick(callback: () => unknown) {
Number.isNaN(document.body.offsetTop) || callback()
}, 0)
}

export function immediateExecution(callback: () => unknown) {
callback()
}
8 changes: 5 additions & 3 deletions src/hooks/useSwitchTransition/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface ModeHookParam<S> {
list: ListItem<S>[]
setList: React.Dispatch<React.SetStateAction<ListItem<S>[]>>
from: boolean
entered: boolean
}

export type SwitchRenderCallback<S> = (state: S, statusState: StatusState & { prevState?: S, nextState?: S }) => React.ReactNode
Expand All @@ -37,6 +38,7 @@ export function useSwitchTransition<S>(state: S, options?: SwitchTransitionOptio
timeout = 300,
mode = 'default',
from = true,
entered = true,
} = options || {}

const keyRef = useRef(0)
Expand All @@ -49,13 +51,13 @@ export function useSwitchTransition<S>(state: S, options?: SwitchTransitionOptio
const hasChanged = useStateChange(state)

// for default mode only
useDefaultMode({ state, timeout, keyRef, mode, list, setList, hasChanged, from })
useDefaultMode({ state, timeout, keyRef, mode, list, setList, hasChanged, from, entered })

// for out-in mode only
useOutInMode({ state, timeout, keyRef, mode, list, setList, hasChanged, from })
useOutInMode({ state, timeout, keyRef, mode, list, setList, hasChanged, from, entered })

// for in-out mode only
useInOutMode({ state, timeout, keyRef, mode, list, setList, hasChanged, from })
useInOutMode({ state, timeout, keyRef, mode, list, setList, hasChanged, from, entered })

const isResolved = list.every(item => item.isResolved)

Expand Down
13 changes: 6 additions & 7 deletions src/hooks/useSwitchTransition/useDefaultMode.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
import { useState } from 'react'
import { STATUS, getState } from '../../status'
import { nextTick } from '../../helpers/setAnimationFrameTimeout'
import { immediateExecution, nextTick } from '../../helpers/setAnimationFrameTimeout'
import { getTimeout } from '../../helpers/getTimeout'
import type { ListItem, ModeHookParam } from './index'

function nowFn(callback: () => unknown) {
return callback()
}

export function useDefaultMode<S>({
state,
timeout,
Expand All @@ -17,11 +13,12 @@ export function useDefaultMode<S>({
setList,
hasChanged,
from,
entered,
}: ModeHookParam<S>) {
const { enterTimeout, exitTimeout } = getTimeout(timeout)
const timeoutIdMap = useState(() => new Map<number, number>())[0]

const nextTickOrNow = from ? nextTick : nowFn
const nextTickOrNow = from ? nextTick : immediateExecution
// skip unmatched mode 🚫
if (mode !== undefined && mode !== 'default')
return
Expand All @@ -47,6 +44,8 @@ export function useDefaultMode<S>({
setList(prev =>
prev.map(item => (isCurItem(item) ? { ...item, ...getState(STATUS.entering) } : item)),
)
if (!entered)
return
const id = window.setTimeout(() => {
setList(prev =>
prev.map(item => (isCurItem(item) ? { ...item, ...getState(STATUS.entered) } : item)),
Expand All @@ -69,7 +68,7 @@ export function useDefaultMode<S>({
timeoutIdMap.delete(item.key)
}

return { ...item, nextState: state, ...getState(STATUS.exiting) }
return { ...item, nextState: state, ...getState(STATUS.exiting), prevState: undefined }
},
),
)
Expand Down
104 changes: 43 additions & 61 deletions src/hooks/useSwitchTransition/useInOutMode.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
import { STATUS, getState } from '../../status'
import type { Canceller } from '../../helpers/setAnimationFrameTimeout'
import { clearAnimationFrameTimeout, setAnimationFrameTimeout } from '../../helpers/setAnimationFrameTimeout'
import { clearAnimationFrameTimeout, immediateExecution, nextTick, setAnimationFrameTimeout } from '../../helpers/setAnimationFrameTimeout'
import { getTimeout } from '../../helpers/getTimeout'
import type { ModeHookParam } from './index'

Expand All @@ -12,71 +12,53 @@ export function useInOutMode<S>({
keyRef,
list,
setList,
hasChanged,
from,
entered,
}: ModeHookParam<S>) {
const { enterTimeout, exitTimeout } = getTimeout(timeout)
const timerRef = useRef<Canceller>({})
const timerRef2 = useRef<Canceller>({})
const timerRef3 = useRef<Canceller>({})
const enteredRef = useRef<Canceller>({})
const allTimers = useState(() => new Set<Canceller>())[0]

useEffect(() => {
// skip unmatched mode 🚫
if (mode !== 'in-out')
return
const nextTickOrNow = from ? nextTick : immediateExecution

const [lastItem, secondLastItem] = [...list].reverse()
// if state has changed && stage is enter (add new item)
if (lastItem.state !== state && lastItem?.isEnter) {
// 1 add new item with stage 'from'
keyRef.current++
setList(prev =>
prev.slice(-1).concat({ state, key: keyRef.current, ...getState(STATUS.from), prevState: lastItem.state }),
)
}
useEffect(() => () => {
clearAnimationFrameTimeout(enteredRef.current)
allTimers.forEach(clearAnimationFrameTimeout)
}, [])

// if state hasn't changed && stage is from (enter that new item)
if (lastItem.state === state && lastItem.status === 'from') {
// 2 set that new item's stage to 'enter' immediately
setAnimationFrameTimeout(() => {
setList([secondLastItem, { ...lastItem, ...getState(STATUS.entering) }])
})
clearAnimationFrameTimeout(timerRef3.current)
timerRef3.current = setAnimationFrameTimeout(() => {
setList([secondLastItem, { ...lastItem, ...getState(STATUS.entered) }])
}, enterTimeout)
}
if (mode !== 'in-out')
return

// if state hasn't changed
// && stage is enter
// && second last item exist
// && second last item enter
// (leave second last item)
if (
lastItem.state === state
&& lastItem?.isEnter
&& secondLastItem?.isEnter
) {
// 3 leave second last item after new item enter animation ends
clearAnimationFrameTimeout(timerRef.current)
timerRef.current = setAnimationFrameTimeout(() => {
setList([{ ...secondLastItem, ...getState(STATUS.exiting) }, lastItem])
}, enterTimeout)
}
if (!hasChanged)
return

// if second last item exist
// && second last item is enter
// (unmount second last item)
if (secondLastItem?.status === 'exiting') {
// 4 unmount second last item after it's leave animation ends
clearAnimationFrameTimeout(timerRef2.current)
timerRef2.current = setAnimationFrameTimeout(() => {
setList(prev => prev.filter(item => item.key !== secondLastItem.key))
}, exitTimeout)
}
}, [keyRef, list, mode, setList, state, enterTimeout, exitTimeout])
const [lastItem] = list.slice(-1)
if (!(lastItem.state !== state && lastItem?.isEnter))
return

useEffect(() => () => {
clearAnimationFrameTimeout(timerRef.current)
clearAnimationFrameTimeout(timerRef2.current)
clearAnimationFrameTimeout(timerRef3.current)
}, [])
const prevKey = keyRef.current
keyRef.current++
setList(prev =>
prev.concat({ state, key: keyRef.current, ...getState(STATUS.from), prevState: lastItem.state }),
)
const curKey = keyRef.current
nextTickOrNow(() => {
setList(list => list.map(item => item.key === curKey ? { ...item, ...getState(STATUS.entering) } : item))
})

const startExitTimer = setAnimationFrameTimeout(() => {
setList(list => list.map(item => item.key === prevKey ? { ...item, ...getState(STATUS.exiting), prevState: undefined, nextState: state } : item))
allTimers.delete(startExitTimer)
if (!entered)
return
setList(list => list.map(item => (item.key === curKey && item.status === 'entering') ? { ...item, ...getState(STATUS.entered) } : item))
}, enterTimeout)
allTimers.add(startExitTimer)

const endExitTimer = setAnimationFrameTimeout(() => {
setList(list => list.filter(item => item.key !== prevKey))
allTimers.delete(endExitTimer)
}, exitTimeout + enterTimeout)
allTimers.add(endExitTimer)
}
77 changes: 43 additions & 34 deletions src/hooks/useSwitchTransition/useOutInMode.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
import { STATUS, getState } from '../../status'
import type { Canceller } from '../../helpers/setAnimationFrameTimeout'
import { clearAnimationFrameTimeout, setAnimationFrameTimeout } from '../../helpers/setAnimationFrameTimeout'
import { clearAnimationFrameTimeout, immediateExecution, nextTick, setAnimationFrameTimeout } from '../../helpers/setAnimationFrameTimeout'
import { getTimeout } from '../../helpers/getTimeout'
import type { ModeHookParam } from './index'

Expand All @@ -12,47 +12,56 @@ export function useOutInMode<S>({
keyRef,
list,
setList,
hasChanged,
from,
entered,
}: ModeHookParam<S>) {
const { enterTimeout, exitTimeout } = getTimeout(timeout)
const timerRef = useRef<Canceller>({})
const startEnterTimerRef = useRef<Canceller>({})
const endEnterTimerRef = useRef<Canceller>({})
const allTimers = useState(() => new Set<Canceller>())[0]
const nextTickOrNow = from ? nextTick : immediateExecution

useEffect(() => {
// skip unmatched mode 🚫
if (mode !== 'out-in')
return
const lastStateRef = useRef<S>()

const [lastItem] = list.slice(-1)
useEffect(() => () => {
clearAnimationFrameTimeout(startEnterTimerRef.current)
}, [])

// if state has changed && stage is enter (trigger prev last item to leave)
if (lastItem.state !== state && lastItem.isEnter) {
// 1 leave prev last item
setList([{ ...lastItem, ...getState(STATUS.exiting), nextState: state }])
}
if (mode !== 'out-in')
return

if (!hasChanged)
return

const [lastItem] = list.slice(-1)
if (lastItem !== undefined)
lastStateRef.current = lastItem.state

// if state has changed && stage is leave (add new item after prev last item leave ani ends)
if (lastItem.state !== state && !lastItem.notExit) {
// 2 add new item after prev last item leave animation ends
clearAnimationFrameTimeout(timerRef.current)
timerRef.current = setAnimationFrameTimeout(() => {
keyRef.current++
setList([{ state, key: keyRef.current, ...getState(STATUS.from), prevState: lastItem.state }])
if (lastItem?.state !== state) {
if (lastItem?.notExit) {
setList(list => list.map(item => item.key === lastItem.key ? { ...lastItem, ...getState(STATUS.exiting), nextState: state, prevState: undefined } : item))
const endExitTimer = setAnimationFrameTimeout(() => {
setList(list => list.filter(item => item.key !== lastItem.key))
allTimers.delete(endExitTimer)
}, exitTimeout)
allTimers.add(endExitTimer)
}

// if state hasn't change && stage is from
if (lastItem.state === state && lastItem.status === 'from') {
// 3 change that new item's stage to 'enter' immediately
setAnimationFrameTimeout(() => {
setList(prev => [{ ...prev[0], ...getState(STATUS.entering) }])
keyRef.current++
const curKey = keyRef.current
clearAnimationFrameTimeout(startEnterTimerRef.current)
clearAnimationFrameTimeout(endEnterTimerRef.current)
startEnterTimerRef.current = setAnimationFrameTimeout(() => {
setList(list => list.concat({ state, key: keyRef.current, ...getState(STATUS.from), prevState: lastStateRef.current }))
nextTickOrNow(() => {
setList(list => list.map(item => item.key === curKey ? { ...item, ...getState(STATUS.entering) } : item))
})
clearAnimationFrameTimeout(timerRef.current)
timerRef.current = setAnimationFrameTimeout(() => {
setList(prev => [{ ...prev[0], ...getState(STATUS.entered) }])
if (!entered)
return
endEnterTimerRef.current = setAnimationFrameTimeout(() => {
setList(list => list.map(item => item.key === curKey ? { ...item, ...getState(STATUS.entered) } : item))
}, enterTimeout)
}
}, [keyRef, list, mode, setList, state, enterTimeout, exitTimeout])

useEffect(() => () => {
clearAnimationFrameTimeout(timerRef.current)
}, [])
}, exitTimeout)
}
}

0 comments on commit 610de95

Please sign in to comment.