From dc717730f3b267f3cdcda7c84a3770f1110bb09b Mon Sep 17 00:00:00 2001 From: "fengzhihao.infeng" Date: Fri, 18 Sep 2026 16:46:52 +0800 Subject: [PATCH] fix: clean up closed viewers when transitions are interrupted --- src/ViewerCore.tsx | 29 ++++++++++++--- src/__tests__/viewer.test.tsx | 66 +++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/src/ViewerCore.tsx b/src/ViewerCore.tsx index c055ec5d..edcb2804 100644 --- a/src/ViewerCore.tsx +++ b/src/ViewerCore.tsx @@ -10,8 +10,6 @@ import classnames from 'classnames'; function noop() { } -// const transitionDuration = 300; - const ACTION_TYPES = { setVisible: 'setVisible', setActiveIndex: 'setActiveIndex', @@ -173,6 +171,28 @@ export default (props: ViewerProps) => { } }, [visible]); + React.useEffect(() => { + if (visible || !state.visible) { + return; + } + // A disabled or interrupted transition may never emit transitionend. + const style = window.getComputedStyle(viewerCore.current); + const toMilliseconds = (value: string) => (parseFloat(value) || 0) * (value.trim().endsWith('ms') ? 1 : 1000); + const durations = style.getPropertyValue('transition-duration').split(',').map(toMilliseconds); + const delays = style.getPropertyValue('transition-delay').split(',').map(toMilliseconds); + const properties = style.getPropertyValue('transition-property').split(','); + const timeout = properties.reduce((longest, property, index) => { + if (property.trim() !== 'opacity' && property.trim() !== 'all') { + return longest; + } + return Math.max(longest, durations[index % durations.length] + delays[index % delays.length]); + }, 0); + const timer = window.setTimeout(() => { + dispatch(createAction(ACTION_TYPES.setVisible, { visible: false })); + }, timeout + 50); + return () => window.clearTimeout(timer); + }, [visible, state.visible]); + React.useEffect(() => { bindEvent(); @@ -642,6 +662,7 @@ export default (props: ViewerProps) => { let viewerStryle: React.CSSProperties = { opacity: (visible && state.visible) ? 1 : 0, display: (visible || state.visible) ? 'block' : 'none', + pointerEvents: visible ? undefined : 'none', }; let activeImg: ImageDecorator = { @@ -657,8 +678,8 @@ export default (props: ViewerProps) => {
{ - if (!visible) { + onTransitionEnd={(event) => { + if (!visible && event.target === event.currentTarget && event.propertyName === 'opacity') { dispatch(createAction(ACTION_TYPES.setVisible, { visible: false, })); diff --git a/src/__tests__/viewer.test.tsx b/src/__tests__/viewer.test.tsx index cfbb76f6..55ee19a7 100644 --- a/src/__tests__/viewer.test.tsx +++ b/src/__tests__/viewer.test.tsx @@ -4,6 +4,7 @@ import ViewerProps from '../ViewerProps'; import { configure, mount } from 'enzyme'; import * as Adapter from 'enzyme-adapter-react-16'; import * as React from 'react'; +import { act } from 'react-dom/test-utils'; const img2 = require('../../demo/images/landscape2.jpg'); const img = require('../../demo/images/landscape.jpg'); @@ -241,6 +242,71 @@ describe('Viewer', () => { expect($$('.react-viewer')[0].style.display).toBe('none'); }); + it('hides the closed viewer when no transitionend event arrives', () => { + viewerHelper.new(); + viewerHelper.open(); + wrapper.find('.react-viewer-close').simulate('click'); + viewerHelper.skipAnimation(); + expect($$('.react-viewer')[0].style.display).toBe('none'); + }); + + it('does not let an old close timer hide a reopened viewer', () => { + viewerHelper.new(); + viewerHelper.open(); + wrapper.find('.react-viewer-close').simulate('click'); + wrapper.find('#viewer-tester-open-btn').simulate('click'); + viewerHelper.skipAnimation(); + expect($$('.react-viewer')[0].style.display).toBe('block'); + expect($$('.react-viewer')[0].style.opacity).toBe('1'); + }); + + it('waits for the configured opacity transition before fallback cleanup', () => { + viewerHelper.new(); + viewerHelper.open(); + const viewer = $$('.react-viewer')[0] as HTMLElement; + // This legacy jsdom does not implement CSS transition properties. + const computedStyle = window.getComputedStyle; + const styleSpy = jest.spyOn(window, 'getComputedStyle').mockImplementation((element) => { + if (element === viewer) { + return { getPropertyValue: (property) => ({ + 'transition-property': 'transform, opacity', + 'transition-duration': '100ms, 0.6s', + 'transition-delay': '0.1s', + }[property] || '') } as CSSStyleDeclaration; + } + return computedStyle(element); + }); + try { + wrapper.find('.react-viewer-close').simulate('click'); + } finally { + styleSpy.mockRestore(); + } + expect(viewer.style.pointerEvents).toBe('none'); + act(() => { jest.advanceTimersByTime(700); }); + expect(viewer.style.display).toBe('block'); + act(() => { jest.advanceTimersByTime(50); }); + expect(viewer.style.display).toBe('none'); + }); + + it('only finishes closing for the root opacity transition', () => { + viewerHelper.new(); + viewerHelper.open(); + const viewer = $$('.react-viewer')[0] as HTMLElement; + wrapper.find('.react-viewer-close').simulate('click'); + wrapper.find('.react-viewer').simulate('transitionend', { + target: $$('.react-viewer-footer')[0], currentTarget: viewer, propertyName: 'opacity', + }); + expect(viewer.style.display).toBe('block'); + wrapper.find('.react-viewer').simulate('transitionend', { + target: viewer, currentTarget: viewer, propertyName: 'transform', + }); + expect(viewer.style.display).toBe('block'); + wrapper.find('.react-viewer').simulate('transitionend', { + target: viewer, currentTarget: viewer, propertyName: 'opacity', + }); + expect(viewer.style.display).toBe('none'); + }); + it('exposes viewer controls to keyboard users', () => { viewerHelper.new(); viewerHelper.open();