Skip to content

Commit 9c700d7

Browse files
authored
fix: Style not sync with step status (#71)
* chore: tmp of it * test: fix test case * test: fix test case * fix: cast motion children hook result to ReactElement
1 parent d121b68 commit 9c700d7

File tree

7 files changed

+198
-78
lines changed

7 files changed

+198
-78
lines changed

docs/demo/debug.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Debug Demo
2+
3+
<code src="../examples/debug.tsx"></code>

docs/examples/debug.less

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
.debug-demo-block {
2+
overflow: hidden;
3+
box-shadow: 0 0 0 3px red;
4+
}
5+
6+
.debug-motion {
7+
&-appear,
8+
&-enter {
9+
&-start {
10+
opacity: 0;
11+
}
12+
13+
&-active {
14+
opacity: 1;
15+
transition: background 0.3s, height 1.3s, opacity 1.3s;
16+
}
17+
}
18+
}

docs/examples/debug.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { clsx } from 'clsx';
2+
import CSSMotion, { type CSSMotionProps } from 'rc-motion';
3+
import React, { useState } from 'react';
4+
import './debug.less';
5+
6+
const onCollapse = () => {
7+
console.log('🔥 Collapse');
8+
return { height: 0 };
9+
};
10+
11+
const onExpand: CSSMotionProps['onAppearActive'] = node => {
12+
console.log('🔥 Expand');
13+
return { height: node.scrollHeight };
14+
};
15+
16+
function DebugDemo() {
17+
const [key, setKey] = useState(0);
18+
19+
return (
20+
<div>
21+
<button
22+
onClick={() => {
23+
setKey(prev => prev + 1);
24+
}}
25+
>
26+
Start
27+
</button>
28+
29+
<CSSMotion
30+
visible
31+
motionName="debug-motion"
32+
motionAppear
33+
onAppearStart={onCollapse}
34+
onAppearActive={onExpand}
35+
key={key}
36+
>
37+
{({ style, className }, ref) => {
38+
console.log('render', className, style);
39+
40+
return (
41+
<div
42+
ref={ref}
43+
className={clsx('debug-demo-block', className)}
44+
style={style}
45+
>
46+
<div
47+
style={{
48+
height: 100,
49+
width: 100,
50+
background: 'blue',
51+
}}
52+
/>
53+
</div>
54+
);
55+
}}
56+
</CSSMotion>
57+
</div>
58+
);
59+
}
60+
61+
export default () => (
62+
<React.StrictMode>
63+
<DebugDemo />
64+
</React.StrictMode>
65+
);

src/CSSMotion.tsx

Lines changed: 73 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -146,12 +146,8 @@ export function genCSSMotion(config: CSSMotionConfig) {
146146
return getDOM(nodeRef.current) as HTMLElement;
147147
}
148148

149-
const [getStatus, statusStep, statusStyle, mergedVisible] = useStatus(
150-
supportMotion,
151-
visible,
152-
getDomElement,
153-
props,
154-
);
149+
const [getStatus, statusStep, statusStyle, mergedVisible, styleReady] =
150+
useStatus(supportMotion, visible, getDomElement, props);
155151
const status = getStatus();
156152

157153
// Record whether content has rendered
@@ -186,73 +182,85 @@ export function genCSSMotion(config: CSSMotionConfig) {
186182
React.useImperativeHandle(ref, () => refObj, []);
187183

188184
// ===================== Render =====================
189-
let motionChildren: React.ReactNode;
190-
const mergedProps = { ...eventProps, visible };
191-
192-
if (!children) {
193-
// No children
194-
motionChildren = null;
195-
} else if (status === STATUS_NONE) {
196-
// Stable children
197-
if (mergedVisible) {
198-
motionChildren = children({ ...mergedProps }, nodeRef);
199-
} else if (!removeOnLeave && renderedRef.current && leavedClassName) {
200-
motionChildren = children(
201-
{ ...mergedProps, className: leavedClassName },
202-
nodeRef,
203-
);
204-
} else if (forceRender || (!removeOnLeave && !leavedClassName)) {
205-
motionChildren = children(
206-
{ ...mergedProps, style: { display: 'none' } },
207-
nodeRef,
208-
);
209-
} else {
210-
motionChildren = null;
211-
}
212-
} else {
213-
// In motion
214-
let statusSuffix: string;
215-
if (statusStep === STEP_PREPARE) {
216-
statusSuffix = 'prepare';
217-
} else if (isActive(statusStep)) {
218-
statusSuffix = 'active';
219-
} else if (statusStep === STEP_START) {
220-
statusSuffix = 'start';
221-
}
222-
223-
const motionCls = getTransitionName(
224-
motionName,
225-
`${status}-${statusSuffix}`,
226-
);
227-
228-
motionChildren = children(
229-
{
230-
...mergedProps,
231-
className: clsx(getTransitionName(motionName, status), {
232-
[motionCls]: motionCls && statusSuffix,
233-
[motionName as string]: typeof motionName === 'string',
234-
}),
235-
style: statusStyle,
236-
},
237-
nodeRef,
238-
);
185+
// return motionChildren as React.ReactElement;
186+
const idRef = React.useRef(0);
187+
if (styleReady) {
188+
idRef.current += 1;
239189
}
240190

241-
// Auto inject ref if child node not have `ref` props
242-
if (React.isValidElement(motionChildren) && supportRef(motionChildren)) {
243-
const originNodeRef = getNodeRef(motionChildren);
191+
// We should render children when motionStyle is sync with stepStatus
192+
return React.useMemo(() => {
193+
let motionChildren: React.ReactNode;
194+
const mergedProps = { ...eventProps, visible };
195+
196+
if (!children) {
197+
// No children
198+
motionChildren = null;
199+
} else if (status === STATUS_NONE) {
200+
// Stable children
201+
if (mergedVisible) {
202+
motionChildren = children({ ...mergedProps }, nodeRef);
203+
} else if (!removeOnLeave && renderedRef.current && leavedClassName) {
204+
motionChildren = children(
205+
{ ...mergedProps, className: leavedClassName },
206+
nodeRef,
207+
);
208+
} else if (forceRender || (!removeOnLeave && !leavedClassName)) {
209+
motionChildren = children(
210+
{ ...mergedProps, style: { display: 'none' } },
211+
nodeRef,
212+
);
213+
} else {
214+
motionChildren = null;
215+
}
216+
} else {
217+
// In motion
218+
let statusSuffix: string;
219+
if (statusStep === STEP_PREPARE) {
220+
statusSuffix = 'prepare';
221+
} else if (isActive(statusStep)) {
222+
statusSuffix = 'active';
223+
} else if (statusStep === STEP_START) {
224+
statusSuffix = 'start';
225+
}
226+
227+
const motionCls = getTransitionName(
228+
motionName,
229+
`${status}-${statusSuffix}`,
230+
);
244231

245-
if (!originNodeRef) {
246-
motionChildren = React.cloneElement(
247-
motionChildren as React.ReactElement,
232+
motionChildren = children(
248233
{
249-
ref: nodeRef,
234+
...mergedProps,
235+
className: clsx(getTransitionName(motionName, status), {
236+
[motionCls]: motionCls && statusSuffix,
237+
[motionName as string]: typeof motionName === 'string',
238+
}),
239+
style: statusStyle,
250240
},
241+
nodeRef,
251242
);
252243
}
253-
}
254244

255-
return motionChildren as React.ReactElement;
245+
// Auto inject ref if child node not have `ref` props
246+
if (
247+
React.isValidElement(motionChildren) &&
248+
supportRef(motionChildren)
249+
) {
250+
const originNodeRef = getNodeRef(motionChildren);
251+
252+
if (!originNodeRef) {
253+
motionChildren = React.cloneElement(
254+
motionChildren as React.ReactElement,
255+
{
256+
ref: nodeRef,
257+
},
258+
);
259+
}
260+
}
261+
262+
return motionChildren;
263+
}, [idRef.current]) as React.ReactElement;
256264
},
257265
);
258266

src/hooks/useStatus.ts

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { useEvent } from '@rc-component/util';
2-
import useState from '@rc-component/util/lib/hooks/useState';
32
import useSyncState from '@rc-component/util/lib/hooks/useSyncState';
43
import * as React from 'react';
54
import { useEffect, useRef } from 'react';
@@ -49,11 +48,19 @@ export default function useStatus(
4948
onLeaveEnd,
5049
onVisibleChanged,
5150
}: CSSMotionProps,
52-
): [() => MotionStatus, StepStatus, React.CSSProperties, boolean] {
51+
): [
52+
status: () => MotionStatus,
53+
stepStatus: StepStatus,
54+
style: React.CSSProperties,
55+
visible: boolean,
56+
styleReady: boolean,
57+
] {
5358
// Used for outer render usage to avoid `visible: false & status: none` to render nothing
54-
const [asyncVisible, setAsyncVisible] = useState<boolean>();
59+
const [asyncVisible, setAsyncVisible] = React.useState<boolean>();
5560
const [getStatus, setStatus] = useSyncState<MotionStatus>(STATUS_NONE);
56-
const [style, setStyle] = useState<React.CSSProperties | undefined>(null);
61+
const [style, setStyle] = React.useState<
62+
[style: React.CSSProperties | undefined, step: StepStatus]
63+
>([null, null]);
5764

5865
const currentStatus = getStatus();
5966

@@ -73,7 +80,7 @@ export default function useStatus(
7380
*/
7481
function updateMotionEndStatus() {
7582
setStatus(STATUS_NONE);
76-
setStyle(null, true);
83+
setStyle([null, null]);
7784
}
7885

7986
const onInternalMotionEnd = useEvent((event: MotionEvent) => {
@@ -161,11 +168,14 @@ export default function useStatus(
161168
}
162169

163170
// Rest step is sync update
164-
if (step in eventHandlers) {
165-
setStyle(eventHandlers[step]?.(getDomElement(), null) || null);
171+
if (newStep in eventHandlers) {
172+
setStyle([
173+
eventHandlers[newStep]?.(getDomElement(), null) || null,
174+
newStep,
175+
]);
166176
}
167177

168-
if (step === STEP_ACTIVE && currentStatus !== STATUS_NONE) {
178+
if (newStep === STEP_ACTIVE && currentStatus !== STATUS_NONE) {
169179
// Patch events when motion needed
170180
patchMotionEvents(getDomElement());
171181

@@ -179,7 +189,7 @@ export default function useStatus(
179189
}
180190
}
181191

182-
if (step === STEP_PREPARED) {
192+
if (newStep === STEP_PREPARED) {
183193
updateMotionEndStatus();
184194
}
185195

@@ -286,13 +296,21 @@ export default function useStatus(
286296
}, [asyncVisible, currentStatus]);
287297

288298
// ============================ Styles ============================
289-
let mergedStyle = style;
299+
let mergedStyle = style[0];
290300
if (eventHandlers[STEP_PREPARE] && step === STEP_START) {
291301
mergedStyle = {
292302
transition: 'none',
293303
...mergedStyle,
294304
};
295305
}
296306

297-
return [getStatus, step, mergedStyle, asyncVisible ?? visible];
307+
const styleStep = style[1];
308+
309+
return [
310+
getStatus,
311+
step,
312+
mergedStyle,
313+
asyncVisible ?? visible,
314+
step === STEP_START || step === STEP_ACTIVE ? styleStep === step : true,
315+
];
298316
}

tests/CSSMotion.spec.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,9 @@ describe('CSSMotion', () => {
194194
});
195195

196196
fireEvent.transitionEnd(container.querySelector('.motion-box'));
197+
act(() => {
198+
jest.runAllTimers();
199+
});
197200

198201
expect(container.querySelector('.motion-box')).toHaveClass('removed');
199202

@@ -805,6 +808,9 @@ describe('CSSMotion', () => {
805808
});
806809

807810
fireEvent.transitionEnd(container.querySelector('.motion-box'));
811+
act(() => {
812+
jest.runAllTimers();
813+
});
808814

809815
expect(container.querySelector('.motion-box')).toBeTruthy();
810816
expect(container.querySelector('.motion-box')).toHaveClass('removed');

tests/StrictMode.spec.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,9 @@
22
react/no-render-return-value, max-classes-per-file,
33
react/prefer-stateless-function, react/no-multi-comp
44
*/
5-
import { fireEvent, render } from '@testing-library/react';
5+
import { act, fireEvent, render } from '@testing-library/react';
66
import { clsx } from 'clsx';
77
import React from 'react';
8-
import { act } from 'react-dom/test-utils';
98
import { genCSSMotion, type CSSMotionRef } from '../src/CSSMotion';
109

1110
describe('StrictMode', () => {
@@ -52,6 +51,9 @@ describe('StrictMode', () => {
5251

5352
// Trigger End
5453
fireEvent.transitionEnd(node);
54+
act(() => {
55+
jest.runAllTimers();
56+
});
5557
expect(node).not.toHaveClass('transition-appear');
5658

5759
expect(ref.current.inMotion()).toBeFalsy();

0 commit comments

Comments
 (0)