Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,16 @@ export function RawList<T>(props: ListProps<T>, ref: React.Ref<ListRef>) {
() => Object.values(heights.maps).reduce((total, curr) => total + curr, 0),
[heights.id, heights.maps],
);
// Defer virtual rendering until after first commit so SSR emits stable, hydration-safe
// markup (no virtual scrollbar / Filler wrap that depends on client-side measurement).
// `useLayoutEffect` here only fires on the client, so `mounted` stays `false` on the
// server and on the first client render that hydrates the SSR output.
const [mounted, setMounted] = useState(false);
useLayoutEffect(() => {
setMounted(true);
}, []);
const inVirtual =
mounted &&
useVirtual &&
data &&
(Math.max(itemHeight * data.length, containerHeight) > height || !!scrollWidth);
Comment on lines 157 to 161
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

🚨 Performance Hazard: Rendering All Items on SSR / First Client Render

By gating inVirtual on mounted, the list will render in non-virtual mode during SSR and the first client render (hydration). This means all items in the data array will be rendered to the DOM (since start is 0 and end is data.length - 1 when inVirtual is false).

For large datasets (e.g., 1,000+ items), this completely defeats the purpose of virtualization on initial load, leading to:

  1. Large SSR HTML payloads and increased server CPU usage.
  2. Slow client-side hydration (TTI bottleneck) as React has to hydrate thousands of DOM nodes, only to immediately throw them away and re-render a few virtualized items once mounted becomes true.

💡 Solution: Render a Safe Subset on SSR & First Render

We can achieve both SSR safety (no hydration mismatch) and high performance by rendering a limited subset of items (e.g., based on height / itemHeight) when mounted is false and useVirtual is true. Since both SSR and the first client render will render the exact same subset, hydration will succeed perfectly without mismatch, and we avoid rendering the entire dataset.

1. Update the start/end calculation (around line 229):

    // Always use virtual scroll bar in avoid shaking
    if (!inVirtual) {
      const ssrCount = useVirtual ? Math.ceil(height / itemHeight) : mergedData.length;
      return {
        scrollHeight: fillerInnerRef.current?.offsetHeight || 0,
        start: 0,
        end: Math.min(mergedData.length, ssrCount) - 1,
        offset: undefined,
      };
    }

2. Prevent redundant onVisibleChange calls on initial mount (around line 563):

Currently, onVisibleChange will be called with the entire dataset on the first render, and then immediately called again with the virtualized subset after mount. We can gate this effect so it only triggers when virtualization is ready:

  useLayoutEffect(() => {
    if (onVisibleChange && (!useVirtual || mounted)) {
      const renderList = mergedData.slice(start, end + 1);

      onVisibleChange(renderList, mergedData);
    }
  }, [start, end, mergedData, mounted, useVirtual]);

Expand Down Expand Up @@ -587,7 +596,7 @@ export function RawList<T>(props: ListProps<T>, ref: React.Ref<ListRef>) {
if (height) {
componentStyle = { [fullHeight ? 'height' : 'maxHeight']: height, ...ScrollStyle };

if (useVirtual) {
if (inVirtual) {
componentStyle.overflowY = 'hidden';

if (scrollWidth) {
Expand Down
52 changes: 52 additions & 0 deletions tests/ssr.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import React from 'react';
import { renderToString } from 'react-dom/server';
import List from '../src';

const ITEM_HEIGHT = 20;

function genData(count: number) {
return new Array(count).fill(null).map((_, index) => ({ id: String(index) }));
}

describe('List.ssr', () => {
function ssr(extra?: Record<string, any>) {
return renderToString(
<List<{ id: string }>
data={genData(100)}
height={100}
itemHeight={ITEM_HEIGHT}
itemKey="id"
{...extra}
>
{(item) => <div key={item.id}>{item.id}</div>}
</List>,
);
}

it('initial SSR render should not enter virtual mode (no scrollbar / Filler wrap)', () => {
const html = ssr();
expect(html).not.toContain('rc-virtual-list-scrollbar');
// No Filler outer wrap with translateY (only emitted when inVirtual is true)
expect(html).not.toContain('translateY');
});

it('initial SSR render should use native overflow:auto on holder', () => {
const html = ssr();
// overflow-y stays "auto" before mount measures the container
expect(html).toContain('overflow-y:auto');
expect(html).not.toContain('overflow-y:hidden');
});

it('initial SSR render should not render horizontal scrollbar even when scrollWidth is set', () => {
const html = ssr({ scrollWidth: 200 });
expect(html).not.toContain('rc-virtual-list-scrollbar');
expect(html).not.toContain('overflow-x:hidden');
});

it('initial SSR render emits all items so the page is readable without JS', () => {
const html = ssr();
// First and last item should both appear in SSR output
expect(html).toContain('>0</div>');
expect(html).toContain('>99</div>');
});
});
Loading