- 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 (