From 3f273235a6cfe6746486ce92db39805dcd6b50bc Mon Sep 17 00:00:00 2001 From: Keith Williams Date: Sat, 19 Aug 2023 02:46:17 +0200 Subject: [PATCH] perf: Cache translations per language/per release (#10843) Co-authored-by: zomars --- apps/web/components/I18nLanguageHandler.tsx | 30 +++++++++++-------- .../security/DisableUserImpersonation.tsx | 3 -- .../UsernameAvailability/PremiumTextfield.tsx | 4 --- .../UsernameTextfield.tsx | 5 ---- apps/web/lib/app-providers.tsx | 8 ++--- .../web/pages/settings/my-account/general.tsx | 24 +++++---------- apps/web/server/lib/ssg.ts | 6 +++- apps/web/server/lib/ssr.ts | 6 +++- .../features/auth/lib/getServerSession.ts | 1 + .../features/auth/lib/next-auth-options.ts | 12 ++++++-- .../lib/ImpersonationProvider.ts | 4 ++- .../components/DisableTeamImpersonation.tsx | 3 -- packages/trpc/server/createNextApiHandler.ts | 5 ++-- .../server/middlewares/localeMiddleware.ts | 19 ------------ .../server/routers/publicViewer/_router.tsx | 6 ++-- .../routers/publicViewer/i18n.handler.ts | 8 +++-- .../routers/publicViewer/i18n.schema.ts | 9 +++++- packages/types/next-auth.d.ts | 2 ++ 18 files changed, 75 insertions(+), 80 deletions(-) delete mode 100644 packages/trpc/server/middlewares/localeMiddleware.ts diff --git a/apps/web/components/I18nLanguageHandler.tsx b/apps/web/components/I18nLanguageHandler.tsx index a531c40961..81a7fb8859 100644 --- a/apps/web/components/I18nLanguageHandler.tsx +++ b/apps/web/components/I18nLanguageHandler.tsx @@ -1,27 +1,33 @@ +import { useSession } from "next-auth/react"; import { useTranslation } from "next-i18next"; import { useEffect } from "react"; import { trpc } from "@calcom/trpc/react"; -export function useViewerI18n() { - return trpc.viewer.public.i18n.useQuery(undefined, { - staleTime: Infinity, - /** - * i18n should never be clubbed with other queries, so that it's caching can be managed independently. - * We intend to not cache i18n query - **/ - trpc: { - context: { skipBatch: true }, - }, - }); +// eslint-disable-next-line turbo/no-undeclared-env-vars +const vercelCommitHash = process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA || "NA"; + +export function useViewerI18n(locale: string) { + return trpc.viewer.public.i18n.useQuery( + { locale, CalComVersion: vercelCommitHash }, + { + /** + * i18n should never be clubbed with other queries, so that it's caching can be managed independently. + **/ + trpc: { + context: { skipBatch: true }, + }, + } + ); } /** * Auto-switches locale client-side to the logged in user's preference */ const I18nLanguageHandler = () => { + const session = useSession(); const { i18n } = useTranslation("common"); - const locale = useViewerI18n().data?.locale || i18n.language; + const locale = useViewerI18n(session.data?.user.locale || "en").data?.locale || i18n.language; useEffect(() => { // bail early when i18n = {} diff --git a/apps/web/components/security/DisableUserImpersonation.tsx b/apps/web/components/security/DisableUserImpersonation.tsx index 164cbc30c3..6300be1aac 100644 --- a/apps/web/components/security/DisableUserImpersonation.tsx +++ b/apps/web/components/security/DisableUserImpersonation.tsx @@ -12,9 +12,6 @@ const DisableUserImpersonation = ({ disableImpersonation }: { disableImpersonati showToast(t("your_user_profile_updated_successfully"), "success"); await utils.viewer.me.invalidate(); }, - async onSettled() { - await utils.viewer.public.i18n.invalidate(); - }, }); return ( diff --git a/apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx b/apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx index 903ffc9748..dbef95668d 100644 --- a/apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx +++ b/apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx @@ -95,7 +95,6 @@ const PremiumTextfield = (props: ICustomUsernameProps) => { debouncedApiCall(inputUsernameValue); }, [debouncedApiCall, inputUsernameValue]); - const utils = trpc.useContext(); const updateUsername = trpc.viewer.updateProfile.useMutation({ onSuccess: async () => { onSuccessMutation && (await onSuccessMutation()); @@ -105,9 +104,6 @@ const PremiumTextfield = (props: ICustomUsernameProps) => { onError: (error) => { onErrorMutation && onErrorMutation(error); }, - async onSettled() { - await utils.viewer.public.i18n.invalidate(); - }, }); // when current username isn't set - Go to stripe to check what username he wanted to buy and was it a premium and was it paid for diff --git a/apps/web/components/ui/UsernameAvailability/UsernameTextfield.tsx b/apps/web/components/ui/UsernameAvailability/UsernameTextfield.tsx index ade47726cb..44421edbb9 100644 --- a/apps/web/components/ui/UsernameAvailability/UsernameTextfield.tsx +++ b/apps/web/components/ui/UsernameAvailability/UsernameTextfield.tsx @@ -66,8 +66,6 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial { onSuccessMutation && (await onSuccessMutation()); @@ -78,9 +76,6 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial { onErrorMutation && onErrorMutation(error); }, - async onSettled() { - await utils.viewer.public.i18n.invalidate(); - }, }); const ActionButtons = () => { diff --git a/apps/web/lib/app-providers.tsx b/apps/web/lib/app-providers.tsx index f73239f6ff..380c73b469 100644 --- a/apps/web/lib/app-providers.tsx +++ b/apps/web/lib/app-providers.tsx @@ -1,7 +1,6 @@ import { TooltipProvider } from "@radix-ui/react-tooltip"; import type { Session } from "next-auth"; -import { SessionProvider } from "next-auth/react"; -import { useSession } from "next-auth/react"; +import { SessionProvider, useSession } from "next-auth/react"; import { EventCollectionProvider } from "next-collect/client"; import type { SSRConfig } from "next-i18next"; import { appWithTranslation } from "next-i18next"; @@ -69,9 +68,10 @@ type AppPropsWithoutNonce = Omit & { const CustomI18nextProvider = (props: AppPropsWithoutNonce) => { /** * i18n should never be clubbed with other queries, so that it's caching can be managed independently. - * We intend to not cache i18n query **/ - const { i18n, locale } = useViewerI18n().data ?? { + const session = useSession(); + const localeToUse = session.data?.user.locale ?? "en"; + const { i18n, locale } = useViewerI18n(localeToUse).data ?? { locale: "en", }; diff --git a/apps/web/pages/settings/my-account/general.tsx b/apps/web/pages/settings/my-account/general.tsx index f773bd8a33..3d92bac93e 100644 --- a/apps/web/pages/settings/my-account/general.tsx +++ b/apps/web/pages/settings/my-account/general.tsx @@ -1,3 +1,4 @@ +import { useSession } from "next-auth/react"; import { Controller, useForm } from "react-hook-form"; import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout"; @@ -20,8 +21,6 @@ import { TimezoneSelect, } from "@calcom/ui"; -import { withQuery } from "@lib/QueryCell"; - import PageWrapper from "@components/PageWrapper"; const SkeletonLoader = ({ title, description }: { title: string; description: string }) => { @@ -45,11 +44,6 @@ interface GeneralViewProps { user: RouterOutputs["viewer"]["me"]; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const WithQuery = withQuery(trpc.viewer.public.i18n as any, undefined, { - trpc: { context: { skipBatch: true } }, -}); - const GeneralQueryView = () => { const { t } = useLocale(); @@ -58,30 +52,26 @@ const GeneralQueryView = () => { if (!user) { throw new Error(t("something_went_wrong")); } - return ( - } - customLoader={} - /> - ); + return ; }; const GeneralView = ({ localeProp, user }: GeneralViewProps) => { const utils = trpc.useContext(); const { t } = useLocale(); + const { update } = useSession(); const mutation = trpc.viewer.updateProfile.useMutation({ - onSuccess: async () => { - // Invalidate our previous i18n cache - await utils.viewer.public.i18n.invalidate(); + onSuccess: async (res) => { + await utils.viewer.me.invalidate(); reset(getValues()); showToast(t("settings_updated_successfully"), "success"); + update(res); }, onError: () => { showToast(t("error_updating_settings"), "error"); }, onSettled: async () => { - await utils.viewer.public.i18n.invalidate(); + await utils.viewer.me.invalidate(); }, }); diff --git a/apps/web/server/lib/ssg.ts b/apps/web/server/lib/ssg.ts index 06d46c0f16..c651239447 100644 --- a/apps/web/server/lib/ssg.ts +++ b/apps/web/server/lib/ssg.ts @@ -9,6 +9,10 @@ import { appRouter } from "@calcom/trpc/server/routers/_app"; // eslint-disable-next-line @typescript-eslint/no-var-requires const { i18n } = require("@calcom/config/next-i18next.config"); +// TODO: Consolidate this constant +// eslint-disable-next-line turbo/no-undeclared-env-vars +const CalComVersion = process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA || "NA"; + /** * Initialize static site rendering tRPC helpers. * Provides a method to prefetch tRPC-queries in a `getStaticProps`-function. @@ -37,7 +41,7 @@ export async function ssgInit(opts: GetStat }); // always preload i18n - await ssg.viewer.public.i18n.fetch(); + await ssg.viewer.public.i18n.fetch({ locale, CalComVersion }); return ssg; } diff --git a/apps/web/server/lib/ssr.ts b/apps/web/server/lib/ssr.ts index 343d625d38..e860b3f30c 100644 --- a/apps/web/server/lib/ssr.ts +++ b/apps/web/server/lib/ssr.ts @@ -7,6 +7,10 @@ import { createProxySSGHelpers } from "@calcom/trpc/react/ssg"; import { createContext } from "@calcom/trpc/server/createContext"; import { appRouter } from "@calcom/trpc/server/routers/_app"; +// TODO: Consolidate this constant +// eslint-disable-next-line turbo/no-undeclared-env-vars +const CalComVersion = process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA || "NA"; + /** * Initialize server-side rendering tRPC helpers. * Provides a method to prefetch tRPC-queries in a `getServerSideProps`-function. @@ -25,7 +29,7 @@ export async function ssrInit(context: GetServerSidePropsContext) { }); // always preload "viewer.public.i18n" - await ssr.viewer.public.i18n.fetch(); + await ssr.viewer.public.i18n.fetch({ locale, CalComVersion }); // So feature flags are available on first render await ssr.viewer.features.map.prefetch(); // Provides a better UX to the users who have already upgraded. diff --git a/packages/features/auth/lib/getServerSession.ts b/packages/features/auth/lib/getServerSession.ts index 97128b1ebe..a2a53e9117 100644 --- a/packages/features/auth/lib/getServerSession.ts +++ b/packages/features/auth/lib/getServerSession.ts @@ -73,6 +73,7 @@ export async function getServerSession(options: { impersonatedByUID: token.impersonatedByUID ?? undefined, belongsToActiveTeam: token.belongsToActiveTeam, organizationId: token.organizationId, + locale: user.locale ?? undefined, }, }; diff --git a/packages/features/auth/lib/next-auth-options.ts b/packages/features/auth/lib/next-auth-options.ts index ef91f9db47..01d3aa66a8 100644 --- a/packages/features/auth/lib/next-auth-options.ts +++ b/packages/features/auth/lib/next-auth-options.ts @@ -1,4 +1,4 @@ -import type { UserPermissionRole, Membership, Team } from "@prisma/client"; +import type { Membership, Team, UserPermissionRole } from "@prisma/client"; import type { AuthOptions, Session } from "next-auth"; import { encode } from "next-auth/jwt"; import type { Provider } from "next-auth/providers"; @@ -85,6 +85,7 @@ const providers: Provider[] = [ organizationId: true, twoFactorEnabled: true, twoFactorSecret: true, + locale: true, organization: { select: { id: true, @@ -181,6 +182,7 @@ const providers: Provider[] = [ role: validateRole(user.role), belongsToActiveTeam: hasActiveTeams, organizationId: user.organizationId, + locale: user.locale, }; }, }), @@ -225,6 +227,7 @@ if (isSAMLLoginEnabled) { email: profile.email || "", name: `${profile.firstName || ""} ${profile.lastName || ""}`.trim(), email_verified: true, + locale: profile.locale, }; }, options: { @@ -356,6 +359,7 @@ export const AUTH_OPTIONS: AuthOptions = { if (trigger === "update") { return { ...token, + locale: session?.locale ?? token.locale, name: session?.name ?? token.name, username: session?.username ?? token.username, email: session?.email ?? token.email, @@ -372,6 +376,7 @@ export const AUTH_OPTIONS: AuthOptions = { email: true, organizationId: true, role: true, + locale: true, teams: { include: { team: true, @@ -386,7 +391,7 @@ export const AUTH_OPTIONS: AuthOptions = { // Check if the existingUser has any active teams const belongsToActiveTeam = checkIfUserBelongsToActiveTeam(existingUser); - const { teams, ...existingUserWithoutTeamsField } = existingUser; + const { teams: _teams, ...existingUserWithoutTeamsField } = existingUser; return { ...existingUserWithoutTeamsField, @@ -416,6 +421,7 @@ export const AUTH_OPTIONS: AuthOptions = { impersonatedByUID: user?.impersonatedByUID, belongsToActiveTeam: user?.belongsToActiveTeam, organizationId: user?.organizationId, + locale: user?.locale, }; } @@ -454,6 +460,7 @@ export const AUTH_OPTIONS: AuthOptions = { impersonatedByUID: token.impersonatedByUID as number, belongsToActiveTeam: token?.belongsToActiveTeam as boolean, organizationId: token?.organizationId, + locale: existingUser.locale, }; } @@ -473,6 +480,7 @@ export const AUTH_OPTIONS: AuthOptions = { impersonatedByUID: token.impersonatedByUID as number, belongsToActiveTeam: token?.belongsToActiveTeam as boolean, organizationId: token?.organizationId, + locale: token.locale, }, }; return calendsoSession; diff --git a/packages/features/ee/impersonation/lib/ImpersonationProvider.ts b/packages/features/ee/impersonation/lib/ImpersonationProvider.ts index 23545b0792..9df72b7113 100644 --- a/packages/features/ee/impersonation/lib/ImpersonationProvider.ts +++ b/packages/features/ee/impersonation/lib/ImpersonationProvider.ts @@ -11,7 +11,7 @@ const teamIdschema = z.object({ }); const auditAndReturnNextUser = async ( - impersonatedUser: Pick, + impersonatedUser: Pick, impersonatedByUID: number, hasTeam?: boolean ) => { @@ -40,6 +40,7 @@ const auditAndReturnNextUser = async ( impersonatedByUID, belongsToActiveTeam: hasTeam, organizationId: impersonatedUser.organizationId, + locale: impersonatedUser.locale, }; return obj; @@ -97,6 +98,7 @@ const ImpersonationProvider = CredentialsProvider({ email: true, organizationId: true, disableImpersonation: true, + locale: true, teams: { where: { disableImpersonation: false, // Ensure they have impersonation enabled diff --git a/packages/features/ee/teams/components/DisableTeamImpersonation.tsx b/packages/features/ee/teams/components/DisableTeamImpersonation.tsx index 5bad6da5a1..c514210f27 100644 --- a/packages/features/ee/teams/components/DisableTeamImpersonation.tsx +++ b/packages/features/ee/teams/components/DisableTeamImpersonation.tsx @@ -23,9 +23,6 @@ const DisableTeamImpersonation = ({ showToast(t("your_user_profile_updated_successfully"), "success"); await utils.viewer.teams.getMembershipbyUser.invalidate(); }, - async onSettled() { - await utils.viewer.public.i18n.invalidate(); - }, }); if (query.isLoading) return <>; diff --git a/packages/trpc/server/createNextApiHandler.ts b/packages/trpc/server/createNextApiHandler.ts index a6ad997b3e..31fccbf06b 100644 --- a/packages/trpc/server/createNextApiHandler.ts +++ b/packages/trpc/server/createNextApiHandler.ts @@ -59,11 +59,12 @@ export function createNextApiHandler(router: AnyRouter, isPublic = false, namesp if (isPublic && paths) { const ONE_DAY_IN_SECONDS = 60 * 60 * 24; const FIVE_MINUTES_IN_SECONDS = 5 * 60; + const ONE_YEAR_IN_SECONDS = 31536000; + const cacheRules = { session: "no-cache", - // i18n data is user specific and thus should not be cached - i18n: "no-cache", + i18n: `max-age=${ONE_YEAR_IN_SECONDS}`, // FIXME: Using `max-age=1, stale-while-revalidate=60` fails some booking tests. "slots.getSchedule": `no-cache`, diff --git a/packages/trpc/server/middlewares/localeMiddleware.ts b/packages/trpc/server/middlewares/localeMiddleware.ts deleted file mode 100644 index 73c29cf2ec..0000000000 --- a/packages/trpc/server/middlewares/localeMiddleware.ts +++ /dev/null @@ -1,19 +0,0 @@ -import sessionMiddleware from "./sessionMiddleware"; - -const localeMiddleware = sessionMiddleware.unstable_pipe(async ({ ctx, next }) => { - const { serverSideTranslations } = await import("next-i18next/serverSideTranslations"); - const { user } = ctx; - - const i18n = await serverSideTranslations( - user?.locale && user?.locale !== ctx.locale ? user.locale : ctx.locale, - ["common", "vital"] - ); - - const locale = user?.locale || ctx.locale; - - return next({ - ctx: { locale, i18n, user: { ...user, locale } }, - }); -}); - -export default localeMiddleware; diff --git a/packages/trpc/server/routers/publicViewer/_router.tsx b/packages/trpc/server/routers/publicViewer/_router.tsx index 421adedb04..9688a83d0f 100644 --- a/packages/trpc/server/routers/publicViewer/_router.tsx +++ b/packages/trpc/server/routers/publicViewer/_router.tsx @@ -1,9 +1,9 @@ -import localeMiddleware from "../../middlewares/localeMiddleware"; import sessionMiddleware from "../../middlewares/sessionMiddleware"; import publicProcedure from "../../procedures/publicProcedure"; import { router } from "../../trpc"; import { slotsRouter } from "../viewer/slots/_router"; import { ZEventInputSchema } from "./event.schema"; +import { i18nInputSchema } from "./i18n.schema"; import { ZSamlTenantProductInputSchema } from "./samlTenantProduct.schema"; import { ZStripeCheckoutSessionInputSchema } from "./stripeCheckoutSession.schema"; @@ -36,7 +36,7 @@ export const publicViewerRouter = router({ }); }), - i18n: publicProcedure.use(localeMiddleware).query(async ({ ctx }) => { + i18n: publicProcedure.input(i18nInputSchema).query(async ({ ctx, input }) => { if (!UNSTABLE_HANDLER_CACHE.i18n) { UNSTABLE_HANDLER_CACHE.i18n = await import("./i18n.handler").then((mod) => mod.i18nHandler); } @@ -46,7 +46,7 @@ export const publicViewerRouter = router({ throw new Error("Failed to load handler"); } - return UNSTABLE_HANDLER_CACHE.i18n({ ctx }); + return UNSTABLE_HANDLER_CACHE.i18n({ ctx, input }); }), countryCode: publicProcedure.query(async ({ ctx }) => { diff --git a/packages/trpc/server/routers/publicViewer/i18n.handler.ts b/packages/trpc/server/routers/publicViewer/i18n.handler.ts index 494e4b53b4..3b5e9556d5 100644 --- a/packages/trpc/server/routers/publicViewer/i18n.handler.ts +++ b/packages/trpc/server/routers/publicViewer/i18n.handler.ts @@ -1,16 +1,20 @@ import type { NextApiRequest, NextApiResponse } from "next"; import type { WithLocale } from "../../createContext"; +import type { I18nInputSchema } from "./i18n.schema"; type I18nOptions = { ctx: WithLocale & { req: NextApiRequest | undefined; res: NextApiResponse | undefined; }; + input: I18nInputSchema; }; -export const i18nHandler = async ({ ctx }: I18nOptions) => { - const { locale, i18n } = ctx; +export const i18nHandler = async ({ input }: I18nOptions) => { + const { locale } = input; + const { serverSideTranslations } = await import("next-i18next/serverSideTranslations"); + const i18n = await serverSideTranslations(locale, ["common", "vital"]); return { i18n, diff --git a/packages/trpc/server/routers/publicViewer/i18n.schema.ts b/packages/trpc/server/routers/publicViewer/i18n.schema.ts index cb0ff5c3b5..467b91492a 100644 --- a/packages/trpc/server/routers/publicViewer/i18n.schema.ts +++ b/packages/trpc/server/routers/publicViewer/i18n.schema.ts @@ -1 +1,8 @@ -export {}; +import { z } from "zod"; + +export const i18nInputSchema = z.object({ + locale: z.string(), + CalComVersion: z.string(), +}); + +export type I18nInputSchema = z.infer; diff --git a/packages/types/next-auth.d.ts b/packages/types/next-auth.d.ts index ff77847504..44c239ef51 100644 --- a/packages/types/next-auth.d.ts +++ b/packages/types/next-auth.d.ts @@ -19,6 +19,7 @@ declare module "next-auth" { organizationId?: number | null; username?: PrismaUser["username"]; role?: PrismaUser["role"] | "INACTIVE_ADMIN"; + locale?: string | null; } } @@ -32,5 +33,6 @@ declare module "next-auth/jwt" { impersonatedByUID?: number | null; belongsToActiveTeam?: boolean; organizationId?: number | null; + locale?: string; } }