Skip to content

Commit

Permalink
Add CrossFlow component
Browse files Browse the repository at this point in the history
- Queues children to make sure children aren't forcefully removed.
- Uses TransitionPresence and thus works just like TransitionPresence
  • Loading branch information
leroykorterink committed Mar 23, 2023
1 parent 745c544 commit bed332d
Show file tree
Hide file tree
Showing 4 changed files with 384 additions and 0 deletions.
263 changes: 263 additions & 0 deletions packages/react-transition-presence/src/CrossFlow/CrossFlow.stories.tsx
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 packages/react-transition-presence/src/CrossFlow/CrossFlow.tsx
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 packages/react-transition-presence/src/_hooks/useIsMounted.ts
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;
}
5 changes: 5 additions & 0 deletions packages/react-transition-presence/src/_utils/getId.ts
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();
}

0 comments on commit bed332d

Please sign in to comment.