perf: Cache translations per language/per release (#10843)

Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
Keith Williams 2023-08-19 02:46:17 +02:00 committed by GitHub
parent e8554ed5c5
commit 3f273235a6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 75 additions and 80 deletions

View File

@ -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 = {}

View File

@ -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 (

View File

@ -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

View File

@ -66,8 +66,6 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
}
}, [inputUsernameValue, debouncedApiCall, currentUsername]);
const utils = trpc.useContext();
const updateUsernameMutation = trpc.viewer.updateProfile.useMutation({
onSuccess: async () => {
onSuccessMutation && (await onSuccessMutation());
@ -78,9 +76,6 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
onError: (error) => {
onErrorMutation && onErrorMutation(error);
},
async onSettled() {
await utils.viewer.public.i18n.invalidate();
},
});
const ActionButtons = () => {

View File

@ -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<AppPropsWithChildren, "pageProps"> & {
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",
};

View File

@ -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 (
<WithQuery
success={({ data }) => <GeneralView user={user} localeProp={data.locale} />}
customLoader={<SkeletonLoader title={t("general")} description={t("general_description")} />}
/>
);
return <GeneralView user={user} localeProp={user.locale} />;
};
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();
},
});

View File

@ -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<TParams extends { locale?: string }>(opts: GetStat
});
// always preload i18n
await ssg.viewer.public.i18n.fetch();
await ssg.viewer.public.i18n.fetch({ locale, CalComVersion });
return ssg;
}

View File

@ -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.

View File

@ -73,6 +73,7 @@ export async function getServerSession(options: {
impersonatedByUID: token.impersonatedByUID ?? undefined,
belongsToActiveTeam: token.belongsToActiveTeam,
organizationId: token.organizationId,
locale: user.locale ?? undefined,
},
};

View File

@ -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;

View File

@ -11,7 +11,7 @@ const teamIdschema = z.object({
});
const auditAndReturnNextUser = async (
impersonatedUser: Pick<User, "id" | "username" | "email" | "name" | "role" | "organizationId">,
impersonatedUser: Pick<User, "id" | "username" | "email" | "name" | "role" | "organizationId" | "locale">,
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

View File

@ -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 <></>;

View File

@ -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`,

View File

@ -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;

View File

@ -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 }) => {

View File

@ -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,

View File

@ -1 +1,8 @@
export {};
import { z } from "zod";
export const i18nInputSchema = z.object({
locale: z.string(),
CalComVersion: z.string(),
});
export type I18nInputSchema = z.infer<typeof i18nInputSchema>;

View File

@ -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;
}
}