Skip to content
Draft
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
2 changes: 1 addition & 1 deletion examples/tanstack-start/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ function RootComponent() {
activeProps={{ style: { fontWeight: 700 } }}
style={{ textDecoration: 'none', color: '#222' }}
>
ssr (current behavior)
ssr
</Link>
<Link
to="/ssr-debug"
Expand Down
12 changes: 8 additions & 4 deletions examples/tanstack-start/src/routes/ssr.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,27 @@ export const Route = createFileRoute('/ssr')({
});

const TILES = createTiles(200);
const SSR_ITEM_COUNT = 30;
const INITIAL_COLUMNS = 3;

function SsrPage() {
return (
<section data-testid="ssr">
<h1>SSR Masonry</h1>
<p style={{ color: '#555' }}>
SSR is enabled by default in TanStack Start. The server HTML for this masonry container
currently renders no items because <code>useWindowVirtualizer.getVirtualItems()</code>{' '}
returns an empty array without a measured viewport — the items appear after client-side
hydration.
Opt-in SSR:{' '}
<code>ssr={`{{ itemCount: ${SSR_ITEM_COUNT}, columnsCount: ${INITIAL_COLUMNS} }}`}</code>{' '}
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.
</p>
<Masonry
data={TILES}
renderItem={renderTile}
columnsCountBreakPoints={{ 0: 1, 640: 2, 1024: 3, 1440: 4 }}
gutter={16}
estimateSize={(i) => TILES[i]!.height}
ssr={{ itemCount: SSR_ITEM_COUNT, columnsCount: INITIAL_COLUMNS }}
/>
</section>
);
Expand Down
62 changes: 56 additions & 6 deletions examples/tanstack-start/tests/ssr-baseline.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { expect, test } from '@playwright/test';

/**
* Captures the SSR behavior of <Masonry> as currently shipped.
* Captures the SSR behavior of <Masonry>.
*
* 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('/');
Expand All @@ -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([]);
});
});
69 changes: 60 additions & 9 deletions package/src/Masonry/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,41 @@ const DEFAULT_OVERSCAN = 3;
const DEFAULT_GUTTER = 20;

type ColumnCountBreakPoints = BreakpointValues<number>;

/**
* 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: Data[];
renderItem: (props: { item: Data; index: number }) => ReactNode;
columnsCountBreakPoints?: ColumnCountBreakPoints;
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<Data = unknown>({
Expand All @@ -24,29 +52,52 @@ export function Masonry<Data = unknown>({
gutter = DEFAULT_GUTTER,
estimateSize,
overscan = DEFAULT_OVERSCAN,
ssr,
}: Props<Data>) {
'use no memo';

const { getResponsiveValue } = useResponsiveValue<number>();

const columnsCount = getResponsiveValue(columnsCountBreakPoints, DEFAULT_COLUMNS_COUNT);

const containerRef = useRef<HTMLDivElement>(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 <div> 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 (
<div
ref={containerRef}
Expand All @@ -56,16 +107,16 @@ export function Masonry<Data = unknown>({
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map(({ lane, key, index, start }) => (
{itemsToRender.map(({ lane, key, index, start }) => (
<div
ref={virtualizer.measureElement}
key={key}
data-index={index} // important for the measureElement to work
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)`,
}}
>
Expand Down
Loading