diff --git a/src/Table.tsx b/src/Table.tsx index b61de752..85313634 100644 --- a/src/Table.tsx +++ b/src/Table.tsx @@ -415,6 +415,15 @@ function Table( const [setScrollTarget, getScrollTarget] = useTimeoutLock(null); + // ======================= Unmount Ref ======================= + const unmountedRef = React.useRef(false); + React.useEffect(() => { + unmountedRef.current = false; + return () => { + unmountedRef.current = true; + }; + }, []); + function forceScroll(scrollLeft: number, target: HTMLDivElement | ((left: number) => void)) { if (!target) { return; @@ -428,7 +437,9 @@ function Table( // ref: https://github.com/ant-design/ant-design/issues/37179 if (target.scrollLeft !== scrollLeft) { setTimeout(() => { - target.scrollLeft = scrollLeft; + if (!unmountedRef.current) { + target.scrollLeft = scrollLeft; + } }, 0); } } @@ -904,6 +915,7 @@ function Table( return {fullTable}; } +// ========== 类型和导出补全 ========== export type ForwardGenericTable = (( props: TableProps & React.RefAttributes, ) => React.ReactElement) & { displayName?: string }; diff --git a/src/stickyScrollBar.tsx b/src/stickyScrollBar.tsx index cec2666c..ac38f943 100644 --- a/src/stickyScrollBar.tsx +++ b/src/stickyScrollBar.tsx @@ -43,6 +43,8 @@ const StickyScrollBar: React.ForwardRefRenderFunction(null); + // 记录上一次的 scrollParents + const lastScrollParentsRef = React.useRef<(HTMLElement | SVGElement)[]>([]); React.useEffect( () => () => { @@ -149,7 +151,14 @@ const StickyScrollBar: React.ForwardRefRenderFunction { - if (!scrollBodyRef.current) return; + if (!scrollBodyRef.current) { + return; + } + + // 清理上一次 scrollParents 的事件监听 + lastScrollParentsRef.current.forEach(p => + p.removeEventListener('scroll', checkScrollBarVisible), + ); const scrollParents: (HTMLElement | SVGElement)[] = []; let parent = getDOM(scrollBodyRef.current); @@ -157,6 +166,7 @@ const StickyScrollBar: React.ForwardRefRenderFunction p.addEventListener('scroll', checkScrollBarVisible, false)); window.addEventListener('resize', checkScrollBarVisible, false); diff --git a/tests/Table.spec.jsx b/tests/Table.spec.jsx index 872cd89c..bc23df20 100644 --- a/tests/Table.spec.jsx +++ b/tests/Table.spec.jsx @@ -6,7 +6,7 @@ import Table, { INTERNAL_COL_DEFINE } from '../src'; import BodyRow from '../src/Body/BodyRow'; import Cell from '../src/Cell'; import { INTERNAL_HOOKS } from '../src/constant'; -import { fireEvent, render } from '@testing-library/react'; +import { fireEvent, render, cleanup } from '@testing-library/react'; describe('Table.Basic', () => { const data = [ @@ -1372,3 +1372,73 @@ describe('Table.Basic', () => { expect(onScroll).toHaveBeenCalled(); }); }); + +describe('Table memory leak and cleanup', () => { + afterEach(() => { + cleanup(); + vi.clearAllTimers(); + }); + + it('should cleanup stickyScrollBar events on unmount', () => { + const addEventListenerSpy = vi.spyOn(window, 'addEventListener'); + const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener'); + const { unmount } = render( + , + ); + unmount(); + // 断言事件被移除 + expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function)); + expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function)); + addEventListenerSpy.mockRestore(); + removeEventListenerSpy.mockRestore(); + }); + + it('should not call setTimeout callback after unmount', () => { + vi.useFakeTimers(); + const { unmount } = render( +
, + ); + unmount(); + // 触发所有定时器 + vi.runAllTimers(); + // 没有报错即通过 + vi.useRealTimers(); + }); + + it('should not leak when mount/unmount multiple times', () => { + for (let i = 0; i < 5; i++) { + const { unmount } = render( +
, + ); + unmount(); + } + // 没有报错即通过 + }); + + it('should cleanup scrollParents events when scrollBodyRef changes', () => { + // 这里只能间接测试,mount/unmount 后事件应被清理 + const addEventListenerSpy = vi.spyOn(window, 'addEventListener'); + const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener'); + const { rerender, unmount } = render( +
, + ); + // 模拟数据变化导致 scrollBodyRef 变化 + rerender( +
, + ); + unmount(); + expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function)); + addEventListenerSpy.mockRestore(); + removeEventListenerSpy.mockRestore(); + }); +});