mirror of
https://github.com/lukevella/rallly.git
synced 2025-04-29 18:26:34 +02:00
⚡️ Prefetch queries with trpc (#1454)
This commit is contained in:
parent
40df1ff9da
commit
82ebcd8752
13 changed files with 196 additions and 125 deletions
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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({
|
||||
|
|
12
apps/web/src/app/[locale]/invite/[urlId]/providers.tsx
Normal file
12
apps/web/src/app/[locale]/invite/[urlId]/providers.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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({
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
|
@ -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");
|
||||
|
|
|
@ -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") {
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
44
apps/web/src/trpc/server/create-ssr-helper.ts
Normal file
44
apps/web/src/trpc/server/create-ssr-helper.ts
Normal 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,
|
||||
});
|
|
@ -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({
|
||||
|
|
Loading…
Add table
Reference in a new issue