Repository: ant-design/ant-design-mobile
Pull request #5491 (2022-07-21 08:42:20)

fix(useResizeEffect): avoid `ResizeObserver loop limit exceeded` error
https://github.com/ant-design/ant-design-mobile/issues/5459


Patch:
diff --git a/src/components/floating-bubble/floating-bubble.tsx b/src/components/floating-bubble/floating-bubble.tsx
index 88d34c26d..046988858 100644
--- a/src/components/floating-bubble/floating-bubble.tsx
+++ b/src/components/floating-bubble/floating-bubble.tsx
@@ -93,7 +93,7 @@ export const FloatingBubble: FC<FloatingBubbleProps> = p => {
       // the component won't trigger drag logic if the user just clicked on the component.
       filterTaps: true,
       // set constraints to the user gesture
-      bounds: boundaryRef as any, // Temp fix. Seems to be a bug with use-gesture.
+      bounds: boundaryRef,
       from: () => [x.get(), y.get()],
     }
   )
diff --git a/src/components/rate/rate.less b/src/components/rate/rate.less
index 85b0efb72..a7a59ced5 100644
--- a/src/components/rate/rate.less
+++ b/src/components/rate/rate.less
@@ -5,6 +5,7 @@
   --active-color: #ffd21e;
   --inactive-color: var(--adm-color-border);
   display: inline-flex;
+  touch-action: pan-y;
   &-box {
     position: relative;
   }
diff --git a/src/components/rate/rate.tsx b/src/components/rate/rate.tsx
index 345bb70e9..f044707d3 100644
--- a/src/components/rate/rate.tsx
+++ b/src/components/rate/rate.tsx
@@ -1,9 +1,10 @@
-import React, { FC } from 'react'
+import React, { FC, useRef } from 'react'
 import classNames from 'classnames'
 import { NativeProps, withNativeProps } from '../../utils/native-props'
 import { mergeProps } from '../../utils/with-default-props'
 import { usePropsValue } from '../../utils/use-props-value'
 import { Star } from './star'
+import { useDrag } from '@use-gesture/react'
 
 const classPrefix = `adm-rate`
 
@@ -30,6 +31,8 @@ const defaultProps = {
 export const Rate: FC<RateProps> = p => {
   const props = mergeProps(defaultProps, p)
   const [value, setValue] = usePropsValue(props)
+
+  const containerRef = useRef<HTMLDivElement>(null)
   const starList = Array(props.count).fill(null)
 
   function renderStar(v: number, half: boolean) {
@@ -56,12 +59,43 @@ export const Rate: FC<RateProps> = p => {
       </div>
     )
   }
+
+  const bind = useDrag(
+    state => {
+      if (props.readOnly) return
+      const {
+        xy: [clientX],
+      } = state
+
+      const container = containerRef.current
+      if (!container) return
+      const rect = container.getBoundingClientRect()
+
+      const rawValue = ((clientX - rect.left) / rect.width) * 5
+
+      const roundedValue = props.allowHalf
+        ? Math.round(rawValue * 2) / 2
+        : Math.round(rawValue)
+
+      setValue(roundedValue)
+    },
+    {
+      axis: 'x',
+      preventScroll: true,
+      pointer: {
+        touch: true,
+      },
+    }
+  )
+
   return withNativeProps(
     props,
     <div
       className={classPrefix}
       role='radiogroup'
       aria-readonly={props.readOnly}
+      ref={containerRef}
+      {...bind()}
     >
       {starList.map((_, i) => (
         <div key={i} className={classNames(`${classPrefix}-box`)}>
diff --git a/src/components/swipe-action/index.en.md b/src/components/swipe-action/index.en.md
index cb74156b7..10fb74718 100644
--- a/src/components/swipe-action/index.en.md
+++ b/src/components/swipe-action/index.en.md
@@ -22,6 +22,7 @@ Swipe to reveal hidden function menus.
 | onAction            | Triggered when operation button is clicked                                           | `(action: Action, e: React.MouseEvent) => void` | -       |
 | rightActions        | List of operation buttons on the right                                               | `Action[]`                                      | `[]`    |
 | stopPropagation     | Stop the propagation of some events.                                                 | `PropagationEvent[]`                            | `[]`    |
+| onActionsReveal     | Triggered when the operation button appears completely                               | (side: 'left' \| 'right') => void               | -       |
 
 ### Action
 
diff --git a/src/components/swipe-action/index.zh.md b/src/components/swipe-action/index.zh.md
index 01c9fe0fe..39f7c874e 100644
--- a/src/components/swipe-action/index.zh.md
+++ b/src/components/swipe-action/index.zh.md
@@ -22,6 +22,7 @@
 | onAction            | 点击操作按钮时触发           | `(action: Action, e: React.MouseEvent) => void` | -      |
 | rightActions        | 右侧的操作按钮列表           | `Action[]`                                      | `[]`   |
 | stopPropagation     | 阻止某些事件的冒泡           | `PropagationEven[]`                             | `[]`   |
+| onActionsReveal     | 按钮完全出现时触发           | (side: 'left' \| 'right') => void               | -      |
 
 ### Action
 
diff --git a/src/components/swipe-action/swipe-action.tsx b/src/components/swipe-action/swipe-action.tsx
index 29dfa5e27..58f0b06f1 100644
--- a/src/components/swipe-action/swipe-action.tsx
+++ b/src/components/swipe-action/swipe-action.tsx
@@ -47,6 +47,7 @@ export type SwipeActionProps = {
   closeOnAction?: boolean
   children: ReactNode
   stopPropagation?: PropagationEvent[]
+  onActionsReveal?: (side: 'left' | 'right') => void
 } & NativeProps<'--background'>
 
 const defaultProps = {
@@ -102,9 +103,13 @@ export const SwipeAction = forwardRef<SwipeActionRef, SwipeActionProps>(
           } else {
             position = 0
           }
+          const targetX = nearest([-rightWidth, 0, leftWidth], position)
           api.start({
-            x: nearest([-rightWidth, 0, leftWidth], position),
+            x: targetX,
           })
+          if (targetX !== 0) {
+            p.onActionsReveal?.(targetX > 0 ? 'left' : 'right')
+          }
           window.setTimeout(() => {
             draggingRef.current = false
           })
@@ -149,6 +154,7 @@ export const SwipeAction = forwardRef<SwipeActionRef, SwipeActionProps>(
             x: getLeftWidth(),
           })
         }
+        p.onActionsReveal?.(side)
       },
       close,
     }))
diff --git a/src/utils/use-resize-effect.tsx b/src/utils/use-resize-effect.tsx
index 097fad227..ac05fb8c7 100644
--- a/src/utils/use-resize-effect.tsx
+++ b/src/utils/use-resize-effect.tsx
@@ -10,11 +10,13 @@ export function useResizeEffect<T extends HTMLElement>(
     const target = targetRef.current
     if (!target) return
     if (window.ResizeObserver) {
+      let animationFrame: number
       const observer = new ResizeObserver(() => {
-        fn(target)
+        animationFrame = window.requestAnimationFrame(() => fn(target))
       })
       observer.observe(target)
       return () => {
+        window.cancelAnimationFrame(animationFrame)
         observer.disconnect()
       }
     } else {


Test patch:
diff --git a/src/components/floating-bubble/tests/floating-bubble.test.tsx b/src/components/floating-bubble/tests/floating-bubble.test.tsx
new file mode 100644
index 000000000..140da43fc
--- /dev/null
+++ b/src/components/floating-bubble/tests/floating-bubble.test.tsx
@@ -0,0 +1,149 @@
+import React from 'react'
+import { render, fireEvent, waitFor, sleep } from 'testing'
+import FloatingBubble from '..'
+
+const classPrefix = `adm-floating-bubble`
+
+function mockRect(el: Element, value: any) {
+  jest.spyOn(el, 'getBoundingClientRect').mockReturnValue(value)
+}
+
+function mockBoundaryRect() {
+  const boundary = document.querySelectorAll(`.${classPrefix}-boundary`)[0]
+  mockRect(boundary, {
+    top: 0,
+    right: 300,
+    bottom: 600,
+    left: 0,
+    x: 0,
+    y: 0,
+  })
+}
+
+function mockButtonRect(data: any = {}) {
+  const btn = document.querySelectorAll(`.${classPrefix}-button`)[0]
+  const defaultRect = {
+    x: 252,
+    left: 252,
+    right: 300,
+    y: 500,
+    top: 552,
+    bottom: 600,
+  }
+  mockRect(btn, {
+    ...defaultRect,
+    ...data,
+  })
+}
+
+describe('FloatingBubble', () => {
+  test('onClick should be called', async () => {
+    const onClick = jest.fn()
+    render(<FloatingBubble onClick={onClick} />)
+    const btn = document.querySelectorAll(`.${classPrefix}-button`)[0]
+    fireEvent.click(btn)
+    waitFor(() => expect(onClick).toBeCalled())
+  })
+
+  test('automatic magnetic attraction to the left', async () => {
+    render(<FloatingBubble axis='xy' magnetic='x' />)
+    mockBoundaryRect()
+    const btn = document.querySelectorAll(`.${classPrefix}-button`)[0]
+    mockButtonRect()
+
+    fireEvent.mouseDown(btn, {
+      buttons: 1,
+      clientX: 303,
+    })
+    fireEvent.mouseMove(btn, {
+      buttons: 1,
+      clientX: 100,
+    })
+    mockButtonRect({
+      x: 100,
+      left: 100,
+      right: 148,
+    })
+    fireEvent.mouseUp(btn)
+
+    await waitFor(() =>
+      expect(btn).toHaveStyle('transform: translate(-300px, 0px)')
+    )
+  })
+
+  test('automatic magnetic attraction to the right', async () => {
+    render(<FloatingBubble axis='xy' magnetic='x' />)
+    mockBoundaryRect()
+    const btn = document.querySelectorAll(`.${classPrefix}-button`)[0]
+    mockButtonRect()
+
+    fireEvent.mouseDown(btn, {
+      buttons: 1,
+      clientX: 255,
+    })
+    fireEvent.mouseMove(btn, {
+      buttons: 1,
+      clientX: 200,
+    })
+    mockButtonRect({
+      x: 200,
+      left: 200,
+      right: 248,
+    })
+    fireEvent.mouseUp(btn)
+
+    await sleep(0)
+    expect(btn).toHaveStyle('transform: translate(0px, 0px)')
+  })
+
+  test('automatic magnetic attraction to the top', async () => {
+    render(<FloatingBubble axis='xy' magnetic='y' />)
+    mockBoundaryRect()
+    const btn = document.querySelectorAll(`.${classPrefix}-button`)[0]
+    mockButtonRect()
+
+    fireEvent.mouseDown(btn, {
+      buttons: 1,
+      clientY: 503,
+    })
+    fireEvent.mouseMove(btn, {
+      buttons: 1,
+      clientY: 200,
+    })
+    mockButtonRect({
+      y: 200,
+      top: 200,
+      bottom: 248,
+    })
+    fireEvent.mouseUp(btn)
+
+    await waitFor(() =>
+      expect(btn).toHaveStyle('transform: translate(0px, -500px)')
+    )
+  })
+
+  test('automatic magnetic attraction to the top', async () => {
+    render(<FloatingBubble axis='xy' magnetic='y' />)
+    mockBoundaryRect()
+    const btn = document.querySelectorAll(`.${classPrefix}-button`)[0]
+    mockButtonRect()
+
+    fireEvent.mouseDown(btn, {
+      buttons: 1,
+      clientY: 603,
+    })
+    fireEvent.mouseMove(btn, {
+      buttons: 1,
+      clientY: 548,
+    })
+    mockButtonRect({
+      y: 500,
+      top: 500,
+      bottom: 548,
+    })
+    fireEvent.mouseUp(btn)
+
+    await sleep(0)
+    expect(btn).toHaveStyle('transform: translate(0px, 0px)')
+  })
+})
diff --git a/src/components/image/test/image.test.tsx b/src/components/image/test/image.test.tsx
index 0b54b834e..8c61856e7 100644
--- a/src/components/image/test/image.test.tsx
+++ b/src/components/image/test/image.test.tsx
@@ -1,15 +1,13 @@
 import React from 'react'
-import { fireEvent, render, testA11y } from 'testing'
+import { fireEvent, render, act } from 'testing'
 import Image from '../index'
 
+const classPrefix = `adm-image`
+
 const demoSrc =
   'https://images.unsplash.com/photo-1567945716310-4745a6b7844b?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&ixlib=rb-1.2.1&auto=format&fit=crop&w=300&q=60'
 
-describe('image', () => {
-  test('a11y', async () => {
-    await testA11y(<Image src={demoSrc} />)
-  })
-
+describe('Image', () => {
   test('onContainerClick can work', () => {
     const onContainerClick = jest.fn()
     const { getByTestId } = render(
@@ -22,4 +20,53 @@ describe('image', () => {
     fireEvent.click(getByTestId('img'))
     expect(onContainerClick).toBeCalledTimes(1)
   })
+
+  test('load successfully', () => {
+    render(<Image src={demoSrc} />)
+    const img = document.querySelectorAll(`.${classPrefix}-img`)[0]
+    fireEvent.load(img)
+    expect(img).toHaveAttribute('src', demoSrc)
+  })
+
+  test('load failed', () => {
+    render(<Image src='404' />)
+    const img = document.querySelectorAll(`.${classPrefix}-img`)[0]
+    fireEvent.error(img)
+    expect(img).not.toBeInTheDocument()
+    expect(
+      document.querySelectorAll(`.${classPrefix}-tip`)[0]
+    ).toBeInTheDocument()
+  })
+
+  test('lazy load should be work', () => {
+    // mock useInViewport
+    // https://github.com/alibaba/hooks/blob/master/packages/hooks/src/useInViewport/__tests__/index.test.ts
+    const mockIntersectionObserver = jest.fn().mockReturnValue({
+      observe: () => null,
+      disconnect: () => null,
+    })
+    window.IntersectionObserver = mockIntersectionObserver
+
+    render(<Image src={demoSrc} lazy />)
+    const img = document.querySelectorAll(`.${classPrefix}-img`)[0]
+    expect(img).not.toHaveAttribute('src')
+
+    const calls = mockIntersectionObserver.mock.calls
+    const [onChange] = calls[calls.length - 1]
+    act(() => {
+      onChange([
+        {
+          isIntersecting: true,
+        },
+      ])
+    })
+    expect(img).toHaveAttribute('src', demoSrc)
+  })
+
+  test('renders with width and height', () => {
+    const { getByTestId } = render(
+      <Image src={demoSrc} width={100} height={100} data-testid='image' />
+    )
+    expect(getByTestId('image')).toHaveStyle('--width: 100px;--height: 100px')
+  })
 })
diff --git a/src/components/swipe-action/tests/swipe-action.test.tsx b/src/components/swipe-action/tests/swipe-action.test.tsx
index 2268b804d..73f09ebd3 100644
--- a/src/components/swipe-action/tests/swipe-action.test.tsx
+++ b/src/components/swipe-action/tests/swipe-action.test.tsx
@@ -227,4 +227,59 @@ describe('SwipeAction', () => {
     fireEvent.click(getByText('delete'))
     expect(onAction).not.toBeCalled()
   })
+
+  test('onActionsReveal should be called when the ref.show is called', async () => {
+    const onActionsReveal = jest.fn()
+    const App = () => {
+      const ref = useRef<SwipeActionRef>(null)
+      return (
+        <>
+          <SwipeAction
+            ref={ref}
+            leftActions={leftActions}
+            onActionsReveal={onActionsReveal}
+          >
+            A
+          </SwipeAction>
+          <button onClick={() => ref.current?.show('left')}>left</button>
+        </>
+      )
+    }
+    const { getByText } = render(<App />)
+
+    fireEvent.click(getByText('left'))
+    await waitFor(() => {
+      expect(onActionsReveal).toBeCalledTimes(1)
+      expect(onActionsReveal).toBeCalledWith('left')
+    })
+  })
+
+  test('onActionsReveal should be called when the operation button is revealed', async () => {
+    const onActionsReveal = jest.fn()
+    const App = () => {
+      return (
+        <SwipeAction
+          rightActions={rightActions}
+          leftActions={leftActions}
+          onActionsReveal={onActionsReveal}
+          data-testid='swipe'
+        >
+          A
+        </SwipeAction>
+      )
+    }
+
+    const { getByTestId } = render(<App />)
+
+    swipe(getByTestId('swipe'), [
+      {
+        clientX: 150,
+      },
+    ])
+
+    await waitFor(() => {
+      expect(onActionsReveal).toBeCalledTimes(1)
+      expect(onActionsReveal).toBeCalledWith('left')
+    })
+  })
 })