refactor: render cached data directly if available during ssr

This commit is contained in:
Nicolas Meienberger 2026-02-28 00:59:05 +01:00
parent 12454ee401
commit 70352e9f91
6 changed files with 84 additions and 29 deletions

View file

@ -51,6 +51,7 @@ type Props = {
repos: Repository[]; repos: Repository[];
scheduleNotifs: ScheduleNotification[]; scheduleNotifs: ScheduleNotification[];
mirrors: ScheduleMirror[]; mirrors: ScheduleMirror[];
snapshots?: Snapshot[];
}; };
scheduleId: string; scheduleId: string;
initialSnapshotId?: string; initialSnapshotId?: string;
@ -78,6 +79,7 @@ export function ScheduleDetailsPage(props: Props) {
failureReason, failureReason,
} = useQuery({ } = useQuery({
...listSnapshotsOptions({ path: { shortId: schedule.repository.shortId }, query: { backupId: schedule.shortId } }), ...listSnapshotsOptions({ path: { shortId: schedule.repository.shortId }, query: { backupId: schedule.shortId } }),
initialData: loaderData.snapshots,
}); });
const updateSchedule = useMutation({ const updateSchedule = useMutation({

View file

@ -5,8 +5,17 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui
import { RepositoryInfoTabContent } from "../tabs/info"; import { RepositoryInfoTabContent } from "../tabs/info";
import { RepositorySnapshotsTabContent } from "../tabs/snapshots"; import { RepositorySnapshotsTabContent } from "../tabs/snapshots";
import { useNavigate, useSearch } from "@tanstack/react-router"; import { useNavigate, useSearch } from "@tanstack/react-router";
import type { BackupSchedule, Snapshot } from "~/client/lib/types";
export default function RepositoryDetailsPage({ repositoryId }: { repositoryId: string }) { export default function RepositoryDetailsPage({
repositoryId,
initialSnapshots,
initialBackupSchedules,
}: {
repositoryId: string;
initialSnapshots?: Snapshot[];
initialBackupSchedules?: BackupSchedule[];
}) {
const navigate = useNavigate(); const navigate = useNavigate();
const { tab } = useSearch({ from: "/(dashboard)/repositories/$repositoryId/" }); const { tab } = useSearch({ from: "/(dashboard)/repositories/$repositoryId/" });
const activeTab = tab || "info"; const activeTab = tab || "info";
@ -27,7 +36,11 @@ export default function RepositoryDetailsPage({ repositoryId }: { repositoryId:
</TabsContent> </TabsContent>
<TabsContent value="snapshots"> <TabsContent value="snapshots">
<Suspense> <Suspense>
<RepositorySnapshotsTabContent repository={data} /> <RepositorySnapshotsTabContent
repository={data}
initialSnapshots={initialSnapshots}
initialBackupSchedules={initialBackupSchedules}
/>
</Suspense> </Suspense>
</TabsContent> </TabsContent>
</Tabs> </Tabs>

View file

@ -11,23 +11,26 @@ import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Input } from "~/client/components/ui/input"; import { Input } from "~/client/components/ui/input";
import { Table, TableBody, TableCell, TableRow } from "~/client/components/ui/table"; import { Table, TableBody, TableCell, TableRow } from "~/client/components/ui/table";
import type { Repository, Snapshot } from "~/client/lib/types"; import type { BackupSchedule, Repository, Snapshot } from "~/client/lib/types";
import { toast } from "sonner"; import { toast } from "sonner";
type Props = { type Props = {
repository: Repository; repository: Repository;
initialSnapshots?: Snapshot[];
initialBackupSchedules?: BackupSchedule[];
}; };
export const RepositorySnapshotsTabContent = ({ repository }: Props) => { export const RepositorySnapshotsTabContent = ({ repository, initialSnapshots, initialBackupSchedules }: Props) => {
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const { data, isFetching, failureReason } = useQuery({ const { data, isPending, failureReason } = useQuery({
...listSnapshotsOptions({ path: { shortId: repository.shortId } }), ...listSnapshotsOptions({ path: { shortId: repository.shortId } }),
initialData: [], initialData: initialSnapshots,
}); });
const schedules = useQuery({ const schedules = useQuery({
...listBackupSchedulesOptions(), ...listBackupSchedulesOptions(),
initialData: initialBackupSchedules,
}); });
const refreshMutation = useMutation({ const refreshMutation = useMutation({
@ -44,7 +47,9 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
refreshMutation.mutate({ path: { shortId: repository.shortId } }); refreshMutation.mutate({ path: { shortId: repository.shortId } });
}; };
const filteredSnapshots = data.filter((snapshot: Snapshot) => { const snapshots = data ?? [];
const filteredSnapshots = snapshots.filter((snapshot: Snapshot) => {
if (!searchQuery) return true; if (!searchQuery) return true;
const searchLower = searchQuery.toLowerCase(); const searchLower = searchQuery.toLowerCase();
@ -58,7 +63,7 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
); );
}); });
const hasNoFilteredSnapshots = !filteredSnapshots?.length; const hasNoFilteredSnapshots = !filteredSnapshots.length;
if (repository.status === "error") { if (repository.status === "error") {
return ( return (
@ -91,7 +96,7 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
); );
} }
if (isFetching && !data.length) { if (isPending) {
return ( return (
<Card> <Card>
<CardContent className="flex items-center justify-center py-12"> <CardContent className="flex items-center justify-center py-12">
@ -101,7 +106,7 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
); );
} }
if (!data.length) { if (!snapshots.length) {
return ( return (
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center text-center py-16 px-4"> <CardContent className="flex flex-col items-center justify-center text-center py-16 px-4">
@ -131,7 +136,7 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
<div className="flex-1"> <div className="flex-1">
<CardTitle>Snapshots</CardTitle> <CardTitle>Snapshots</CardTitle>
<CardDescription className="mt-1"> <CardDescription className="mt-1">
Backup snapshots stored in this repository. Total: {data.length} Backup snapshots stored in this repository. Total: {snapshots.length}
</CardDescription> </CardDescription>
</div> </div>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
@ -180,7 +185,7 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
<span> <span>
{hasNoFilteredSnapshots {hasNoFilteredSnapshots
? "No snapshots match filters." ? "No snapshots match filters."
: `Showing ${filteredSnapshots.length} of ${data.length}`} : `Showing ${filteredSnapshots.length} of ${snapshots.length}`}
</span> </span>
</div> </div>
</Card> </Card>

View file

@ -9,6 +9,7 @@ import {
listSnapshotsOptions, listSnapshotsOptions,
} from "~/client/api-client/@tanstack/react-query.gen"; } from "~/client/api-client/@tanstack/react-query.gen";
import { ScheduleDetailsPage } from "~/client/modules/backups/routes/backup-details"; import { ScheduleDetailsPage } from "~/client/modules/backups/routes/backup-details";
import { prefetchOrSkip } from "~/utils/prefetch";
export const Route = createFileRoute("/(dashboard)/backups/$backupId/")({ export const Route = createFileRoute("/(dashboard)/backups/$backupId/")({
component: RouteComponent, component: RouteComponent,
@ -24,14 +25,21 @@ export const Route = createFileRoute("/(dashboard)/backups/$backupId/")({
context.queryClient.ensureQueryData({ ...getScheduleMirrorsOptions({ path: { shortId: backupId } }) }), context.queryClient.ensureQueryData({ ...getScheduleMirrorsOptions({ path: { shortId: backupId } }) }),
]); ]);
void context.queryClient.prefetchQuery({ const snapshotOptions = listSnapshotsOptions({
...listSnapshotsOptions({ path: { shortId: schedule.repository.shortId },
path: { shortId: schedule.repository.shortId }, query: { backupId: schedule.shortId },
query: { backupId: schedule.shortId },
}),
}); });
return { schedule, notifs, repos, scheduleNotifs, mirrors }; await prefetchOrSkip(context.queryClient, snapshotOptions);
return {
schedule,
notifs,
repos,
scheduleNotifs,
mirrors,
snapshots: context.queryClient.getQueryData(snapshotOptions.queryKey),
};
}, },
staticData: { staticData: {
breadcrumb: (match) => [ breadcrumb: (match) => [

View file

@ -6,23 +6,26 @@ import {
listSnapshotsOptions, listSnapshotsOptions,
} from "~/client/api-client/@tanstack/react-query.gen"; } from "~/client/api-client/@tanstack/react-query.gen";
import RepositoryDetailsPage from "~/client/modules/repositories/routes/repository-details"; import RepositoryDetailsPage from "~/client/modules/repositories/routes/repository-details";
import { prefetchOrSkip } from "~/utils/prefetch";
export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/")({ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/")({
component: RouteComponent, component: RouteComponent,
errorComponent: (e) => <div>{e.error.message}</div>, errorComponent: (e) => <div>{e.error.message}</div>,
loader: async ({ params, context }) => { loader: async ({ params, context }) => {
void context.queryClient.prefetchQuery({ const snapshotOptions = listSnapshotsOptions({ path: { shortId: params.repositoryId } });
...listSnapshotsOptions({ path: { shortId: params.repositoryId } }), const schedulesOptions = listBackupSchedulesOptions();
});
void context.queryClient.prefetchQuery({
...listBackupSchedulesOptions(),
});
const res = await context.queryClient.ensureQueryData({ const [res] = await Promise.all([
...getRepositoryOptions({ path: { shortId: params.repositoryId } }), context.queryClient.ensureQueryData(getRepositoryOptions({ path: { shortId: params.repositoryId } })),
}); prefetchOrSkip(context.queryClient, snapshotOptions),
prefetchOrSkip(context.queryClient, schedulesOptions),
]);
return res; return {
...res,
snapshots: context.queryClient.getQueryData(snapshotOptions.queryKey),
backupSchedules: context.queryClient.getQueryData(schedulesOptions.queryKey),
};
}, },
validateSearch: type({ tab: "string?" }), validateSearch: type({ tab: "string?" }),
staticData: { staticData: {
@ -44,6 +47,13 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/")
function RouteComponent() { function RouteComponent() {
const { repositoryId } = Route.useParams(); const { repositoryId } = Route.useParams();
const { snapshots, backupSchedules } = Route.useLoaderData();
return <RepositoryDetailsPage repositoryId={repositoryId} />; return (
<RepositoryDetailsPage
repositoryId={repositoryId}
initialSnapshots={snapshots}
initialBackupSchedules={backupSchedules}
/>
);
} }

17
app/utils/prefetch.ts Normal file
View file

@ -0,0 +1,17 @@
import type { FetchQueryOptions, QueryClient, QueryKey } from "@tanstack/react-query";
export async function prefetchOrSkip<
TQueryFnData = unknown,
TError = Error,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
queryClient: QueryClient,
options: FetchQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
timeoutMs = 150,
): Promise<void> {
await Promise.race([
queryClient.prefetchQuery(options),
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
]);
}