refactor: implementation of locale calculated server-side (#11534)

This commit is contained in:
Greg Pabian 2023-10-10 18:36:28 +02:00 committed by GitHub
parent b4c6388ce0
commit 778485b31d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 139 additions and 120 deletions

View File

@ -1,12 +1,7 @@
import { lookup } from "bcp-47-match";
import { useSession } from "next-auth/react";
import { useTranslation } from "next-i18next";
import { useEffect } from "react";
import { CALCOM_VERSION } from "@calcom/lib/constants";
import { trpc } from "@calcom/trpc/react";
function useViewerI18n(locale: string) {
export function useViewerI18n(locale: string) {
return trpc.viewer.public.i18n.useQuery(
{ locale, CalComVersion: CALCOM_VERSION },
{
@ -19,46 +14,3 @@ function useViewerI18n(locale: string) {
}
);
}
function useClientLocale(locales: string[]) {
const session = useSession();
// If the user is logged in, use their locale
if (session.data?.user.locale) return session.data.user.locale;
// If the user is not logged in, use the browser locale
if (typeof window !== "undefined") {
// This is the only way I found to ensure the prefetched locale is used on first render
// FIXME: Find a better way to pick the best matching locale from the browser
return lookup(locales, window.navigator.language) || window.navigator.language;
}
// If the browser is not available, use English
return "en";
}
export function useClientViewerI18n(locales: string[]) {
const clientLocale = useClientLocale(locales);
return useViewerI18n(clientLocale);
}
/**
* Auto-switches locale client-side to the logged in user's preference
*/
const I18nLanguageHandler = (props: { locales: string[] }) => {
const { locales } = props;
const { i18n } = useTranslation("common");
const locale = useClientViewerI18n(locales).data?.locale || i18n.language;
useEffect(() => {
// bail early when i18n = {}
if (Object.keys(i18n).length === 0) return;
// if locale is ready and the i18n.language does != locale - changeLanguage
if (locale && i18n.language !== locale) {
i18n.changeLanguage(locale);
}
// set dir="rtl|ltr"
document.dir = i18n.dir();
document.documentElement.setAttribute("lang", locale);
}, [locale, i18n]);
return null;
};
export default I18nLanguageHandler;

View File

@ -13,8 +13,6 @@ import type { AppProps } from "@lib/app-providers";
import AppProviders from "@lib/app-providers";
import { seoConfig } from "@lib/config/next-seo.config";
import I18nLanguageHandler from "@components/I18nLanguageHandler";
export interface CalPageWrapper {
(props?: AppProps): JSX.Element;
PageWrapper?: AppProps["Component"]["PageWrapper"];
@ -72,7 +70,6 @@ function PageWrapper(props: AppProps) {
}
{...seoConfig.defaultNextSeo}
/>
<I18nLanguageHandler locales={props.router.locales || []} />
<Script
nonce={nonce}
id="page-status"

View File

@ -7,7 +7,7 @@ import { appWithTranslation } from "next-i18next";
import { ThemeProvider } from "next-themes";
import type { AppProps as NextAppProps, AppProps as NextJsAppProps } from "next/app";
import type { ParsedUrlQuery } from "querystring";
import type { ComponentProps, PropsWithChildren, ReactNode } from "react";
import type { PropsWithChildren, ReactNode } from "react";
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
@ -17,9 +17,10 @@ import { useFlags } from "@calcom/features/flags/hooks";
import { MetaProvider } from "@calcom/ui";
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
import type { WithLocaleProps } from "@lib/withLocale";
import type { WithNonceProps } from "@lib/withNonce";
import { useClientViewerI18n } from "@components/I18nLanguageHandler";
import { useViewerI18n } from "@components/I18nLanguageHandler";
const I18nextAdapter = appWithTranslation<
NextJsAppProps<SSRConfig> & {
@ -30,10 +31,12 @@ const I18nextAdapter = appWithTranslation<
// Workaround for https://github.com/vercel/next.js/issues/8592
export type AppProps = Omit<
NextAppProps<
WithNonceProps & {
themeBasis?: string;
session: Session;
} & Record<string, unknown>
WithLocaleProps<
WithNonceProps<{
themeBasis?: string;
session: Session;
}>
>
>,
"Component"
> & {
@ -68,8 +71,8 @@ const CustomI18nextProvider = (props: AppPropsWithoutNonce) => {
/**
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
**/
const clientViewerI18n = useClientViewerI18n(props.router.locales || []);
const { i18n, locale } = clientViewerI18n.data || {};
const clientViewerI18n = useViewerI18n(props.pageProps.newLocale);
const i18n = clientViewerI18n.data?.i18n;
const passedProps = {
...props,
@ -77,8 +80,7 @@ const CustomI18nextProvider = (props: AppPropsWithoutNonce) => {
...props.pageProps,
...i18n,
},
router: locale ? { locale } : props.router,
} as unknown as ComponentProps<typeof I18nextAdapter>;
};
return <I18nextAdapter {...passedProps} />;
};
@ -233,7 +235,9 @@ const AppProviders = (props: AppPropsWithChildren) => {
// No need to have intercom on public pages - Good for Page Performance
const isBookingPage = useIsBookingPage();
const { pageProps, ...rest } = props;
const { _nonce, ...restPageProps } = pageProps;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { nonce, ...restPageProps } = pageProps;
const propsWithoutNonce = {
pageProps: {
...restPageProps,
@ -243,8 +247,8 @@ const AppProviders = (props: AppPropsWithChildren) => {
const RemainingProviders = (
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
<SessionProvider session={pageProps.session ?? undefined}>
<CustomI18nextProvider {...propsWithoutNonce}>
<CustomI18nextProvider {...propsWithoutNonce}>
<SessionProvider session={pageProps.session ?? undefined}>
<TooltipProvider>
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
<CalcomThemeProvider
@ -260,8 +264,8 @@ const AppProviders = (props: AppPropsWithChildren) => {
</FeatureFlagsProvider>
</CalcomThemeProvider>
</TooltipProvider>
</CustomI18nextProvider>
</SessionProvider>
</SessionProvider>
</CustomI18nextProvider>
</EventCollectionProvider>
);

View File

@ -0,0 +1,3 @@
export type WithLocaleProps<T extends Record<string, unknown>> = T & {
newLocale: string;
};

View File

@ -1,8 +1,8 @@
import type { GetServerSideProps, GetServerSidePropsContext } from "next";
import type { GetServerSideProps } from "next";
import { csp } from "@lib/csp";
export type WithNonceProps = {
export type WithNonceProps<T extends Record<string, any>> = T & {
nonce?: string;
};
@ -11,9 +11,16 @@ export type WithNonceProps = {
* Note that if the Components are not adding any script tag then this is not needed. Even in absence of this, Document.getInitialProps would be able to generate nonce itself which it needs to add script tags common to all pages
* There is no harm in wrapping a `getServerSideProps` fn with this even if it doesn't add any script tag.
*/
export default function withNonce(getServerSideProps: GetServerSideProps) {
return async (context: GetServerSidePropsContext) => {
export default function withNonce<T extends Record<string, any>>(
getServerSideProps: GetServerSideProps<T>
): GetServerSideProps<WithNonceProps<T>> {
return async (context) => {
const ssrResponse = await getServerSideProps(context);
if (!("props" in ssrResponse)) {
return ssrResponse;
}
const { nonce } = csp(context.req, context.res);
// Skip nonce property if it's not available instead of setting it to undefined because undefined can't be serialized.
@ -23,10 +30,6 @@ export default function withNonce(getServerSideProps: GetServerSideProps) {
}
: null;
if (!("props" in ssrResponse)) {
return ssrResponse;
}
// Helps in debugging that withNonce was used but a valid nonce couldn't be set
context.res.setHeader("x-csp", nonce ? "ssr" : "false");

View File

@ -1,5 +1,8 @@
import type { IncomingMessage } from "http";
import type { AppContextType } from "next/dist/shared/lib/utils";
import React from "react";
import { getLocale } from "@calcom/features/auth/lib/getLocale";
import { trpc } from "@calcom/trpc/react";
import type { AppProps } from "@lib/app-providers";
@ -8,8 +11,35 @@ import "../styles/globals.css";
function MyApp(props: AppProps) {
const { Component, pageProps } = props;
if (Component.PageWrapper !== undefined) return Component.PageWrapper(props);
return <Component {...pageProps} />;
}
export default trpc.withTRPC(MyApp);
declare global {
interface Window {
calNewLocale: string;
}
}
MyApp.getInitialProps = async (ctx: AppContextType) => {
const { req } = ctx.ctx;
let newLocale = "en";
if (req) {
newLocale = await getLocale(req as IncomingMessage & { cookies: Record<string, any> });
} else if (typeof window !== "undefined" && window.calNewLocale) {
newLocale = window.calNewLocale;
}
return {
pageProps: {
newLocale,
},
};
};
const WrappedMyApp = trpc.withTRPC(MyApp);
export default WrappedMyApp;

View File

@ -1,13 +1,15 @@
import type { IncomingMessage } from "http";
import type { NextPageContext } from "next";
import type { DocumentContext, DocumentProps } from "next/document";
import Document, { Head, Html, Main, NextScript } from "next/document";
import { z } from "zod";
import { getLocale } from "@calcom/features/auth/lib/getLocale";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { csp } from "@lib/csp";
type Props = Record<string, unknown> & DocumentProps;
type Props = Record<string, unknown> & DocumentProps & { newLocale: string };
function setHeader(ctx: NextPageContext, name: string, value: string) {
try {
ctx.res?.setHeader(name, value);
@ -26,6 +28,10 @@ class MyDocument extends Document<Props> {
setHeader(ctx, "x-csp", "initialPropsOnly");
}
const newLocale = ctx.req
? await getLocale(ctx.req as IncomingMessage & { cookies: Record<string, any> })
: "en";
const asPath = ctx.asPath || "";
// Use a dummy URL as default so that URL parsing works for relative URLs as well. We care about searchParams and pathname only
const parsedUrl = new URL(asPath, "https://dummyurl");
@ -36,17 +42,26 @@ class MyDocument extends Document<Props> {
!isEmbedSnippetGeneratorPath;
const embedColorScheme = parsedUrl.searchParams.get("ui.color-scheme");
const initialProps = await Document.getInitialProps(ctx);
return { isEmbed, embedColorScheme, nonce, ...initialProps };
return { isEmbed, embedColorScheme, nonce, ...initialProps, newLocale };
}
render() {
const { locale } = this.props.__NEXT_DATA__;
const { isEmbed, embedColorScheme } = this.props;
const { isEmbed, embedColorScheme, newLocale } = this.props;
const nonceParsed = z.string().safeParse(this.props.nonce);
const nonce = nonceParsed.success ? nonceParsed.data : "";
return (
<Html lang={locale} style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}>
<Html
lang={newLocale}
style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}>
<Head nonce={nonce}>
<script
nonce={nonce}
id="newLocale"
dangerouslySetInnerHTML={{
__html: `window.calNewLocale = "${newLocale}";`,
}}
/>
<link rel="apple-touch-icon" sizes="180x180" href="/api/logo?type=apple-touch-icon" />
<link rel="icon" type="image/png" sizes="32x32" href="/api/logo?type=favicon-32" />
<link rel="icon" type="image/png" sizes="16x16" href="/api/logo?type=favicon-16" />

View File

@ -5,6 +5,7 @@ import Link from "next/link";
import type { CSSProperties } from "react";
import { useForm } from "react-hook-form";
import { getLocale } from "@calcom/features/auth/lib/getLocale";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import { Button, PasswordField, Form } from "@calcom/ui";
@ -162,12 +163,13 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
} catch (e) {
resetPasswordRequest = null;
}
const locale = await getLocale(context.req);
return {
props: {
isRequestExpired: !resetPasswordRequest,
requestId: id,
csrfToken: await getCsrfToken({ req: context.req }),
...(await serverSideTranslations(context.locale || "en", ["common"])),
...(await serverSideTranslations(locale, ["common"])),
},
};
}

View File

@ -7,6 +7,7 @@ import Link from "next/link";
import type { CSSProperties, SyntheticEvent } from "react";
import React from "react";
import { getLocale } from "@calcom/features/auth/lib/getLocale";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, EmailField } from "@calcom/ui";
@ -154,11 +155,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
res.end();
return { props: {} };
}
const locale = await getLocale(context.req);
return {
props: {
csrfToken: await getCsrfToken(context),
...(await serverSideTranslations(context.locale || "en", ["common"])),
...(await serverSideTranslations(locale, ["common"])),
},
};
};

View File

@ -51,7 +51,8 @@ export default function Login({
samlTenantID,
samlProductID,
totpEmail,
}: inferSSRProps<typeof _getServerSideProps> & WithNonceProps) {
}: // eslint-disable-next-line @typescript-eslint/ban-types
inferSSRProps<typeof _getServerSideProps> & WithNonceProps<{}>) {
const searchParams = useSearchParams();
const { t } = useLocale();
const router = useRouter();

View File

@ -6,6 +6,7 @@ import type { CSSProperties } from "react";
import { Suspense } from "react";
import { z } from "zod";
import { getLocale } from "@calcom/features/auth/lib/getLocale";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@ -219,10 +220,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
if (user.completedOnboarding) {
return { redirect: { permanent: false, destination: "/event-types" } };
}
const locale = await getLocale(context.req);
return {
props: {
...(await serverSideTranslations(context.locale ?? "", ["common"])),
...(await serverSideTranslations(locale, ["common"])),
trpcState: ssr.dehydrate(),
hasPendingInvites: user.teams.find((team) => team.accepted === false) ?? false,
},

View File

@ -68,7 +68,11 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
await utils.viewer.me.invalidate();
reset(getValues());
showToast(t("settings_updated_successfully"), "success");
update(res);
await update(res);
if (res.locale) {
window.calNewLocale = res.locale;
}
},
onError: () => {
showToast(t("error_updating_settings"), "error");

View File

@ -2,8 +2,8 @@ import type { GetServerSidePropsContext } from "next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import superjson from "superjson";
import { getLocale } from "@calcom/features/auth/lib/getLocale";
import { CALCOM_VERSION } from "@calcom/lib/constants";
import { getLocaleFromRequest } from "@calcom/lib/getLocaleFromRequest";
import { createProxySSGHelpers } from "@calcom/trpc/react/ssg";
import { createContext } from "@calcom/trpc/server/createContext";
import { appRouter } from "@calcom/trpc/server/routers/_app";
@ -16,7 +16,7 @@ import { appRouter } from "@calcom/trpc/server/routers/_app";
*/
export async function ssrInit(context: GetServerSidePropsContext, options?: { noI18nPreload: boolean }) {
const ctx = await createContext(context);
const locale = await getLocaleFromRequest(context.req);
const locale = await getLocale(context.req);
const i18n = await serverSideTranslations(locale, ["common", "vital"]);
const ssr = createProxySSGHelpers({

View File

@ -0,0 +1,33 @@
import { parse } from "accept-language-parser";
import type { GetTokenParams } from "next-auth/jwt";
import { getToken } from "next-auth/jwt";
/**
* This is a slimmed down version of the `getServerSession` function from
* `next-auth`.
*
* Instead of requiring the entire options object for NextAuth, we create
* a compatible session using information from the incoming token.
*
* The downside to this is that we won't refresh sessions if the users
* token has expired (30 days). This should be fine as we call `/auth/session`
* frequently enough on the client-side to keep the session alive.
*/
export const getLocale = async (req: GetTokenParams["req"]): Promise<string> => {
const token = await getToken({
req,
});
const tokenLocale = token?.["locale"];
if (tokenLocale !== undefined) {
return tokenLocale;
}
const acceptLanguage =
req.headers instanceof Headers ? req.headers.get("accept-language") : req.headers["accept-language"];
const languages = acceptLanguage ? parse(acceptLanguage) : [];
return languages[0]?.code ?? "en";
};

View File

@ -1,22 +0,0 @@
/* eslint-disable @typescript-eslint/no-var-requires */
import parser from "accept-language-parser";
import type { GetServerSidePropsContext, NextApiRequest } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import type { Maybe } from "@calcom/trpc/server";
const { i18n } = require("@calcom/config/next-i18next.config");
export async function getLocaleFromRequest(
req: NextApiRequest | GetServerSidePropsContext["req"]
): Promise<string> {
const session = await getServerSession({ req });
if (session?.user?.locale) return session.user.locale;
let preferredLocale: string | null | undefined;
if (req.headers["accept-language"]) {
preferredLocale = parser.pick(i18n.locales, req.headers["accept-language"], {
loose: true,
}) as Maybe<string>;
}
return preferredLocale ?? i18n.defaultLocale;
}

View File

@ -3,7 +3,7 @@ import type { GetServerSidePropsContext, NextApiRequest, NextApiResponse } from
import type { Session } from "next-auth";
import type { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { getLocaleFromRequest } from "@calcom/lib/getLocaleFromRequest";
import { getLocale } from "@calcom/features/auth/lib/getLocale";
import prisma from "@calcom/prisma";
import type { SelectedCalendar, User as PrismaUser } from "@calcom/prisma/client";
@ -62,7 +62,7 @@ export async function createContextInner(opts: CreateInnerContextOptions) {
* @link https://trpc.io/docs/context
*/
export const createContext = async ({ req, res }: CreateContextOptions, sessionGetter?: GetSessionFn) => {
const locale = await getLocaleFromRequest(req);
const locale = await getLocale(req);
const session = !!sessionGetter ? await sessionGetter({ req, res }) : null;
const contextInner = await createContextInner({ locale, session });
return {

View File

@ -92,7 +92,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
const orgMetadata = teamMetadataSchema.parse(user.organization?.metadata || {});
// This helps to prevent reaching the 4MB payload limit by avoiding base64 and instead passing the avatar url
const locale = user?.locale || ctx.locale;
const locale = user?.locale ?? ctx.locale;
const isOrgAdmin = !!user.organization?.members.length;
// Want to reduce the amount of data being sent

View File

@ -1,13 +1,6 @@
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;
};