️ Prefetch queries with trpc (#1454)

This commit is contained in:
Luke Vella 2024-12-02 00:46:41 +00:00 committed by GitHub
parent 40df1ff9da
commit 82ebcd8752
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 196 additions and 125 deletions

View file

@ -1,4 +1,5 @@
import { cn } from "@rallly/ui";
import { dehydrate, Hydrate } from "@tanstack/react-query";
import React from "react";
import { MobileNavigation } from "@/app/[locale]/(admin)/mobile-navigation";
@ -6,33 +7,39 @@ import { ProBadge } from "@/app/[locale]/(admin)/pro-badge";
import { Sidebar } from "@/app/[locale]/(admin)/sidebar";
import { LogoLink } from "@/app/components/logo-link";
import { PayWallDialog } from "@/components/pay-wall-dialog";
import { createSSRHelper } from "@/trpc/server/create-ssr-helper";
export default async function Layout({
children,
}: {
children: React.ReactNode;
}) {
const helpers = await createSSRHelper();
await helpers.user.subscription.prefetch();
const dehydratedState = dehydrate(helpers.queryClient);
return (
<PayWallDialog>
<div className="flex flex-col pb-16 md:pb-0">
<div
className={cn(
"fixed inset-y-0 z-50 hidden w-72 shrink-0 flex-col gap-y-4 overflow-y-auto p-6 md:flex",
)}
>
<div className="flex w-full items-center justify-between gap-4">
<LogoLink />
<ProBadge />
<Hydrate state={dehydratedState}>
<PayWallDialog>
<div className="flex flex-col pb-16 md:pb-0">
<div
className={cn(
"fixed inset-y-0 z-50 hidden w-72 shrink-0 flex-col gap-y-4 overflow-y-auto p-6 md:flex",
)}
>
<div className="flex w-full items-center justify-between gap-4">
<LogoLink />
<ProBadge />
</div>
<Sidebar />
</div>
<div className={cn("grow space-y-4 p-3 md:ml-72 md:p-4 lg:p-6")}>
<div className="max-w-5xl">{children}</div>
</div>
<div className="fixed bottom-0 z-20 flex h-16 w-full flex-col justify-center bg-gray-100/90 backdrop-blur-md md:hidden">
<MobileNavigation />
</div>
<Sidebar />
</div>
<div className={cn("grow space-y-4 p-3 md:ml-72 md:p-4 lg:p-6")}>
<div className="max-w-5xl">{children}</div>
</div>
<div className="fixed bottom-0 z-20 flex h-16 w-full flex-col justify-center bg-gray-100/90 backdrop-blur-md md:hidden">
<MobileNavigation />
</div>
</div>
</PayWallDialog>
</PayWallDialog>
</Hydrate>
);
}

View file

@ -1,3 +1,4 @@
import { dehydrate, Hydrate } from "@tanstack/react-query";
import { HomeIcon } from "lucide-react";
import { Trans } from "react-i18next/TransWithoutContext";
@ -11,27 +12,32 @@ import {
PageTitle,
} from "@/app/components/page-layout";
import { getTranslation } from "@/i18n/server";
import { createSSRHelper } from "@/trpc/server/create-ssr-helper";
export default async function Page({ params }: { params: Params }) {
const { t } = await getTranslation(params.locale);
const helpers = await createSSRHelper();
await helpers.dashboard.info.prefetch();
return (
<div>
<PageContainer>
<PageHeader>
<div className="flex items-center gap-x-3">
<PageIcon>
<HomeIcon />
</PageIcon>
<PageTitle>
<Trans t={t} i18nKey="home" defaults="Home" />
</PageTitle>
</div>
</PageHeader>
<PageContent>
<Dashboard />
</PageContent>
</PageContainer>
</div>
<Hydrate state={dehydrate(helpers.queryClient)}>
<div>
<PageContainer>
<PageHeader>
<div className="flex items-center gap-x-3">
<PageIcon>
<HomeIcon />
</PageIcon>
<PageTitle>
<Trans t={t} i18nKey="home" defaults="Home" />
</PageTitle>
</div>
</PageHeader>
<PageContent>
<Dashboard />
</PageContent>
</PageContainer>
</div>
</Hydrate>
);
}

View file

@ -1,59 +1,26 @@
"use client";
import { notFound, useParams, useSearchParams } from "next/navigation";
import React from "react";
import { dehydrate, Hydrate } from "@tanstack/react-query";
import { LegacyPollContextProvider } from "@/components/poll/poll-context-provider";
import { VisibilityProvider } from "@/components/visibility";
import { PermissionsContext } from "@/contexts/permissions";
import { trpc } from "@/trpc/client";
import { createSSRHelper } from "@/trpc/server/create-ssr-helper";
import Loader from "./loading";
import Providers from "./providers";
const Prefetch = ({ children }: React.PropsWithChildren) => {
const searchParams = useSearchParams();
const token = searchParams?.get("token") as string;
const params = useParams<{ urlId: string }>();
const urlId = params?.urlId as string;
const { data: permission } = trpc.auth.getUserPermission.useQuery(
{ token },
{
enabled: !!token,
},
);
const { data: poll, error } = trpc.polls.get.useQuery(
{ urlId },
{
retry: false,
},
);
const { data: participants } = trpc.polls.participants.list.useQuery({
pollId: urlId,
});
const comments = trpc.polls.comments.list.useQuery({ pollId: urlId });
if (error?.data?.code === "NOT_FOUND") {
notFound();
}
if (!poll || !participants || !comments.isFetched) {
return <Loader />;
}
export default async function Layout({
children,
params,
}: {
params: { urlId: string };
children: React.ReactNode;
}) {
const trpc = await createSSRHelper();
await Promise.all([
trpc.polls.get.prefetch({ urlId: params.urlId }),
trpc.polls.participants.list.prefetch({ pollId: params.urlId }),
trpc.polls.comments.list.prefetch({ pollId: params.urlId }),
]);
return (
<PermissionsContext.Provider value={{ userId: permission?.userId ?? null }}>
{children}
</PermissionsContext.Provider>
);
};
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<Prefetch>
<LegacyPollContextProvider>
<VisibilityProvider>{children}</VisibilityProvider>
</LegacyPollContextProvider>
</Prefetch>
<Hydrate state={dehydrate(trpc.queryClient)}>
<Providers>{children}</Providers>
</Hydrate>
);
}

View file

@ -3,10 +3,38 @@ import { absoluteUrl } from "@rallly/utils/absolute-url";
import { notFound } from "next/navigation";
import { InvitePage } from "@/app/[locale]/invite/[urlId]/invite-page";
import { PermissionProvider } from "@/contexts/permissions";
import { getTranslation } from "@/i18n/server";
import { createSSRHelper } from "@/trpc/server/create-ssr-helper";
export default async function Page() {
return <InvitePage />;
const PermissionContext = async ({
children,
token,
}: React.PropsWithChildren<{ token?: string }>) => {
const helpers = await createSSRHelper();
let impersonatedUserId: string | null = null;
if (token) {
const res = await helpers.auth.getUserPermission.fetch({ token });
impersonatedUserId = res?.userId ?? null;
}
return (
<PermissionProvider userId={impersonatedUserId}>
{children}
</PermissionProvider>
);
};
export default async function Page({
searchParams,
}: {
params: { urlId: string };
searchParams: { token: string };
}) {
return (
<PermissionContext token={searchParams.token}>
<InvitePage />
</PermissionContext>
);
}
export async function generateMetadata({

View file

@ -0,0 +1,12 @@
"use client";
import { LegacyPollContextProvider } from "@/components/poll/poll-context-provider";
import { VisibilityProvider } from "@/components/visibility";
export default function Providers({ children }: { children: React.ReactNode }) {
return (
<LegacyPollContextProvider>
<VisibilityProvider>{children}</VisibilityProvider>
</LegacyPollContextProvider>
);
}

View file

@ -1,18 +1,34 @@
import { prisma } from "@rallly/database";
import { dehydrate, Hydrate } from "@tanstack/react-query";
import { notFound } from "next/navigation";
import { PollLayout } from "@/components/layouts/poll-layout";
import { createSSRHelper } from "@/trpc/server/create-ssr-helper";
export default async function Layout({
children,
params,
}: React.PropsWithChildren<{ params: { urlId: string } }>) {
const trpc = await createSSRHelper();
// Prefetch all queries used in PollLayout
await Promise.all([
trpc.polls.get.prefetch({ urlId: params.urlId }),
trpc.polls.participants.list.prefetch({ pollId: params.urlId }),
trpc.polls.getWatchers.prefetch({ pollId: params.urlId }),
trpc.polls.comments.list.prefetch({ pollId: params.urlId }),
]);
const poll = await prisma.poll.findUnique({ where: { id: params.urlId } });
if (!poll) {
notFound();
}
return <PollLayout>{children}</PollLayout>;
return (
<Hydrate state={dehydrate(trpc.queryClient)}>
<PollLayout>{children}</PollLayout>
</Hydrate>
);
}
export async function generateMetadata({

View file

@ -21,15 +21,9 @@ export const ParticipantsProvider: React.FunctionComponent<{
children?: React.ReactNode;
pollId: string;
}> = ({ children, pollId }) => {
const { data: participants } = trpc.polls.participants.list.useQuery(
{
pollId,
},
{
staleTime: 1000 * 10,
cacheTime: Infinity,
},
);
const { data: participants } = trpc.polls.participants.list.useQuery({
pollId,
});
const getParticipants = (
optionId: string,

View file

@ -1,3 +1,4 @@
"use client";
import React from "react";
import { useParticipants } from "@/components/participants-provider";
@ -5,12 +6,23 @@ import { useUser } from "@/components/user-provider";
import { usePoll } from "@/contexts/poll";
import { useRole } from "@/contexts/role";
export const PermissionsContext = React.createContext<{
const PermissionsContext = React.createContext<{
userId: string | null;
}>({
userId: null,
});
export const PermissionProvider = ({
children,
userId,
}: React.PropsWithChildren<{ userId: string | null }>) => {
return (
<PermissionsContext.Provider value={{ userId }}>
{children}
</PermissionsContext.Provider>
);
};
export const usePermissions = () => {
const poll = usePoll();
const context = React.useContext(PermissionsContext);

View file

@ -1,19 +1,10 @@
import { useParams } from "next/navigation";
import React from "react";
import { trpc } from "@/trpc/client";
export const usePoll = () => {
const params = useParams<{ urlId: string }>();
const [urlId] = React.useState(params?.urlId as string);
const pollQuery = trpc.polls.get.useQuery(
{ urlId },
{
staleTime: Infinity,
},
);
const pollQuery = trpc.polls.get.useQuery({ urlId: params?.urlId as string });
if (!pollQuery.data) {
throw new Error("Expected poll to be prefetched");

View file

@ -2,8 +2,10 @@ import { posthogApiHandler } from "@rallly/posthog/server";
import * as Sentry from "@sentry/nextjs";
import { TRPCError } from "@trpc/server";
import { createNextApiHandler } from "@trpc/server/adapters/next";
import requestIp from "request-ip";
import { getServerSession } from "@/auth";
import type { TRPCContext } from "@/trpc/context";
import type { AppRouter } from "@/trpc/routers";
import { appRouter } from "@/trpc/routers";
import { getEmailClient } from "@/utils/emails";
@ -27,7 +29,7 @@ const trpcApiHandler = createNextApiHandler<AppRouter>({
});
}
const res = {
return {
user: {
id: session.user.id,
isGuest: session.user.email === null,
@ -35,11 +37,8 @@ const trpcApiHandler = createNextApiHandler<AppRouter>({
image: session.user.image ?? undefined,
getEmailClient: () => getEmailClient(session.user.locale ?? undefined),
},
req: opts.req,
res: opts.res,
};
return res;
ip: requestIp.getClientIp(opts.req) ?? undefined,
} satisfies TRPCContext;
},
onError({ error }) {
if (error.code === "INTERNAL_SERVER_ERROR") {

View file

@ -1,5 +1,4 @@
import type { EmailClient } from "@rallly/emails";
import type { NextApiRequest, NextApiResponse } from "next";
export type TRPCContext = {
user: {
@ -9,6 +8,5 @@ export type TRPCContext = {
getEmailClient: (locale?: string) => EmailClient;
image?: string;
};
req: NextApiRequest;
res: NextApiResponse;
ip?: string;
};

View file

@ -0,0 +1,44 @@
import { createServerSideHelpers } from "@trpc/react-query/server";
import { TRPCError } from "@trpc/server";
import superjson from "superjson";
import { getServerSession } from "@/auth";
import { getEmailClient } from "@/utils/emails";
import type { TRPCContext } from "../context";
import { appRouter } from "../routers";
async function createContext(): Promise<TRPCContext> {
const session = await getServerSession();
if (!session) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Unauthorized",
});
}
const res = {
user: {
id: session.user.id,
isGuest: session.user.email === null,
locale: session.user.locale ?? undefined,
image: session.user.image ?? undefined,
getEmailClient: () => getEmailClient(session.user.locale ?? undefined),
},
};
return res;
}
/**
* Server-Side Helper
* @description use this function to call tRPC procedures server-side and hydrate `react-query`'s cache
* @see https://trpc.io/docs/client/nextjs/server-side-helpers#1-internal-router
*/
export const createSSRHelper = async () =>
createServerSideHelpers({
router: appRouter,
ctx: await createContext(),
transformer: superjson,
});

View file

@ -1,7 +1,6 @@
import { initTRPC, TRPCError } from "@trpc/server";
import { Ratelimit } from "@upstash/ratelimit";
import { kv } from "@vercel/kv";
import requestIp from "request-ip";
import superjson from "superjson";
import { isSelfHosted } from "@/utils/constants";
@ -85,16 +84,14 @@ export const rateLimitMiddleware = middleware(async ({ ctx, next }) => {
limiter: Ratelimit.slidingWindow(5, "1 m"),
});
const clientIp = requestIp.getClientIp(ctx.req);
if (!clientIp) {
if (!ctx.ip) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to get client IP",
});
}
const res = await ratelimit.limit(clientIp);
const res = await ratelimit.limit(ctx.ip);
if (!res.success) {
throw new TRPCError({