From 3100f4e933edb3ac1ce7b9e631d02ca2661b07ac Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Sun, 28 Jun 2026 00:47:08 +0800 Subject: [PATCH 1/3] fix: render sticky group header in static container to stop scroll jitter The sticky group header overlay was rendered through rc-virtual-list's extraRender, which places it inside the natively-scrolled holder. To stay pinned it recomputed top = scrollTop - offsetY on every React render. The content scrolls via the holder's native scrollTop (synchronous) while the overlay's compensating top lands through a React commit that can paint one frame later, so on transient frames the header lags the scroll by one step and snaps back - the jitter. Render the overlay into the outer, non-scrolled container via Portal and make top viewport-relative (0 within a group, negative only when the next header pushes it up). Within a group there is no per-frame compensation, so nothing can lag. overflow: hidden on the list root clips the pushed-up header to the viewport so it cannot bleed over content above the list. --- assets/index.less | 1 + src/VirtualList/index.tsx | 1 + src/VirtualList/useStickyGroupHeader.tsx | 48 ++++++++------ tests/hooks.test.tsx | 81 +++++++++++++++--------- 4 files changed, 81 insertions(+), 50 deletions(-) diff --git a/assets/index.less b/assets/index.less index dcd058f..079d4fe 100644 --- a/assets/index.less +++ b/assets/index.less @@ -2,6 +2,7 @@ .@{listy-prefix-cls} { position: relative; + overflow: hidden; &-group-header { background-color: #fff; diff --git a/src/VirtualList/index.tsx b/src/VirtualList/index.tsx index 26fffa0..96f32e9 100644 --- a/src/VirtualList/index.tsx +++ b/src/VirtualList/index.tsx @@ -138,6 +138,7 @@ function VirtualList( headerRows, groupKeyToItems, prefixCls, + listRef, }); // ============================ Render Row ============================ diff --git a/src/VirtualList/useStickyGroupHeader.tsx b/src/VirtualList/useStickyGroupHeader.tsx index b71cef3..518eca1 100644 --- a/src/VirtualList/useStickyGroupHeader.tsx +++ b/src/VirtualList/useStickyGroupHeader.tsx @@ -1,5 +1,9 @@ import * as React from 'react'; -import type { ListProps as VirtualListProps } from '@rc-component/virtual-list'; +import Portal from '@rc-component/portal'; +import type { + ListProps as VirtualListProps, + ListRef as RcVirtualListRef, +} from '@rc-component/virtual-list'; import type { Group } from '../hooks/useGroupSegments'; import GroupHeader from '../GroupHeader'; @@ -41,6 +45,7 @@ export interface StickyHeaderParams { headerRows: HeaderRow[]; groupKeyToItems: Map; prefixCls: string; + listRef: React.RefObject; } export default function useStickyGroupHeader< @@ -54,17 +59,23 @@ export default function useStickyGroupHeader< headerRows, groupKeyToItems, prefixCls, + listRef, } = params; // ============================ Extra Render ========================== const extraRender = React.useCallback( (info: ExtraRenderInfo) => { - const { getSize, offsetY, scrollTop, start, virtual } = info; + const { getSize, scrollTop, start, virtual } = info; if (!enabled || !group || !headerRows.length || !virtual) { return null; } + const container = listRef.current?.nativeElement; + if (!container) { + return null; + } + // The sticky header is the latest group header before the visible range. const activeHeaderIdx = findActiveHeaderIndex(headerRows, start); const currHeader = headerRows[activeHeaderIdx]; @@ -73,29 +84,26 @@ export default function useStickyGroupHeader< const currentSize = getSize(currHeader.groupKey); const headerHeight = currentSize.bottom - currentSize.top; - // Convert the virtual list scroll position into the overlay top offset. - const fixedTop = scrollTop - offsetY; - - // Let the next group header push the current fixed header away. const nextHeader = headerRows[activeHeaderIdx + 1]; - const nextTop = nextHeader - ? getSize(nextHeader.groupKey).top - headerHeight - offsetY - : fixedTop; - const top = Math.min(fixedTop, nextTop); + const top = nextHeader + ? Math.min(0, getSize(nextHeader.groupKey).top - headerHeight - scrollTop) + : 0; - // Render a cloned header above the virtual list items. + // Render a cloned header pinned over the virtual list. return ( - + container}> + + ); }, - [enabled, group, headerRows, groupKeyToItems, prefixCls], + [enabled, group, headerRows, groupKeyToItems, prefixCls, listRef], ); // ============================== Return ============================== diff --git a/tests/hooks.test.tsx b/tests/hooks.test.tsx index 77ca99e..33e6fd8 100644 --- a/tests/hooks.test.tsx +++ b/tests/hooks.test.tsx @@ -1,6 +1,9 @@ import React from 'react'; import { render, renderHook } from '@testing-library/react'; -import type { ListProps as VirtualListProps } from '@rc-component/virtual-list'; +import type { + ListProps as VirtualListProps, + ListRef as RcVirtualListRef, +} from '@rc-component/virtual-list'; import useGroupSegments from '../src/hooks/useGroupSegments'; import useFlattenRows from '../src/VirtualList/useFlattenRows'; @@ -43,6 +46,13 @@ const StickyHeaderTester = ({ return <>{extraRender(info)}; }; +const createListRef = ( + nativeElement: HTMLElement, +): React.RefObject => + ({ current: { nativeElement } } as unknown as React.RefObject< + RcVirtualListRef | null + >); + describe('useGroupSegments', () => { it('groups items by key across the full data set', () => { const items: GroupedItem[] = [ @@ -191,6 +201,8 @@ describe('useStickyGroupHeader', () => { )); const info = createRenderInfo({ scrollTop: 5, start: 5 }); + const container = document.createElement('div'); + document.body.appendChild(container); const params: StickyHeaderParams = { enabled: true, group: { @@ -200,25 +212,28 @@ describe('useStickyGroupHeader', () => { headerRows, groupKeyToItems: baseItemsMap, prefixCls: PREFIX_CLS, + listRef: createListRef(container), }; - const { container: renderContainer } = render( - , - ); + render(); - const stickyHeader = renderContainer.querySelector( + const stickyHeader = container.querySelector( `.${PREFIX_CLS}-group-header-fixed`, ); expect(stickyHeader).not.toBeNull(); expect(stickyHeader).toHaveClass(`${PREFIX_CLS}-group-header`); expect(stickyHeader).toHaveClass(`${PREFIX_CLS}-group-header-fixed`); expect(stickyHeader).toHaveTextContent('Group 2-3'); - expect(stickyHeader).toHaveStyle({ top: '5px' }); + // Last group, nothing to push it: pinned at the container top. + expect(stickyHeader).toHaveStyle({ top: '0px' }); expect(title).toHaveBeenCalledWith('Group 2', baseItems.slice(3, 6)); + document.body.removeChild(container); }); it('skips sticky header rendering when virtual list is disabled', () => { const info = createRenderInfo({ virtual: false }); + const container = document.createElement('div'); + document.body.appendChild(container); const params: StickyHeaderParams = { enabled: true, group: { @@ -228,16 +243,16 @@ describe('useStickyGroupHeader', () => { headerRows, groupKeyToItems: baseItemsMap, prefixCls: PREFIX_CLS, + listRef: createListRef(container), }; - const { container: renderContainer } = render( - , - ); + render(); - const stickyHeader = renderContainer.querySelector( + const stickyHeader = container.querySelector( `.${PREFIX_CLS}-group-header-fixed`, ); expect(stickyHeader).toBeNull(); + document.body.removeChild(container); }); it('uses the visible start row to resolve the active header', () => { @@ -246,11 +261,12 @@ describe('useStickyGroupHeader', () => { )); const info = createRenderInfo({ - offsetY: 80, scrollTop: 80, start: 4, }); + const container = document.createElement('div'); + document.body.appendChild(container); const params: StickyHeaderParams = { enabled: true, group: { @@ -260,32 +276,36 @@ describe('useStickyGroupHeader', () => { headerRows, groupKeyToItems: baseItemsMap, prefixCls: PREFIX_CLS, + listRef: createListRef(container), }; - const { container: renderContainer } = render( - , - ); + render(); - const stickyHeader = renderContainer.querySelector( + const stickyHeader = container.querySelector( `.${PREFIX_CLS}-group-header-fixed`, ); expect(stickyHeader).not.toBeNull(); expect(stickyHeader).toHaveTextContent('Group 2'); expect(stickyHeader).toHaveStyle({ top: '0px' }); expect(title).toHaveBeenCalledWith('Group 2', baseItems.slice(3, 6)); + document.body.removeChild(container); }); - it('offsets the fixed header by the extra render scrollTop', () => { + it('keeps the fixed header pinned at 0 within a group regardless of scroll', () => { const title = jest.fn().mockImplementation((key: React.Key) => ( {String(key)} )); + // Active group is Group 1, with Group 2's header far below the viewport. const info = createRenderInfo({ - offsetY: 64, scrollTop: 80, - start: 4, + start: 1, + getSize: (key: React.Key) => + key === 'Group 2' ? { top: 500, bottom: 524 } : { top: 0, bottom: 24 }, }); + const container = document.createElement('div'); + document.body.appendChild(container); const params: StickyHeaderParams = { enabled: true, group: { @@ -295,18 +315,18 @@ describe('useStickyGroupHeader', () => { headerRows, groupKeyToItems: baseItemsMap, prefixCls: PREFIX_CLS, + listRef: createListRef(container), }; - const { container: renderContainer } = render( - , - ); + render(); - const stickyHeader = renderContainer.querySelector( + const stickyHeader = container.querySelector( `.${PREFIX_CLS}-group-header-fixed`, ); expect(stickyHeader).not.toBeNull(); - expect(stickyHeader).toHaveTextContent('Group 2'); - expect(stickyHeader).toHaveStyle({ top: '16px' }); + expect(stickyHeader).toHaveTextContent('Group 1'); + expect(stickyHeader).toHaveStyle({ top: '0px' }); + document.body.removeChild(container); }); it('pushes the fixed header away when the next group reaches the top', () => { @@ -315,7 +335,6 @@ describe('useStickyGroupHeader', () => { )); const info = createRenderInfo({ - offsetY: 64, scrollTop: 70, start: 3, getSize: (key: React.Key) => { @@ -329,6 +348,8 @@ describe('useStickyGroupHeader', () => { }, }); + const container = document.createElement('div'); + document.body.appendChild(container); const params: StickyHeaderParams = { enabled: true, group: { @@ -338,17 +359,17 @@ describe('useStickyGroupHeader', () => { headerRows, groupKeyToItems: baseItemsMap, prefixCls: PREFIX_CLS, + listRef: createListRef(container), }; - const { container: renderContainer } = render( - , - ); + render(); - const stickyHeader = renderContainer.querySelector( + const stickyHeader = container.querySelector( `.${PREFIX_CLS}-group-header-fixed`, ); expect(stickyHeader).not.toBeNull(); expect(stickyHeader).toHaveTextContent('Group 1'); - expect(stickyHeader).toHaveStyle({ top: '-4px' }); + expect(stickyHeader).toHaveStyle({ top: '-10px' }); + document.body.removeChild(container); }); }); From 81d0c43713f33e0e39c13acce4871ab0b7e7c934 Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Sun, 28 Jun 2026 01:22:19 +0800 Subject: [PATCH 2/3] fix: adjust sticky group header styles and cleanup test cases --- assets/index.less | 12 ++++++++++- src/VirtualList/useStickyGroupHeader.tsx | 18 +++++++++------- tests/hooks.test.tsx | 27 ++++++++++-------------- 3 files changed, 32 insertions(+), 25 deletions(-) diff --git a/assets/index.less b/assets/index.less index 079d4fe..bc4626c 100644 --- a/assets/index.less +++ b/assets/index.less @@ -2,7 +2,6 @@ .@{listy-prefix-cls} { position: relative; - overflow: hidden; &-group-header { background-color: #fff; @@ -21,6 +20,17 @@ left: 0; right: 0; transform: translateY(0); + pointer-events: auto; + } + + &-holder { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow: hidden; + pointer-events: none; } } diff --git a/src/VirtualList/useStickyGroupHeader.tsx b/src/VirtualList/useStickyGroupHeader.tsx index 518eca1..efba050 100644 --- a/src/VirtualList/useStickyGroupHeader.tsx +++ b/src/VirtualList/useStickyGroupHeader.tsx @@ -92,14 +92,16 @@ export default function useStickyGroupHeader< // Render a cloned header pinned over the virtual list. return ( container}> - +
+ +
); }, diff --git a/tests/hooks.test.tsx b/tests/hooks.test.tsx index 33e6fd8..645e89b 100644 --- a/tests/hooks.test.tsx +++ b/tests/hooks.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, renderHook } from '@testing-library/react'; +import { cleanup, render, renderHook } from '@testing-library/react'; import type { ListProps as VirtualListProps, ListRef as RcVirtualListRef, @@ -192,6 +192,11 @@ describe('useStickyGroupHeader', () => { ['Group 2', baseItems.slice(3, 6)], ]); + afterEach(() => { + cleanup(); + document.body.innerHTML = ''; + }); + it('renders sticky header for the active header row', () => { const title = jest .fn() @@ -226,9 +231,7 @@ describe('useStickyGroupHeader', () => { expect(stickyHeader).toHaveTextContent('Group 2-3'); // Last group, nothing to push it: pinned at the container top. expect(stickyHeader).toHaveStyle({ top: '0px' }); - expect(title).toHaveBeenCalledWith('Group 2', baseItems.slice(3, 6)); - document.body.removeChild(container); - }); + expect(title).toHaveBeenCalledWith('Group 2', baseItems.slice(3, 6)); }); it('skips sticky header rendering when virtual list is disabled', () => { const info = createRenderInfo({ virtual: false }); @@ -251,9 +254,7 @@ describe('useStickyGroupHeader', () => { const stickyHeader = container.querySelector( `.${PREFIX_CLS}-group-header-fixed`, ); - expect(stickyHeader).toBeNull(); - document.body.removeChild(container); - }); + expect(stickyHeader).toBeNull(); }); it('uses the visible start row to resolve the active header', () => { const title = jest.fn().mockImplementation((key: React.Key) => ( @@ -287,9 +288,7 @@ describe('useStickyGroupHeader', () => { expect(stickyHeader).not.toBeNull(); expect(stickyHeader).toHaveTextContent('Group 2'); expect(stickyHeader).toHaveStyle({ top: '0px' }); - expect(title).toHaveBeenCalledWith('Group 2', baseItems.slice(3, 6)); - document.body.removeChild(container); - }); + expect(title).toHaveBeenCalledWith('Group 2', baseItems.slice(3, 6)); }); it('keeps the fixed header pinned at 0 within a group regardless of scroll', () => { const title = jest.fn().mockImplementation((key: React.Key) => ( @@ -325,9 +324,7 @@ describe('useStickyGroupHeader', () => { ); expect(stickyHeader).not.toBeNull(); expect(stickyHeader).toHaveTextContent('Group 1'); - expect(stickyHeader).toHaveStyle({ top: '0px' }); - document.body.removeChild(container); - }); + expect(stickyHeader).toHaveStyle({ top: '0px' }); }); it('pushes the fixed header away when the next group reaches the top', () => { const title = jest.fn().mockImplementation((key: React.Key) => ( @@ -369,7 +366,5 @@ describe('useStickyGroupHeader', () => { ); expect(stickyHeader).not.toBeNull(); expect(stickyHeader).toHaveTextContent('Group 1'); - expect(stickyHeader).toHaveStyle({ top: '-10px' }); - document.body.removeChild(container); - }); + expect(stickyHeader).toHaveStyle({ top: '-10px' }); }); }); From c98f06f8612541c749ecf1eb512809f5e04db908 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 29 Jun 2026 15:12:08 +0800 Subject: [PATCH 3/3] chore: trigger Vercel preview