Skip to content

Commit

Permalink
Remove mandatory key for defer flow
Browse files Browse the repository at this point in the history
  • Loading branch information
leroykorterink committed Mar 23, 2023
1 parent 19253b4 commit 745c544
Show file tree
Hide file tree
Showing 5 changed files with 137 additions and 172 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ type ChildProps = {
background: string;
duration?: number;
delayIn?: number;
onClick(): void;
onClick?(): void;
};

// Forces a re-render, useful to test for unwanted side-effects
Expand All @@ -30,24 +30,31 @@ function useRerender(): () => void {
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 () => {
useBeforeUnmount(async (abortSignal) => {
console.log('animate-out', background);
return gsap.fromTo(ref.current, { opacity: 1 }, { opacity: 0, duration });
}, []);

// show when mounted/unmounted
useEffect(() => {
console.log('mounted', background);
const animation = gsap.fromTo(ref.current, { opacity: 1 }, { opacity: 0, duration });

return () => {
console.log('unmounted', background);
};
abortSignal.addEventListener('abort', () => {
animation.pause(0);
});

return animation;
}, []);

return (
Expand All @@ -66,15 +73,14 @@ function Child({ background, onClick, duration = 1, delayIn = 0 }: ChildProps):
);
}

export function DeferFlow(): ReactElement {
export function DeferFlowExample(): ReactElement {
const [isRedVisible, setIsRedVisible] = useState(true);

return (
<>
<TransitionPresence>
{isRedVisible ? (
<Child
key="red"
background="red"
// eslint-disable-next-line react/jsx-no-bind
onClick={(): void => {
Expand All @@ -83,7 +89,6 @@ export function DeferFlow(): ReactElement {
/>
) : (
<Child
key="blue"
background="blue"
// eslint-disable-next-line react/jsx-no-bind
onClick={(): void => {
Expand All @@ -98,94 +103,57 @@ export function DeferFlow(): ReactElement {
);
}

export function CrossFlow(): ReactElement {
export function DeferFlowFragmentExample(): ReactElement {
const [isRedVisible, setIsRedVisible] = useState(true);

return (
<>
<TransitionPresence crossFlow>
{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);
}}
/>
)}
</TransitionPresence>

<div style={{ marginTop: 24 }}>Click the square (isRedVisible: {String(isRedVisible)})</div>
</>
);
}

export function CrossFlowRerender(): ReactElement {
const [isRedVisible, setIsRedVisible] = useState(true);

// trigger rerender in the parent to see how it affects the TransitionPresence
const rerender = useRerender();

const onClickBlue = useCallback(() => {
setIsRedVisible(true);
}, [setIsRedVisible]);

const onClickRed = useCallback(() => {
setIsRedVisible(false);
}, [setIsRedVisible]);

return (
<>
<TransitionPresence crossFlow>
<TransitionPresence>
{isRedVisible ? (
<Child key="red" background="red" onClick={onClickRed} />
// eslint-disable-next-line react/jsx-no-useless-fragment
<>
<Child
background="red"
// eslint-disable-next-line react/jsx-no-bind
onClick={(): void => {
setIsRedVisible(false);
}}
/>
<Child background="yellow" />
</>
) : (
/* remove key to trigger error log */
<Child key="blue" background="blue" onClick={onClickBlue} />
// eslint-disable-next-line react/jsx-no-useless-fragment
<>
<Child background="yellow" />
<Child
background="blue"
// eslint-disable-next-line react/jsx-no-bind
onClick={(): void => {
setIsRedVisible(true);
}}
/>
</>
)}
</TransitionPresence>

<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>
<div style={{ marginTop: 24 }}>
Click the blue/red square (isRedVisible: {String(isRedVisible)})
</div>
</>
);
}

export function StartCompleteCallbacks(): ReactElement {
const [isRedVisible, setIsRedVisible] = useState(true);

const onTransitionComplete = useCallback(() => {
// eslint-disable-next-line no-console
console.log('onTransitionComplete');
}, []);

return (
<>
<TransitionPresence
/* eslint-disable-next-line react/jsx-no-bind,@typescript-eslint/explicit-function-return-type */
onStart={() => {
// eslint-disable-next-line no-console
console.log('start');
}}
/* eslint-disable-next-line react/jsx-no-bind,@typescript-eslint/explicit-function-return-type */
onComplete={() => {
// eslint-disable-next-line no-console
console.log('completed');
}}
>
<TransitionPresence onTransitionComplete={onTransitionComplete}>
{isRedVisible ? (
<Child
key="red"
Expand Down Expand Up @@ -238,15 +206,8 @@ export function RerenderUnmountIssue(): ReactElement {
{items.map((item, index) => (
// eslint-disable-next-line react/no-array-index-key
<div key={index} style={{ width: index === 0 ? 400 : 200 }}>
<TransitionPresence crossFlow={index <= 1}>
<Child
/* eslint-disable-next-line react/no-array-index-key */
key={item + index}
background={item}
onClick={onNextItem}
duration={2}
delayIn={0}
/>
<TransitionPresence>
<Child background={item} onClick={onNextItem} duration={2} delayIn={0} />
</TransitionPresence>
</div>
))}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,23 @@
import * as React from 'react';
import {
Children,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactElement,
type Key,
useRef,
type ReactFragment,
} from 'react';
import { childrenAreEqual } from '../_utils/childrenAreEqual.js';
import { tick } from '../_utils/tick.js';
import {
useBeforeUnmount,
type BeforeUnmountCallback,
} from '../useBeforeUnmount/useBeforeUnmount.js';
import { TransitionPresenceContext } from './TransitionPresence.context.js';

export type TransitionPresenceProps = {
children: ReactElement | null;
crossFlow?: boolean;
onStart?(): void;
onComplete?(): void;
children: ReactElement | ReactFragment | null;
onTransitionComplete?(children: ReactElement | ReactFragment): void | Promise<void>;
};

/**
Expand All @@ -29,16 +27,10 @@ export type TransitionPresenceProps = {
*/
export function TransitionPresence({
children,
crossFlow,
onStart,
onComplete,
onTransitionComplete,
}: TransitionPresenceProps): ReactElement {
const lastRenderChildren = useRef<
[children: ReactElement | null, deferredChildren: ReactElement | null]
>([null, null]);
const isReRender = useRef(false);
const beforeUnmountCallbacks = useMemo(() => new Set<BeforeUnmountCallback>(), []);
const [deferredChildren, setDeferredChildren] = useState<ReactElement | null>(children);
const [previousChildren, setPreviousChildren] = useState<typeof children>(children);

const beforeUnmount = useCallback(
async (abortSignal: AbortSignal) => {
Expand All @@ -53,91 +45,46 @@ export function TransitionPresence({
[beforeUnmountCallbacks],
);

// Check if the children or deferredChildren have changed since the previous render
isReRender.current = ((): boolean => {
const [previousChildren, previousDeferredChildren] = lastRenderChildren.current;
lastRenderChildren.current = [children, deferredChildren];
return (
areChildrenEqual(previousChildren, children) &&
areChildrenEqual(previousDeferredChildren, deferredChildren)
);
})();
const onTransitionCompleteRef = useRef(onTransitionComplete);
onTransitionCompleteRef.current = onTransitionComplete;

useEffect(() => {
if (isReRender.current) {
// The children and the deferred children are the same,
// so we should not trigger a new transition.
// If we do, we accidentally animate out new children.
return;
}
if (areChildrenEqual(children, deferredChildren)) {
setDeferredChildren(children);
if (childrenAreEqual(children, previousChildren)) {
return;
}

const abortController = new AbortController();

(async (): Promise<void> => {
onStart?.();
// Defer children update for before unmount lifecycle
await beforeUnmount(abortController.signal);

setDeferredChildren(children);
onComplete?.();
onTransitionCompleteRef.current?.(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
previousChildren!,
);

// Remove old children to make sure new children are re-initialized
setPreviousChildren(null);
await tick();

// Set new children
setPreviousChildren(children);
await tick();
})();

return () => {
abortController.abort();
};
}, [children, deferredChildren, onStart, onComplete, beforeUnmount, beforeUnmountCallbacks]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [beforeUnmount, beforeUnmountCallbacks, children]);

// Apply same effect when TransitionPresence in tree updates
useBeforeUnmount(beforeUnmount, []);

// Validate that children is only 1 valid React element
if (children !== null) {
Children.only(children);
}

const shouldRenderOldChildren = crossFlow && !areChildrenEqual(children, deferredChildren, false);

return (
<TransitionPresenceContext.Provider value={beforeUnmountCallbacks}>
{deferredChildren}
{shouldRenderOldChildren && children}
{previousChildren}
</TransitionPresenceContext.Provider>
);
}

function areChildrenEqual(
childrenA: ReactElement | null,
childrenB: ReactElement | null,
showError = true,
): boolean {
const keyA = getKey(childrenA, showError);
const keyB = getKey(childrenB, showError);

if (childrenA === childrenB) {
return true;
}

if (!keyA && !keyB) {
return false;
}

return keyA === keyB;
}

function getKey(children: ReactElement | null, showError = false): Key | undefined {
if (!children) {
return undefined;
}

const key = children.key ?? undefined;

if (showError && !key) {
// eslint-disable-next-line no-console
console.error('TransitionPresence: Child must have a "key" defined', children);
}

return key;
}
Loading

0 comments on commit 745c544

Please sign in to comment.