Fix components rerendering unnecessarily

Before, some providers (WebRtcProvider, CallProvider...) were rendered conditionally.
This was causing all their children to re-render when the provider rendered.

Instead of using conditional rendering, these providers now use `ConditionalContextProvider`.
It always renders the provider, but sets its value to `initialValue` if the dependencies are not all defined.
If all dependencies are defined, the value is the result of the `useProviderValue` hook.

Changes:
- New file: `ConditionalContextProvider`
- New file: `HookComponent` - Component that calls a hook when mounted and calls a `callback` function with the hook result as argument
- For `WebRtcProvider` and `CallProvider`, use `createOptionalContext` to create the context and `ConditionalContextProvider` to provide their value only when the conditions are met
- In `WebRtcProvider`, set the webRtcConnection to undefined when not in a call
- For all providers, wrap their `value` prop in `useMemo` to avoid unnecessary rerenders

Change-Id: Ide947e216d54599aabc71cf4bd026bd20d6e0daf
diff --git a/client/src/contexts/AuthProvider.tsx b/client/src/contexts/AuthProvider.tsx
index 94f3dcf..8221745 100644
--- a/client/src/contexts/AuthProvider.tsx
+++ b/client/src/contexts/AuthProvider.tsx
@@ -94,21 +94,23 @@
     axiosInstance.get<IAccount>('/account').then(({ data }) => setAccount(Account.fromInterface(data)));
   }, [axiosInstance, logout]);
 
-  if (!token || !account || !axiosInstance) {
+  const value = useMemo(() => {
+    if (!token || !account || !axiosInstance) {
+      return;
+    }
+
+    return {
+      token,
+      logout,
+      account,
+      accountId: account.id,
+      axiosInstance,
+    };
+  }, [token, logout, account, axiosInstance]);
+
+  if (!value) {
     return <ProcessingRequest open />;
   }
 
-  return (
-    <AuthContext.Provider
-      value={{
-        token,
-        logout,
-        account,
-        accountId: account.id,
-        axiosInstance,
-      }}
-    >
-      {children}
-    </AuthContext.Provider>
-  );
+  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
 };
diff --git a/client/src/contexts/CallManagerProvider.tsx b/client/src/contexts/CallManagerProvider.tsx
index 9bde186..7d8d843 100644
--- a/client/src/contexts/CallManagerProvider.tsx
+++ b/client/src/contexts/CallManagerProvider.tsx
@@ -16,7 +16,7 @@
  * <https://www.gnu.org/licenses/>.
  */
 import { CallBegin, WebSocketMessageType } from 'jami-web-common';
-import { createContext, useCallback, useContext, useEffect, useState } from 'react';
+import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
 import { useNavigate } from 'react-router-dom';
 
 import { RemoteVideoOverlay } from '../components/VideoOverlay';
@@ -29,7 +29,7 @@
 import WebRtcProvider from './WebRtcProvider';
 import { WebSocketContext } from './WebSocketProvider';
 
-type CallData = {
+export type CallData = {
   conversationId: string;
   role: CallRole;
   withVideoOn?: boolean;
@@ -43,15 +43,23 @@
   exitCall: () => void;
 };
 
-export const CallManagerContext = createContext<ICallManagerContext>(undefined!);
+const defaultCallManagerContext: ICallManagerContext = {
+  callData: undefined,
+  callConversation: undefined,
+
+  startCall: () => {},
+  exitCall: () => {},
+};
+
+export const CallManagerContext = createContext<ICallManagerContext>(defaultCallManagerContext);
 CallManagerContext.displayName = 'CallManagerContext';
 
 export default ({ children }: WithChildren) => {
   const [callData, setCallData] = useState<CallData>();
   const webSocket = useContext(WebSocketContext);
   const navigate = useNavigate();
-  const conversationId = callData?.conversationId;
-  const { conversation } = useConversationQuery(conversationId);
+  const { conversation } = useConversationQuery(callData?.conversationId);
+  const { urlParams } = useUrlParams<ConversationRouteParams>();
 
   const failStartCall = useCallback(() => {
     throw new Error('Cannot start call: Already in a call');
@@ -90,37 +98,26 @@
     };
   }, [webSocket, navigate, startCall, callData]);
 
-  return (
-    <CallManagerContext.Provider
-      value={{
-        startCall,
-        callData,
-        callConversation: conversation,
-        exitCall,
-      }}
-    >
-      <CallManagerProvider>{children}</CallManagerProvider>
-    </CallManagerContext.Provider>
+  const value = useMemo(
+    () => ({
+      startCall,
+      callData,
+      callConversation: conversation,
+      exitCall,
+    }),
+    [startCall, callData, conversation, exitCall]
   );
-};
-
-const CallManagerProvider = ({ children }: WithChildren) => {
-  const { callData } = useContext(CallManagerContext);
-  const { urlParams } = useUrlParams<ConversationRouteParams>();
-  const conversationId = urlParams.conversationId;
-
-  if (!callData) {
-    return <>{children}</>;
-  }
 
   return (
-    <WebRtcProvider>
-      <CallProvider>
-        {callData.conversationId !== conversationId && (
-          <RemoteVideoOverlay callConversationId={callData.conversationId} />
-        )}
-        {children}
-      </CallProvider>
-    </WebRtcProvider>
+    <CallManagerContext.Provider value={value}>
+      <WebRtcProvider>
+        <CallProvider>
+          {callData && callData.conversationId !== urlParams.conversationId && (
+            <RemoteVideoOverlay callConversationId={callData.conversationId} />
+          )}
+          {children}
+        </CallProvider>
+      </WebRtcProvider>
+    </CallManagerContext.Provider>
   );
 };
diff --git a/client/src/contexts/CallProvider.tsx b/client/src/contexts/CallProvider.tsx
index b20d0e7..8be6dff 100644
--- a/client/src/contexts/CallProvider.tsx
+++ b/client/src/contexts/CallProvider.tsx
@@ -16,15 +16,15 @@
  * <https://www.gnu.org/licenses/>.
  */
 import { CallAction, CallBegin, WebSocketMessageType } from 'jami-web-common';
-import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
-import { Navigate } from 'react-router-dom';
+import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
 
-import LoadingPage from '../components/Loading';
+import { createOptionalContext } from '../hooks/createOptionalContext';
 import { Conversation } from '../models/conversation';
 import { callTimeoutMs } from '../utils/constants';
 import { AsyncSetState, SetState, WithChildren } from '../utils/utils';
-import { CallManagerContext } from './CallManagerProvider';
-import { MediaDevicesInfo, MediaInputKind, WebRtcContext } from './WebRtcProvider';
+import { CallData, CallManagerContext } from './CallManagerProvider';
+import ConditionalContextProvider from './ConditionalContextProvider';
+import { IWebRtcContext, MediaDevicesInfo, MediaInputKind, useWebRtcContext } from './WebRtcProvider';
 import { IWebSocketContext, WebSocketContext } from './WebSocketProvider';
 
 export type CallRole = 'caller' | 'receiver';
@@ -70,71 +70,53 @@
   endCall: () => void;
 }
 
-const defaultCallContext: ICallContext = {
-  mediaDevices: {
-    audioinput: [],
-    audiooutput: [],
-    videoinput: [],
-  },
-  currentMediaDeviceIds: {
-    audioinput: {
-      id: undefined,
-      setId: async () => {},
-    },
-    audiooutput: {
-      id: undefined,
-      setId: async () => {},
-    },
-    videoinput: {
-      id: undefined,
-      setId: async () => {},
-    },
-  },
-
-  isAudioOn: false,
-  setIsAudioOn: () => {},
-  videoStatus: VideoStatus.Off,
-  updateVideoStatus: () => Promise.reject(),
-  isChatShown: false,
-  setIsChatShown: () => {},
-  isFullscreen: false,
-  setIsFullscreen: () => {},
-  callRole: 'caller',
-  callStatus: CallStatus.Default,
-  callStartTime: undefined,
-
-  acceptCall: (_: boolean) => {},
-  endCall: () => {},
-};
-
-export const CallContext = createContext<ICallContext>(defaultCallContext);
+const optionalCallContext = createOptionalContext<ICallContext>('CallContext');
+export const useCallContext = optionalCallContext.useOptionalContext;
 
 export default ({ children }: WithChildren) => {
   const webSocket = useContext(WebSocketContext);
-  const { callConversation, callData } = useContext(CallManagerContext);
+  const { callConversation, callData, exitCall } = useContext(CallManagerContext);
+  const webRtcContext = useWebRtcContext(true);
 
-  if (!webSocket || !callConversation || !callData?.conversationId) {
-    return <LoadingPage />;
-  }
+  const dependencies = useMemo(
+    () => ({
+      webSocket,
+      webRtcContext,
+      callConversation,
+      callData,
+      exitCall,
+      conversationId: callData?.conversationId,
+    }),
+    [webSocket, webRtcContext, callConversation, callData, exitCall]
+  );
 
   return (
-    <CallProvider webSocket={webSocket} conversation={callConversation} conversationId={callData?.conversationId}>
+    <ConditionalContextProvider
+      Context={optionalCallContext.Context}
+      initialValue={undefined}
+      dependencies={dependencies}
+      useProviderValue={CallProvider}
+    >
       {children}
-    </CallProvider>
+    </ConditionalContextProvider>
   );
 };
 
 const CallProvider = ({
-  children,
-  conversation,
+  webRtcContext,
+  callConversation,
+  callData,
+  exitCall,
   conversationId,
   webSocket,
-}: WithChildren & {
+}: {
   webSocket: IWebSocketContext;
-  conversation: Conversation;
+  webRtcContext: IWebRtcContext;
+  callConversation: Conversation;
+  callData: CallData;
+  exitCall: () => void;
   conversationId: string;
-}) => {
-  const { callData, exitCall } = useContext(CallManagerContext);
+}): ICallContext => {
   const {
     localStream,
     updateScreenShare,
@@ -143,9 +125,13 @@
     closeConnection,
     getMediaDevices,
     updateLocalStream,
-  } = useContext(WebRtcContext);
+  } = webRtcContext;
 
-  const [mediaDevices, setMediaDevices] = useState(defaultCallContext.mediaDevices);
+  const [mediaDevices, setMediaDevices] = useState<MediaDevicesInfo>({
+    audioinput: [],
+    audiooutput: [],
+    videoinput: [],
+  });
   const [audioInputDeviceId, setAudioInputDeviceId] = useState<string>();
   const [audioOutputDeviceId, setAudioOutputDeviceId] = useState<string>();
   const [videoDeviceId, setVideoDeviceId] = useState<string>();
@@ -161,7 +147,7 @@
   // TODO: This logic will have to change to support multiple people in a call. Could we move this logic to the server?
   //       The client could make a single request with the conversationId, and the server would be tasked with sending
   //       all the individual requests to the members of the conversation.
-  const contactUri = useMemo(() => conversation.getFirstMember().contact.uri, [conversation]);
+  const contactUri = useMemo(() => callConversation.getFirstMember().contact.uri, [callConversation]);
 
   useEffect(() => {
     if (callStatus !== CallStatus.InCall) {
@@ -406,32 +392,40 @@
     };
   }, [updateLocalStream, audioInputDeviceId, audioOutputDeviceId, videoDeviceId]);
 
-  if (!callData || !callRole) {
-    console.error('Invalid route. Redirecting...');
-    return <Navigate to={'/'} />;
-  }
-
-  return (
-    <CallContext.Provider
-      value={{
-        mediaDevices,
-        currentMediaDeviceIds,
-        isAudioOn,
-        setIsAudioOn,
-        videoStatus,
-        updateVideoStatus,
-        isChatShown,
-        setIsChatShown,
-        isFullscreen,
-        setIsFullscreen,
-        callRole,
-        callStatus,
-        callStartTime,
-        acceptCall,
-        endCall,
-      }}
-    >
-      {children}
-    </CallContext.Provider>
+  return useMemo(
+    () => ({
+      mediaDevices,
+      currentMediaDeviceIds,
+      isAudioOn,
+      setIsAudioOn,
+      videoStatus,
+      updateVideoStatus,
+      isChatShown,
+      setIsChatShown,
+      isFullscreen,
+      setIsFullscreen,
+      callRole,
+      callStatus,
+      callStartTime,
+      acceptCall,
+      endCall,
+    }),
+    [
+      mediaDevices,
+      currentMediaDeviceIds,
+      isAudioOn,
+      setIsAudioOn,
+      videoStatus,
+      updateVideoStatus,
+      isChatShown,
+      setIsChatShown,
+      isFullscreen,
+      setIsFullscreen,
+      callRole,
+      callStatus,
+      callStartTime,
+      acceptCall,
+      endCall,
+    ]
   );
 };
diff --git a/client/src/contexts/ConditionalContextProvider.tsx b/client/src/contexts/ConditionalContextProvider.tsx
new file mode 100644
index 0000000..3c47cef
--- /dev/null
+++ b/client/src/contexts/ConditionalContextProvider.tsx
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2022 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program.  If not, see
+ * <https://www.gnu.org/licenses/>.
+ */
+import { Context, useCallback, useState } from 'react';
+
+import HookComponent from '../hooks/HookComponent';
+import { isRequired, PartialNotOptional, WithChildren } from '../utils/utils';
+
+export type ConditionalContextProviderProps<T, B extends object> = WithChildren & {
+  Context: Context<T>;
+  initialValue: T;
+
+  /**
+   * Object containing the values used to get the provider value with `useProviderValue`.
+   * If one or more field is undefined, the hook will not be called and `initialValue` will be used for the provider
+   * value.
+   *
+   * Should be wrapped in `useMemo` to avoid unnecessary hook calls.
+   */
+  dependencies: PartialNotOptional<B>;
+  useProviderValue: (dependencies: B) => T;
+};
+
+/**
+ * A context provider with `initialValue` as its value if not all props of the dependencies object are defined.
+ * If all props are defined, the provider value is the result of `useProviderValue`.
+ */
+const ConditionalContextProvider = <T, B extends object>({
+  Context,
+  initialValue,
+  dependencies,
+  useProviderValue,
+  children,
+}: ConditionalContextProviderProps<T, B>) => {
+  const [value, setValue] = useState(initialValue);
+  const unmountCallback = useCallback(() => setValue(initialValue), [initialValue]);
+
+  return (
+    <>
+      {isRequired(dependencies) && (
+        <HookComponent
+          callback={setValue}
+          unmountCallback={unmountCallback}
+          useHook={useProviderValue}
+          args={dependencies}
+        />
+      )}
+      <Context.Provider value={value}>{children}</Context.Provider>
+    </>
+  );
+};
+
+export default ConditionalContextProvider;
diff --git a/client/src/contexts/ConversationProvider.tsx b/client/src/contexts/ConversationProvider.tsx
index e2aa4bc..8488feb 100644
--- a/client/src/contexts/ConversationProvider.tsx
+++ b/client/src/contexts/ConversationProvider.tsx
@@ -16,7 +16,7 @@
  * <https://www.gnu.org/licenses/>.
  */
 import { ConversationView, WebSocketMessageType } from 'jami-web-common';
-import { useContext, useEffect } from 'react';
+import { useContext, useEffect, useMemo } from 'react';
 
 import LoadingPage from '../components/Loading';
 import { createOptionalContext } from '../hooks/createOptionalContext';
@@ -58,21 +58,23 @@
     webSocket.send(WebSocketMessageType.ConversationView, conversationView);
   }, [accountId, conversation, conversationId, webSocket]);
 
+  const value = useMemo(() => {
+    if (!conversation || !conversationId) {
+      return;
+    }
+
+    return {
+      conversationId,
+      conversation,
+    };
+  }, [conversationId, conversation]);
+
   if (isLoading) {
     return <LoadingPage />;
   }
-  if (isError || !conversation || !conversationId) {
+  if (isError || !value) {
     return <div>Error loading conversation: {conversationId}</div>;
   }
 
-  return (
-    <ConversationContext.Provider
-      value={{
-        conversationId,
-        conversation,
-      }}
-    >
-      {children}
-    </ConversationContext.Provider>
-  );
+  return <ConversationContext.Provider value={value}>{children}</ConversationContext.Provider>;
 };
diff --git a/client/src/contexts/CustomThemeProvider.tsx b/client/src/contexts/CustomThemeProvider.tsx
index a78aefb..7a6f346 100644
--- a/client/src/contexts/CustomThemeProvider.tsx
+++ b/client/src/contexts/CustomThemeProvider.tsx
@@ -42,13 +42,16 @@
 
   const theme = useMemo(() => buildDefaultTheme(mode), [mode]);
 
+  const value = useMemo(
+    () => ({
+      mode,
+      toggleMode,
+    }),
+    [mode, toggleMode]
+  );
+
   return (
-    <CustomThemeContext.Provider
-      value={{
-        mode,
-        toggleMode,
-      }}
-    >
+    <CustomThemeContext.Provider value={value}>
       <ThemeProvider theme={theme}>{children}</ThemeProvider>
     </CustomThemeContext.Provider>
   );
diff --git a/client/src/contexts/MessengerProvider.tsx b/client/src/contexts/MessengerProvider.tsx
index 51a19fb..c02a1b3 100644
--- a/client/src/contexts/MessengerProvider.tsx
+++ b/client/src/contexts/MessengerProvider.tsx
@@ -16,7 +16,7 @@
  * <https://www.gnu.org/licenses/>.
  */
 import { ConversationMessage, IConversation, LookupResult, WebSocketMessageType } from 'jami-web-common';
-import { createContext, ReactNode, useContext, useEffect, useState } from 'react';
+import { createContext, ReactNode, useContext, useEffect, useMemo, useState } from 'react';
 
 import { Contact } from '../models/contact';
 import { Conversation } from '../models/conversation';
@@ -105,17 +105,16 @@
     // return () => controller.abort() // crash on React18
   }, [accountId, searchQuery, axiosInstance]);
 
-  return (
-    <MessengerContext.Provider
-      value={{
-        conversations,
-        setSearchQuery,
-        searchResult,
-        newContactId,
-        setNewContactId,
-      }}
-    >
-      {children}
-    </MessengerContext.Provider>
+  const value = useMemo(
+    () => ({
+      conversations,
+      setSearchQuery,
+      searchResult,
+      newContactId,
+      setNewContactId,
+    }),
+    [conversations, setSearchQuery, searchResult, newContactId, setNewContactId]
   );
+
+  return <MessengerContext.Provider value={value}>{children}</MessengerContext.Provider>;
 };
diff --git a/client/src/contexts/WebRtcProvider.tsx b/client/src/contexts/WebRtcProvider.tsx
index bb99717..55a3a1d 100644
--- a/client/src/contexts/WebRtcProvider.tsx
+++ b/client/src/contexts/WebRtcProvider.tsx
@@ -17,20 +17,21 @@
  */
 
 import { WebRtcIceCandidate, WebRtcSdp, WebSocketMessageType } from 'jami-web-common';
-import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
+import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
 
-import LoadingPage from '../components/Loading';
+import { createOptionalContext } from '../hooks/createOptionalContext';
 import { Conversation } from '../models/conversation';
 import { WithChildren } from '../utils/utils';
 import { useAuthContext } from './AuthProvider';
 import { CallManagerContext } from './CallManagerProvider';
+import ConditionalContextProvider from './ConditionalContextProvider';
 import { IWebSocketContext, WebSocketContext } from './WebSocketProvider';
 
 export type MediaDevicesInfo = Record<MediaDeviceKind, MediaDeviceInfo[]>;
 export type MediaInputKind = 'audio' | 'video';
 export type MediaInputIds = Record<MediaInputKind, string | false | undefined>;
 
-interface IWebRtcContext {
+export interface IWebRtcContext {
   iceConnectionState: RTCIceConnectionState | undefined;
 
   localStream: MediaStream | undefined;
@@ -44,19 +45,8 @@
   closeConnection: () => void;
 }
 
-const defaultWebRtcContext: IWebRtcContext = {
-  iceConnectionState: undefined,
-  localStream: undefined,
-  screenShareLocalStream: undefined,
-  remoteStreams: undefined,
-  getMediaDevices: async () => Promise.reject(),
-  updateLocalStream: async () => Promise.reject(),
-  updateScreenShare: async () => Promise.reject(),
-  sendWebRtcOffer: async () => Promise.reject(),
-  closeConnection: () => {},
-};
-
-export const WebRtcContext = createContext<IWebRtcContext>(defaultWebRtcContext);
+const optionalWebRtcContext = createOptionalContext<IWebRtcContext>('WebRtcContext');
+export const useWebRtcContext = optionalWebRtcContext.useOptionalContext;
 
 export default ({ children }: WithChildren) => {
   const { account } = useAuthContext();
@@ -65,7 +55,12 @@
   const { callConversation, callData } = useContext(CallManagerContext);
 
   useEffect(() => {
-    if (!webRtcConnection && account) {
+    if (webRtcConnection && !callData) {
+      setWebRtcConnection(undefined);
+      return;
+    }
+
+    if (!webRtcConnection && account && callData) {
       const iceServers: RTCIceServer[] = [];
 
       if (account.details['TURN.enable'] === 'true') {
@@ -84,31 +79,36 @@
 
       setWebRtcConnection(new RTCPeerConnection({ iceServers }));
     }
-  }, [account, webRtcConnection]);
+  }, [account, webRtcConnection, callData]);
 
-  if (!webRtcConnection || !webSocket || !callConversation || !callData?.conversationId) {
-    return <LoadingPage />;
-  }
+  const dependencies = useMemo(
+    () => ({
+      webRtcConnection,
+      webSocket,
+      conversation: callConversation,
+      conversationId: callData?.conversationId,
+    }),
+    [webRtcConnection, webSocket, callConversation, callData?.conversationId]
+  );
 
   return (
-    <WebRtcProvider
-      webRtcConnection={webRtcConnection}
-      webSocket={webSocket}
-      conversation={callConversation}
-      conversationId={callData.conversationId}
+    <ConditionalContextProvider
+      Context={optionalWebRtcContext.Context}
+      initialValue={undefined}
+      dependencies={dependencies}
+      useProviderValue={useWebRtcContextValue}
     >
       {children}
-    </WebRtcProvider>
+    </ConditionalContextProvider>
   );
 };
 
-const WebRtcProvider = ({
-  children,
+const useWebRtcContextValue = ({
   conversation,
   conversationId,
   webRtcConnection,
   webSocket,
-}: WithChildren & {
+}: {
   webRtcConnection: RTCPeerConnection;
   webSocket: IWebSocketContext;
   conversation: Conversation;
@@ -438,21 +438,28 @@
     webRtcConnection.close();
   }, [webRtcConnection, localStream, screenShareLocalStream]);
 
-  return (
-    <WebRtcContext.Provider
-      value={{
-        iceConnectionState,
-        localStream,
-        screenShareLocalStream,
-        remoteStreams,
-        getMediaDevices,
-        updateLocalStream,
-        updateScreenShare,
-        sendWebRtcOffer,
-        closeConnection,
-      }}
-    >
-      {children}
-    </WebRtcContext.Provider>
+  return useMemo(
+    () => ({
+      iceConnectionState,
+      localStream,
+      screenShareLocalStream,
+      remoteStreams,
+      getMediaDevices,
+      updateLocalStream,
+      updateScreenShare,
+      sendWebRtcOffer,
+      closeConnection,
+    }),
+    [
+      iceConnectionState,
+      localStream,
+      screenShareLocalStream,
+      remoteStreams,
+      getMediaDevices,
+      updateLocalStream,
+      updateScreenShare,
+      sendWebRtcOffer,
+      closeConnection,
+    ]
   );
 };