diff --git a/docs/demo/debug.md b/docs/demo/debug.md new file mode 100644 index 0000000..258ca77 --- /dev/null +++ b/docs/demo/debug.md @@ -0,0 +1,3 @@ +# Debug Demo + + diff --git a/docs/examples/debug.less b/docs/examples/debug.less new file mode 100644 index 0000000..7c00927 --- /dev/null +++ b/docs/examples/debug.less @@ -0,0 +1,18 @@ +.debug-demo-block { + overflow: hidden; + box-shadow: 0 0 0 3px red; +} + +.debug-motion { + &-appear, + &-enter { + &-start { + opacity: 0; + } + + &-active { + opacity: 1; + transition: background 0.3s, height 1.3s, opacity 1.3s; + } + } +} diff --git a/docs/examples/debug.tsx b/docs/examples/debug.tsx new file mode 100644 index 0000000..9939815 --- /dev/null +++ b/docs/examples/debug.tsx @@ -0,0 +1,65 @@ +import { clsx } from 'clsx'; +import CSSMotion, { type CSSMotionProps } from 'rc-motion'; +import React, { useState } from 'react'; +import './debug.less'; + +const onCollapse = () => { + console.log('🔥 Collapse'); + return { height: 0 }; +}; + +const onExpand: CSSMotionProps['onAppearActive'] = node => { + console.log('🔥 Expand'); + return { height: node.scrollHeight }; +}; + +function DebugDemo() { + const [key, setKey] = useState(0); + + return ( +
+ + + + {({ style, className }, ref) => { + console.log('render', className, style); + + return ( +
+
+
+ ); + }} + +
+ ); +} + +export default () => ( + + + +); diff --git a/src/CSSMotion.tsx b/src/CSSMotion.tsx index fc16278..d7550c2 100644 --- a/src/CSSMotion.tsx +++ b/src/CSSMotion.tsx @@ -146,12 +146,8 @@ export function genCSSMotion(config: CSSMotionConfig) { return getDOM(nodeRef.current) as HTMLElement; } - const [getStatus, statusStep, statusStyle, mergedVisible] = useStatus( - supportMotion, - visible, - getDomElement, - props, - ); + const [getStatus, statusStep, statusStyle, mergedVisible, styleReady] = + useStatus(supportMotion, visible, getDomElement, props); const status = getStatus(); // Record whether content has rendered @@ -186,73 +182,85 @@ export function genCSSMotion(config: CSSMotionConfig) { React.useImperativeHandle(ref, () => refObj, []); // ===================== Render ===================== - let motionChildren: React.ReactNode; - const mergedProps = { ...eventProps, visible }; - - if (!children) { - // No children - motionChildren = null; - } else if (status === STATUS_NONE) { - // Stable children - if (mergedVisible) { - motionChildren = children({ ...mergedProps }, nodeRef); - } else if (!removeOnLeave && renderedRef.current && leavedClassName) { - motionChildren = children( - { ...mergedProps, className: leavedClassName }, - nodeRef, - ); - } else if (forceRender || (!removeOnLeave && !leavedClassName)) { - motionChildren = children( - { ...mergedProps, style: { display: 'none' } }, - nodeRef, - ); - } else { - motionChildren = null; - } - } else { - // In motion - let statusSuffix: string; - if (statusStep === STEP_PREPARE) { - statusSuffix = 'prepare'; - } else if (isActive(statusStep)) { - statusSuffix = 'active'; - } else if (statusStep === STEP_START) { - statusSuffix = 'start'; - } - - const motionCls = getTransitionName( - motionName, - `${status}-${statusSuffix}`, - ); - - motionChildren = children( - { - ...mergedProps, - className: clsx(getTransitionName(motionName, status), { - [motionCls]: motionCls && statusSuffix, - [motionName as string]: typeof motionName === 'string', - }), - style: statusStyle, - }, - nodeRef, - ); + // return motionChildren as React.ReactElement; + const idRef = React.useRef(0); + if (styleReady) { + idRef.current += 1; } - // Auto inject ref if child node not have `ref` props - if (React.isValidElement(motionChildren) && supportRef(motionChildren)) { - const originNodeRef = getNodeRef(motionChildren); + // We should render children when motionStyle is sync with stepStatus + return React.useMemo(() => { + let motionChildren: React.ReactNode; + const mergedProps = { ...eventProps, visible }; + + if (!children) { + // No children + motionChildren = null; + } else if (status === STATUS_NONE) { + // Stable children + if (mergedVisible) { + motionChildren = children({ ...mergedProps }, nodeRef); + } else if (!removeOnLeave && renderedRef.current && leavedClassName) { + motionChildren = children( + { ...mergedProps, className: leavedClassName }, + nodeRef, + ); + } else if (forceRender || (!removeOnLeave && !leavedClassName)) { + motionChildren = children( + { ...mergedProps, style: { display: 'none' } }, + nodeRef, + ); + } else { + motionChildren = null; + } + } else { + // In motion + let statusSuffix: string; + if (statusStep === STEP_PREPARE) { + statusSuffix = 'prepare'; + } else if (isActive(statusStep)) { + statusSuffix = 'active'; + } else if (statusStep === STEP_START) { + statusSuffix = 'start'; + } + + const motionCls = getTransitionName( + motionName, + `${status}-${statusSuffix}`, + ); - if (!originNodeRef) { - motionChildren = React.cloneElement( - motionChildren as React.ReactElement, + motionChildren = children( { - ref: nodeRef, + ...mergedProps, + className: clsx(getTransitionName(motionName, status), { + [motionCls]: motionCls && statusSuffix, + [motionName as string]: typeof motionName === 'string', + }), + style: statusStyle, }, + nodeRef, ); } - } - return motionChildren as React.ReactElement; + // Auto inject ref if child node not have `ref` props + if ( + React.isValidElement(motionChildren) && + supportRef(motionChildren) + ) { + const originNodeRef = getNodeRef(motionChildren); + + if (!originNodeRef) { + motionChildren = React.cloneElement( + motionChildren as React.ReactElement, + { + ref: nodeRef, + }, + ); + } + } + + return motionChildren; + }, [idRef.current]) as React.ReactElement; }, ); diff --git a/src/hooks/useStatus.ts b/src/hooks/useStatus.ts index 6d24f16..b2fde69 100644 --- a/src/hooks/useStatus.ts +++ b/src/hooks/useStatus.ts @@ -1,5 +1,4 @@ import { useEvent } from '@rc-component/util'; -import useState from '@rc-component/util/lib/hooks/useState'; import useSyncState from '@rc-component/util/lib/hooks/useSyncState'; import * as React from 'react'; import { useEffect, useRef } from 'react'; @@ -49,11 +48,19 @@ export default function useStatus( onLeaveEnd, onVisibleChanged, }: CSSMotionProps, -): [() => MotionStatus, StepStatus, React.CSSProperties, boolean] { +): [ + status: () => MotionStatus, + stepStatus: StepStatus, + style: React.CSSProperties, + visible: boolean, + styleReady: boolean, +] { // Used for outer render usage to avoid `visible: false & status: none` to render nothing - const [asyncVisible, setAsyncVisible] = useState(); + const [asyncVisible, setAsyncVisible] = React.useState(); const [getStatus, setStatus] = useSyncState(STATUS_NONE); - const [style, setStyle] = useState(null); + const [style, setStyle] = React.useState< + [style: React.CSSProperties | undefined, step: StepStatus] + >([null, null]); const currentStatus = getStatus(); @@ -73,7 +80,7 @@ export default function useStatus( */ function updateMotionEndStatus() { setStatus(STATUS_NONE); - setStyle(null, true); + setStyle([null, null]); } const onInternalMotionEnd = useEvent((event: MotionEvent) => { @@ -161,11 +168,14 @@ export default function useStatus( } // Rest step is sync update - if (step in eventHandlers) { - setStyle(eventHandlers[step]?.(getDomElement(), null) || null); + if (newStep in eventHandlers) { + setStyle([ + eventHandlers[newStep]?.(getDomElement(), null) || null, + newStep, + ]); } - if (step === STEP_ACTIVE && currentStatus !== STATUS_NONE) { + if (newStep === STEP_ACTIVE && currentStatus !== STATUS_NONE) { // Patch events when motion needed patchMotionEvents(getDomElement()); @@ -179,7 +189,7 @@ export default function useStatus( } } - if (step === STEP_PREPARED) { + if (newStep === STEP_PREPARED) { updateMotionEndStatus(); } @@ -286,7 +296,7 @@ export default function useStatus( }, [asyncVisible, currentStatus]); // ============================ Styles ============================ - let mergedStyle = style; + let mergedStyle = style[0]; if (eventHandlers[STEP_PREPARE] && step === STEP_START) { mergedStyle = { transition: 'none', @@ -294,5 +304,13 @@ export default function useStatus( }; } - return [getStatus, step, mergedStyle, asyncVisible ?? visible]; + const styleStep = style[1]; + + return [ + getStatus, + step, + mergedStyle, + asyncVisible ?? visible, + step === STEP_START || step === STEP_ACTIVE ? styleStep === step : true, + ]; } diff --git a/tests/CSSMotion.spec.tsx b/tests/CSSMotion.spec.tsx index d455ad9..54dc31d 100644 --- a/tests/CSSMotion.spec.tsx +++ b/tests/CSSMotion.spec.tsx @@ -194,6 +194,9 @@ describe('CSSMotion', () => { }); fireEvent.transitionEnd(container.querySelector('.motion-box')); + act(() => { + jest.runAllTimers(); + }); expect(container.querySelector('.motion-box')).toHaveClass('removed'); @@ -805,6 +808,9 @@ describe('CSSMotion', () => { }); fireEvent.transitionEnd(container.querySelector('.motion-box')); + act(() => { + jest.runAllTimers(); + }); expect(container.querySelector('.motion-box')).toBeTruthy(); expect(container.querySelector('.motion-box')).toHaveClass('removed'); diff --git a/tests/StrictMode.spec.tsx b/tests/StrictMode.spec.tsx index 9b82653..7dc872f 100644 --- a/tests/StrictMode.spec.tsx +++ b/tests/StrictMode.spec.tsx @@ -2,10 +2,9 @@ react/no-render-return-value, max-classes-per-file, react/prefer-stateless-function, react/no-multi-comp */ -import { fireEvent, render } from '@testing-library/react'; +import { act, fireEvent, render } from '@testing-library/react'; import { clsx } from 'clsx'; import React from 'react'; -import { act } from 'react-dom/test-utils'; import { genCSSMotion, type CSSMotionRef } from '../src/CSSMotion'; describe('StrictMode', () => { @@ -52,6 +51,9 @@ describe('StrictMode', () => { // Trigger End fireEvent.transitionEnd(node); + act(() => { + jest.runAllTimers(); + }); expect(node).not.toHaveClass('transition-appear'); expect(ref.current.inMotion()).toBeFalsy();