From c783bce8c1d29480ad0c80e9f2ab98d0f5e41990 Mon Sep 17 00:00:00 2001 From: hanyuxinting Date: Wed, 3 Jun 2026 23:54:06 +0800 Subject: [PATCH 1/2] fix(swipe): resolve infinite re-render loop causing page freeze - Fix useCallback refs with unstable deps (props.leftAction/rightAction) that triggered setActionWidth on every render, causing an infinite loop - Add width equality check before updating state to break the cycle - Fix `opened` ref accessed without `.current` in both H5 and Taro versions Closes #3433 Co-Authored-By: Claude Opus 4.6 --- src/packages/swipe/swipe.taro.tsx | 5 +++-- src/packages/swipe/swipe.tsx | 35 +++++++++++++++++-------------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/packages/swipe/swipe.taro.tsx b/src/packages/swipe/swipe.taro.tsx index 8ff06cabc1..e2ad4fd88b 100644 --- a/src/packages/swipe/swipe.taro.tsx +++ b/src/packages/swipe/swipe.taro.tsx @@ -142,7 +142,8 @@ export const Swipe = forwardRef< if (touch.isHorizontal()) { lockClick.current = true const newState = { ...state, dragging: true } - const isEdge = !opened || touch.deltaX.current * startOffset.current < 0 + const isEdge = + !opened.current || touch.deltaX.current * startOffset.current < 0 if (isEdge) { preventDefault(event, true) } @@ -170,7 +171,7 @@ export const Swipe = forwardRef< const toggle = (side: PositionX) => { const offset = Math.abs(state.offset) const base = 0.3 - const baseNum = opened ? 1 - base : base + const baseNum = opened.current ? 1 - base : base const width = side === 'left' ? actionWidth.current.left : actionWidth.current.right if (width && offset > Number(width) * baseNum) { diff --git a/src/packages/swipe/swipe.tsx b/src/packages/swipe/swipe.tsx index df5ee1f38f..991a664ed7 100644 --- a/src/packages/swipe/swipe.tsx +++ b/src/packages/swipe/swipe.tsx @@ -52,6 +52,8 @@ export const Swipe = forwardRef< left: 0, right: 0, }) + const actionWidthRef = useRef(actionWidth) + actionWidthRef.current = actionWidth const wrapperStyle = { transform: `translate3d(${state.offset}px, 0, 0)`, transitionDuration: state.dragging ? '0s' : '.6s', @@ -78,7 +80,8 @@ export const Swipe = forwardRef< if (touch.isHorizontal()) { lockClick.current = true const newState = { ...state, dragging: true } - const isEdge = !opened || touch.deltaX.current * startOffset.current < 0 + const isEdge = + !opened.current || touch.deltaX.current * startOffset.current < 0 if (isEdge) { preventDefault(event, true) } @@ -108,7 +111,7 @@ export const Swipe = forwardRef< const toggle = (side: PositionX) => { const offset = Math.abs(state.offset) const base = 0.3 - const baseNum = opened ? 1 - base : base + const baseNum = opened.current ? 1 - base : base const width = side === 'left' ? leftWidth : rightWidth if (width && offset > Number(width) * baseNum) { @@ -151,22 +154,22 @@ export const Swipe = forwardRef< } return 0 } - const leftRef = useCallback( - (node: Element | null) => { - if (node !== null) { - setActionWidth((v) => ({ ...v, left: getNodeWidth(node) })) + const leftRef = useCallback((node: Element | null) => { + if (node !== null) { + const width = getNodeWidth(node) + if (width !== actionWidthRef.current.left) { + setActionWidth((v) => ({ ...v, left: width })) } - }, - [props.leftAction] - ) - const rightRef = useCallback( - (node: Element | null) => { - if (node !== null) { - setActionWidth((v) => ({ ...v, right: getNodeWidth(node) })) + } + }, []) + const rightRef = useCallback((node: Element | null) => { + if (node !== null) { + const width = getNodeWidth(node) + if (width !== actionWidthRef.current.right) { + setActionWidth((v) => ({ ...v, right: width })) } - }, - [props.rightAction] - ) + } + }, []) const renderActionContent = (side: PositionX, measuredRef: any) => { if (props[`${side}Action`]) { return ( From a938cf8cb957e0eddf81295bf903dfd4ab9b873a Mon Sep 17 00:00:00 2001 From: hanyuxinting Date: Thu, 4 Jun 2026 12:47:35 +0800 Subject: [PATCH 2/2] test(swipe): add touch interaction tests for coverage Add tests for swipe open/close via touch events and disabled state to cover the useCallback ref, onTouchMove, and toggle logic paths. Co-Authored-By: Claude Opus 4.6 --- src/packages/swipe/__tests__/swipe.spec.tsx | 196 +++++++++++++++++++- 1 file changed, 195 insertions(+), 1 deletion(-) diff --git a/src/packages/swipe/__tests__/swipe.spec.tsx b/src/packages/swipe/__tests__/swipe.spec.tsx index 83901c44d3..649c303675 100644 --- a/src/packages/swipe/__tests__/swipe.spec.tsx +++ b/src/packages/swipe/__tests__/swipe.spec.tsx @@ -1,10 +1,11 @@ import * as React from 'react' -import { render } from '@testing-library/react' +import { render, fireEvent, act } from '@testing-library/react' import '@testing-library/jest-dom' import Swipe from '../index' import Cell from '../../cell' import Button from '../../button' import InputNumber from '../../inputnumber' +import * as getRectModule from '@/utils/get-rect' test('base swipe', () => { const { container } = render( @@ -96,3 +97,196 @@ test('base swipe content', async () => { container.querySelector('.nut-swipe .nut-swipe-right .nut-button-wrap') ).toHaveTextContent('购物车') }) + +test('swipe right to open via touch', () => { + const spy = jest.spyOn(getRectModule, 'getRect').mockReturnValue({ + width: 80, + height: 40, + top: 0, + left: 0, + right: 80, + bottom: 40, + }) + + const onOpen = jest.fn() + const { container } = render( + + 删除 + + } + onOpen={onOpen} + > + + + ) + + const wrapper = container.querySelector('.nut-swipe') as HTMLElement + + act(() => { + fireEvent.touchStart(wrapper, { + touches: [{ clientX: 200, clientY: 0, pageX: 200, pageY: 0 }], + }) + }) + act(() => { + fireEvent.touchMove(wrapper, { + touches: [{ clientX: 100, clientY: 0, pageX: 100, pageY: 0 }], + }) + }) + act(() => { + fireEvent.touchEnd(wrapper, { + changedTouches: [{ clientX: 100, clientY: 0 }], + }) + }) + + expect(onOpen).toHaveBeenCalled() + spy.mockRestore() +}) + +test('swipe left to open via touch', () => { + const spy = jest.spyOn(getRectModule, 'getRect').mockReturnValue({ + width: 80, + height: 40, + top: 0, + left: 0, + right: 80, + bottom: 40, + }) + + const onOpen = jest.fn() + const { container } = render( + + 选择 + + } + onOpen={onOpen} + > + + + ) + + const wrapper = container.querySelector('.nut-swipe') as HTMLElement + + act(() => { + fireEvent.touchStart(wrapper, { + touches: [{ clientX: 100, clientY: 0, pageX: 100, pageY: 0 }], + }) + }) + act(() => { + fireEvent.touchMove(wrapper, { + touches: [{ clientX: 200, clientY: 0, pageX: 200, pageY: 0 }], + }) + }) + act(() => { + fireEvent.touchEnd(wrapper, { + changedTouches: [{ clientX: 200, clientY: 0 }], + }) + }) + + expect(onOpen).toHaveBeenCalled() + spy.mockRestore() +}) + +test('swipe close after opened', () => { + const spy = jest.spyOn(getRectModule, 'getRect').mockReturnValue({ + width: 80, + height: 40, + top: 0, + left: 0, + right: 80, + bottom: 40, + }) + + const onClose = jest.fn() + const { container } = render( + + 删除 + + } + onClose={onClose} + > + + + ) + + const wrapper = container.querySelector('.nut-swipe') as HTMLElement + + // Open first + act(() => { + fireEvent.touchStart(wrapper, { + touches: [{ clientX: 200, clientY: 0, pageX: 200, pageY: 0 }], + }) + }) + act(() => { + fireEvent.touchMove(wrapper, { + touches: [{ clientX: 100, clientY: 0, pageX: 100, pageY: 0 }], + }) + }) + act(() => { + fireEvent.touchEnd(wrapper, { + changedTouches: [{ clientX: 100, clientY: 0 }], + }) + }) + + // Close by swiping back + act(() => { + fireEvent.touchStart(wrapper, { + touches: [{ clientX: 100, clientY: 0, pageX: 100, pageY: 0 }], + }) + }) + act(() => { + fireEvent.touchMove(wrapper, { + touches: [{ clientX: 200, clientY: 0, pageX: 200, pageY: 0 }], + }) + }) + act(() => { + fireEvent.touchEnd(wrapper, { + changedTouches: [{ clientX: 200, clientY: 0 }], + }) + }) + + expect(onClose).toHaveBeenCalled() + spy.mockRestore() +}) + +test('disabled swipe should not respond to touch', () => { + const onOpen = jest.fn() + const { container } = render( + + 删除 + + } + onOpen={onOpen} + > + + + ) + + const wrapper = container.querySelector('.nut-swipe') as HTMLElement + + act(() => { + fireEvent.touchStart(wrapper, { + touches: [{ clientX: 200, clientY: 0, pageX: 200, pageY: 0 }], + }) + }) + act(() => { + fireEvent.touchMove(wrapper, { + touches: [{ clientX: 100, clientY: 0, pageX: 100, pageY: 0 }], + }) + }) + act(() => { + fireEvent.touchEnd(wrapper, { + changedTouches: [{ clientX: 100, clientY: 0 }], + }) + }) + + expect(onOpen).not.toHaveBeenCalled() +})