Skip to content

fix: show the correct group header when scrolled flush to its top#50

Merged
zombieJ merged 3 commits into
react-component:masterfrom
aojunhao123:fix/sticky-group-header-empty-at-boundary
Jun 29, 2026
Merged

fix: show the correct group header when scrolled flush to its top#50
zombieJ merged 3 commits into
react-component:masterfrom
aojunhao123:fix/sticky-group-header-empty-at-boundary

Conversation

@aojunhao123

@aojunhao123 aojunhao123 commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator

Before

Clipboard-20260628-060614-174

After

Clipboard-20260628-121038-241

Problem

In the grouped sticky list the sticky header can render blank — it shows the previous group's header pinned off-screen instead of the current group's title. Two distinct triggers, same symptom:

  1. Jumping to a groupscrollTo({ groupKey, align: 'top' }) (e.g. Scroll to G8 then Scroll to G17) lands a header exactly at the viewport top.
  2. Trackpad / momentum scrolling on a HiDPI (Retina) screen — the scroll rests a fraction of a pixel short of a header top.

Root cause

1. Index vs pixel coordinate mismatch. The active header was chosen from the virtual list's start row index:

findActiveHeaderIndex(headerRows, start) // last header with rowIndex <= start

but the overlay's top is computed in pixels (getSize(...).top, scrollTop). rc-virtual-list derives start with currentItemBottom >= offsetTop, so when a header sits flush at the top the previous group's trailing row (bottom === scrollTop) is still counted as start → the lookup picks the previous group and the top math pins that stale header at a negative offset, clipping the title.

2. Sub-pixel rounding. Selecting by pixel offset fixes (1), but scrollTop is rounded to the device's sub-pixel grid while header tops come from summed item heights. On a Retina screen a trackpad scroll routinely rests ~0.5px below a header's true top, so a strict getSize(top) <= scrollTop flips back to the previous group — the same blank, but only reachable via real (non-integer) scrolling rather than scrollTo.

Fix

  • Select by pixel offset (commit 1): last header with getSize(groupKey).top <= scrollTop — the same coordinate space as the positioning math, so selection and top can't disagree at an exact boundary.
  • Tolerate sub-pixel (commit 2): compare with 1px of slack (getSize(top) <= scrollTop + 1). 1px absorbs the rounding (the sub-pixel grid is <1px at any DPR) yet is far below the gap between consecutive headers, so it never skips a header. At the 1px-early switch point the incoming header's top evaluates to exactly 0 (items_next + 1 > 0), so the tolerance can never re-introduce a negative/clipped header.

Tests

  • Exact-alignment boundary — header flush at the top while start points at the previous group's last row. Verified red→green against the old start-based code.
  • Sub-pixel offsetscrollTop 0.5px short of a header top still resolves to the current group. Verified red→green (fails at strict <=, passes with the tolerance).
  • Full unit suite green.
  • Behaviour confirmed in a real Chrome at DPR 2: a fine-grained scroll sweep across a boundary switches cleanly — no blank, the incoming header pins at top: 0.

Incidental cleanup

Once selection no longer needs row indices, rowIndex became dead — removed it, and collapsed the flatten hook's headerRows: { groupKey: K }[] to groupKeys: K[].

🤖 Generated with Claude Code

Summary by CodeRabbit

  • 新功能

    • 虚拟列表的分组固定表头支持更稳定的滚动定位与切换。
    • 固定表头现在可正常悬浮在列表外部,显示效果更一致。
  • 问题修复

    • 修复分组表头在接近视口顶部、切换到下一组时的位置偏移问题。
    • 修复细微滚动偏差下,固定表头显示组别不准确的问题。

@coderabbitai

coderabbitai Bot commented Jun 27, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0dcc8381-4592-4fb9-8c93-ef9fbbcdb8be

📥 Commits

Reviewing files that changed from the base of the PR and between c51f0c8 and a8acbfb.

📒 Files selected for processing (4)
  • src/VirtualList/index.tsx
  • src/VirtualList/useFlattenRows.ts
  • src/VirtualList/useStickyGroupHeader.tsx
  • tests/hooks.test.tsx
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/VirtualList/index.tsx
  • src/VirtualList/useFlattenRows.ts
  • src/VirtualList/useStickyGroupHeader.tsx
  • tests/hooks.test.tsx

Walkthrough

将分组虚拟列表的粘性表头定位机制从基于行索引的 headerRows{ groupKey, rowIndex }[])重构为直接使用分组键数组 groupKeys: K[],相应更新 useFlattenRows 返回结构、useStickyGroupHeader 的二分查找逻辑与入参契约,并同步修改 VirtualList 调用点和测试。

Changes

粘性组头 headerRows → groupKeys 重构

Layer / File(s) Summary
useFlattenRows 契约与实现更新
src/VirtualList/useFlattenRows.ts
FlattenRowsResult 接口将 headerRows: { groupKey, rowIndex }[] 替换为 groupKeys: K[];flatten 循环改为将 groupKey 直接追加到 groupKeys;无分组路径返回空 groupKeys
useStickyGroupHeader 重写:groupKeys + 位置二分查找
src/VirtualList/useStickyGroupHeader.tsx
新增 HEADER_TOP_TOLERANCEfindActiveHeaderIndex 改为基于 groupKeys + getHeaderTop + scrollTop 的二分查找;StickyHeaderParams 移除 headerRows 改为 groupKeysextraRender 的早退条件、activeHeaderIdx 计算、style.top 推开公式及 GroupHeadergroupKey prop 均跟随新逻辑更新;useCallback 依赖数组同步替换为 groupKeys
VirtualList 调用点更新
src/VirtualList/index.tsx
useFlattenRows 解构 groupKeysgroupKeyToItems(移除 headerRows),并将 groupKeys 传入 useStickyGroupHeader
测试契约更新
tests/hooks.test.tsx
新增 createListRef 辅助函数与 afterEach 清理;useFlattenRows 断言改为校验 groupKeysuseStickyGroupHeader 各用例注入 groupKeys + listRef,断言固定头 DOM 样式(top 值)、文本与边界滚动行为。

估计代码审查工作量

🎯 3 (Moderate) | ⏱️ ~20 minutes

可能相关的 PR

  • react-component/listy#49:同样修改 useStickyGroupHeader.tsx,引入了通过 listRef 和 Portal 进行视口相对定位渲染的机制,与本 PR 的 Portal 渲染路径直接相关。
  • react-component/listy#46:同样修改 useStickyGroupHeader.tsx 中基于表头行索引的定位逻辑,与本 PR 的重构起点直接重叠。
  • react-component/listy#43:修改了同一 hook 的 StickyHeaderParams 类型定义及 Portal/容器逻辑,与本 PR 的参数契约变更存在代码级关联。

建议审查者

  • zombieJ

🐇 headerRows 已成昨日梦,
groupKeys 轻盈列队行。
二分查找寻组顶,
Portal 渲染固定城。
小兔跳过每一行,代码焕然一新春! 🌸

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 标题准确概括了本次修复:滚动到分组头部边界时显示正确的组头。
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jun 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (8abaee9) to head (a8acbfb).

Additional details and impacted files
@@            Coverage Diff            @@
##            master       #50   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            8         8           
  Lines          209       211    +2     
  Branches        62        62           
=========================================
+ Hits           209       211    +2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the sticky group header implementation in VirtualList to render the header inside a portal container rather than directly above the list items. It replaces the index-based headerRows tracking with a scroll-position-based groupKeys lookup. The review feedback highlights an edge case where scrolling above the first group header (such as during rubber-banding) incorrectly defaults to activating the first header. To resolve this, the reviewer suggests initializing the active index to -1, returning null early when no header is active, and adding a corresponding test case.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines 21 to 23
let left = 0;
let right = headerRows.length - 1;
let right = groupKeys.length - 1;
let activeIndex = 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When scrollTop is less than the first group header's top (e.g., during rubber-banding/elastic scrolling on iOS/macOS, or if there is header/padding space above the first group), findActiveHeaderIndex currently defaults to 0. This causes the first group's sticky header to remain visible at top: 0 even though the real header has not yet reached the top of the viewport, leading to a visual glitch (duplicate/detached header). We should initialize activeIndex to -1 to indicate that no header is active yet.

Suggested change
let left = 0;
let right = headerRows.length - 1;
let right = groupKeys.length - 1;
let activeIndex = 0;
let left = 0;
let right = groupKeys.length - 1;
let activeIndex = -1;

Comment on lines +78 to +83
const activeHeaderIdx = findActiveHeaderIndex(
groupKeys,
(groupKey) => getSize(groupKey).top,
scrollTop,
);
const currGroupKey = groupKeys[activeHeaderIdx];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Handle the case where activeHeaderIdx is -1 (no active header) by returning null early. This prevents rendering a redundant sticky header when the list is scrolled above the first group header, and avoids potential runtime errors when trying to access groupKeys[-1].

Suggested change
const activeHeaderIdx = findActiveHeaderIndex(
groupKeys,
(groupKey) => getSize(groupKey).top,
scrollTop,
);
const currGroupKey = groupKeys[activeHeaderIdx];
const activeHeaderIdx = findActiveHeaderIndex(
groupKeys,
(groupKey) => getSize(groupKey).top,
scrollTop,
);
if (activeHeaderIdx === -1) {
return null;
}
const currGroupKey = groupKeys[activeHeaderIdx];

Comment thread tests/hooks.test.tsx
Comment on lines +369 to 371
expect(title).toHaveBeenCalledWith('Group 2', baseItems.slice(3, 6));
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add a test case to verify that the sticky header is not rendered when the list is scrolled above the first group header (e.g., during rubber-banding).

    expect(title).toHaveBeenCalledWith('Group 2', baseItems.slice(3, 6));
  });

  it('returns null and does not render sticky header when scrolled above the first group header', () => {
    const title = jest.fn().mockImplementation((key: React.Key) => (
      <span data-testid="sticky-title">{String(key)}</span>
    ));

    const info = createRenderInfo({
      scrollTop: -10,
      start: 0,
      getSize: (key: React.Key) => ({ top: 0, bottom: 20 }),
    });

    const container = document.createElement('div');
    document.body.appendChild(container);
    const params: StickyHeaderParams<GroupedItem> = {
      enabled: true,
      group: { 
        key: (item) => item.group,
        title,
      },
      groupKeys,
      groupKeyToItems: baseItemsMap,
      prefixCls: PREFIX_CLS,
      listRef: createListRef(container),
    };

    render(<StickyHeaderTester params={params} info={info} />);

    const stickyHeader = container.querySelector(
      `.${PREFIX_CLS}-group-header-fixed`,
    );
    expect(stickyHeader).toBeNull();
  });
});

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/VirtualList/useStickyGroupHeader.tsx`:
- Around line 72-75: The sticky header logic in useStickyGroupHeader is reading
listRef.current directly inside extraRender, which can be null on the first
RcVirtualList render and cause the header to miss its initial frame. Move the
container lookup into a synced state or layout effect, then have the Portal use
that stable container instead of accessing listRef.current during render; keep
the fix localized around useStickyGroupHeader and the Portal container handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a4fe3e85-fe77-4b06-b8fe-1104d25bda81

📥 Commits

Reviewing files that changed from the base of the PR and between 077d6d8 and c805d6a.

📒 Files selected for processing (5)
  • assets/index.less
  • src/VirtualList/index.tsx
  • src/VirtualList/useFlattenRows.ts
  • src/VirtualList/useStickyGroupHeader.tsx
  • tests/hooks.test.tsx

Comment thread src/VirtualList/useStickyGroupHeader.tsx
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[]`.
… header

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.
Remove comments explaining scrollTop behavior in useStickyGroupHeader.tsx.
@aojunhao123
aojunhao123 force-pushed the fix/sticky-group-header-empty-at-boundary branch from c51f0c8 to a8acbfb Compare June 29, 2026 07:26
@vercel

vercel Bot commented Jun 29, 2026

Copy link
Copy Markdown

@aojunhao123 is attempting to deploy a commit to the React Component Team on Vercel.

A member of the Team first needs to authorize it.

@zombieJ zombieJ left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@zombieJ
zombieJ merged commit f2e2d62 into react-component:master Jun 29, 2026
8 of 9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants