From 98f00f92e122b4a415997a40300baee808eb1331 Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Sun, 28 Jun 2026 02:47:59 +0800 Subject: [PATCH 1/3] fix: show the correct group header when scrolled flush to its top MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a group header lands exactly at the viewport top (which scrollTo({ groupKey, align: 'top' }) always does), the sticky overlay rendered the *previous* group's header pinned off-screen, blanking the title. The active header was resolved from the virtual list's `start` row index, whose `>=` range boundary includes the previous group's trailing row at exact alignment — an off-by-one against the pixel-based `top` positioning. Resolve the active header by pixel offset (getSize(key).top <= scrollTop), the same coordinate space as the positioning math. Add a regression test for the exact-alignment boundary. Incidental cleanup: drop the now-dead `rowIndex` and collapse the flatten hook's `headerRows` to `groupKeys: K[]`. --- src/VirtualList/index.tsx | 4 +- src/VirtualList/useFlattenRows.ts | 10 +-- src/VirtualList/useStickyGroupHeader.tsx | 44 ++++++------- tests/hooks.test.tsx | 79 ++++++++++++------------ 4 files changed, 70 insertions(+), 67 deletions(-) diff --git a/src/VirtualList/index.tsx b/src/VirtualList/index.tsx index 96f32e9..a435e9d 100644 --- a/src/VirtualList/index.tsx +++ b/src/VirtualList/index.tsx @@ -58,7 +58,7 @@ function VirtualList( }); // ============================== Rows ================================ - const { rows, headerRows, groupKeyToItems } = useFlattenRows( + const { rows, groupKeys, groupKeyToItems } = useFlattenRows( data, groupData, group, @@ -135,7 +135,7 @@ function VirtualList( const extraRender = useStickyGroupHeader({ enabled: !!(sticky && group), group, - headerRows, + groupKeys, groupKeyToItems, prefixCls, listRef, diff --git a/src/VirtualList/useFlattenRows.ts b/src/VirtualList/useFlattenRows.ts index 689b6a7..17dd954 100644 --- a/src/VirtualList/useFlattenRows.ts +++ b/src/VirtualList/useFlattenRows.ts @@ -8,7 +8,7 @@ export type Row = export interface FlattenRowsResult { rows: Row[]; - headerRows: { groupKey: K; rowIndex: number }[]; + groupKeys: K[]; groupKeyToItems: Map; } @@ -25,7 +25,7 @@ export default function useFlattenRows( return React.useMemo(() => { // ============================== Init ================================ const flatRows: Row[] = []; - const headerRows: { groupKey: K; rowIndex: number }[] = []; + const groupKeys: K[] = []; const groupKeyToItems = new Map(); // ============================ No Group ============================== @@ -34,7 +34,7 @@ export default function useFlattenRows( flatRows.push({ type: 'item', item, index }); }); - return { rows: flatRows, headerRows, groupKeyToItems }; + return { rows: flatRows, groupKeys, groupKeyToItems }; } // ============================= Flatten ============================== @@ -44,7 +44,7 @@ export default function useFlattenRows( groupItems.map(({ item }) => item), ); - headerRows.push({ groupKey, rowIndex: flatRows.length }); + groupKeys.push(groupKey); flatRows.push({ type: 'header', groupKey }); groupItems.forEach(({ item, index }) => { @@ -53,6 +53,6 @@ export default function useFlattenRows( }); // ============================== Return ============================== - return { rows: flatRows, headerRows, groupKeyToItems }; + return { rows: flatRows, groupKeys, groupKeyToItems }; }, [data, group, groupData]); } diff --git a/src/VirtualList/useStickyGroupHeader.tsx b/src/VirtualList/useStickyGroupHeader.tsx index efba050..bc8947e 100644 --- a/src/VirtualList/useStickyGroupHeader.tsx +++ b/src/VirtualList/useStickyGroupHeader.tsx @@ -12,22 +12,20 @@ type ExtraRenderInfo = Parameters< NonNullable['extraRender']> >[0]; -type HeaderRow = { groupKey: K; rowIndex: number }; - // ============================== Utils =============================== -// `headerRows` is sorted by rowIndex. Find the last header not after `start`. function findActiveHeaderIndex( - headerRows: HeaderRow[], - start: number, + groupKeys: K[], + getHeaderTop: (groupKey: K) => number, + scrollTop: number, ) { let left = 0; - let right = headerRows.length - 1; + let right = groupKeys.length - 1; let activeIndex = 0; while (left <= right) { const mid = Math.floor((left + right) / 2); - if (headerRows[mid].rowIndex <= start) { + if (getHeaderTop(groupKeys[mid]) <= scrollTop) { activeIndex = mid; left = mid + 1; } else { @@ -42,7 +40,7 @@ function findActiveHeaderIndex( export interface StickyHeaderParams { enabled: boolean; group: Group | undefined; - headerRows: HeaderRow[]; + groupKeys: K[]; groupKeyToItems: Map; prefixCls: string; listRef: React.RefObject; @@ -56,7 +54,7 @@ export default function useStickyGroupHeader< const { enabled, group, - headerRows, + groupKeys, groupKeyToItems, prefixCls, listRef, @@ -65,9 +63,9 @@ export default function useStickyGroupHeader< // ============================ Extra Render ========================== const extraRender = React.useCallback( (info: ExtraRenderInfo) => { - const { getSize, scrollTop, start, virtual } = info; + const { getSize, scrollTop, virtual } = info; - if (!enabled || !group || !headerRows.length || !virtual) { + if (!enabled || !group || !groupKeys.length || !virtual) { return null; } @@ -76,17 +74,21 @@ export default function useStickyGroupHeader< return null; } - // The sticky header is the latest group header before the visible range. - const activeHeaderIdx = findActiveHeaderIndex(headerRows, start); - const currHeader = headerRows[activeHeaderIdx]; + // The sticky header is the group whose section the viewport top sits in. + const activeHeaderIdx = findActiveHeaderIndex( + groupKeys, + (groupKey) => getSize(groupKey).top, + scrollTop, + ); + const currGroupKey = groupKeys[activeHeaderIdx]; - const groupItems = groupKeyToItems.get(currHeader.groupKey) || []; - const currentSize = getSize(currHeader.groupKey); + const groupItems = groupKeyToItems.get(currGroupKey) || []; + const currentSize = getSize(currGroupKey); const headerHeight = currentSize.bottom - currentSize.top; - const nextHeader = headerRows[activeHeaderIdx + 1]; - const top = nextHeader - ? Math.min(0, getSize(nextHeader.groupKey).top - headerHeight - scrollTop) + const nextGroupKey = groupKeys[activeHeaderIdx + 1]; + const top = nextGroupKey + ? Math.min(0, getSize(nextGroupKey).top - headerHeight - scrollTop) : 0; // Render a cloned header pinned over the virtual list. @@ -96,7 +98,7 @@ export default function useStickyGroupHeader< ); }, - [enabled, group, headerRows, groupKeyToItems, prefixCls, listRef], + [enabled, group, groupKeys, groupKeyToItems, prefixCls, listRef], ); // ============================== Return ============================== diff --git a/tests/hooks.test.tsx b/tests/hooks.test.tsx index 645e89b..b017d4f 100644 --- a/tests/hooks.test.tsx +++ b/tests/hooks.test.tsx @@ -140,10 +140,7 @@ describe('useFlattenRows', () => { { type: 'header', groupKey: 'B' }, { type: 'item', item: items[1], index: 1 }, ]); - expect(result.current.headerRows).toEqual([ - { groupKey: 'A', rowIndex: 0 }, - { groupKey: 'B', rowIndex: 3 }, - ]); + expect(result.current.groupKeys).toEqual(['A', 'B']); expect(result.current.groupKeyToItems).toEqual( new Map([ ['A', [items[0], items[2]]], @@ -168,7 +165,7 @@ describe('useFlattenRows', () => { { type: 'item', item: items[0], index: 0 }, { type: 'item', item: items[1], index: 1 }, ], - headerRows: [], + groupKeys: [], groupKeyToItems: new Map(), }); }); @@ -183,10 +180,7 @@ describe('useStickyGroupHeader', () => { { id: 4, group: 'Group 2' }, { id: 5, group: 'Group 2' }, ]; - const headerRows = [ - { groupKey: 'Group 1', rowIndex: 0 }, - { groupKey: 'Group 2', rowIndex: 4 }, - ]; + const groupKeys = ['Group 1', 'Group 2']; const baseItemsMap = new Map([ ['Group 1', baseItems.slice(0, 3)], ['Group 2', baseItems.slice(3, 6)], @@ -214,7 +208,7 @@ describe('useStickyGroupHeader', () => { key: (item) => item.group, title, }, - headerRows, + groupKeys, groupKeyToItems: baseItemsMap, prefixCls: PREFIX_CLS, listRef: createListRef(container), @@ -243,7 +237,7 @@ describe('useStickyGroupHeader', () => { key: (item) => item.group, title: () => noop, }, - headerRows, + groupKeys, groupKeyToItems: baseItemsMap, prefixCls: PREFIX_CLS, listRef: createListRef(container), @@ -256,14 +250,17 @@ describe('useStickyGroupHeader', () => { ); expect(stickyHeader).toBeNull(); }); - it('uses the visible start row to resolve the active header', () => { + 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({ 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'); @@ -274,7 +271,7 @@ describe('useStickyGroupHeader', () => { key: (item) => item.group, title, }, - headerRows, + groupKeys, groupKeyToItems: baseItemsMap, prefixCls: PREFIX_CLS, listRef: createListRef(container), @@ -286,21 +283,26 @@ describe('useStickyGroupHeader', () => { `.${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)); }); + expect(stickyHeader).toHaveTextContent('Group 1'); + expect(stickyHeader).toHaveStyle({ top: '0px' }); }); - it('keeps the fixed header pinned at 0 within a group regardless of scroll', () => { + it('pushes the fixed header away when the next group reaches the top', () => { 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({ - scrollTop: 80, - start: 1, - getSize: (key: React.Key) => - key === 'Group 2' ? { top: 500, bottom: 524 } : { top: 0, bottom: 24 }, + scrollTop: 70, + start: 3, + getSize: (key: React.Key) => { + if (key === 'Group 1') { + return { top: 0, bottom: 20 }; + } + if (key === 'Group 2') { + return { top: 80, bottom: 100 }; + } + return { top: 0, bottom: 0 }; + }, }); const container = document.createElement('div'); @@ -311,7 +313,7 @@ describe('useStickyGroupHeader', () => { key: (item) => item.group, title, }, - headerRows, + groupKeys, groupKeyToItems: baseItemsMap, prefixCls: PREFIX_CLS, listRef: createListRef(container), @@ -324,25 +326,21 @@ describe('useStickyGroupHeader', () => { ); expect(stickyHeader).not.toBeNull(); expect(stickyHeader).toHaveTextContent('Group 1'); - expect(stickyHeader).toHaveStyle({ top: '0px' }); }); + expect(stickyHeader).toHaveStyle({ top: '-10px' }); }); - it('pushes the fixed header away when the next group reaches the top', () => { + it('activates the current group, not the previous one, when its header sits flush at the top', () => { const title = jest.fn().mockImplementation((key: React.Key) => ( {String(key)} )); + // Group 2's header sits exactly at the viewport top (scrollTop === its top), + // while `start` is Group 1's last item row (3) — the row before Group 2's + // header row (4). This is the off-by-one boundary. const info = createRenderInfo({ - scrollTop: 70, + scrollTop: 200, start: 3, - getSize: (key: React.Key) => { - if (key === 'Group 1') { - return { top: 0, bottom: 20 }; - } - if (key === 'Group 2') { - return { top: 80, bottom: 100 }; - } - return { top: 0, bottom: 0 }; - }, + getSize: (key: React.Key) => + key === 'Group 2' ? { top: 200, bottom: 220 } : { top: 0, bottom: 20 }, }); const container = document.createElement('div'); @@ -353,7 +351,7 @@ describe('useStickyGroupHeader', () => { key: (item) => item.group, title, }, - headerRows, + groupKeys, groupKeyToItems: baseItemsMap, prefixCls: PREFIX_CLS, listRef: createListRef(container), @@ -365,6 +363,9 @@ describe('useStickyGroupHeader', () => { `.${PREFIX_CLS}-group-header-fixed`, ); expect(stickyHeader).not.toBeNull(); - expect(stickyHeader).toHaveTextContent('Group 1'); - expect(stickyHeader).toHaveStyle({ top: '-10px' }); }); + // The current group (Group 2), not the previous one (Group 1). + expect(stickyHeader).toHaveTextContent('Group 2'); + expect(stickyHeader).toHaveStyle({ top: '0px' }); + expect(title).toHaveBeenCalledWith('Group 2', baseItems.slice(3, 6)); + }); }); From 0b0592da4bd2e9ff0b1a24c0115d8bd1ca859409 Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Sun, 28 Jun 2026 14:30:43 +0800 Subject: [PATCH 2/3] fix: tolerate sub-pixel scroll offset when resolving the active group header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pixel-based selection compared header tops (summed item heights) against `scrollTop` with a strict `<=`. On a HiDPI screen the scroll offset snaps to a sub-pixel grid, so a trackpad scroll routinely rests ~0.5px short of a header's true top. The strict compare then resolved to the previous group and pinned its header off-screen, blanking the title — the same symptom as the original bug, but only reachable via real (non-integer) scrolling rather than `scrollTo`. Allow a 1px tolerance, which absorbs the rounding and stays far below the gap between consecutive headers. Add a regression test for the sub-pixel-short offset. --- src/VirtualList/useStickyGroupHeader.tsx | 10 ++++++- tests/hooks.test.tsx | 38 ++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/VirtualList/useStickyGroupHeader.tsx b/src/VirtualList/useStickyGroupHeader.tsx index bc8947e..712d6e7 100644 --- a/src/VirtualList/useStickyGroupHeader.tsx +++ b/src/VirtualList/useStickyGroupHeader.tsx @@ -13,6 +13,14 @@ type ExtraRenderInfo = Parameters< >[0]; // ============================== Utils =============================== +// `scrollTop` is the (possibly sub-pixel rounded) scroll offset while header +// tops come from summed item heights, so the two can disagree by a fraction of +// a pixel. On a Retina/HiDPI screen a trackpad scroll routinely rests ~0.5px +// short of a header top; without slack the strict compare would resolve to the +// previous group and pin a stale header off-screen. 1px absorbs the rounding +// and is far smaller than any real gap between consecutive headers. +const HEADER_TOP_TOLERANCE = 1; + function findActiveHeaderIndex( groupKeys: K[], getHeaderTop: (groupKey: K) => number, @@ -25,7 +33,7 @@ function findActiveHeaderIndex( while (left <= right) { const mid = Math.floor((left + right) / 2); - if (getHeaderTop(groupKeys[mid]) <= scrollTop) { + if (getHeaderTop(groupKeys[mid]) <= scrollTop + HEADER_TOP_TOLERANCE) { activeIndex = mid; left = mid + 1; } else { diff --git a/tests/hooks.test.tsx b/tests/hooks.test.tsx index b017d4f..906458a 100644 --- a/tests/hooks.test.tsx +++ b/tests/hooks.test.tsx @@ -368,4 +368,42 @@ describe('useStickyGroupHeader', () => { expect(stickyHeader).toHaveStyle({ top: '0px' }); expect(title).toHaveBeenCalledWith('Group 2', baseItems.slice(3, 6)); }); + + it('tolerates a sub-pixel scroll offset just short of the header top', () => { + const title = jest.fn().mockImplementation((key: React.Key) => ( + {String(key)} + )); + + // On a HiDPI screen the scroll offset can rest a fraction of a pixel below + // a header's true top (here 199.5 vs 200). Without tolerance the strict + // compare resolves to the previous group and blanks the title. + const info = createRenderInfo({ + scrollTop: 199.5, + start: 3, + getSize: (key: React.Key) => + key === 'Group 2' ? { top: 200, bottom: 220 } : { top: 0, bottom: 20 }, + }); + + const container = document.createElement('div'); + document.body.appendChild(container); + const params: StickyHeaderParams = { + enabled: true, + group: { + key: (item) => item.group, + title, + }, + groupKeys, + groupKeyToItems: baseItemsMap, + prefixCls: PREFIX_CLS, + listRef: createListRef(container), + }; + + render(); + + const stickyHeader = container.querySelector( + `.${PREFIX_CLS}-group-header-fixed`, + ); + expect(stickyHeader).not.toBeNull(); + expect(stickyHeader).toHaveTextContent('Group 2'); + }); }); From a8acbfb2fc7616a7e35adda45566b2d1a5644e4a Mon Sep 17 00:00:00 2001 From: Koyomi <1844749591@qq.com> Date: Sun, 28 Jun 2026 20:17:25 +0800 Subject: [PATCH 3/3] Remove unnecessary comments in useStickyGroupHeader Remove comments explaining scrollTop behavior in useStickyGroupHeader.tsx. --- src/VirtualList/useStickyGroupHeader.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/VirtualList/useStickyGroupHeader.tsx b/src/VirtualList/useStickyGroupHeader.tsx index 712d6e7..2762277 100644 --- a/src/VirtualList/useStickyGroupHeader.tsx +++ b/src/VirtualList/useStickyGroupHeader.tsx @@ -13,12 +13,6 @@ type ExtraRenderInfo = Parameters< >[0]; // ============================== Utils =============================== -// `scrollTop` is the (possibly sub-pixel rounded) scroll offset while header -// tops come from summed item heights, so the two can disagree by a fraction of -// a pixel. On a Retina/HiDPI screen a trackpad scroll routinely rests ~0.5px -// short of a header top; without slack the strict compare would resolve to the -// previous group and pin a stale header off-screen. 1px absorbs the rounding -// and is far smaller than any real gap between consecutive headers. const HEADER_TOP_TOLERANCE = 1; function findActiveHeaderIndex(