From 107d63cc211c5d123265d8b94d4b3f020cbb4351 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Wed, 27 May 2026 12:08:32 +0800 Subject: [PATCH] fix: defer virtual rendering until after first commit for SSR safety Initial render (SSR and the first client render that hydrates it) cannot know the container size, so rendering virtual scrollbars, the Filler outer wrap (translateY) or forcing `overflow: hidden` on the holder produces markup that diverges from the post-mount UI and can cause hydration mismatches. Gate `inVirtual` and the holder overflow style behind a `mounted` flag that flips to `true` in `useLayoutEffect`. SSR now emits the same markup as a non-virtual list (all items rendered, native scroll on the holder); after mount the component re-renders with the real virtual layout based on measured sizes. Co-Authored-By: Claude Opus 4.7 --- src/List.tsx | 11 +++++++++- tests/ssr.test.tsx | 52 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 tests/ssr.test.tsx diff --git a/src/List.tsx b/src/List.tsx index 046cae2..46b199c 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -146,7 +146,16 @@ export function RawList(props: ListProps, ref: React.Ref) { () => 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); @@ -587,7 +596,7 @@ export function RawList(props: ListProps, ref: React.Ref) { if (height) { componentStyle = { [fullHeight ? 'height' : 'maxHeight']: height, ...ScrollStyle }; - if (useVirtual) { + if (inVirtual) { componentStyle.overflowY = 'hidden'; if (scrollWidth) { diff --git a/tests/ssr.test.tsx b/tests/ssr.test.tsx new file mode 100644 index 0000000..54305da --- /dev/null +++ b/tests/ssr.test.tsx @@ -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) { + return renderToString( + + data={genData(100)} + height={100} + itemHeight={ITEM_HEIGHT} + itemKey="id" + {...extra} + > + {(item) =>
{item.id}
} + , + ); + } + + 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'); + expect(html).toContain('>99'); + }); +});