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,
+ ]
);
};