Skip to content

Commit 4bb3cdb

Browse files
gruckionmxkaske
andauthored
fix: 🐛 Fix SSR prefetch by adding HydrationBoundary and stable cursor (#44)
* fix: 🐛 Fix SSR prefetch by adding HydrationBoundary and stable cursor fix: add HydrationBoundary and fix prefetch for data tables - Wrap all data tables in HydrationBoundary for proper SSR hydration - Add non-zero staleTime to prevent immediate client-side refetching - Fix infinite query initialPageParam to use stable cursor value - Pass server-parsed search params to client to avoid timestamp mismatches - Add loading states for better navigation experience Fixes prefetchQuery/prefetchInfiniteQuery not working without proper hydration setup The commit follows conventional commits format: - Type: fix (this is a bug fix - prefetch wasn't working) - Scope: Could optionally add scope like fix(data-table): if you prefer - Description: Clear and concise - Body: Lists the key changes - Footer: Explains what issue it fixes * chore: remove default cursor query param --------- Co-authored-by: Maximilian Kaske <[email protected]>
1 parent c850cdf commit 4bb3cdb

File tree

15 files changed

+409
-52
lines changed

15 files changed

+409
-52
lines changed

src/app/default/api/route.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { NextResponse } from "next/server";
2+
import { data } from "@/app/default/data";
3+
4+
export async function GET() {
5+
// Simulate network delay
6+
await new Promise(resolve => setTimeout(resolve, 100));
7+
8+
return NextResponse.json({
9+
data,
10+
total: data.length,
11+
});
12+
}

src/app/default/client.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"use client";
2+
3+
import { useQuery } from "@tanstack/react-query";
4+
import { columns } from "./columns";
5+
import { filterFields } from "./constants";
6+
import { DataTable } from "./data-table";
7+
import { dataOptions } from "./query-options";
8+
import { useQueryStates } from "nuqs";
9+
import { searchParamsParser } from "./search-params";
10+
11+
export function Client() {
12+
const [search] = useQueryStates(searchParamsParser);
13+
const { data } = useQuery(dataOptions(search));
14+
15+
if (!data) return null;
16+
17+
return (
18+
<DataTable
19+
columns={columns}
20+
data={data.data}
21+
filterFields={filterFields}
22+
defaultColumnFilters={Object.entries(search)
23+
.map(([key, value]) => ({
24+
id: key,
25+
value,
26+
}))
27+
.filter(({ value }) => value ?? undefined)}
28+
/>
29+
);
30+
}

src/app/default/loading.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { Skeleton } from "./skeleton";
2+
3+
export default function Loading() {
4+
return <Skeleton />;
5+
}

src/app/default/page.tsx

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,23 @@
1-
import * as React from "react";
2-
import { columns } from "./columns";
3-
import { filterFields } from "./constants";
4-
import { data } from "./data";
5-
import { DataTable } from "./data-table";
1+
import { getQueryClient } from "@/providers/get-query-client";
2+
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
63
import { searchParamsCache } from "./search-params";
7-
import { Skeleton } from "./skeleton";
4+
import { dataOptions } from "./query-options";
5+
import { Client } from "./client";
86

97
export default async function Page({
108
searchParams,
119
}: {
1210
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
1311
}) {
1412
const search = searchParamsCache.parse(await searchParams);
13+
const queryClient = getQueryClient();
14+
await queryClient.prefetchQuery(dataOptions(search));
15+
16+
const dehydratedState = dehydrate(queryClient);
1517

1618
return (
17-
<React.Suspense fallback={<Skeleton />}>
18-
<DataTable
19-
columns={columns}
20-
data={data}
21-
filterFields={filterFields}
22-
defaultColumnFilters={Object.entries(search)
23-
.map(([key, value]) => ({
24-
id: key,
25-
value,
26-
}))
27-
.filter(({ value }) => value ?? undefined)}
28-
/>
29-
</React.Suspense>
19+
<HydrationBoundary state={dehydratedState}>
20+
<Client />
21+
</HydrationBoundary>
3022
);
3123
}

src/app/default/query-options.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { queryOptions } from "@tanstack/react-query";
2+
import type { ColumnSchema } from "./types";
3+
4+
interface ApiResponse {
5+
data: ColumnSchema[];
6+
total: number;
7+
}
8+
9+
export const dataOptions = (search: Record<string, any>) =>
10+
queryOptions({
11+
queryKey: ["default-data", search],
12+
queryFn: async () => {
13+
// Use absolute URL for server-side fetching
14+
const baseUrl = typeof window === 'undefined'
15+
? `http://localhost:${process.env.PORT || 3001}`
16+
: '';
17+
18+
const response = await fetch(`${baseUrl}/default/api`);
19+
if (!response.ok) {
20+
throw new Error("Failed to fetch data");
21+
}
22+
const result: ApiResponse = await response.json();
23+
return result;
24+
},
25+
staleTime: 1000 * 60 * 5, // 5 minutes
26+
});

src/app/infinite/api/helpers.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,12 +122,14 @@ export function percentileData(data: ColumnSchema[]): ColumnSchema[] {
122122
export function splitData(data: ColumnSchema[], search: SearchParamsType) {
123123
let newData: ColumnSchema[] = [];
124124
const now = new Date();
125+
// cursor undefined = "now"
126+
const cursorTime = search.cursor?.getTime() ?? now.getTime();
125127

126128
// TODO: write a helper function for this
127129
data.forEach((item) => {
128130
if (search.direction === "next") {
129131
if (
130-
item.date.getTime() < search.cursor.getTime() &&
132+
item.date.getTime() < cursorTime &&
131133
newData.length < search.size
132134
) {
133135
newData.push(item);
@@ -139,7 +141,7 @@ export function splitData(data: ColumnSchema[], search: SearchParamsType) {
139141
}
140142
} else if (search.direction === "prev") {
141143
if (
142-
item.date.getTime() > search.cursor.getTime() &&
144+
item.date.getTime() > cursorTime &&
143145
// REMINDER: we need to make sure that we don't get items that are in the future which we do with mockLive data
144146
item.date.getTime() < now.getTime()
145147
) {

src/app/infinite/client.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,19 +76,23 @@ export function Client() {
7676
});
7777
}, [facets]);
7878

79+
const defaultColumnFilters = React.useMemo(() => {
80+
return Object.entries(filter)
81+
.map(([key, value]) => ({
82+
id: key,
83+
value,
84+
}))
85+
.filter(({ value }) => value ?? undefined);
86+
}, [filter]);
87+
7988
return (
8089
<DataTableInfinite
8190
columns={columns}
8291
data={flatData}
8392
totalRows={totalDBRowCount}
8493
filterRows={filterDBRowCount}
8594
totalRowsFetched={totalFetched}
86-
defaultColumnFilters={Object.entries(filter)
87-
.map(([key, value]) => ({
88-
id: key,
89-
value,
90-
}))
91-
.filter(({ value }) => value ?? undefined)}
95+
defaultColumnFilters={defaultColumnFilters}
9296
defaultColumnSorting={sort ? [sort] : undefined}
9397
defaultRowSelection={search.uuid ? { [search.uuid]: true } : undefined}
9498
// FIXME: make it configurable - TODO: use `columnHidden: boolean` in `filterFields`

src/app/infinite/loading.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { Skeleton } from "./skeleton";
2+
3+
export default function Loading() {
4+
return <Skeleton />;
5+
}

src/app/infinite/page.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
1-
import * as React from "react";
2-
import { searchParamsCache } from "./search-params";
31
import { getQueryClient } from "@/providers/get-query-client";
4-
import { dataOptions } from "./query-options";
2+
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
3+
import { SearchParams } from "nuqs";
54
import { Client } from "./client";
5+
import { dataOptions } from "./query-options";
6+
import { searchParamsCache } from "./search-params";
67

78
export default async function Page({
89
searchParams,
910
}: {
10-
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
11+
searchParams: Promise<SearchParams>;
1112
}) {
12-
const search = searchParamsCache.parse(await searchParams);
13+
const search = await searchParamsCache.parse(searchParams);
1314
const queryClient = getQueryClient();
1415
await queryClient.prefetchInfiniteQuery(dataOptions(search));
1516

16-
return <Client />;
17+
const dehydratedState = dehydrate(queryClient);
18+
19+
return (
20+
<HydrationBoundary state={dehydratedState}>
21+
<Client />
22+
</HydrationBoundary>
23+
);
1724
}

src/app/infinite/query-options.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ import type {
88
} from "./schema";
99
import { searchParamsSerializer, type SearchParamsType } from "./search-params";
1010

11+
function getBaseUrl() {
12+
if (typeof window !== "undefined") return ""; // browser should use relative url
13+
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // SSR on Vercel
14+
return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR
15+
}
16+
1117
export type LogsMeta = {
1218
currentPercentiles: Record<Percentile, number>;
1319
};
@@ -27,12 +33,24 @@ export type InfiniteQueryResponse<TData, TMeta = unknown> = {
2733
nextCursor: number | null;
2834
};
2935

36+
// Query key = filters only (no cursor/pagination state)
37+
// This ensures server/client keys match regardless of when they run
38+
function getStableQueryKey(search: SearchParamsType) {
39+
return searchParamsSerializer({
40+
...search,
41+
uuid: null,
42+
live: null,
43+
cursor: null,
44+
direction: null,
45+
});
46+
}
47+
3048
export const dataOptions = (search: SearchParamsType) => {
49+
// cursor undefined = "now", otherwise use the URL value
50+
const initialCursor = search.cursor?.getTime() ?? Date.now();
51+
3152
return infiniteQueryOptions({
32-
queryKey: [
33-
"data-table",
34-
searchParamsSerializer({ ...search, uuid: null, live: null }),
35-
], // remove uuid/live as it would otherwise retrigger a fetch
53+
queryKey: ["data-table", getStableQueryKey(search)],
3654
queryFn: async ({ pageParam }) => {
3755
const cursor = new Date(pageParam.cursor);
3856
const direction = pageParam.direction as "next" | "prev" | undefined;
@@ -43,13 +61,13 @@ export const dataOptions = (search: SearchParamsType) => {
4361
uuid: null,
4462
live: null,
4563
});
46-
const response = await fetch(`/infinite/api${serialize}`);
64+
const response = await fetch(`${getBaseUrl()}/infinite/api${serialize}`);
4765
const json = await response.json();
4866
return SuperJSON.parse<InfiniteQueryResponse<ColumnSchema[], LogsMeta>>(
4967
json,
5068
);
5169
},
52-
initialPageParam: { cursor: new Date().getTime(), direction: "next" },
70+
initialPageParam: { cursor: initialCursor, direction: "next" },
5371
getPreviousPageParam: (firstPage, _pages) => {
5472
if (!firstPage.prevCursor) return null;
5573
return { cursor: firstPage.prevCursor, direction: "prev" };
@@ -60,5 +78,6 @@ export const dataOptions = (search: SearchParamsType) => {
6078
},
6179
refetchOnWindowFocus: false,
6280
placeholderData: keepPreviousData,
81+
staleTime: 1000 * 60 * 5, // 5 minutes
6382
});
6483
};

0 commit comments

Comments
 (0)