perf(team): measureElement + suppress remount animation on virtualized rows

Fifth step of the virtualization plan. Two small, coupled changes that
make the virtualized path stable without a merged-ref helper.

- Attach `rowVirtualizer.measureElement` to the existing virtualizer
  wrapper div. Because the wrapper carries no padding or margin, its
  bounding box matches the inner row, so the observer ref (which stays
  on the inner AnimatedHeightReveal node) and the measure ref (on the
  outer wrapper) address the same effective height. No merged ref
  callback is needed.
- Suppress mount-based entry animation inside the virtualized path.
  The virtualizer mounts and unmounts rows as the user scrolls them in
  and out; without this, the "new item" fade would replay every time
  an older row re-entered the viewport. `renderTimelineRow` now takes
  an optional `suppressEntryAnimation` flag and forwards `isNew=false`
  to both `LeadThoughtsGroupRow` and `MemoizedMessageRowWithObserver`
  when set. The direct render path is unchanged.

Still dormant in this release — `viewport.virtualizationEnabled` stays
false at every call site. PR #6 adds the threshold gate, tests, and
opt-in wiring.
This commit is contained in:
Mike 2026-04-20 00:40:07 +05:00
parent 7c4247bc73
commit b9c2dd5480

View file

@ -698,7 +698,18 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
// render path; separators and dividers are their own rows rather than
// being bundled into Fragments, which is the contract the virtualizer will
// consume in a follow-up PR.
const renderTimelineRow = (row: TimelineRow): React.JSX.Element | null => {
//
// `suppressEntryAnimation` is set when the caller is the virtualized path:
// the virtualizer mounts and unmounts rows as they enter and leave the
// viewport, so relying on mount as a signal of "this item is new" would
// replay the entry animation every time the user scrolls back to an old
// row. In the direct render path the flag stays false and animation still
// runs on real data-set additions.
const renderTimelineRow = (
row: TimelineRow,
options?: { suppressEntryAnimation?: boolean }
): React.JSX.Element | null => {
const suppressEntry = options?.suppressEntryAnimation === true;
switch (row.kind) {
case 'session-separator':
return (
@ -735,7 +746,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
isTeamAlive={pinnedCanBeLive ? isTeamAlive : undefined}
leadActivity={pinnedCanBeLive ? leadActivity : undefined}
leadContextUpdatedAt={pinnedCanBeLive ? leadContextUpdatedAt : undefined}
isNew={newItemKeys.has(key)}
isNew={!suppressEntry && newItemKeys.has(key)}
onVisible={onMessageVisible}
observerRoot={observerRoot}
zebraShade={zebraShadeSet.has(itemIndex)}
@ -772,7 +783,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
memberColor={renderProps.memberColor}
recipientColor={renderProps.recipientColor}
isUnread={isUnread}
isNew={newItemKeys.has(key)}
isNew={!suppressEntry && newItemKeys.has(key)}
zebraShade={zebraShadeSet.has(itemIndex)}
memberColorMap={colorMap}
localMemberNames={localMemberNames}
@ -826,6 +837,14 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
return (
<div
key={virtualRow.key}
// `measureElement` swaps each row's estimated height for its
// real rendered height as it mounts, so the virtualizer can
// correct totalSize and downstream row positions. The wrapper
// div carries no padding/margin, so its bounding box matches
// the inner row's bounding box — this is why a merged ref
// callback between the observer and `measureElement` isn't
// needed here.
ref={rowVirtualizer.measureElement}
data-index={virtualRow.index}
style={{
position: 'absolute',
@ -839,7 +858,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
transform: `translateY(${virtualRow.start - rowVirtualizer.options.scrollMargin}px)`,
}}
>
{renderTimelineRow(row)}
{renderTimelineRow(row, { suppressEntryAnimation: true })}
</div>
);
})}