From 0cf3d90cca69d46266c1324edc0c0f793e844f0d Mon Sep 17 00:00:00 2001 From: huangkairan <56213366+huangkairan@users.noreply.github.com> Date: Thu, 27 Oct 2022 18:31:57 +0800 Subject: [PATCH] fix: SubMenu in React18 sync problem (#537) Co-authored-by: huangkairan --- src/Menu.tsx | 19 ++++++++++++------- tests/React18.spec.tsx | 37 ++++++++++++++++++++++++++++++++++++- tests/util.ts | 11 +++++++++++ 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/src/Menu.tsx b/src/Menu.tsx index 5b490b8e..b4c12434 100644 --- a/src/Menu.tsx +++ b/src/Menu.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { flushSync } from 'react-dom'; import type { CSSMotionProps } from 'rc-motion'; import classNames from 'classnames'; import shallowEqual from 'shallowequal'; @@ -271,13 +272,18 @@ const Menu = React.forwardRef((props, ref) => { }); const triggerOpenKeys = (keys: string[]) => { - setMergedOpenKeys(keys); + // Prevent React18 auto batch since trigger openKeys on same time + // which makes mergedOpenKeys closure problem + flushSync(() => { + setMergedOpenKeys(keys); + }); onOpenChange?.(keys); }; // >>>>> Cache & Reset open keys when inlineCollapsed changed - const [inlineCacheOpenKeys, setInlineCacheOpenKeys] = - React.useState(mergedOpenKeys); + const [inlineCacheOpenKeys, setInlineCacheOpenKeys] = React.useState( + mergedOpenKeys, + ); const isInlineMode = mergedMode === 'inline'; @@ -329,10 +335,9 @@ const Menu = React.forwardRef((props, ref) => { [registerPath, unregisterPath], ); - const pathUserContext = React.useMemo( - () => ({ isSubPathKey }), - [isSubPathKey], - ); + const pathUserContext = React.useMemo(() => ({ isSubPathKey }), [ + isSubPathKey, + ]); React.useEffect(() => { refreshOverflowKeys( diff --git a/tests/React18.spec.tsx b/tests/React18.spec.tsx index 33160caa..a263a15c 100644 --- a/tests/React18.spec.tsx +++ b/tests/React18.spec.tsx @@ -1,6 +1,7 @@ /* eslint-disable no-undef */ import React from 'react'; -import { act, render } from '@testing-library/react'; +import { act, fireEvent, render } from '@testing-library/react'; +import { sleep } from './util'; import Menu, { MenuItem, SubMenu } from '../src'; import type { MenuProps } from '../src'; @@ -55,5 +56,39 @@ describe('React18', () => { .querySelector('.rc-menu-submenu-title').textContent, ).toEqual('submenu1'); }); + + it('prevent React 18 auto batch', async () => { + const handleOpenChange = jest.fn(); + const { container } = render( + + + 1 + + + 2 + + , + ); + + // Enter + fireEvent.mouseEnter(container.querySelector('.rc-menu-submenu-title')); + runAllTimer(); + expect(container.querySelector('.rc-menu-submenu-open')).toBeTruthy(); + // Leave + fireEvent.mouseLeave(container.querySelector('.rc-menu-submenu-title')); + act(() => { + jest.runAllTimers(); + }); + expect(container.querySelector('.rc-menu-submenu-open')).toBeFalsy(); + await act(async () => { + await sleep(); + }); + // Enter + fireEvent.mouseEnter( + container.querySelectorAll('.rc-menu-submenu-title')[1], + ); + jest.runAllTimers(); + expect(container.querySelector('.rc-menu-submenu-open')).toBeTruthy(); + }); }); /* eslint-enable */ diff --git a/tests/util.ts b/tests/util.ts index 25094366..10000a08 100644 --- a/tests/util.ts +++ b/tests/util.ts @@ -1,3 +1,4 @@ +import { act } from '@testing-library/react'; export function isActive(container: HTMLElement, index: number, active = true) { const checker = expect(container.querySelectorAll('li.rc-menu-item')[index]); @@ -11,3 +12,13 @@ export function isActive(container: HTMLElement, index: number, active = true) { export function last(elements: NodeListOf) { return elements[elements.length - 1]; } + +const globalTimeout = global.setTimeout; + +export const sleep = async (timeout = 0) => { + await act(async () => { + await new Promise(resolve => { + globalTimeout(resolve, timeout); + }); + }); +};