-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Queues children to make sure children aren't forcefully removed. - Uses TransitionPresence and thus works just like TransitionPresence
- Loading branch information
1 parent
745c544
commit bed332d
Showing
4 changed files
with
384 additions
and
0 deletions.
There are no files selected for viewing
263 changes: 263 additions & 0 deletions
263
packages/react-transition-presence/src/CrossFlow/CrossFlow.stories.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,263 @@ | ||
/* eslint-disable react/no-multi-comp, react/jsx-no-literals,no-console */ | ||
// eslint-disable-next-line import/no-extraneous-dependencies | ||
import { useAnimation } from '@mediamonks/react-animation'; | ||
import gsap from 'gsap'; | ||
import { useCallback, useEffect, useRef, useState, type ReactElement } from 'react'; | ||
import { useBeforeUnmount } from '../useBeforeUnmount/useBeforeUnmount.js'; | ||
import { CrossFlow } from './CrossFlow.js'; | ||
|
||
export default { | ||
title: 'components/CrossFlow', | ||
}; | ||
|
||
type ChildProps = { | ||
background: string; | ||
duration?: number; | ||
delayIn?: number; | ||
onClick?(): void; | ||
}; | ||
|
||
// Forces a re-render, useful to test for unwanted side-effects | ||
function useRerender(): () => void { | ||
// eslint-disable-next-line react/hook-use-state | ||
const [count, setCount] = useState(0); | ||
console.log('rerender', count); | ||
return () => { | ||
setCount((previous) => previous + 1); | ||
}; | ||
} | ||
|
||
function Child({ background, onClick, duration = 1, delayIn = 0 }: ChildProps): ReactElement { | ||
const ref = useRef<HTMLButtonElement>(null); | ||
|
||
// show when mounted/unmounted | ||
useEffect(() => { | ||
console.log('mounted', background); | ||
|
||
return () => { | ||
console.log('unmounted', background); | ||
}; | ||
}, [background]); | ||
|
||
useAnimation(() => { | ||
console.log('animate-in', background); | ||
return gsap.fromTo(ref.current, { opacity: 0 }, { opacity: 1, duration, delay: delayIn }); | ||
}, []); | ||
|
||
// show visible animation during "before unmount" lifecycle | ||
useBeforeUnmount(async (abortSignal) => { | ||
console.log('animate-out', background); | ||
|
||
const animation = gsap.fromTo(ref.current, { opacity: 1 }, { opacity: 0, duration }); | ||
|
||
abortSignal.addEventListener('abort', () => { | ||
animation.pause(0); | ||
}); | ||
|
||
return animation; | ||
}, []); | ||
|
||
return ( | ||
<button | ||
ref={ref} | ||
aria-label="Click to change color" | ||
type="button" | ||
style={{ | ||
background, | ||
border: 'none', | ||
width: 200, | ||
height: 200, | ||
}} | ||
onClick={onClick} | ||
/> | ||
); | ||
} | ||
|
||
export function CrossFlowExample(): ReactElement { | ||
const [isRedVisible, setIsRedVisible] = useState(true); | ||
|
||
return ( | ||
<> | ||
<CrossFlow> | ||
{isRedVisible ? ( | ||
<Child | ||
background="red" | ||
// eslint-disable-next-line react/jsx-no-bind | ||
onClick={(): void => { | ||
setIsRedVisible(false); | ||
}} | ||
/> | ||
) : ( | ||
<Child | ||
background="blue" | ||
// eslint-disable-next-line react/jsx-no-bind | ||
onClick={(): void => { | ||
setIsRedVisible(true); | ||
}} | ||
/> | ||
)} | ||
</CrossFlow> | ||
|
||
<div style={{ marginTop: 24 }}>Click the square (isRedVisible: {String(isRedVisible)})</div> | ||
</> | ||
); | ||
} | ||
|
||
export function CrossFlowFragmentExample(): ReactElement { | ||
const [isRedVisible, setIsRedVisible] = useState(true); | ||
|
||
return ( | ||
<> | ||
<CrossFlow> | ||
{isRedVisible ? ( | ||
<> | ||
<Child | ||
background="red" | ||
// eslint-disable-next-line react/jsx-no-bind | ||
onClick={(): void => { | ||
setIsRedVisible(false); | ||
}} | ||
/> | ||
<Child background="yellow" /> | ||
</> | ||
) : ( | ||
<> | ||
<Child background="yellow" /> | ||
<Child | ||
background="blue" | ||
// eslint-disable-next-line react/jsx-no-bind | ||
onClick={(): void => { | ||
setIsRedVisible(true); | ||
}} | ||
/> | ||
</> | ||
)} | ||
</CrossFlow> | ||
|
||
<div style={{ marginTop: 24 }}>Click the square (isRedVisible: {String(isRedVisible)})</div> | ||
</> | ||
); | ||
} | ||
|
||
export function CrossFlowRerenderExample(): ReactElement { | ||
const [isRedVisible, setIsRedVisible] = useState(true); | ||
|
||
// trigger rerender in the parent to see how it affects the CrossFlow | ||
const rerender = useRerender(); | ||
|
||
const onClickBlue = useCallback(() => { | ||
setIsRedVisible(true); | ||
}, [setIsRedVisible]); | ||
|
||
const onClickRed = useCallback(() => { | ||
setIsRedVisible(false); | ||
}, [setIsRedVisible]); | ||
|
||
return ( | ||
<> | ||
<CrossFlow> | ||
{isRedVisible ? ( | ||
<Child key="red" background="red" onClick={onClickRed} /> | ||
) : ( | ||
/* remove key to trigger error log */ | ||
<Child key="blue" background="blue" onClick={onClickBlue} /> | ||
)} | ||
</CrossFlow> | ||
|
||
<div style={{ marginTop: 24 }}>Click the square (isRedVisible: {String(isRedVisible)})</div> | ||
<button | ||
type="button" | ||
/* eslint-disable-next-line react/jsx-no-bind */ | ||
onClick={(): void => { | ||
rerender(); | ||
}} | ||
> | ||
trigger rerender | ||
</button> | ||
</> | ||
); | ||
} | ||
|
||
export function TransitionCompleteCallbackExample(): ReactElement { | ||
const [isRedVisible, setIsRedVisible] = useState(true); | ||
|
||
const onTransitionComplete = useCallback(() => { | ||
// eslint-disable-next-line no-console | ||
console.log('onTransitionComplete'); | ||
}, []); | ||
|
||
return ( | ||
<> | ||
<CrossFlow onTransitionComplete={onTransitionComplete}> | ||
{isRedVisible ? ( | ||
<Child | ||
key="red" | ||
background="red" | ||
// eslint-disable-next-line react/jsx-no-bind | ||
onClick={(): void => { | ||
setIsRedVisible(false); | ||
}} | ||
/> | ||
) : ( | ||
<Child | ||
key="blue" | ||
background="blue" | ||
// eslint-disable-next-line react/jsx-no-bind | ||
onClick={(): void => { | ||
setIsRedVisible(true); | ||
}} | ||
/> | ||
)} | ||
</CrossFlow> | ||
|
||
<div style={{ marginTop: 24 }}>Click the square (isRedVisible: {String(isRedVisible)})</div> | ||
</> | ||
); | ||
} | ||
|
||
const initialItems = ['red', 'blue', 'green', 'yellow']; | ||
|
||
// TODO: Remove when issue is fixed | ||
export function CrossFlowRerenderUnmountIssue(): ReactElement { | ||
const [items, setItems] = useState(() => initialItems); | ||
const rerender = useRerender(); | ||
|
||
const onReset = useCallback(() => { | ||
setItems(initialItems); | ||
}, [setItems]); | ||
|
||
const onNextItem = useCallback(() => { | ||
setItems((previous) => { | ||
const [first, ...rest] = previous; | ||
return [...rest, first]; | ||
}); | ||
|
||
// This would trigger out animations when | ||
// not properly handled in the CrossFlow | ||
setTimeout(() => { | ||
rerender(); | ||
}, 100); | ||
}, [setItems, rerender]); | ||
|
||
return ( | ||
<div> | ||
<div style={{ display: 'flex' }}> | ||
{items.map((item, index) => { | ||
const Component = index <= 1 ? CrossFlow : CrossFlow; | ||
|
||
return ( | ||
// eslint-disable-next-line react/no-array-index-key | ||
<div key={index} style={{ width: index === 0 ? 400 : 200 }}> | ||
<Component> | ||
<Child background={item} onClick={onNextItem} duration={2} delayIn={0} /> | ||
</Component> | ||
</div> | ||
); | ||
})} | ||
</div> | ||
<button type="button" onClick={onReset}> | ||
Reset | ||
</button> | ||
</div> | ||
); | ||
} |
99 changes: 99 additions & 0 deletions
99
packages/react-transition-presence/src/CrossFlow/CrossFlow.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,99 @@ | ||
import { | ||
cloneElement, | ||
useCallback, | ||
useEffect, | ||
useRef, | ||
useState, | ||
type ReactElement, | ||
type ReactFragment, | ||
} from 'react'; | ||
import { | ||
TransitionPresence, | ||
type TransitionPresenceProps, | ||
} from '../TransitionPresence/TransitionPresence.js'; | ||
import { useIsMounted } from '../_hooks/useIsMounted.js'; | ||
import { childrenAreEqual } from '../_utils/childrenAreEqual.js'; | ||
import { getId } from '../_utils/getId.js'; | ||
|
||
type CrossFlowQueue = Record<string, TransitionPresenceProps['children']>; | ||
|
||
// eslint-disable-next-line react/no-multi-comp | ||
export function CrossFlow({ | ||
children, | ||
onTransitionComplete, | ||
}: TransitionPresenceProps): ReactElement { | ||
const previousChildren = useRef<typeof children>(children); | ||
const [queue, setQueue] = useState<CrossFlowQueue>(() => | ||
children === null | ||
? {} | ||
: { | ||
[getId()]: children, | ||
}, | ||
); | ||
|
||
const isMounted = useIsMounted(); | ||
|
||
useEffect(() => { | ||
// Don't do anything during the first render | ||
if (!isMounted.current) { | ||
return; | ||
} | ||
|
||
// If the children are equal we don't want to do anything | ||
const areChildrenEqual = childrenAreEqual(children, previousChildren.current); | ||
previousChildren.current = children; | ||
|
||
if (areChildrenEqual) { | ||
return; | ||
} | ||
|
||
// Add new children to the queue and remove the old children instance for | ||
// its key to start the out transition | ||
setQueue((value) => { | ||
const emptyQueue: Record<string, null> = {}; | ||
|
||
// eslint-disable-next-line guard-for-in | ||
for (const key in value) { | ||
emptyQueue[key] = null; | ||
} | ||
|
||
if (children === null) { | ||
return emptyQueue; | ||
} | ||
|
||
return { | ||
...emptyQueue, | ||
[getId()]: children, | ||
}; | ||
}); | ||
}, [children, isMounted, previousChildren]); | ||
|
||
const onTransitionCompleteCleanup = useCallback( | ||
(_children: ReactElement | ReactFragment) => { | ||
setQueue((value) => { | ||
if ('key' in _children && _children.key !== null) { | ||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete | ||
delete value[_children.key]; | ||
} | ||
|
||
return { ...value }; | ||
}); | ||
|
||
onTransitionComplete?.(_children); | ||
}, | ||
[onTransitionComplete], | ||
); | ||
|
||
return ( | ||
<> | ||
{Object.entries(queue).map(([key, queuedChildren]) => ( | ||
<TransitionPresence onTransitionComplete={onTransitionCompleteCleanup} key={key}> | ||
{queuedChildren && | ||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment | ||
// @ts-expect-error | ||
cloneElement(queuedChildren, { key })} | ||
</TransitionPresence> | ||
))} | ||
</> | ||
); | ||
} |
17 changes: 17 additions & 0 deletions
17
packages/react-transition-presence/src/_hooks/useIsMounted.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
import { useEffect, useRef, type RefObject } from 'react'; | ||
|
||
export function useIsMounted(): RefObject<boolean> { | ||
const isMounted = useRef(false); | ||
|
||
useEffect(() => { | ||
queueMicrotask(() => { | ||
isMounted.current = true; | ||
}); | ||
|
||
return () => { | ||
isMounted.current = false; | ||
}; | ||
}, []); | ||
|
||
return isMounted; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
let id = 0; | ||
|
||
export function getId(): string { | ||
return (id++ % Number.MAX_SAFE_INTEGER).toString(); | ||
} |