From 3c9ea1a23ab8088ae9ef766318bf32d6535481c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E8=89=B3=E5=85=B5?= Date: Sun, 28 Jun 2026 13:33:28 +0800 Subject: [PATCH 1/3] refactor: migrate CSSMotionList to FC --- src/CSSMotionList.tsx | 255 +++++++++++++++++++++++------------------- 1 file changed, 143 insertions(+), 112 deletions(-) diff --git a/src/CSSMotionList.tsx b/src/CSSMotionList.tsx index 04ca651..24410f6 100644 --- a/src/CSSMotionList.tsx +++ b/src/CSSMotionList.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import type { CSSMotionProps } from './CSSMotion'; import OriginCSSMotion, { isRefNotConsumed } from './CSSMotion'; +import useIsomorphicLayoutEffect from './hooks/useIsomorphicLayoutEffect'; import type { KeyObject } from './util/diff'; import { diffKeys, @@ -67,6 +68,49 @@ export interface CSSMotionListState { keyEntities: KeyObject[]; } +function getDerivedKeyEntities( + keys: CSSMotionListProps['keys'], + keyEntities: KeyObject[], +) { + const parsedKeyObjects = parseKeys(keys); + const mixedKeyEntities = diffKeys(keyEntities, parsedKeyObjects); + + return mixedKeyEntities.filter(entity => { + const prevEntity = keyEntities.find(({ key }) => entity.key === key); + + // Remove if already mark as removed + if ( + prevEntity && + prevEntity.status === STATUS_REMOVED && + entity.status === STATUS_REMOVE + ) { + return false; + } + return true; + }); +} + +function shallowEqualKeyObject(origin: KeyObject, target: KeyObject) { + const originRecord = origin as Record; + const targetRecord = target as Record; + const originKeys = Object.keys(originRecord); + const targetKeys = Object.keys(targetRecord); + + return ( + originKeys.length === targetKeys.length && + originKeys.every(key => originRecord[key] === targetRecord[key]) + ); +} + +function isSameKeyEntities(origin: KeyObject[], target: KeyObject[]) { + return ( + origin.length === target.length && + origin.every((entity, index) => + shallowEqualKeyObject(entity, target[index]), + ) + ); +} + /** * Generate a CSSMotionList component with config * @param transitionSupport No need since CSSMotionList no longer depends on transition support @@ -75,123 +119,110 @@ export interface CSSMotionListState { export function genCSSMotionList( transitionSupport: boolean, CSSMotion = OriginCSSMotion, -): React.ComponentClass { - class CSSMotionList extends React.Component< - CSSMotionListProps, - CSSMotionListState - > { - static defaultProps = { - component: 'div', - }; - - state: CSSMotionListState = { - keyEntities: [], - }; - - static getDerivedStateFromProps( - { keys }: CSSMotionListProps, - { keyEntities }: CSSMotionListState, - ) { - const parsedKeyObjects = parseKeys(keys); - const mixedKeyEntities = diffKeys(keyEntities, parsedKeyObjects); - - return { - keyEntities: mixedKeyEntities.filter(entity => { - const prevEntity = keyEntities.find(({ key }) => entity.key === key); - - // Remove if already mark as removed - if ( - prevEntity && - prevEntity.status === STATUS_REMOVED && - entity.status === STATUS_REMOVE - ) { - return false; - } - return true; - }), - }; - } +): React.ComponentType { + const CSSMotionList: React.FC = props => { + const { + component = 'div', + children, + onVisibleChanged, + onAllRemoved, + ...restProps + } = props; + const { keys } = restProps; + + const [keyEntities, setKeyEntities] = React.useState(() => + getDerivedKeyEntities(keys, []), + ); + const restKeysCountRef = React.useRef(null); + const onAllRemovedRef = React.useRef(onAllRemoved); + + onAllRemovedRef.current = onAllRemoved; + + useIsomorphicLayoutEffect(() => { + setKeyEntities(prevKeyEntities => { + const nextKeyEntities = getDerivedKeyEntities(keys, prevKeyEntities); + + return isSameKeyEntities(prevKeyEntities, nextKeyEntities) + ? prevKeyEntities + : nextKeyEntities; + }); + }); - // ZombieJ: Return the count of rest keys. It's safe to refactor if need more info. - removeKey = (removeKey: React.Key) => { - this.setState( - prevState => { - const nextKeyEntities = prevState.keyEntities.map(entity => { - if (entity.key !== removeKey) return entity; - return { - ...entity, - status: STATUS_REMOVED, - }; - }); + useIsomorphicLayoutEffect(() => { + if (restKeysCountRef.current !== null) { + const restKeysCount = restKeysCountRef.current; + restKeysCountRef.current = null; + + if (restKeysCount === 0) { + onAllRemovedRef.current?.(); + } + } + }, [keyEntities]); + // ZombieJ: Return the count of rest keys. It's safe to refactor if need more info. + const removeKey = React.useCallback((removedKey: React.Key) => { + setKeyEntities(prevKeyEntities => { + const nextKeyEntities = prevKeyEntities.map(entity => { + if (entity.key !== removedKey) return entity; return { - keyEntities: nextKeyEntities, + ...entity, + status: STATUS_REMOVED, }; - }, - () => { - const { keyEntities } = this.state; - const restKeysCount = keyEntities.filter( - ({ status }) => status !== STATUS_REMOVED, - ).length; - - if (restKeysCount === 0 && this.props.onAllRemoved) { - this.props.onAllRemoved(); - } - }, - ); - }; - - render() { - const { keyEntities } = this.state; - const { - component, - children, - onVisibleChanged, - onAllRemoved, - ...restProps - } = this.props; - - const Component = component || React.Fragment; - - const motionProps: CSSMotionProps = {}; - MOTION_PROP_NAMES.forEach(prop => { - motionProps[prop] = restProps[prop]; - delete restProps[prop]; + }); + + restKeysCountRef.current = nextKeyEntities.filter( + ({ status }) => status !== STATUS_REMOVED, + ).length; + + return isSameKeyEntities(prevKeyEntities, nextKeyEntities) + ? prevKeyEntities + : nextKeyEntities; }); - delete restProps.keys; - - return ( - - {keyEntities.map(({ status, ...eventProps }, index) => { - const visible = status === STATUS_ADD || status === STATUS_KEEP; - return ( - { - onVisibleChanged?.(changedVisible, { key: eventProps.key }); - - if (!changedVisible) { - this.removeKey(eventProps.key); - } - }} - > - {isRefNotConsumed(children) - ? props => - (children as ChildrenWithoutRef)({ - ...props, - index, - }) - : (props, ref) => children({ ...props, index }, ref)} - - ); - })} - - ); - } - } + }, []); + + const Component = component || React.Fragment; + + const motionProps: CSSMotionProps = {}; + MOTION_PROP_NAMES.forEach(prop => { + motionProps[prop] = restProps[prop]; + delete restProps[prop]; + }); + delete restProps.keys; + + return ( + + {keyEntities.map(({ status, ...eventProps }, index) => { + const visible = status === STATUS_ADD || status === STATUS_KEEP; + return ( + { + onVisibleChanged?.(changedVisible, { key: eventProps.key }); + + if (!changedVisible) { + removeKey(eventProps.key); + } + }} + > + {isRefNotConsumed(children) + ? motionChildrenProps => + (children as ChildrenWithoutRef)({ + ...motionChildrenProps, + index, + }) + : (motionChildrenProps, ref) => + children({ ...motionChildrenProps, index }, ref)} + + ); + })} + + ); + }; + + CSSMotionList.displayName = 'CSSMotionList'; return CSSMotionList; } From c6e68ba68f61974402a4ce065e3ed4f1cb1dc35f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E8=89=B3=E5=85=B5?= Date: Sun, 28 Jun 2026 14:22:41 +0800 Subject: [PATCH 2/3] fix: preserve CSSMotionList behavior --- src/CSSMotionList.tsx | 63 ++++++++++++++++++++---------------- tests/CSSMotionList.spec.tsx | 30 +++++++++++++++++ 2 files changed, 66 insertions(+), 27 deletions(-) diff --git a/src/CSSMotionList.tsx b/src/CSSMotionList.tsx index 24410f6..489d962 100644 --- a/src/CSSMotionList.tsx +++ b/src/CSSMotionList.tsx @@ -91,6 +91,10 @@ function getDerivedKeyEntities( } function shallowEqualKeyObject(origin: KeyObject, target: KeyObject) { + if (origin === target) { + return true; + } + const originRecord = origin as Record; const targetRecord = target as Record; const originKeys = Object.keys(originRecord); @@ -98,11 +102,19 @@ function shallowEqualKeyObject(origin: KeyObject, target: KeyObject) { return ( originKeys.length === targetKeys.length && - originKeys.every(key => originRecord[key] === targetRecord[key]) + originKeys.every( + key => + Object.prototype.hasOwnProperty.call(targetRecord, key) && + Object.is(originRecord[key], targetRecord[key]), + ) ); } function isSameKeyEntities(origin: KeyObject[], target: KeyObject[]) { + if (origin === target) { + return true; + } + return ( origin.length === target.length && origin.every((entity, index) => @@ -133,36 +145,37 @@ export function genCSSMotionList( const [keyEntities, setKeyEntities] = React.useState(() => getDerivedKeyEntities(keys, []), ); - const restKeysCountRef = React.useRef(null); + const prevKeyEntitiesRef = React.useRef(keyEntities); const onAllRemovedRef = React.useRef(onAllRemoved); onAllRemovedRef.current = onAllRemoved; - useIsomorphicLayoutEffect(() => { - setKeyEntities(prevKeyEntities => { - const nextKeyEntities = getDerivedKeyEntities(keys, prevKeyEntities); - - return isSameKeyEntities(prevKeyEntities, nextKeyEntities) - ? prevKeyEntities - : nextKeyEntities; - }); - }); + let mergedKeyEntities = keyEntities; + const nextKeyEntities = getDerivedKeyEntities(keys, keyEntities); + if (!isSameKeyEntities(keyEntities, nextKeyEntities)) { + setKeyEntities(nextKeyEntities); + mergedKeyEntities = nextKeyEntities; + } useIsomorphicLayoutEffect(() => { - if (restKeysCountRef.current !== null) { - const restKeysCount = restKeysCountRef.current; - restKeysCountRef.current = null; - - if (restKeysCount === 0) { - onAllRemovedRef.current?.(); - } + const prevActiveCount = prevKeyEntitiesRef.current.filter( + ({ status }) => status !== STATUS_REMOVED, + ).length; + const currentActiveCount = mergedKeyEntities.filter( + ({ status }) => status !== STATUS_REMOVED, + ).length; + + if (prevActiveCount > 0 && currentActiveCount === 0) { + onAllRemovedRef.current?.(); } - }, [keyEntities]); + + prevKeyEntitiesRef.current = mergedKeyEntities; + }, [mergedKeyEntities]); // ZombieJ: Return the count of rest keys. It's safe to refactor if need more info. const removeKey = React.useCallback((removedKey: React.Key) => { setKeyEntities(prevKeyEntities => { - const nextKeyEntities = prevKeyEntities.map(entity => { + const nextRemovedKeyEntities = prevKeyEntities.map(entity => { if (entity.key !== removedKey) return entity; return { ...entity, @@ -170,13 +183,9 @@ export function genCSSMotionList( }; }); - restKeysCountRef.current = nextKeyEntities.filter( - ({ status }) => status !== STATUS_REMOVED, - ).length; - - return isSameKeyEntities(prevKeyEntities, nextKeyEntities) + return isSameKeyEntities(prevKeyEntities, nextRemovedKeyEntities) ? prevKeyEntities - : nextKeyEntities; + : nextRemovedKeyEntities; }); }, []); @@ -191,7 +200,7 @@ export function genCSSMotionList( return ( - {keyEntities.map(({ status, ...eventProps }, index) => { + {mergedKeyEntities.map(({ status, ...eventProps }, index) => { const visible = status === STATUS_ADD || status === STATUS_KEEP; return ( { '1', ); }); + + it('should update event props when key object shape changes', () => { + const CSSMotionList = genCSSMotionList(false); + const hasOwn = Object.prototype.hasOwnProperty; + + const Demo = ({ keys }: { keys: CSSMotionListProps['keys'] }) => ( + + {props => ( +
+ )} + + ); + + const { container, rerender } = render( + , + ); + const getBox = () => container.querySelector('.motion-box'); + + expect(getBox()).toHaveAttribute('data-has-foo', 'true'); + expect(getBox()).toHaveAttribute('data-has-bar', 'false'); + + rerender(); + + expect(getBox()).toHaveAttribute('data-has-foo', 'false'); + expect(getBox()).toHaveAttribute('data-has-bar', 'true'); + }); }); From 5f6b97a1ae02a1a9388771a2f328ce0b8cf58c37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E8=89=B3=E5=85=B5?= Date: Sun, 28 Jun 2026 14:43:42 +0800 Subject: [PATCH 3/3] test: cover CSSMotionList behavior --- src/CSSMotionList.tsx | 6 ++- tests/CSSMotionList.spec.tsx | 71 ++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/CSSMotionList.tsx b/src/CSSMotionList.tsx index 489d962..49df3b5 100644 --- a/src/CSSMotionList.tsx +++ b/src/CSSMotionList.tsx @@ -75,7 +75,7 @@ function getDerivedKeyEntities( const parsedKeyObjects = parseKeys(keys); const mixedKeyEntities = diffKeys(keyEntities, parsedKeyObjects); - return mixedKeyEntities.filter(entity => { + const nextKeyEntities = mixedKeyEntities.filter(entity => { const prevEntity = keyEntities.find(({ key }) => entity.key === key); // Remove if already mark as removed @@ -88,6 +88,10 @@ function getDerivedKeyEntities( } return true; }); + + return isSameKeyEntities(keyEntities, nextKeyEntities) + ? keyEntities + : nextKeyEntities; } function shallowEqualKeyObject(origin: KeyObject, target: KeyObject) { diff --git a/tests/CSSMotionList.spec.tsx b/tests/CSSMotionList.spec.tsx index 995b187..5392b12 100644 --- a/tests/CSSMotionList.spec.tsx +++ b/tests/CSSMotionList.spec.tsx @@ -174,6 +174,77 @@ describe('CSSMotionList', () => { ); }); + it('should support children ref when component is false', () => { + const CSSMotionList = genCSSMotionList(false); + + const { container } = render( + + {({ key }, ref) => ( +
} className="motion-box"> + {key} +
+ )} +
, + ); + + expect(container.children).toHaveLength(1); + expect(container.querySelector('.motion-box')).toHaveTextContent('a'); + }); + + it('should not require onAllRemoved', () => { + const CSSMotionList = genCSSMotionList(false); + + const Demo = ({ keys }: { keys: string[] }) => ( + + {({ key }) =>
{key}
} +
+ ); + + const { rerender } = render(); + + act(() => { + jest.runAllTimers(); + }); + + expect(() => { + rerender(); + act(() => { + jest.runAllTimers(); + }); + }).not.toThrow(); + }); + + it('should skip state update when removed key is already removed', () => { + const CSSMotion = React.forwardRef( + ({ children, eventProps, onVisibleChanged }, _ref) => ( + + ), + ); + const CSSMotionList = genCSSMotionList(false, CSSMotion); + + const Demo = ({ keys }: { keys: string[] }) => ( + + {({ key }) => {key}} + + ); + + const { container, rerender } = render(); + rerender(); + const trigger = container.querySelector('.trigger'); + fireEvent.click(trigger); + + expect(container.querySelector('.motion-box')).toBeFalsy(); + }); + it('should update event props when key object shape changes', () => { const CSSMotionList = genCSSMotionList(false); const hasOwn = Object.prototype.hasOwnProperty;