From 699d910ab43dbc2aefd2066d28930775af72d32a Mon Sep 17 00:00:00 2001 From: Afzal Sayed <14029371+afzalsayed96@users.noreply.github.com> Date: Fri, 15 Apr 2022 03:19:51 +0530 Subject: [PATCH] Perf: Optimize event-types page (#2436) * Perf: Optimize event-types page * Memoize layout in Shell * setQueryState without awaiting mutate for optimistic update * Update Shell.tsx * Fix types * Update auth-index.test.ts Co-authored-by: zomars Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- apps/web/components/Shell.tsx | 126 +++++++++-------- apps/web/lib/QueryCell.tsx | 41 ++++++ apps/web/pages/availability/index.tsx | 7 +- apps/web/pages/event-types/index.tsx | 144 +++++++++++--------- apps/web/pages/settings/profile.tsx | 10 +- apps/web/playwright/auth/auth-index.test.ts | 4 +- 6 files changed, 203 insertions(+), 129 deletions(-) diff --git a/apps/web/components/Shell.tsx b/apps/web/components/Shell.tsx index 15efb679..b0e7a75d 100644 --- a/apps/web/components/Shell.tsx +++ b/apps/web/components/Shell.tsx @@ -1,23 +1,25 @@ import { SelectorIcon } from "@heroicons/react/outline"; import { + ArrowLeftIcon, CalendarIcon, ClockIcon, CogIcon, ExternalLinkIcon, LinkIcon, LogoutIcon, - ViewGridIcon, - MoonIcon, MapIcon, - ArrowLeftIcon, + MoonIcon, + ViewGridIcon, } from "@heroicons/react/solid"; -import { signOut, useSession } from "next-auth/react"; +import { SessionContextValue, signOut, useSession } from "next-auth/react"; import Link from "next/link"; import { useRouter } from "next/router"; import React, { Fragment, ReactNode, useEffect } from "react"; import { Toaster } from "react-hot-toast"; import { useIsEmbed } from "@calcom/embed-core"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { UserPlan } from "@calcom/prisma/client"; import Button from "@calcom/ui/Button"; import Dropdown, { DropdownMenuContent, @@ -30,9 +32,8 @@ import TrialBanner from "@ee/components/TrialBanner"; import HelpMenuItem from "@ee/components/support/HelpMenuItem"; import classNames from "@lib/classNames"; -import { NEXT_PUBLIC_BASE_URL } from "@lib/config/constants"; +import { WEBAPP_URL } from "@lib/config/constants"; import { shouldShowOnboarding } from "@lib/getting-started"; -import { useLocale } from "@lib/hooks/useLocale"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry"; import { trpc } from "@lib/trpc"; @@ -58,7 +59,6 @@ function useRedirectToLoginIfUnauthenticated(isPublic = false) { const { data: session, status } = useSession(); const loading = status === "loading"; const router = useRouter(); - const shouldDisplayUnauthed = router.pathname.startsWith("/apps"); useEffect(() => { if (isPublic) { @@ -69,7 +69,7 @@ function useRedirectToLoginIfUnauthenticated(isPublic = false) { router.replace({ pathname: "/auth/login", query: { - callbackUrl: `${NEXT_PUBLIC_BASE_URL}/${location.pathname}${location.search}`, + callbackUrl: `${WEBAPP_URL}/${location.pathname}${location.search}`, }, }); } @@ -78,7 +78,6 @@ function useRedirectToLoginIfUnauthenticated(isPublic = false) { return { loading: loading && !session, - shouldDisplayUnauthed, session, }; } @@ -122,28 +121,14 @@ export function ShellSubHeading(props: { ); } -export default function Shell(props: { - centered?: boolean; - title?: string; - heading?: ReactNode; - subtitle?: ReactNode; - children: ReactNode; - CTA?: ReactNode; - large?: boolean; - HeadingLeftIcon?: ReactNode; - backPath?: string; // renders back button to specified path - // use when content needs to expand with flex - flexChildrenContainer?: boolean; - isPublic?: boolean; -}) { +const Layout = ({ + status, + plan, + ...props +}: LayoutProps & { status: SessionContextValue["status"]; plan?: UserPlan }) => { const isEmbed = useIsEmbed(); - const { t } = useLocale(); const router = useRouter(); - const { loading, session } = useRedirectToLoginIfUnauthenticated(props.isPublic); - const { isRedirectingToOnboarding } = useRedirectToOnboardingIfNeeded(); - - const telemetry = useTelemetry(); - + const { t } = useLocale(); const navigation = [ { name: t("event_types_page_title"), @@ -188,35 +173,10 @@ export default function Shell(props: { current: router.asPath.startsWith("/settings"), }, ]; - - useEffect(() => { - telemetry.withJitsu((jitsu) => { - return jitsu.track(telemetryEventTypes.pageView, collectPageParameters(router.asPath)); - }); - }, [telemetry, router.asPath]); - const pageTitle = typeof props.heading === "string" ? props.heading : props.title; - const query = useMeQuery(); - const user = query.data; - - const i18n = useViewerI18n(); - const { status } = useSession(); - - if (i18n.status === "loading" || query.status === "loading" || isRedirectingToOnboarding || loading) { - // show spinner whilst i18n is loading to avoid language flicker - return ( -
- -
- ); - } - - if (!session && !props.isPublic) return null; - return ( <> - © {new Date().getFullYear()} Cal.com, Inc. v.{pkg.version + "-"} {process.env.NEXT_PUBLIC_WEBSITE_URL === "https://cal.com" ? "h" : "sh"} - - -{user && user.plan} + + -{plan} @@ -426,6 +386,60 @@ export default function Shell(props: { ); +}; + +const MemoizedLayout = React.memo(Layout); + +type LayoutProps = { + centered?: boolean; + title?: string; + heading?: ReactNode; + subtitle?: ReactNode; + children: ReactNode; + CTA?: ReactNode; + large?: boolean; + HeadingLeftIcon?: ReactNode; + backPath?: string; // renders back button to specified path + // use when content needs to expand with flex + flexChildrenContainer?: boolean; + isPublic?: boolean; +}; + +export default function Shell(props: LayoutProps) { + const router = useRouter(); + const { loading, session } = useRedirectToLoginIfUnauthenticated(props.isPublic); + const { isRedirectingToOnboarding } = useRedirectToOnboardingIfNeeded(); + const telemetry = useTelemetry(); + + useEffect(() => { + telemetry.withJitsu((jitsu) => { + return jitsu.track(telemetryEventTypes.pageView, collectPageParameters(router.asPath)); + }); + }, [telemetry, router.asPath]); + + const query = useMeQuery(); + const user = query.data; + + const i18n = useViewerI18n(); + const { status } = useSession(); + + if (i18n.status === "loading" || query.status === "loading" || isRedirectingToOnboarding || loading) { + // show spinner whilst i18n is loading to avoid language flicker + return ( +
+ +
+ ); + } + + if (!session && !props.isPublic) return null; + + return ( + <> + + + + ); } function UserDropdown({ small }: { small?: boolean }) { diff --git a/apps/web/lib/QueryCell.tsx b/apps/web/lib/QueryCell.tsx index f3dce5a9..79a7d7db 100644 --- a/apps/web/lib/QueryCell.tsx +++ b/apps/web/lib/QueryCell.tsx @@ -9,8 +9,21 @@ import { import { Alert } from "@calcom/ui/Alert"; +import { trpc } from "@lib/trpc"; + import Loader from "@components/Loader"; +import type { AppRouter } from "@server/routers/_app"; +import type { TRPCClientErrorLike } from "@trpc/client"; +import type { UseTRPCQueryOptions } from "@trpc/react"; +// import type { inferProcedures } from "@trpc/react/src/createReactQueryHooks"; +import type { + inferHandlerInput, + inferProcedureInput, + inferProcedureOutput, + ProcedureRecord, +} from "@trpc/server"; + type ErrorLike = { message: string; }; @@ -72,3 +85,31 @@ export function QueryCell( // impossible state return null; } + +type inferProcedures> = { + [TPath in keyof TObj]: { + input: inferProcedureInput; + output: inferProcedureOutput; + }; +}; +type TQueryValues = inferProcedures; +type TQueries = AppRouter["_def"]["queries"]; +type TError = TRPCClientErrorLike; + +const withQuery = ( + pathAndInput: [path: TPath, ...args: inferHandlerInput], + params?: UseTRPCQueryOptions +) => { + return function WithQuery( + opts: Omit< + Partial> & + QueryCellOptionsNoEmpty, + "query" + > + ) { + const query = trpc.useQuery(pathAndInput, params); + return ; + }; +}; + +export { withQuery }; diff --git a/apps/web/pages/availability/index.tsx b/apps/web/pages/availability/index.tsx index bcefe1d1..01880eae 100644 --- a/apps/web/pages/availability/index.tsx +++ b/apps/web/pages/availability/index.tsx @@ -9,7 +9,7 @@ import showToast from "@calcom/lib/notification"; import { Button } from "@calcom/ui"; import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@calcom/ui/Dropdown"; -import { QueryCell } from "@lib/QueryCell"; +import { withQuery } from "@lib/QueryCell"; import { HttpError } from "@lib/core/http/error"; import { inferQueryOutput, trpc } from "@lib/trpc"; @@ -99,13 +99,14 @@ export function AvailabilityList({ schedules }: inferQueryOutput<"viewer.availab ); } +const WithQuery = withQuery(["viewer.availability.list"]); + export default function AvailabilityPage() { const { t } = useLocale(); - const query = trpc.useQuery(["viewer.availability.list"]); return (
}> - } /> + } />
); diff --git a/apps/web/pages/event-types/index.tsx b/apps/web/pages/event-types/index.tsx index cbc04b83..86697910 100644 --- a/apps/web/pages/event-types/index.tsx +++ b/apps/web/pages/event-types/index.tsx @@ -30,7 +30,7 @@ import Dropdown, { DropdownMenuSeparator, } from "@calcom/ui/Dropdown"; -import { QueryCell } from "@lib/QueryCell"; +import { withQuery } from "@lib/QueryCell"; import classNames from "@lib/classNames"; import { HttpError } from "@lib/core/http/error"; import { inferQueryOutput, trpc } from "@lib/trpc"; @@ -63,38 +63,83 @@ type EventTypeGroup = inferQueryOutput<"viewer.eventTypes">["eventTypeGroups"][n type EventType = EventTypeGroup["eventTypes"][number]; interface EventTypeListProps { group: EventTypeGroup; + groupIndex: number; readOnly: boolean; types: EventType[]; } -export const EventTypeList = ({ group, readOnly, types }: EventTypeListProps): JSX.Element => { +const Item = ({ type, group, readOnly }: any) => { + const { t } = useLocale(); + + return ( + + +
+ + {type.title} + + {`/${group.profile.slug}/${type.slug}`} + {type.hidden && ( + + {t("hidden")} + + )} + {readOnly && ( + + {t("readonly")} + + )} +
+ +
+ + ); +}; + +const MemoizedItem = React.memo(Item); + +export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeListProps): JSX.Element => { const { t } = useLocale(); const router = useRouter(); const utils = trpc.useContext(); const mutation = trpc.useMutation("viewer.eventTypeOrder", { - onError: (err) => { + onError: async (err) => { console.error(err.message); - }, - async onSettled() { await utils.cancelQuery(["viewer.eventTypes"]); await utils.invalidateQueries(["viewer.eventTypes"]); }, }); - const [sortableTypes, setSortableTypes] = useState(types); - useEffect(() => { - setSortableTypes(types); - }, [types]); - function moveEventType(index: number, increment: 1 | -1) { - const newList = [...sortableTypes]; - const type = sortableTypes[index]; - const tmp = sortableTypes[index + increment]; + function moveEventType(index: number, increment: 1 | -1) { + const newList = [...types]; + + const type = types[index]; + const tmp = types[index + increment]; if (tmp) { newList[index] = tmp; newList[index + increment] = type; } - setSortableTypes(newList); + + utils.cancelQuery(["viewer.eventTypes"]); + utils.setQueryData(["viewer.eventTypes"], (data) => + Object.assign(data, { + eventTypesGroups: [ + data?.eventTypeGroups.slice(0, groupIndex), + Object.assign(group, { + eventTypes: newList, + }), + data?.eventTypeGroups.slice(groupIndex + 1), + ], + }) + ); + mutation.mutate({ ids: newList.map((type) => type.id), }); @@ -155,7 +200,7 @@ export const EventTypeList = ({ group, readOnly, types }: EventTypeListProps): J return (
    - {sortableTypes.map((type, index) => ( + {types.map((type, index) => (
  • - {sortableTypes.length > 1 && ( + {types.length > 1 && ( <>