Skip to content

feat(messages): add virtualization and memoization calculating#179

Open
ananas7 wants to merge 1 commit into
mainfrom
feature/optimization
Open

feat(messages): add virtualization and memoization calculating#179
ananas7 wants to merge 1 commit into
mainfrom
feature/optimization

Conversation

@ananas7

@ananas7 ananas7 commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

@gravity-ui

gravity-ui Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

🚀 Prerelease version published!

Install this PR version:

npm i --save-dev @gravity-ui/aikit@2.2.1-beta.87a92c9daef221a048c64c4e9b6dba113c11f023.0

@gravity-ui-bot

Copy link
Copy Markdown

Preview is ready.

@gravity-ui

gravity-ui Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

🎭 Component Tests Report is ready.

@ananas7 ananas7 marked this pull request as ready for review June 8, 2026 13:45
@ananas7 ananas7 force-pushed the feature/optimization branch from b33a97c to 87a92c9 Compare June 9, 2026 08:59

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.

useVirtualStickToBottom is the riskiest part of this change (~150 lines of scroll logic), but it's only covered indirectly by the DOM windowing test. We should add behavioral tests for prepend anchoring, user-scroll-up during streaming, and streamingSignal pinning — screenshots won't work here (dynamic row heights), so assert scroll metrics instead.

Suggested approach: Playwright CT with a deterministic harness story (virtualized + controlled prepend/stream), plus optional Jest unit tests with a mocked listApi.

// helper — same math as the hook (SCROLL_THRESHOLD = 10)
async function distanceFromBottom(locator: Locator) {
  return locator.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight);
}

const scroller = page.locator('.g-aikit-message-list__list'); // react-window owns scroll, not the root

1. Prepend should not jump to the bottom

test('should preserve viewport when prepending older messages', async ({mount, page}) => {
  await mount(<MessageListStories.WithVirtualizedPreviousMessages />);

  await scroller.evaluate((el) => { el.scrollTop = 0; });
  await expect(page.getByText('User message 11')).toBeVisible();

  const scrollTopBefore = await scroller.evaluate((el) => el.scrollTop);
  // trigger load-more → wait for prepend
  await expect(page.getByText('User message 6')).toBeVisible();

  await expect(page.getByText('User message 11')).toBeVisible();
  const scrollTopAfter = await scroller.evaluate((el) => el.scrollTop);
  expect(Math.abs(scrollTopAfter - scrollTopBefore)).toBeLessThan(50);
});

2. Scroll up disables auto-pin during streaming

test('should not auto-scroll when user scrolled up during streaming', async ({mount, page}) => {
  await mount(<MessageListStories.WithVirtualizedStreaming />); // deterministic chunks, no Math.random()

  await scroller.evaluate((el) => { el.scrollTop = 0; });
  const distBefore = await distanceFromBottom(scroller);

  await page.evaluate(() => window.__appendStreamChunk?.('more text'));
  await page.waitForTimeout(100); // rAF pin

  expect(await distanceFromBottom(scroller)).toBeGreaterThan(10);
});

3. streamingSignal keeps the bottom pinned without onRowsRendered

test('should stay pinned while last row grows in place', async ({mount, page}) => {
  await mount(<MessageListStories.WithVirtualizedStreaming />);

  for (let i = 0; i < 5; i++) {
    await page.evaluate(() => window.__appendStreamChunk?.('word '));
    await page.waitForTimeout(50);
    expect(await distanceFromBottom(scroller)).toBeLessThan(10);
  }
});

Optional: Jest unit test

Mock useListCallbackRef, rerender with a new firstMessageId + higher messagesCount, and assert scrollToRow is called with index: added + headerOffset (prepend), not rowCount - 1.

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.

The plain path (useScrollPreservation) measures scrollHeight delta and shifts scrollTop by the same number of pixels — exact, regardless of why content grew.

The virtualized path (useVirtualStickToBottom) detects prepend by firstMessageId change + count growth, then calls scrollToRow({index: added + headerOffset, align: 'start'}). Three issues:

  1. Misses prepends when the top id doesn't change. Cursor-based pagination that re-returns the cursor message, or any merge/dedup, leaves firstMessageId stable → isPrepend = false → viewport jumps.
  2. Loses intra-message scroll offset. Even with perfectly known row heights, align: 'start' snaps the old-top message to the viewport top. If the user was scrolled partway through it, that offset is silently dropped (up to one message height of jump).
  3. False positives on replacement. If firstMessageId changes for a non-prepend reason (re-sort, full replacement), the prepend correction fires for the wrong scenario. Cheap guard: also require prevFirstId to still exist in the new array.

Suggested fix: measure pixel delta in the virtualized path too — capture listApi.getRowOffset(headerOffset) (or scrollTop + the old-top row's offset) before the prepend commits, then in useLayoutEffect set scrollTop = newOffsetOfOldTop - savedDeltaFromViewportTop. Matches the plain path's behavior and handles all three cases.

);
};

export const MessageBalloon = memo(MessageBalloonComponent);

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.

Redundant memo on leaf message components

This PR adds memo across the message tree (MessageItem, AssistantMessage, BaseMessage, UserMessage, ToolMessage, ThinkingMessage, MessageBalloon). The top-level wrappers are the ones that matter for virtualization; the leaf-level ones add diff noise without measurable benefit.

What actually helps in the virtualized list:

  • MessageItem (MessageItem.tsx:134) — skips re-rendering ~1999 unchanged rows during streaming.
  • AssistantMessage (AssistantMessage.tsx:111) — same for the heavy assistant path (registry, normalizeContent, part rendering).

If MessageItem bails out, AssistantMessage / ToolMessage / ThinkingMessage are never reached. If MessageItem re-renders (message changed), leaf props change too, so their memo cannot skip anyway.

Leaf memo is especially low-value:

  • MessageBalloon (MessageBalloon/index.tsx:25) — a single <div> wrapper. memo shallow-compares children, which is almost always a new reference on parent re-render, so it rarely bails out.
  • ToolMessage, ThinkingMessage, UserMessage — only rendered under already-memoized parents (MessageItemAssistantMessage / UserMessage). Internal stacking does not add a second line of defense in the list path.
  • Standalone library usage is a weak counter-argument: consumers typically pass inline JSX/callbacks (McpToolRenderer.tsx:37), so leaf memo still does not bail out unless the consumer stabilizes props themselves.

Suggested change (in this PR or a quick follow-up):

  • Keep: MessageItem, AssistantMessage (and optionally BaseMessage if you want a standalone export guard).
  • Revert leaf memo on: MessageBalloon, ToolMessage, ThinkingMessage, UserMessage — or at minimum MessageBalloon.

Small diff, no impact on virtualization behavior, less memo-stack complexity.

);
}

export const AssistantMessage = memo(AssistantMessageComponent) as typeof AssistantMessageComponent;

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.

displayName? )

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.

3 participants