diff --git a/examples/tanstack-start/src/routes/__root.tsx b/examples/tanstack-start/src/routes/__root.tsx index 1c9eb56..7eecbf3 100644 --- a/examples/tanstack-start/src/routes/__root.tsx +++ b/examples/tanstack-start/src/routes/__root.tsx @@ -49,7 +49,7 @@ function RootComponent() { activeProps={{ style: { fontWeight: 700 } }} style={{ textDecoration: 'none', color: '#222' }} > - ssr (current behavior) + ssr

SSR Masonry

- SSR is enabled by default in TanStack Start. The server HTML for this masonry container - currently renders no items because useWindowVirtualizer.getVirtualItems(){' '} - returns an empty array without a measured viewport — the items appear after client-side - hydration. + Opt-in SSR:{' '} + ssr={`{{ itemCount: ${SSR_ITEM_COUNT}, columnsCount: ${INITIAL_COLUMNS} }}`}{' '} + renders the first {SSR_ITEM_COUNT} tiles in the server HTML using the same lane-assignment + algorithm the client uses. Disable JS in your browser or view source to confirm the items + are present before hydration.

TILES[i]!.height} + ssr={{ itemCount: SSR_ITEM_COUNT, columnsCount: INITIAL_COLUMNS }} /> ); diff --git a/examples/tanstack-start/tests/ssr-baseline.spec.ts b/examples/tanstack-start/tests/ssr-baseline.spec.ts index c96e49c..c8201f6 100644 --- a/examples/tanstack-start/tests/ssr-baseline.spec.ts +++ b/examples/tanstack-start/tests/ssr-baseline.spec.ts @@ -1,12 +1,15 @@ import { expect, test } from '@playwright/test'; /** - * Captures the SSR behavior of as currently shipped. + * Captures the SSR behavior of . * - * The server HTML emits the page chrome and an empty masonry container; the - * actual tile markup is produced after client-side hydration. + * - The home route opts out of SSR via `ssr: false` — server emits no tile markup. + * - The /ssr route opts in via `ssrItemCount` — server emits the first N positioned tiles + * using the same lane-assignment code path the client uses. */ +const SSR_ITEM_COUNT = 30; + test.describe('Masonry SSR behavior', () => { test('home route disables SSR via ssr:false', async ({ request }) => { const response = await request.get('/'); @@ -26,20 +29,67 @@ test.describe('Masonry SSR behavior', () => { await expect(page.getByTestId('tile').first()).toBeVisible(); }); - test('ssr route emits the page chrome but no tile markup', async ({ request }) => { + test('ssr route emits the first N positioned tiles in raw HTML', async ({ request }) => { const response = await request.get('/ssr'); expect(response.status()).toBe(200); const html = await response.text(); expect(html).toContain('SSR Masonry'); + const tileMatches = html.match(/data-testid="tile"/g) ?? []; - expect(tileMatches.length).toBe(0); + expect(tileMatches.length).toBe(SSR_ITEM_COUNT); + }); + + test('ssr route HTML carries computed lane offsets (translateY)', async ({ request }) => { + const response = await request.get('/ssr'); + const html = await response.text(); + + // Items are emitted in index order in a flat list. Item 0/1/2 are seeded into + // lanes 0/1/2 (left-to-right), so the first three translateY values are all 0px. + const translateMatches = html.match(/translateY\((\d+(?:\.\d+)?)px\)/g) ?? []; + expect(translateMatches.length).toBe(SSR_ITEM_COUNT); + expect(translateMatches.slice(0, 3)).toEqual([ + 'translateY(0px)', + 'translateY(0px)', + 'translateY(0px)', + ]); }); - test('client hydration produces tiles after mount', async ({ page }) => { + test('ssr lane positions use single calc(% + px) form across all phases', async ({ request }) => { + const response = await request.get('/ssr'); + const html = await response.text(); + + // Single calc form is used everywhere — server, first paint, and post-mount — so the + // browser computes the same lane positions whether or not container width is known in JS. + // For container W, gutter g, columns n, lane k: left = k*(100/n)% + k*(g/n)px. + const lane0 = html.match(/data-index="0"[^>]*left:([^;"]+)/)?.[1]; + const lane1 = html.match(/data-index="1"[^>]*left:(calc\([^)]+\))/)?.[1]; + const lane2 = html.match(/data-index="2"[^>]*left:(calc\([^)]+\))/)?.[1]; + + // Lane 0 → calc(0% + 0px) + expect(lane0).toBe('calc(0% + 0px)'); + // Lane 1 → calc(33.333…% + 5.333…px) (n=3, g=16) + expect(lane1).toMatch(/^calc\(33\.\d+% \+ 5\.\d+px\)$/); + // Lane 2 → calc(66.666…% + 10.666…px) + expect(lane2).toMatch(/^calc\(66\.\d+% \+ 10\.\d+px\)$/); + }); + + test('client hydration produces tiles after mount with no hydration warnings', async ({ + page, + }) => { + const consoleErrors: string[] = []; + page.on('console', (msg) => { + if (msg.type() === 'error') consoleErrors.push(msg.text()); + }); + await page.goto('/ssr'); await expect(page.getByTestId('tile').first()).toBeVisible(); const count = await page.getByTestId('tile').count(); expect(count).toBeGreaterThan(0); + + const hydrationErrors = consoleErrors.filter((err) => + /hydrat|did not match|Server HTML/i.test(err) + ); + expect(hydrationErrors).toEqual([]); }); }); diff --git a/package/src/Masonry/index.tsx b/package/src/Masonry/index.tsx index 23dc439..d468732 100644 --- a/package/src/Masonry/index.tsx +++ b/package/src/Masonry/index.tsx @@ -8,6 +8,28 @@ const DEFAULT_OVERSCAN = 3; const DEFAULT_GUTTER = 20; type ColumnCountBreakPoints = BreakpointValues; + +/** + * Server-side rendering configuration. Pass to opt into rendering positioned items in the + * server HTML. The first paint on the client takes the same code path while + * `containerRef.current` is null, so React hydration matches exactly. + * + * Layout shifts on/after `useEffect` (e.g. lane count changing because the actual viewport + * crosses a breakpoint different from `columnsCount`) are post-mount reflows, not hydration + * mismatches. Provide accurate hints to minimize them. + */ +export interface SSRConfig { + /** Number of items to render in server HTML. Required to opt in. */ + itemCount: number; + /** + * Distance from document top to grid container — substitutes for + * `containerRef.current.offsetTop` while ref is null. Defaults to 0. + */ + scrollMargin?: number; + /** Columns count used during SSR / first paint. Defaults to `DEFAULT_COLUMNS_COUNT`. */ + columnsCount?: number; +} + interface Props { data: Data[]; renderItem: (props: { item: Data; index: number }) => ReactNode; @@ -15,6 +37,12 @@ interface Props { gutter?: number; // px estimateSize?: (index: number) => number; overscan?: number; + /** + * SSR config. When unset, the server emits an empty container (default behavior). When set, + * the first `ssr.itemCount` items render with positions computed from the same code path + * the client uses. + */ + ssr?: SSRConfig; } export function Masonry({ @@ -24,29 +52,52 @@ export function Masonry({ gutter = DEFAULT_GUTTER, estimateSize, overscan = DEFAULT_OVERSCAN, + ssr, }: Props) { 'use no memo'; const { getResponsiveValue } = useResponsiveValue(); - const columnsCount = getResponsiveValue(columnsCountBreakPoints, DEFAULT_COLUMNS_COUNT); - const containerRef = useRef(null); - const laneWidth = containerRef.current - ? (containerRef.current.offsetWidth - (columnsCount - 1) * gutter) / columnsCount - : 0; + // SSR/hydration: windowWidth=0 → DEFAULT. Post-mount / client-remount: real breakpoint. + const responsiveColumns = getResponsiveValue(columnsCountBreakPoints, DEFAULT_COLUMNS_COUNT); + // Pre-ref: ssr override → else responsive (DEFAULT on server, real on client-remount). + const columnsCount = containerRef.current + ? responsiveColumns + : (ssr?.columnsCount ?? responsiveColumns); const virtualizer = useWindowVirtualizer({ count: data.length, estimateSize: estimateSize ?? (() => 0), overscan, lanes: columnsCount, - scrollMargin: containerRef.current?.offsetTop ?? 0, + scrollMargin: containerRef.current?.offsetTop ?? ssr?.scrollMargin ?? 0, gap: gutter, laneAssignmentMode: 'measured', }); + // Calling getVirtualItems() triggers the internal getMeasurements() memo chain, populating + // the public `measurementsCache` field as a side effect. We can't call getMeasurements() + // directly because TanStack Virtual marks it private — `measurementsCache` is the public + // surface for the same data. The two `visibleItems.length === 0` cases are: (a) server + // (no rect), (b) client first paint (effect not yet fired). Both produce identical output + // for hydration to match exactly. Slicing to `ssr.itemCount` is essential — without it we'd + // emit one positioned
per data item and defeat virtualization at SSR time. + const visibleItems = virtualizer.getVirtualItems(); + const itemsToRender = + visibleItems.length === 0 && ssr + ? virtualizer.measurementsCache.slice(0, ssr.itemCount) + : visibleItems; + + // Lane width: (W - (n-1)*gutter) / n. + // width: 100/n% - (n-1)/n * gutter px + // left: lane * (laneWidth + gutter) = lane * 100/n% + lane * gutter/n px + // CSS calc handles unknown W identically on server, first paint, and post-mount, so there + // is no SSR-vs-client drift. Items stay in the same flat parent — lane reassignment via + // `laneAssignmentMode: 'measured'` updates only the `left` attribute, no remount. + const laneWidthCalc = `calc(${100 / columnsCount}% - ${(gutter * (columnsCount - 1)) / columnsCount}px)`; + return (
({ position: 'relative', }} > - {virtualizer.getVirtualItems().map(({ lane, key, index, start }) => ( + {itemsToRender.map(({ lane, key, index, start }) => (
({ style={{ position: 'absolute', top: 0, - left: `${(laneWidth + gutter) * lane}px`, - width: `${laneWidth}px`, + left: `calc(${(100 * lane) / columnsCount}% + ${(gutter * lane) / columnsCount}px)`, + width: laneWidthCalc, transform: `translateY(${start - virtualizer.options.scrollMargin}px)`, }} >