feat(messages): add virtualization and memoization calculating#179
feat(messages): add virtualization and memoization calculating#179ananas7 wants to merge 1 commit into
Conversation
|
🚀 Prerelease version published! Install this PR version: npm i --save-dev @gravity-ui/aikit@2.2.1-beta.87a92c9daef221a048c64c4e9b6dba113c11f023.0 |
|
Preview is ready. |
|
🎭 Component Tests Report is ready. |
b33a97c to
87a92c9
Compare
There was a problem hiding this comment.
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 root1. 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.
There was a problem hiding this comment.
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:
- Misses prepends when the top id doesn't change. Cursor-based pagination that re-returns the cursor message, or any merge/dedup, leaves
firstMessageIdstable →isPrepend = false→ viewport jumps. - 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). - False positives on replacement. If
firstMessageIdchanges for a non-prepend reason (re-sort, full replacement), the prepend correction fires for the wrong scenario. Cheap guard: also requireprevFirstIdto 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); |
There was a problem hiding this comment.
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.memoshallow-compareschildren, 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 (MessageItem→AssistantMessage/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 leafmemostill does not bail out unless the consumer stabilizes props themselves.
Suggested change (in this PR or a quick follow-up):
- Keep:
MessageItem,AssistantMessage(and optionallyBaseMessageif you want a standalone export guard). - Revert leaf
memoon:MessageBalloon,ToolMessage,ThinkingMessage,UserMessage— or at minimumMessageBalloon.
Small diff, no impact on virtualization behavior, less memo-stack complexity.
| ); | ||
| } | ||
|
|
||
| export const AssistantMessage = memo(AssistantMessageComponent) as typeof AssistantMessageComponent; |
No description provided.