diff --git a/src/components/center-popup/center-popup.tsx b/src/components/center-popup/center-popup.tsx index 29f26dd9fe..00714fe0e6 100644 --- a/src/components/center-popup/center-popup.tsx +++ b/src/components/center-popup/center-popup.tsx @@ -8,6 +8,7 @@ import { renderToContainer } from '../../utils/render-to-container' import { ShouldRender } from '../../utils/should-render' import { useInnerVisible } from '../../utils/use-inner-visible' import { useLockScroll } from '../../utils/use-lock-scroll' +import { useSpringResumeOnVisible } from '../popup/use-spring-resume-on-visible' import { mergeProps } from '../../utils/with-default-props' import { withStopPropagation } from '../../utils/with-stop-propagation' import { useConfig } from '../config-provider' @@ -42,6 +43,17 @@ export const CenterPopup: FC = props => { const mergedProps = mergeProps(defaultProps, componentConfig, props) const unmountedRef = useUnmountedRef() + const [active, setActive] = useState(mergedProps.visible) + const activeRef = useRef(active) + activeRef.current = active + const { shouldCallAfterClose } = useSpringResumeOnVisible({ + visible: mergedProps.visible, + activeRef, + setActive, + afterClose: mergedProps.afterClose, + unmountedRef, + }) + const style = useSpring({ scale: mergedProps.visible ? 1 : 0.8, opacity: mergedProps.visible ? 1 : 0, @@ -53,16 +65,16 @@ export const CenterPopup: FC = props => { }, onRest: () => { if (unmountedRef.current) return - setActive(mergedProps.visible) if (mergedProps.visible) { + setActive(true) mergedProps.afterShow?.() - } else { + } else if (shouldCallAfterClose()) { + setActive(false) mergedProps.afterClose?.() } }, }) - const [active, setActive] = useState(mergedProps.visible) useIsomorphicLayoutEffect(() => { if (mergedProps.visible) { setActive(true) diff --git a/src/components/mask/mask.tsx b/src/components/mask/mask.tsx index 99d0aadaf1..9bd55dee96 100644 --- a/src/components/mask/mask.tsx +++ b/src/components/mask/mask.tsx @@ -15,6 +15,7 @@ import { PropagationEvent, withStopPropagation, } from '../../utils/with-stop-propagation' +import { useSpringResumeOnVisible } from '../popup/use-spring-resume-on-visible' const classPrefix = `adm-mask` @@ -69,8 +70,18 @@ export const Mask: FC = p => { }, [props.color, props.opacity]) const [active, setActive] = useState(props.visible) + const activeRef = useRef(active) + activeRef.current = active const unmountedRef = useUnmountedRef() + const { shouldCallAfterClose } = useSpringResumeOnVisible({ + visible: props.visible, + activeRef, + setActive, + afterClose: props.afterClose, + unmountedRef, + }) + const { opacity } = useSpring({ opacity: props.visible ? 1 : 0, config: { @@ -85,10 +96,11 @@ export const Mask: FC = p => { }, onRest: () => { if (unmountedRef.current) return - setActive(props.visible) if (props.visible) { + setActive(true) props.afterShow?.() - } else { + } else if (shouldCallAfterClose()) { + setActive(false) props.afterClose?.() } }, diff --git a/src/components/popup/popup.tsx b/src/components/popup/popup.tsx index c736cb0b08..8e84bedd06 100644 --- a/src/components/popup/popup.tsx +++ b/src/components/popup/popup.tsx @@ -14,6 +14,7 @@ import { defaultPopupBaseProps, PopupBaseProps } from './popup-base-props' import { useInnerVisible } from '../../utils/use-inner-visible' import { useConfig } from '../config-provider' import { useDrag } from '@use-gesture/react' +import { useSpringResumeOnVisible } from './use-spring-resume-on-visible' const classPrefix = `adm-popup` @@ -41,16 +42,26 @@ export const Popup: FC = p => { ) const [active, setActive] = useState(props.visible) + const activeRef = useRef(active) + activeRef.current = active const ref = useRef(null) useLockScroll(ref, props.disableBodyScroll && active ? 'strict' : false) + const unmountedRef = useUnmountedRef() + const { shouldCallAfterClose } = useSpringResumeOnVisible({ + visible: props.visible, + activeRef, + setActive, + afterClose: props.afterClose, + unmountedRef, + }) + useIsomorphicLayoutEffect(() => { if (props.visible) { setActive(true) } }, [props.visible]) - const unmountedRef = useUnmountedRef() const { percent } = useSpring({ percent: props.visible ? 0 : 100, config: { @@ -61,10 +72,11 @@ export const Popup: FC = p => { }, onRest: () => { if (unmountedRef.current) return - setActive(props.visible) if (props.visible) { + setActive(true) props.afterShow?.() - } else { + } else if (shouldCallAfterClose()) { + setActive(false) props.afterClose?.() } }, diff --git a/src/components/popup/tests/popup.test.tsx b/src/components/popup/tests/popup.test.tsx index 33b951eac2..3d2e1f7fdb 100644 --- a/src/components/popup/tests/popup.test.tsx +++ b/src/components/popup/tests/popup.test.tsx @@ -1,9 +1,33 @@ import * as React from 'react' -import { mockDrag, render, screen, testA11y } from 'testing' +import { act, mockDrag, render, screen, testA11y } from 'testing' import Popup from '..' import ConfigProvider from '../../config-provider' +function setVisibilityState(state: 'visible' | 'hidden') { + Object.defineProperty(document, 'visibilityState', { + value: state, + writable: true, + }) +} + describe('Popup', () => { + const originalVisibilityState = Object.getOwnPropertyDescriptor( + document, + 'visibilityState' + ) + + afterEach(() => { + if (originalVisibilityState) { + Object.defineProperty( + document, + 'visibilityState', + originalVisibilityState + ) + } else { + delete (document as any).visibilityState + } + }) + test('a11y', async () => { await testA11y(foobar) }) @@ -91,4 +115,51 @@ describe('Popup', () => { expect(screen.getByText('bamboo')).toBeVisible() }) }) + + describe('visibilitychange', () => { + it('should call afterClose when page becomes visible after close while hidden', () => { + const afterClose = jest.fn() + const { rerender } = render( + + foobar + + ) + + // The popup is visible, now close it + rerender( + + foobar + + ) + + setVisibilityState('hidden') + act(() => { + document.dispatchEvent(new Event('visibilitychange')) + }) + + // Simulate page becoming visible (e.g. user switches back to tab) + setVisibilityState('visible') + act(() => { + document.dispatchEvent(new Event('visibilitychange')) + }) + + expect(afterClose).toHaveBeenCalledTimes(1) + }) + + it('should not call afterClose when popup is still visible', () => { + const afterClose = jest.fn() + render( + + foobar + + ) + + setVisibilityState('visible') + act(() => { + document.dispatchEvent(new Event('visibilitychange')) + }) + + expect(afterClose).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/components/popup/tests/use-spring-resume-on-visible.test.ts b/src/components/popup/tests/use-spring-resume-on-visible.test.ts new file mode 100644 index 0000000000..4ae96158e4 --- /dev/null +++ b/src/components/popup/tests/use-spring-resume-on-visible.test.ts @@ -0,0 +1,187 @@ +import { renderHook, act } from '@testing-library/react' +import { useSpringResumeOnVisible } from '../use-spring-resume-on-visible' + +function setVisibilityState(state: 'visible' | 'hidden') { + Object.defineProperty(document, 'visibilityState', { + value: state, + writable: true, + }) +} + +describe('useSpringResumeOnVisible', () => { + const mockSetActive = jest.fn() + const mockAfterClose = jest.fn() + const mockUnmountedRef = { current: false } + const mockActiveRef = { current: true } + const originalVisibilityState = Object.getOwnPropertyDescriptor( + document, + 'visibilityState' + ) + + beforeEach(() => { + jest.clearAllMocks() + mockUnmountedRef.current = false + mockActiveRef.current = true + }) + + afterEach(() => { + if (originalVisibilityState) { + Object.defineProperty( + document, + 'visibilityState', + originalVisibilityState + ) + } + }) + + it('should call setActive(false) and afterClose when page becomes visible after close while hidden', () => { + renderHook(() => + useSpringResumeOnVisible({ + visible: false, + activeRef: mockActiveRef, + setActive: mockSetActive, + afterClose: mockAfterClose, + unmountedRef: mockUnmountedRef, + }) + ) + + setVisibilityState('visible') + act(() => { + document.dispatchEvent(new Event('visibilitychange')) + }) + + expect(mockSetActive).toHaveBeenCalledWith(false) + expect(mockAfterClose).toHaveBeenCalledTimes(1) + }) + + it('should not call afterClose when page is already visible and active matches visible', () => { + mockActiveRef.current = false + + renderHook(() => + useSpringResumeOnVisible({ + visible: false, + activeRef: mockActiveRef, + setActive: mockSetActive, + afterClose: mockAfterClose, + unmountedRef: mockUnmountedRef, + }) + ) + + setVisibilityState('visible') + act(() => { + document.dispatchEvent(new Event('visibilitychange')) + }) + + expect(mockSetActive).not.toHaveBeenCalled() + expect(mockAfterClose).not.toHaveBeenCalled() + }) + + it('should not call afterClose when component is unmounted', () => { + mockUnmountedRef.current = true + + renderHook(() => + useSpringResumeOnVisible({ + visible: false, + activeRef: mockActiveRef, + setActive: mockSetActive, + afterClose: mockAfterClose, + unmountedRef: mockUnmountedRef, + }) + ) + + setVisibilityState('visible') + act(() => { + document.dispatchEvent(new Event('visibilitychange')) + }) + + expect(mockSetActive).not.toHaveBeenCalled() + expect(mockAfterClose).not.toHaveBeenCalled() + }) + + it('shouldCallAfterClose should prevent double-calling afterClose', () => { + const { result } = renderHook(() => + useSpringResumeOnVisible({ + visible: false, + activeRef: mockActiveRef, + setActive: mockSetActive, + afterClose: mockAfterClose, + unmountedRef: mockUnmountedRef, + }) + ) + + // Simulate visibilitychange handler calling afterClose first + setVisibilityState('visible') + act(() => { + document.dispatchEvent(new Event('visibilitychange')) + }) + + expect(mockAfterClose).toHaveBeenCalledTimes(1) + + // Now onRest fires later - shouldCallAfterClose should return false + expect(result.current.shouldCallAfterClose()).toBe(false) + }) + + it('shouldCallAfterClose should return true when afterClose has not been called', () => { + mockActiveRef.current = false + + const { result } = renderHook(() => + useSpringResumeOnVisible({ + visible: false, + activeRef: mockActiveRef, + setActive: mockSetActive, + afterClose: mockAfterClose, + unmountedRef: mockUnmountedRef, + }) + ) + + // onRest fires normally (no visibilitychange handler intervention) + expect(result.current.shouldCallAfterClose()).toBe(true) + // Second call should return false + expect(result.current.shouldCallAfterClose()).toBe(false) + }) + + it('should reset closedRef when visible becomes true', () => { + const { result, rerender } = renderHook( + ({ visible }: { visible: boolean }) => + useSpringResumeOnVisible({ + visible, + activeRef: mockActiveRef, + setActive: mockSetActive, + afterClose: mockAfterClose, + unmountedRef: mockUnmountedRef, + }), + { initialProps: { visible: false } } + ) + + // Simulate afterClose being called (via onRest or visibilitychange) + // shouldCallAfterClose returns true on first call, then false on subsequent calls + expect(result.current.shouldCallAfterClose()).toBe(true) + expect(result.current.shouldCallAfterClose()).toBe(false) + + // Now visible becomes true (new show cycle) - closedRef should be reset + rerender({ visible: true }) + + // shouldCallAfterClose should be reset and return true again + expect(result.current.shouldCallAfterClose()).toBe(true) + }) + + it('should not trigger on visibilitychange when document is hidden', () => { + renderHook(() => + useSpringResumeOnVisible({ + visible: false, + activeRef: mockActiveRef, + setActive: mockSetActive, + afterClose: mockAfterClose, + unmountedRef: mockUnmountedRef, + }) + ) + + setVisibilityState('hidden') + act(() => { + document.dispatchEvent(new Event('visibilitychange')) + }) + + expect(mockSetActive).not.toHaveBeenCalled() + expect(mockAfterClose).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/popup/use-spring-resume-on-visible.ts b/src/components/popup/use-spring-resume-on-visible.ts new file mode 100644 index 0000000000..1a742e707e --- /dev/null +++ b/src/components/popup/use-spring-resume-on-visible.ts @@ -0,0 +1,52 @@ +import { useIsomorphicLayoutEffect } from 'ahooks' +import type { RefObject } from 'react' +import { useCallback, useEffect, useRef } from 'react' + +export function useSpringResumeOnVisible({ + visible, + activeRef, + setActive, + afterClose, + unmountedRef, +}: { + visible: boolean + activeRef: RefObject + setActive: (value: boolean) => void + afterClose?: () => void + unmountedRef: RefObject +}) { + const closedRef = useRef(false) + const afterCloseRef = useRef(afterClose) + afterCloseRef.current = afterClose + + useIsomorphicLayoutEffect(() => { + if (visible) { + closedRef.current = false + } + }, [visible]) + + const visibleRef = useRef(visible) + visibleRef.current = visible + + useEffect(() => { + const handler = () => { + if (document.visibilityState !== 'visible') return + if (unmountedRef.current) return + if (!visibleRef.current && activeRef.current && !closedRef.current) { + closedRef.current = true + setActive(false) + afterCloseRef.current?.() + } + } + document.addEventListener('visibilitychange', handler) + return () => document.removeEventListener('visibilitychange', handler) + }, [activeRef, setActive, unmountedRef]) + + const shouldCallAfterClose = useCallback((): boolean => { + if (closedRef.current) return false + closedRef.current = true + return true + }, []) + + return { shouldCallAfterClose } +}