️ 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 { cn } from "@rallly/ui";
import { dehydrate, Hydrate } from "@tanstack/react-query";
import React from "react"; import React from "react";
import { MobileNavigation } from "@/app/[locale]/(admin)/mobile-navigation"; import { MobileNavigation } from "@/app/[locale]/(admin)/mobile-navigation";
@ -6,13 +7,18 @@ import { ProBadge } from "@/app/[locale]/(admin)/pro-badge";
import { Sidebar } from "@/app/[locale]/(admin)/sidebar"; import { Sidebar } from "@/app/[locale]/(admin)/sidebar";
import { LogoLink } from "@/app/components/logo-link"; import { LogoLink } from "@/app/components/logo-link";
import { PayWallDialog } from "@/components/pay-wall-dialog"; import { PayWallDialog } from "@/components/pay-wall-dialog";
import { createSSRHelper } from "@/trpc/server/create-ssr-helper";
export default async function Layout({ export default async function Layout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const helpers = await createSSRHelper();
await helpers.user.subscription.prefetch();
const dehydratedState = dehydrate(helpers.queryClient);
return ( return (
<Hydrate state={dehydratedState}>
<PayWallDialog> <PayWallDialog>
<div className="flex flex-col pb-16 md:pb-0"> <div className="flex flex-col pb-16 md:pb-0">
<div <div
@ -34,5 +40,6 @@ export default async function Layout({
</div> </div>
</div> </div>
</PayWallDialog> </PayWallDialog>
</Hydrate>
); );
} }

View file

@ -1,3 +1,4 @@
import { dehydrate, Hydrate } from "@tanstack/react-query";
import { HomeIcon } from "lucide-react"; import { HomeIcon } from "lucide-react";
import { Trans } from "react-i18next/TransWithoutContext"; import { Trans } from "react-i18next/TransWithoutContext";
@ -11,10 +12,14 @@ import {
PageTitle, PageTitle,
} from "@/app/components/page-layout"; } from "@/app/components/page-layout";
import { getTranslation } from "@/i18n/server"; import { getTranslation } from "@/i18n/server";
import { createSSRHelper } from "@/trpc/server/create-ssr-helper";
export default async function Page({ params }: { params: Params }) { export default async function Page({ params }: { params: Params }) {
const { t } = await getTranslation(params.locale); const { t } = await getTranslation(params.locale);
const helpers = await createSSRHelper();
await helpers.dashboard.info.prefetch();
return ( return (
<Hydrate state={dehydrate(helpers.queryClient)}>
<div> <div>
<PageContainer> <PageContainer>
<PageHeader> <PageHeader>
@ -32,6 +37,7 @@ export default async function Page({ params }: { params: Params }) {
</PageContent> </PageContent>
</PageContainer> </PageContainer>
</div> </div>
</Hydrate>
); );
} }

View file

@ -1,59 +1,26 @@
"use client"; import { dehydrate, Hydrate } from "@tanstack/react-query";
import { notFound, useParams, useSearchParams } from "next/navigation";
import React from "react";
import { LegacyPollContextProvider } from "@/components/poll/poll-context-provider"; import { createSSRHelper } from "@/trpc/server/create-ssr-helper";
import { VisibilityProvider } from "@/components/visibility";
import { PermissionsContext } from "@/contexts/permissions";
import { trpc } from "@/trpc/client";
import Loader from "./loading"; import Providers from "./providers";
const Prefetch = ({ children }: React.PropsWithChildren) => { export default async function Layout({
const searchParams = useSearchParams(); children,
const token = searchParams?.get("token") as string; params,
const params = useParams<{ urlId: string }>(); }: {
const urlId = params?.urlId as string; params: { urlId: string };
const { data: permission } = trpc.auth.getUserPermission.useQuery( children: React.ReactNode;
{ token }, }) {
{ const trpc = await createSSRHelper();
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 />;
}
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 ( return (
<PermissionsContext.Provider value={{ userId: permission?.userId ?? null }}> <Hydrate state={dehydrate(trpc.queryClient)}>
{children} <Providers>{children}</Providers>
</PermissionsContext.Provider> </Hydrate>
);
};
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<Prefetch>
<LegacyPollContextProvider>
<VisibilityProvider>{children}</VisibilityProvider>
</LegacyPollContextProvider>
</Prefetch>
); );
} }

View file

@ -3,10 +3,38 @@ import { absoluteUrl } from "@rallly/utils/absolute-url";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { InvitePage } from "@/app/[locale]/invite/[urlId]/invite-page"; import { InvitePage } from "@/app/[locale]/invite/[urlId]/invite-page";
import { PermissionProvider } from "@/contexts/permissions";
import { getTranslation } from "@/i18n/server"; import { getTranslation } from "@/i18n/server";
import { createSSRHelper } from "@/trpc/server/create-ssr-helper";
export default async function Page() { const PermissionContext = async ({
return <InvitePage />; 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({ 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 { prisma } from "@rallly/database";
import { dehydrate, Hydrate } from "@tanstack/react-query";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { PollLayout } from "@/components/layouts/poll-layout"; import { PollLayout } from "@/components/layouts/poll-layout";
import { createSSRHelper } from "@/trpc/server/create-ssr-helper";
export default async function Layout({ export default async function Layout({
children, children,
params, params,
}: React.PropsWithChildren<{ params: { urlId: string } }>) { }: 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 } }); const poll = await prisma.poll.findUnique({ where: { id: params.urlId } });
if (!poll) { if (!poll) {
notFound(); notFound();
} }
return <PollLayout>{children}</PollLayout>; return (
<Hydrate state={dehydrate(trpc.queryClient)}>
<PollLayout>{children}</PollLayout>
</Hydrate>
);
} }
export async function generateMetadata({ export async function generateMetadata({

View file

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

View file

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

View file

@ -1,19 +1,10 @@
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import React from "react";
import { trpc } from "@/trpc/client"; import { trpc } from "@/trpc/client";
export const usePoll = () => { export const usePoll = () => {
const params = useParams<{ urlId: string }>(); const params = useParams<{ urlId: string }>();
const pollQuery = trpc.polls.get.useQuery({ urlId: params?.urlId as string });
const [urlId] = React.useState(params?.urlId as string);
const pollQuery = trpc.polls.get.useQuery(
{ urlId },
{
staleTime: Infinity,
},
);
if (!pollQuery.data) { if (!pollQuery.data) {
throw new Error("Expected poll to be prefetched"); 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 * as Sentry from "@sentry/nextjs";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { createNextApiHandler } from "@trpc/server/adapters/next"; import { createNextApiHandler } from "@trpc/server/adapters/next";
import requestIp from "request-ip";
import { getServerSession } from "@/auth"; import { getServerSession } from "@/auth";
import type { TRPCContext } from "@/trpc/context";
import type { AppRouter } from "@/trpc/routers"; import type { AppRouter } from "@/trpc/routers";
import { appRouter } from "@/trpc/routers"; import { appRouter } from "@/trpc/routers";
import { getEmailClient } from "@/utils/emails"; import { getEmailClient } from "@/utils/emails";
@ -27,7 +29,7 @@ const trpcApiHandler = createNextApiHandler<AppRouter>({
}); });
} }
const res = { return {
user: { user: {
id: session.user.id, id: session.user.id,
isGuest: session.user.email === null, isGuest: session.user.email === null,
@ -35,11 +37,8 @@ const trpcApiHandler = createNextApiHandler<AppRouter>({
image: session.user.image ?? undefined, image: session.user.image ?? undefined,
getEmailClient: () => getEmailClient(session.user.locale ?? undefined), getEmailClient: () => getEmailClient(session.user.locale ?? undefined),
}, },
req: opts.req, ip: requestIp.getClientIp(opts.req) ?? undefined,
res: opts.res, } satisfies TRPCContext;
};
return res;
}, },
onError({ error }) { onError({ error }) {
if (error.code === "INTERNAL_SERVER_ERROR") { if (error.code === "INTERNAL_SERVER_ERROR") {

View file

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

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