From bb4071a51836242058648814244fdce3b9ed1407 Mon Sep 17 00:00:00 2001 From: Wonhee Lee <2wheeh@gmail.com> Date: Thu, 7 May 2026 18:08:44 +0900 Subject: [PATCH 1/3] w --- examples/tanstack-start/src/routes/ssr.tsx | 12 ++- .../tanstack-start/tests/ssr-baseline.spec.ts | 44 +++++++++-- package/src/Masonry/index.tsx | 74 +++++++++++++++++-- 3 files changed, 112 insertions(+), 18 deletions(-) diff --git a/examples/tanstack-start/src/routes/ssr.tsx b/examples/tanstack-start/src/routes/ssr.tsx index 5faff5f..38224c6 100644 --- a/examples/tanstack-start/src/routes/ssr.tsx +++ b/examples/tanstack-start/src/routes/ssr.tsx @@ -8,16 +8,19 @@ export const Route = createFileRoute('/ssr')({ }); const TILES = createTiles(200); +const SSR_ITEM_COUNT = 30; +const INITIAL_COLUMNS = 3; function SsrPage() { return (

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..b00d1f0 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,49 @@ 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('client hydration produces tiles after mount', async ({ page }) => { + test('ssr route HTML carries computed lane offsets (translateY)', async ({ request }) => { + const response = await request.get('/ssr'); + const html = await response.text(); + + // Each rendered item carries an absolute-position style with a translateY offset + // computed by the same getMeasurements()/measurementsCache path the client uses. + // The first three items should sit at y=0 (one per lane in a 3-column layout). + 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 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..8ae8910 100644 --- a/package/src/Masonry/index.tsx +++ b/package/src/Masonry/index.tsx @@ -8,6 +8,34 @@ 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; + /** + * Container width hint used when `containerRef.current` is null (server + first paint). + * Without this, lane width falls back to a `%` calc — visually OK but not pixel-accurate + * until the client measures the real container. + */ + containerWidth?: 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 +43,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,17 +58,22 @@ 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 + const measuredColumns = getResponsiveValue(columnsCountBreakPoints, DEFAULT_COLUMNS_COUNT); + const columnsCount = containerRef.current + ? measuredColumns + : (ssr?.columnsCount ?? measuredColumns); + + const containerWidth = containerRef.current?.offsetWidth ?? ssr?.containerWidth ?? 0; + const laneWidth = containerWidth + ? (containerWidth - (columnsCount - 1) * gutter) / columnsCount : 0; const virtualizer = useWindowVirtualizer({ @@ -42,11 +81,24 @@ export function Masonry({ 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; + 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: laneWidth + ? `${(laneWidth + gutter) * lane}px` + : `${(100 / columnsCount) * lane}%`, + // When laneWidth is unknown, approximate with `calc(100/n% - gutter*(n-1)/n px)` + // so the n columns + (n-1) gutters sum to 100% of the container. + width: laneWidth + ? `${laneWidth}px` + : `calc(${100 / columnsCount}% - ${(gutter * (columnsCount - 1)) / columnsCount}px)`, transform: `translateY(${start - virtualizer.options.scrollMargin}px)`, }} > From b6b8d3058f1dfa693a0f7f54bcca3733fefadefb Mon Sep 17 00:00:00 2001 From: Wonhee Lee <2wheeh@gmail.com> Date: Thu, 7 May 2026 18:48:27 +0900 Subject: [PATCH 2/3] t --- examples/tanstack-start/src/routes/__root.tsx | 2 +- .../tanstack-start/tests/ssr-baseline.spec.ts | 23 +++++++++++++++++++ package/src/Masonry/index.tsx | 18 ++++++++++----- 3 files changed, 36 insertions(+), 7 deletions(-) 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 { ]); }); + test('ssr lane lefts use calc(% + px) form to match post-mount px positions', async ({ + request, + }) => { + const response = await request.get('/ssr'); + const html = await response.text(); + + // For container width W, gutter g, columns n, lane k: + // post-mount px form: left = (laneWidth + g) * k = k * (W + g) / n + // = k * 100/n % + k * g/n px (when expressed against W as 100%) + // The SSR `%` fallback must include the `+ k*g/n px` term — without it positions + // drift left by `k * g / n` (e.g. n=3, g=16: ~5.33px at lane 1, ~10.67px at lane 2). + 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) + 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, }) => { diff --git a/package/src/Masonry/index.tsx b/package/src/Masonry/index.tsx index 8ae8910..ba7bee6 100644 --- a/package/src/Masonry/index.tsx +++ b/package/src/Masonry/index.tsx @@ -66,10 +66,12 @@ export function Masonry({ const containerRef = useRef(null); - const measuredColumns = getResponsiveValue(columnsCountBreakPoints, DEFAULT_COLUMNS_COUNT); + // 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 - ? measuredColumns - : (ssr?.columnsCount ?? measuredColumns); + ? responsiveColumns + : (ssr?.columnsCount ?? responsiveColumns); const containerWidth = containerRef.current?.offsetWidth ?? ssr?.containerWidth ?? 0; const laneWidth = containerWidth @@ -116,11 +118,15 @@ export function Masonry({ style={{ position: 'absolute', top: 0, + // When laneWidth is known: left = (laneWidth + gutter) * lane. + // When unknown: left in % needs gutter compensation, otherwise it drifts left + // by `lane * gutter / n` vs the post-mount px form. Derivation: + // client px form: lane * (laneWidth + g) = lane * (W + g) / n + // = lane * 100/n % + lane * g/n (when expressed against container W) left: laneWidth ? `${(laneWidth + gutter) * lane}px` - : `${(100 / columnsCount) * lane}%`, - // When laneWidth is unknown, approximate with `calc(100/n% - gutter*(n-1)/n px)` - // so the n columns + (n-1) gutters sum to 100% of the container. + : `calc(${(100 * lane) / columnsCount}% + ${(gutter * lane) / columnsCount}px)`, + // Width: laneWidth = (W - (n-1)g) / n = 100/n% - (n-1)/n * g px (in calc form). width: laneWidth ? `${laneWidth}px` : `calc(${100 / columnsCount}% - ${(gutter * (columnsCount - 1)) / columnsCount}px)`, From dd347649c893191a8fa8863419955d96161e0af2 Mon Sep 17 00:00:00 2001 From: Wonhee Lee <2wheeh@gmail.com> Date: Thu, 7 May 2026 19:12:45 +0900 Subject: [PATCH 3/3] q --- .../tanstack-start/tests/ssr-baseline.spec.ts | 19 ++++------- package/src/Masonry/index.tsx | 33 ++++++------------- 2 files changed, 17 insertions(+), 35 deletions(-) diff --git a/examples/tanstack-start/tests/ssr-baseline.spec.ts b/examples/tanstack-start/tests/ssr-baseline.spec.ts index 883e5ea..c8201f6 100644 --- a/examples/tanstack-start/tests/ssr-baseline.spec.ts +++ b/examples/tanstack-start/tests/ssr-baseline.spec.ts @@ -44,9 +44,8 @@ test.describe('Masonry SSR behavior', () => { const response = await request.get('/ssr'); const html = await response.text(); - // Each rendered item carries an absolute-position style with a translateY offset - // computed by the same getMeasurements()/measurementsCache path the client uses. - // The first three items should sit at y=0 (one per lane in a 3-column layout). + // 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([ @@ -56,24 +55,20 @@ test.describe('Masonry SSR behavior', () => { ]); }); - test('ssr lane lefts use calc(% + px) form to match post-mount px positions', async ({ - request, - }) => { + 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(); - // For container width W, gutter g, columns n, lane k: - // post-mount px form: left = (laneWidth + g) * k = k * (W + g) / n - // = k * 100/n % + k * g/n px (when expressed against W as 100%) - // The SSR `%` fallback must include the `+ k*g/n px` term — without it positions - // drift left by `k * g / n` (e.g. n=3, g=16: ~5.33px at lane 1, ~10.67px at lane 2). + // 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) + // 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\)$/); diff --git a/package/src/Masonry/index.tsx b/package/src/Masonry/index.tsx index ba7bee6..d468732 100644 --- a/package/src/Masonry/index.tsx +++ b/package/src/Masonry/index.tsx @@ -21,12 +21,6 @@ type ColumnCountBreakPoints = BreakpointValues; export interface SSRConfig { /** Number of items to render in server HTML. Required to opt in. */ itemCount: number; - /** - * Container width hint used when `containerRef.current` is null (server + first paint). - * Without this, lane width falls back to a `%` calc — visually OK but not pixel-accurate - * until the client measures the real container. - */ - containerWidth?: number; /** * Distance from document top to grid container — substitutes for * `containerRef.current.offsetTop` while ref is null. Defaults to 0. @@ -73,11 +67,6 @@ export function Masonry({ ? responsiveColumns : (ssr?.columnsCount ?? responsiveColumns); - const containerWidth = containerRef.current?.offsetWidth ?? ssr?.containerWidth ?? 0; - const laneWidth = containerWidth - ? (containerWidth - (columnsCount - 1) * gutter) / columnsCount - : 0; - const virtualizer = useWindowVirtualizer({ count: data.length, estimateSize: estimateSize ?? (() => 0), @@ -101,6 +90,14 @@ export function Masonry({ ? 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 (
({ style={{ position: 'absolute', top: 0, - // When laneWidth is known: left = (laneWidth + gutter) * lane. - // When unknown: left in % needs gutter compensation, otherwise it drifts left - // by `lane * gutter / n` vs the post-mount px form. Derivation: - // client px form: lane * (laneWidth + g) = lane * (W + g) / n - // = lane * 100/n % + lane * g/n (when expressed against container W) - left: laneWidth - ? `${(laneWidth + gutter) * lane}px` - : `calc(${(100 * lane) / columnsCount}% + ${(gutter * lane) / columnsCount}px)`, - // Width: laneWidth = (W - (n-1)g) / n = 100/n% - (n-1)/n * g px (in calc form). - width: laneWidth - ? `${laneWidth}px` - : `calc(${100 / columnsCount}% - ${(gutter * (columnsCount - 1)) / columnsCount}px)`, + left: `calc(${(100 * lane) / columnsCount}% + ${(gutter * lane) / columnsCount}px)`, + width: laneWidthCalc, transform: `translateY(${start - virtualizer.options.scrollMargin}px)`, }} >