From 7b1ca51ffad2ce80d3da060354e14740bcab4718 Mon Sep 17 00:00:00 2001 From: Atomys Date: Sun, 20 Nov 2022 18:49:13 +0100 Subject: [PATCH] fix: sideba rerender by re-implement apollo cache --- web/ui/package.json | 1 + .../containers/clusters/ClusterSidebar.tsx | 29 +--- web/ui/src/lib/apollo.ts | 152 +++++++++++------- web/ui/src/pages/_app.tsx | 17 +- web/ui/src/types/next.d.ts | 19 ++- 5 files changed, 130 insertions(+), 88 deletions(-) diff --git a/web/ui/package.json b/web/ui/package.json index 6dea7992..aad39ff0 100644 --- a/web/ui/package.json +++ b/web/ui/package.json @@ -35,6 +35,7 @@ "@types/uuid": "^8.3.4", "axios": "^0.27.2", "classnames": "^2.3.1", + "deepmerge": "^4.2.2", "google-protobuf": "^3.20.1-rc.1", "graphql": "^16.6.0", "graphql-request": "^5.0.0", diff --git a/web/ui/src/containers/clusters/ClusterSidebar.tsx b/web/ui/src/containers/clusters/ClusterSidebar.tsx index 83c66422..63b97f85 100644 --- a/web/ui/src/containers/clusters/ClusterSidebar.tsx +++ b/web/ui/src/containers/clusters/ClusterSidebar.tsx @@ -2,13 +2,12 @@ import { CampusClusterMapData, CampusNames } from '@components/ClusterMap'; import Search from '@components/Search'; import useSidebar, { Menu, MenuCategory, MenuItem } from '@components/Sidebar'; import { useClusterSidebarDataQuery } from '@graphql.d'; -import { LocalStorageKeys } from '@lib/localStorageKeys'; +import { isFirstLoading } from '@lib/apollo'; import '@lib/prototypes/string'; import { clusterURL } from '@lib/searchEngine'; -import useLocalStorage from '@lib/useLocalStorage'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import React, { useEffect } from 'react'; +import React from 'react'; /** * ClusterSidebar is the sidebar for the cluster page. It contains the cluster @@ -28,7 +27,7 @@ export const ClusterSidebar = ({ const router = useRouter(); const campusKeys = Object.keys(CampusClusterMapData) as Array; - const { data: { me, locationsStatsByPrefixes = [] } = {}, loading } = + const { data: { me, locationsStatsByPrefixes = [] } = {}, networkStatus } = useClusterSidebarDataQuery({ variables: { campusName: campus, @@ -37,7 +36,7 @@ export const ClusterSidebar = ({ ).filter((e) => e !== '_data'), }, }); - const myCampusNameLowerFromAPI = me?.currentCampus?.name?.toLowerCase() || ''; + const myCampusName = me?.currentCampus?.name?.toLowerCase() || ''; const freePlacesPerCluster: { [key: string]: number } = locationsStatsByPrefixes .map((l) => { @@ -51,18 +50,7 @@ export const ClusterSidebar = ({ {} ); - const [myCampusNameCached, setMyCampusName] = useLocalStorage( - LocalStorageKeys.MyCurrentCampusName, - '' - ); - - useEffect(() => { - if (myCampusNameCached !== myCampusNameLowerFromAPI) { - setMyCampusName(myCampusNameLowerFromAPI); - } - }, [myCampusNameLowerFromAPI]); // eslint-disable-line react-hooks/exhaustive-deps - - if (loading && !myCampusNameCached) { + if (isFirstLoading(networkStatus)) { return (
@@ -106,16 +94,15 @@ export const ClusterSidebar = ({ .sort((a, b) => { // Sort the campus list in alphabetical order and put the current // campus at the top. - return a?.equalsIgnoreCase(myCampusNameCached) + return a?.equalsIgnoreCase(myCampusName) ? -1 - : b?.equalsIgnoreCase(myCampusNameCached) + : b?.equalsIgnoreCase(myCampusName) ? 1 : a.localeCompare(b); }) .map((campusName) => { const campusData = CampusClusterMapData[campusName]._data; - const isMyCampus = - myCampusNameCached?.equalsIgnoreCase(campusName); + const isMyCampus = campusName?.equalsIgnoreCase(myCampusName); const activeCampus = campus == campusName; return ( diff --git a/web/ui/src/lib/apollo.ts b/web/ui/src/lib/apollo.ts index b1f902f6..3af5130b 100644 --- a/web/ui/src/lib/apollo.ts +++ b/web/ui/src/lib/apollo.ts @@ -9,69 +9,105 @@ import { } from '@apollo/client'; import { setContext } from '@apollo/client/link/context'; import { onError } from '@apollo/client/link/error'; +import merge from 'deepmerge'; import Cookies from 'js-cookie'; -import { GetServerSidePropsContext } from 'next'; -import { NextRequest } from 'next/server'; // eslint-disable-line - -export type ServerSideRequest = NextRequest | GetServerSidePropsContext['req']; +import { useMemo } from 'react'; +import { ServerSideRequest } from 'types/next'; +/** + * the token cookie name is used to store the token in the cookie and to read it + * from the cookie + */ const tokenCookieName = '__s42.auth-token'; -const errorLink = onError(({ graphQLErrors }) => { - if (graphQLErrors) - graphQLErrors.forEach( - ({ message, locations, path }) => - new Error( - `[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}` - ) - ); -}); - -const httpLink = createHttpLink({ - uri: process.env.NEXT_PUBLIC_GRAPHQL_API, - credentials: 'include', -}); - -const authLink = setContext((_, context) => { - let authToken = Cookies.get(tokenCookieName); - - if (!authToken) { - authToken = context.authToken; - } +// Reuse client on the client-side. +let apolloClient: ApolloClient; - return { - headers: { - ...context.headers, - Authorization: authToken ? `Bearer ${authToken}` : '', - }, - }; -}); +/** + * Creates and configures the ApolloClient instance. + * @returns {ApolloClient} The ApolloClient instance. + */ +const createApolloClient = () => { + const errorLink = onError(({ graphQLErrors }) => { + if (graphQLErrors) + graphQLErrors.forEach( + ({ message, locations, path }) => + new Error( + `[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}` + ) + ); + }); + + const httpLink = createHttpLink({ + uri: process.env.NEXT_PUBLIC_GRAPHQL_API, + credentials: 'include', + }); + + const authLink = setContext((_, context) => { + let authToken = Cookies.get(tokenCookieName); + + if (!authToken) { + authToken = context.authToken; + } + + return { + headers: { + ...context.headers, + Authorization: authToken ? `Bearer ${authToken}` : '', + }, + }; + }); + + return new ApolloClient({ + link: from([authLink, errorLink, httpLink]), + version: process.env.NEXT_PUBLIC_VERSION, + ssrMode: typeof window === 'undefined', + connectToDevTools: process.env.NODE_ENV === 'development', + cache: new InMemoryCache(), + credentials: 'include', + defaultOptions: {}, + }); +}; /** - * Definition of the Apollo client used in the application. + * Creates and initialize a new Apollo Client instance with an optional initial + * state. + * @param initialState The initial state to hydrate the client with. + * @returns The new Apollo Client instance. */ -export const apolloClient = new ApolloClient({ - link: from([authLink, errorLink, httpLink]), - version: process.env.NEXT_PUBLIC_VERSION, - ssrMode: typeof window === 'undefined', - connectToDevTools: process.env.NODE_ENV === 'development', - cache: new InMemoryCache(), - credentials: 'include', - defaultOptions: { - watchQuery: { - fetchPolicy: 'cache-and-network', - errorPolicy: 'ignore', - }, - query: { - fetchPolicy: typeof window === 'undefined' ? 'no-cache' : 'network-only', - errorPolicy: 'all', - }, - mutate: { - fetchPolicy: typeof window === 'undefined' ? 'no-cache' : 'network-only', - errorPolicy: 'all', - }, - }, -}); +export const initializeApollo = (initialState: any = null) => { + const client = apolloClient ?? createApolloClient(); + + // If your page has Next.js data fetching methods that use Apollo Client, the initial state + // get hydrated here + if (initialState) { + // Get existing cache, loaded during client side data fetching + const existingCache = client.extract(); + + // Merge the existing cache into data passed from getStaticProps/getServerSideProps + const data = merge(initialState, existingCache); + + // Restore the cache with the merged data + client.cache.restore(data); + } + // For SSG and SSR always create a new Apollo Client + if (typeof window === 'undefined') return client; + // Create the Apollo Client once in the client + if (!apolloClient) apolloClient = client; + + return client; +}; + +/** This function is used to get the initialized Apollo Client or initialize a + * new one if it doesn't exist. The client is memorized to prevent it from + * being reinitialized on every request on the client side with Memo. + * + * This is the default function to use when you want to use Apollo Client. + */ +export const useApollo = (initialState: any) => { + const store = useMemo(() => initializeApollo(initialState), [initialState]); + return store; +}; /** * This function is used to authenticate a server-side request with the @@ -82,6 +118,7 @@ export const queryAuthenticatedSSR = async ( opts: QueryOptions ): Promise> => { const { query, context, ...rest } = opts; + const client = createApolloClient(); const token = typeof req.cookies?.get === 'function' @@ -89,7 +126,7 @@ export const queryAuthenticatedSSR = async ( : // @ts-ignore req.cookies[tokenCookieName]; - return apolloClient.query({ + return client.query({ query, context: { authToken: token, @@ -98,7 +135,6 @@ export const queryAuthenticatedSSR = async ( ...rest, }); }; - /** * Retrieve if the current network status is a refetch event or not * @param networkStatus - the network status of apollo query @@ -121,4 +157,4 @@ export const isFirstLoading = (networkStatus: NetworkStatus): boolean => { ); }; -export default apolloClient; +export default useApollo; diff --git a/web/ui/src/pages/_app.tsx b/web/ui/src/pages/_app.tsx index ee3c5175..5a4f03e6 100644 --- a/web/ui/src/pages/_app.tsx +++ b/web/ui/src/pages/_app.tsx @@ -1,9 +1,8 @@ import { ApolloProvider } from '@apollo/client'; import useNotification from '@components/Notification'; import { Theme } from '@graphql.d'; -import apolloClient from '@lib/apollo'; +import { useApollo } from '@lib/apollo'; import useSettings, { useTheme } from '@lib/useSettings'; -import { NextComponentType, NextPageContext } from 'next'; import { SessionProvider, SessionProviderProps } from 'next-auth/react'; import { AppProps } from 'next/app'; import Script from 'next/script'; @@ -13,16 +12,20 @@ import '../styles/globals.css'; const Interface = ({ Component, session, - pageProps, -}: AppProps & { - Component: NextComponentType; -} & SessionProviderProps) => { + pageProps: props, +}: AppProps & SessionProviderProps) => { + const { initialApolloState, ...pageProps } = props; + const apolloClient = useApollo(initialApolloState); + const { NotificationProvider, NotificationContainer } = useNotification(); // eslint-disable-next-line react-hooks/exhaustive-deps const MemorizedComponent = useMemo(() => Component, [pageProps]); const [settings] = useSettings({ apolloClient }); useTheme(settings.theme || Theme.AUTO); + // Use the layout defined at the page level, if available + const getLayout = MemorizedComponent.getLayout || ((page) => page); + return ( - + {getLayout()} diff --git a/web/ui/src/types/next.d.ts b/web/ui/src/types/next.d.ts index 507e49fd..f0416568 100644 --- a/web/ui/src/types/next.d.ts +++ b/web/ui/src/types/next.d.ts @@ -1,10 +1,25 @@ -import type { NextComponentType, NextPageContext } from 'next'; +import type { + GetServerSidePropsContext, + NextComponentType, + NextPageContext, +} from 'next'; import type { Session } from 'next-auth'; import type { Router } from 'next/router'; +import { NextRequest } from 'next/server'; +export type ServerSideRequest = NextRequest | GetServerSidePropsContext['req']; + +declare module 'next' { + type NextPage

= React.ComponentType

& { + getInitialProps?(context: NextPageContext): IP | Promise; + getLayout?: (page: ReactElement) => ReactNode; + }; +} declare module 'next/app' { type AppProps

> = { - Component: NextComponentType; + Component: NextComponentType & { + getLayout?: (page: ReactElement) => ReactNode; + }; router: Router; __N_SSG?: boolean; __N_SSP?: boolean;