diff --git a/packages/x/components/bubble/BubbleList.tsx b/packages/x/components/bubble/BubbleList.tsx index c639551fb..adb169d67 100644 --- a/packages/x/components/bubble/BubbleList.tsx +++ b/packages/x/components/bubble/BubbleList.tsx @@ -7,6 +7,7 @@ import { useXProviderContext } from '../x-provider'; import Bubble from './Bubble'; import { BubbleContext } from './context'; import DividerBubble from './Divider'; +import { useCompatibleScroll } from './hooks/useCompatibleScroll'; import { BubbleItemType, BubbleListProps, @@ -138,6 +139,8 @@ const BubbleList: React.ForwardRefRenderFunction const bubblesRef = React.useRef({}); + const { reset } = useCompatibleScroll(autoScroll ? scrollBoxRef.current : null); + // ============================ Prefix ============================ const { getPrefixCls } = useXProviderContext(); @@ -165,8 +168,9 @@ const BubbleList: React.ForwardRefRenderFunction const lastItemKey = items[items.length - 1]?.key || items.length; React.useEffect(() => { if (!scrollBoxRef.current) return; + reset(); scrollBoxRef.current?.scrollTo({ top: autoScroll ? 0 : scrollBoxRef.current.scrollHeight }); - }, [lastItemKey, autoScroll]); + }, [lastItemKey, autoScroll, reset]); // ============================= Refs ============================= useProxyImperativeHandle(ref, () => { diff --git a/packages/x/components/bubble/__tests__/__snapshots__/demo-extend.test.ts.snap b/packages/x/components/bubble/__tests__/__snapshots__/demo-extend.test.ts.snap index 0d5e01827..f0c3c51cb 100644 --- a/packages/x/components/bubble/__tests__/__snapshots__/demo-extend.test.ts.snap +++ b/packages/x/components/bubble/__tests__/__snapshots__/demo-extend.test.ts.snap @@ -1654,6 +1654,9 @@ exports[`renders components/bubble/demo/list.tsx extend context correctly 1`] =
+
@@ -2763,6 +2766,9 @@ exports[`renders components/bubble/demo/list-scroll.tsx extend context correctly
+
diff --git a/packages/x/components/bubble/__tests__/list-scroll.test.tsx b/packages/x/components/bubble/__tests__/list-scroll.test.tsx new file mode 100644 index 000000000..2d5ed41ed --- /dev/null +++ b/packages/x/components/bubble/__tests__/list-scroll.test.tsx @@ -0,0 +1,364 @@ +import { act, renderHook } from '@testing-library/react'; +import { waitFakeTimer } from '../../../tests/utils'; +import { useCompatibleScroll } from '../hooks/useCompatibleScroll'; + +// Create a DOM element with column-reverse flex direction +const createColumnReverseDom = () => { + const dom = document.createElement('div'); + dom.style.cssText = + 'height: 400px; overflow: auto; display: flex; flex-direction: column-reverse;'; + + return dom; +}; + +// Create a DOM element with column flex direction +const createColumnDom = () => { + const dom = document.createElement('div'); + dom.style.cssText = 'height: 400px; overflow: auto; display: flex; flex-direction: column;'; + return dom; +}; + +// Setup scroll properties for a DOM element +const setupScrollProperties = ( + dom: HTMLElement, + scrollHeight = 1000, + scrollTop = 0, + clientHeight = 400, +) => { + Object.defineProperty(dom, 'scrollHeight', { + value: scrollHeight, + writable: true, + }); + Object.defineProperty(dom, 'scrollTop', { + value: scrollTop, + writable: true, + }); + Object.defineProperty(dom, 'clientHeight', { + value: clientHeight, + writable: true, + }); +}; + +function spyOnGetComputedStyle(reverse = true) { + jest.spyOn(window, 'getComputedStyle').mockImplementation( + () => + ({ + flexDirection: reverse ? 'column-reverse' : 'column', + }) as any, + ); +} + +describe('useCompatibleScroll', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + let mockDom: HTMLElement; + let intersectionCallback: (entries: any[]) => void; + let mutationCallback: () => void; + + // Mock DOM methods + const mockIntersectionObserver = jest.fn(); + const mockMutationObserver = jest.fn(); + + beforeEach(() => { + // Reset DOM + document.body.innerHTML = ''; + + // Create mock DOM element with proper scroll properties + mockDom = createColumnReverseDom(); + setupScrollProperties(mockDom); + + document.body.appendChild(mockDom); + + // Setup IntersectionObserver mock + mockIntersectionObserver.mockImplementation((callback) => { + intersectionCallback = callback; + return { + observe: jest.fn(), + disconnect: jest.fn(), + unobserve: jest.fn(), + }; + }); + + // Setup MutationObserver mock + mockMutationObserver.mockImplementation((callback) => { + mutationCallback = callback; + return { + observe: jest.fn(), + disconnect: jest.fn(), + takeRecords: jest.fn(), + }; + }); + + // Setup mocks + global.IntersectionObserver = mockIntersectionObserver; + global.MutationObserver = mockMutationObserver; + }); + + afterEach(() => { + document.body.innerHTML = ''; + jest.restoreAllMocks(); + jest.clearAllMocks(); + jest.clearAllTimers(); + }); + + describe('Initialization', () => { + it('should not initialize when dom is null', () => { + renderHook(() => useCompatibleScroll(null)); + + expect(mockIntersectionObserver).not.toHaveBeenCalled(); + expect(mockMutationObserver).not.toHaveBeenCalled(); + }); + + it('should not initialize when flexDirection is not column-reverse', () => { + // Mock getComputedStyle to return a non-column-reverse flexDirection + spyOnGetComputedStyle(false); + + // Create a DOM element with flexDirection other than column-reverse + const nonReverseDom = createColumnDom(); + document.body.appendChild(nonReverseDom); + + renderHook(() => useCompatibleScroll(nonReverseDom)); + + expect(mockIntersectionObserver).not.toHaveBeenCalled(); + expect(mockMutationObserver).not.toHaveBeenCalled(); + }); + + it('should initialize observers when dom is provided and flexDirection is column-reverse', () => { + // Mock getComputedStyle to return column-reverse flexDirection + spyOnGetComputedStyle(); + + renderHook(() => useCompatibleScroll(mockDom)); + + expect(mockIntersectionObserver).toHaveBeenCalled(); + expect(mockMutationObserver).toHaveBeenCalled(); + }); + }); + + describe('Sentinel Element', () => { + it('should create sentinel element with correct styles', () => { + // Mock getComputedStyle to return column-reverse flexDirection + spyOnGetComputedStyle(); + + renderHook(() => useCompatibleScroll(mockDom)); + + expect(mockDom.firstChild).toBeTruthy(); + const sentinel = mockDom.firstChild as HTMLElement; + expect(sentinel.style.position).toBe(''); + expect(sentinel.style.bottom).toBe('0px'); + expect(sentinel.style.flexShrink).toBe('0'); + expect(sentinel.style.pointerEvents).toBe('none'); + expect(sentinel.style.height).toBe('10px'); + expect(sentinel.style.visibility).toBe('hidden'); + }); + }); + + describe('Scroll Handling', () => { + it('should handle scroll events correctly', () => { + // Mock getComputedStyle to return column-reverse flexDirection + spyOnGetComputedStyle(); + + // Create a mock setTimeout return value + const mockTimeoutId = 12345; + jest.spyOn(global, 'setTimeout').mockImplementation(() => mockTimeoutId as any); + const mockClearTimeout = jest.spyOn(global, 'clearTimeout'); + + renderHook(() => useCompatibleScroll(mockDom)); + + // Set initial state + Object.defineProperty(mockDom, 'scrollTop', { value: -300, writable: true }); + + // First scroll event to set up the timeout + act(() => { + mockDom.dispatchEvent(new Event('scroll')); + }); + + // Verify setTimeout was called + expect(setTimeout).toHaveBeenCalled(); + + // Second scroll event should clear the previous timeout + act(() => { + mockDom.dispatchEvent(new Event('scroll')); + }); + + // Verify clearTimeout was called with the correct timeout ID + expect(mockClearTimeout).toHaveBeenCalledWith(mockTimeoutId); + }); + }); + + describe('Reset to Bottom', () => { + it('should reset internal state', () => { + spyOnGetComputedStyle(); + + const { result } = renderHook(() => useCompatibleScroll(mockDom)); + + act(() => { + result.current.reset(); + }); + + expect(result.current.reset).not.toThrow(); + }); + }); + + describe('Scroll Lock Enforcement', () => { + it('should lock scroll when not at bottom', async () => { + // Mock getComputedStyle to return column-reverse flexDirection + spyOnGetComputedStyle(); + + renderHook(() => useCompatibleScroll(mockDom)); + + act(() => { + Object.defineProperty(mockDom, 'scrollTop', { value: -300, writable: true }); + intersectionCallback([{ isIntersecting: false }]); + mockDom.dispatchEvent(new Event('scroll')); + }); + + await waitFakeTimer(100, 1); + + act(() => { + Object.defineProperty(mockDom, 'scrollHeight', { value: 1200, writable: true }); + mutationCallback(); + }); + + expect(mockDom.scrollTop).toBe(-500); + }); + + it('should not lock scroll when scrolling', () => { + // Mock getComputedStyle to return column-reverse flexDirection + spyOnGetComputedStyle(); + + renderHook(() => useCompatibleScroll(mockDom)); + + act(() => { + Object.defineProperty(mockDom, 'scrollTop', { value: -300, writable: true }); + intersectionCallback([{ isIntersecting: false }]); + mockDom.dispatchEvent(new Event('scroll')); + }); + + act(() => { + Object.defineProperty(mockDom, 'scrollHeight', { value: 1200, writable: true }); + mutationCallback(); + }); + + expect(mockDom.scrollTop).toBe(-300); + }); + + it('should onScroll return early after call enforceScrollLock', () => { + // Mock getComputedStyle to return column-reverse flexDirection + spyOnGetComputedStyle(); + + jest.spyOn(global, 'setTimeout'); + jest.spyOn(global, 'clearTimeout'); + + renderHook(() => useCompatibleScroll(mockDom)); + + act(() => { + Object.defineProperty(mockDom, 'scrollHeight', { value: 1200, writable: true }); + mutationCallback(); + }); + + expect(clearTimeout).not.toHaveBeenCalled(); + expect(setTimeout).not.toHaveBeenCalled(); + }); + + it('should not lock scroll when at bottom', () => { + // Mock getComputedStyle to return column-reverse flexDirection + spyOnGetComputedStyle(); + + renderHook(() => useCompatibleScroll(mockDom)); + + // At bottom + act(() => { + intersectionCallback([{ isIntersecting: true }]); + }); + + // Scroll event should not lock position + act(() => { + Object.defineProperty(mockDom, 'scrollHeight', { value: 1200, writable: true }); + mutationCallback(); + }); + + expect(mockDom.scrollTop).toBe(0); + }); + + it('should not throw error when enforcing scroll lock with null dom', () => { + // Create a new mock DOM element for this specific test + const testDom = createColumnReverseDom(); + setupScrollProperties(testDom, 1000, -200, 400); + document.body.appendChild(testDom); + + // Mock getComputedStyle to return column-reverse flexDirection + spyOnGetComputedStyle(); + + const { unmount } = renderHook(() => useCompatibleScroll(testDom)); + + // Set up initial state + act(() => { + // Set initial scroll position + Object.defineProperty(testDom, 'scrollTop', { value: -200, writable: true }); + Object.defineProperty(testDom, 'scrollHeight', { value: 1000, writable: true }); + // Trigger scroll event to update lockedScrollBottomPos + testDom.dispatchEvent(new Event('scroll')); + }); + + // Unmount to set dom to null + unmount(); + + // Change scrollHeight to simulate content being added + Object.defineProperty(testDom, 'scrollHeight', { value: 1200, writable: true }); + + act(() => { + intersectionCallback([{ isIntersecting: false }]); + mutationCallback(); + }); + + // Should not throw + expect(() => { + mutationCallback(); + }).not.toThrow(); + }); + }); + + describe('Edge Cases', () => { + it('should handle edge cases correctly', () => { + // Mock getComputedStyle to return column-reverse flexDirection + spyOnGetComputedStyle(); + + // Test 1: Should not throw error when dom becomes undefined after mount + const { result, unmount } = renderHook(() => useCompatibleScroll(mockDom)); + + // Unmount to set dom to undefined + unmount(); + + // Should not throw when calling reset + expect(() => { + act(() => { + result.current.reset(); + }); + }).not.toThrow(); + }); + }); + + describe('Cleanup', () => { + it('should cleanup observers and sentinel element on unmount', () => { + // Mock getComputedStyle to return column-reverse flexDirection + spyOnGetComputedStyle(); + + const { unmount } = renderHook(() => useCompatibleScroll(mockDom)); + + // Verify sentinel was created + expect(mockDom.firstChild).toBeTruthy(); + + unmount(); + + // Verify cleanup + expect(mockIntersectionObserver).toHaveBeenCalled(); + expect(mockMutationObserver).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/x/components/bubble/hooks/useCompatibleScroll.ts b/packages/x/components/bubble/hooks/useCompatibleScroll.ts new file mode 100644 index 000000000..54b0f464a --- /dev/null +++ b/packages/x/components/bubble/hooks/useCompatibleScroll.ts @@ -0,0 +1,121 @@ +import { useCallback, useEffect, useRef } from 'react'; + +/** + * Safari 兼容的倒序滚动视窗锁定 + * @param {HTMLElement} dom - 倒序滚动容器元素(flex-direction: column-reverse) + */ +export function useCompatibleScroll(dom?: HTMLElement | null) { + // 底部哨兵 + const sentinelRef = useRef(null); + const isAtBottom = useRef(true); + const shouldLock = useRef(false); + const lockedScrollBottomPos = useRef(0); + const scrolling = useRef>(undefined); + const callOnScrollNotNative = useRef(false); + + const disable = !dom || getComputedStyle(dom).flexDirection !== 'column-reverse'; + + // 初始化哨兵元素 + useEffect(() => { + if (disable) return; + if (!sentinelRef.current) { + const sentinel = document.createElement('div'); + // sentinel.style.position = 'absolute'; + sentinel.style.bottom = '0'; + sentinel.style.flexShrink = '0'; + sentinel.style.pointerEvents = 'none'; + sentinel.style.height = '10px'; + sentinel.style.visibility = 'hidden'; + + dom.insertBefore(sentinel, dom.firstChild); + sentinelRef.current = sentinel; + } + + const intersectionObserver = new IntersectionObserver( + ([entry]) => { + isAtBottom.current = entry.isIntersecting; + shouldLock.current = !entry.isIntersecting; + }, + { root: dom, threshold: 0.0 }, + ); + + intersectionObserver.observe(sentinelRef.current); + + // 监听 DOM 内容变化,锁定视窗 + const mutationObserver = new MutationObserver(() => { + shouldLock.current && !disable && enforceScrollLock(); + }); + + mutationObserver.observe(dom, { + childList: true, + subtree: true, + attributes: false, + }); + + return () => { + intersectionObserver.disconnect(); + mutationObserver.disconnect(); + clearTimeout(scrolling.current); + if (sentinelRef.current && sentinelRef.current.parentNode) { + sentinelRef.current.parentNode.removeChild(sentinelRef.current); + sentinelRef.current = null; + } + }; + }, [dom, disable]); + + const handleScroll = useCallback(() => { + const { scrollTop, scrollHeight } = dom!; + // 倒序, top 在变化,但 bottom 固定 + lockedScrollBottomPos.current = scrollHeight + scrollTop; + // 检测并恢复自然触发状态 + if (callOnScrollNotNative.current) { + callOnScrollNotNative.current = false; + return; + } + if (scrolling.current) { + clearTimeout(scrolling.current); + } + scrolling.current = setTimeout(() => { + clearTimeout(scrolling.current); + scrolling.current = undefined; + }, 50); + }, [dom]); + + useEffect(() => { + if (!disable) { + dom.addEventListener('scroll', handleScroll, { capture: true }); + } + return () => dom?.removeEventListener('scroll', handleScroll, { capture: true }); + }, [dom, disable, handleScroll]); + + // 强制锁定滚动位置 + const enforceScrollLock = useCallback(() => { + /** + * 同时发生滚动+内容变化,有两种可选行为: + * 1、强制锁定视窗,可视内容不变,但会造成滚动抖动。 + * 2、不锁定视窗,内容会变化(safari行为)。 + * 出于鲁棒性考虑,选择行为2,在滚动结束后再锁视窗 + * 最终效果: + * 1、滚动+内容变化同时发生,表现为 safari 行为 + * 2、仅内容变化,表现为 chrome 行为(无论是否贴底) + **/ + // requestAnimationFrame(() => { + if (scrolling.current) return; + const targetScroll = lockedScrollBottomPos.current - dom!.scrollHeight; + dom!.scrollTop = targetScroll; + // 赋值 scrollTop 会立即触发 onScroll + callOnScrollNotNative.current = true; + // }); + }, [dom]); + + const reset = useCallback(() => { + if (disable) return; + isAtBottom.current = true; + shouldLock.current = false; + lockedScrollBottomPos.current = dom.scrollHeight; + }, [dom, disable]); + + return { + reset, + }; +} diff --git a/packages/x/components/bubble/index.zh-CN.md b/packages/x/components/bubble/index.zh-CN.md index a65ce4676..5b680b4f6 100644 --- a/packages/x/components/bubble/index.zh-CN.md +++ b/packages/x/components/bubble/index.zh-CN.md @@ -19,19 +19,19 @@ demo: ## 代码演示 -基本 -变体与形状 -边栏与位置 -系统信息气泡 -分割线气泡 -气泡头 -气泡尾 -加载中 -动画 -流式传输 -自定义渲染内容 -渲染markdown内容 -使用 GPT-Vis 渲染图表 +基本 +变体与形状 +边栏与位置 +系统信息气泡 +分割线气泡 +气泡头 +气泡尾 +加载中 +动画 +流式传输 +自定义渲染内容 +渲染markdown内容 +使用 GPT-Vis 渲染图表 可编辑气泡 ## 列表演示