-
-
Notifications
You must be signed in to change notification settings - Fork 96
fix: 🐛 Fix SSR prefetch by adding HydrationBoundary and stable cursor #44
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
import { NextResponse } from "next/server"; | ||
import { data } from "@/app/default/data"; | ||
|
||
export async function GET() { | ||
// Simulate network delay | ||
await new Promise(resolve => setTimeout(resolve, 100)); | ||
|
||
return NextResponse.json({ | ||
data, | ||
total: data.length, | ||
}); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
"use client"; | ||
|
||
import { useQuery } from "@tanstack/react-query"; | ||
import { columns } from "./columns"; | ||
import { filterFields } from "./constants"; | ||
import { DataTable } from "./data-table"; | ||
import { dataOptions } from "./query-options"; | ||
import { useQueryStates } from "nuqs"; | ||
import { searchParamsParser } from "./search-params"; | ||
|
||
export function Client() { | ||
const [search] = useQueryStates(searchParamsParser); | ||
const { data } = useQuery(dataOptions(search)); | ||
|
||
if (!data) return null; | ||
|
||
return ( | ||
<DataTable | ||
columns={columns} | ||
data={data.data} | ||
filterFields={filterFields} | ||
defaultColumnFilters={Object.entries(search) | ||
.map(([key, value]) => ({ | ||
id: key, | ||
value, | ||
})) | ||
.filter(({ value }) => value ?? undefined)} | ||
/> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
import { Skeleton } from "./skeleton"; | ||
|
||
export default function Loading() { | ||
return <Skeleton />; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,31 +1,23 @@ | ||
import * as React from "react"; | ||
import { columns } from "./columns"; | ||
import { filterFields } from "./constants"; | ||
import { data } from "./data"; | ||
import { DataTable } from "./data-table"; | ||
import { getQueryClient } from "@/providers/get-query-client"; | ||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; | ||
import { searchParamsCache } from "./search-params"; | ||
import { Skeleton } from "./skeleton"; | ||
import { dataOptions } from "./query-options"; | ||
import { Client } from "./client"; | ||
|
||
export default async function Page({ | ||
searchParams, | ||
}: { | ||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>; | ||
}) { | ||
const search = searchParamsCache.parse(await searchParams); | ||
const queryClient = getQueryClient(); | ||
await queryClient.prefetchQuery(dataOptions(search)); | ||
|
||
const dehydratedState = dehydrate(queryClient); | ||
|
||
return ( | ||
<React.Suspense fallback={<Skeleton />}> | ||
<DataTable | ||
columns={columns} | ||
data={data} | ||
filterFields={filterFields} | ||
defaultColumnFilters={Object.entries(search) | ||
.map(([key, value]) => ({ | ||
id: key, | ||
value, | ||
})) | ||
.filter(({ value }) => value ?? undefined)} | ||
/> | ||
</React.Suspense> | ||
<HydrationBoundary state={dehydratedState}> | ||
<Client /> | ||
</HydrationBoundary> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
import { queryOptions } from "@tanstack/react-query"; | ||
import type { ColumnSchema } from "./types"; | ||
|
||
interface ApiResponse { | ||
data: ColumnSchema[]; | ||
total: number; | ||
} | ||
|
||
export const dataOptions = (search: Record<string, any>) => | ||
queryOptions({ | ||
queryKey: ["default-data", search], | ||
queryFn: async () => { | ||
// Use absolute URL for server-side fetching | ||
const baseUrl = typeof window === 'undefined' | ||
? `http://localhost:${process.env.PORT || 3001}` | ||
: ''; | ||
|
||
const response = await fetch(`${baseUrl}/default/api`); | ||
if (!response.ok) { | ||
throw new Error("Failed to fetch data"); | ||
} | ||
const result: ApiResponse = await response.json(); | ||
return result; | ||
}, | ||
staleTime: 1000 * 60 * 5, // 5 minutes | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
import { Skeleton } from "./skeleton"; | ||
|
||
export default function Loading() { | ||
return <Skeleton />; | ||
} |
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the when adding the absolute url to the I'm trying to debug it currently the |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,17 +1,23 @@ | ||
import * as React from "react"; | ||
import { searchParamsCache } from "./search-params"; | ||
import { getQueryClient } from "@/providers/get-query-client"; | ||
import { dataOptions } from "./query-options"; | ||
import { Client } from "./client"; | ||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; | ||
|
||
export default async function Page({ | ||
searchParams, | ||
}: { | ||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>; | ||
}) { | ||
const search = searchParamsCache.parse(await searchParams); | ||
const search = await searchParamsCache.parse(searchParams); | ||
const queryClient = getQueryClient(); | ||
await queryClient.prefetchInfiniteQuery(dataOptions(search)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. when logging on server component (/infinite/page.tsx) const queryOptions = dataOptions(search);
console.log('server key', queryOptions); // [ 'data-table', [ 'data-table', '' ] ]
await queryClient.prefetchInfiniteQuery(queryOptions); while in client component (/infinite/client.tsx) const queryOptions = dataOptions(search);
console.log('client key', queryOptions); // [ 'data-table', '?cursor=1758483849134' ]
const { .. } = useInfiniteQuery(queryOptions); while To be exact, the @franky47 is that known? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah the problem is in the definition of the default value for If you log the timestamp for this So on the server, the serializer sees the default value as equal (comparison by timestamp), but on the client it doesn't, and therefore renders it to the URL, giving you different React Query keys. |
||
|
||
return <Client />; | ||
const dehydratedState = dehydrate(queryClient); | ||
|
||
return ( | ||
<HydrationBoundary state={dehydratedState}> | ||
<Client search={search} /> | ||
</HydrationBoundary> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -49,7 +49,7 @@ export const dataOptions = (search: SearchParamsType) => { | |
json, | ||
); | ||
}, | ||
initialPageParam: { cursor: new Date().getTime(), direction: "next" }, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. good catch! |
||
initialPageParam: { cursor: search.cursor.getTime(), direction: "next" }, | ||
getPreviousPageParam: (firstPage, _pages) => { | ||
if (!firstPage.prevCursor) return null; | ||
return { cursor: firstPage.prevCursor, direction: "prev" }; | ||
|
@@ -60,5 +60,6 @@ export const dataOptions = (search: SearchParamsType) => { | |
}, | ||
refetchOnWindowFocus: false, | ||
placeholderData: keepPreviousData, | ||
}); | ||
staleTime: 1000 * 60 * 5, // 5 minutes | ||
}) | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion: could you import the data directly here when in server-side (à la RSC), rather than going through HTTP server -> server? Or does it pose bundling separation issues?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
agree with you @franky47 - if it's a production app, I'd do a db query straight to get the data. Here, to keep the data consistent and change it only in a single place, I'd suggest to leave the http call.
@gruckion I think we would need to use the
https://${VERCEL_URL}
on non dev environments to make it work.we should probably do the same for the
/infinite/query-options
to make it fetch from the server.