Repository: mattermost/mattermost-mobile
Pull request #8673: Avoid unnecessary re-renders
Author: login: enahum
Labels: name: 2: Dev Review
description: Requires review by a core commiter
name: CherryPick/Done
description: Successfully cherry-picked to the quality or patch release tracked in the milestone
name: Build Apps for PR
description: Build the mobile app for iOS and Android to test
name: 3: QA Review
description: Requires review by a QA tester
name: release-note-none
Created: 2025-03-13T02:00:47Z | Merged: 2025-03-13T13:28:48Z

#### Summary
At times rendering a markdown table was causing a jump display on loop and this was due to a few things

1. The message component, that renders the markdown updates the state on layout to determine if the component should be displayed as a long message, this was being triggered multiple times, so the first fix is to set the height only if needed.
2. The reason the onLayout was firing multiple times was because the theme even though is the same, it was being updated multiple times as the provider uses JSON.parse which returns a new object every time, so the fix here was to create a memory cache of the themes so that is always the same object until it changes.
3. Additionally, when switching themes, we were updating it more than once, when changing which is correct but also when loading the screen and in this case it was not needed at all.

#### Ticket Link
https://mattermost.atlassian.net/browse/MM-56369

#### Release Note
```release-note
NONE
```



Diff:
diff --git a/app/components/post_list/post/body/message/message.tsx b/app/components/post_list/post/body/message/message.tsx
index d8101ce8d3..0cffaecc10 100644
--- a/app/components/post_list/post/body/message/message.tsx
+++ b/app/components/post_list/post/body/message/message.tsx
@@ -67,7 +67,12 @@ const Message = ({currentUser, isHighlightWithoutNotificationLicensed, highlight
     const blockStyles = getMarkdownBlockStyles(theme);
     const textStyles = getMarkdownTextStyles(theme);
 
-    const onLayout = useCallback((event: LayoutChangeEvent) => setHeight(event.nativeEvent.layout.height), []);
+    const onLayout = useCallback((event: LayoutChangeEvent) => {
+        const h = event.nativeEvent.layout.height;
+        if (h > maxHeight) {
+            setHeight(event.nativeEvent.layout.height);
+        }
+    }, [maxHeight]);
     const onPress = () => setOpen(!open);
 
     const channelMentions = useMemo(() => {
diff --git a/app/context/theme/index.tsx b/app/context/theme/index.tsx
index c01a096a18..e8e172e172 100644
--- a/app/context/theme/index.tsx
+++ b/app/context/theme/index.tsx
@@ -6,6 +6,7 @@ import React, {type ComponentType, createContext, useEffect, useState} from 'rea
 import {Appearance} from 'react-native';
 
 import {Preferences} from '@constants';
+import useDidUpdate from '@hooks/did_update';
 import {queryThemePreferences} from '@queries/servers/preference';
 import {observeCurrentTeamId} from '@queries/servers/system';
 import {setThemeDefaults, updateThemeIfNeeded} from '@utils/theme';
@@ -33,22 +34,43 @@ export function getDefaultThemeByAppearance(): Theme {
 export const ThemeContext = createContext(getDefaultThemeByAppearance());
 const {Consumer, Provider} = ThemeContext;
 
+const themeCache = new Map<string, Theme>();
+let clearingThemeCache = false;
+const clearThemeCache = () => {
+    if (clearingThemeCache) {
+        return;
+    }
+    clearingThemeCache = true;
+    themeCache.clear();
+    setTimeout(() => {
+        // We set this timeout to avoid clearing the cache multiple times
+        // this would happen as we have a Provider for each screen in the stack
+        // and when the themes changes we only want to invalidate the cache once
+        clearingThemeCache = false;
+    }, 300);
+};
+
 const getTheme = (teamId: string | undefined, themes: PreferenceModel[]): Theme => {
-    if (teamId) {
-        const teamTheme = themes.find((t) => t.name === teamId) || themes[0];
-        if (teamTheme?.value) {
-            try {
-                const theme = setThemeDefaults(JSON.parse(teamTheme.value));
-                return theme;
-            } catch {
-                // no theme change
-            }
-        }
+    if (teamId && themeCache.has(teamId)) {
+        return themeCache.get(teamId)!;
     }
 
-    const defaultTheme = getDefaultThemeByAppearance();
+    const newTheme = (() => {
+        if (teamId) {
+            const teamTheme = themes.find((t) => t.name === teamId) || themes[0];
+            if (teamTheme?.value) {
+                try {
+                    return setThemeDefaults(JSON.parse(teamTheme.value));
+                } catch {
+                    // no theme change
+                }
+            }
+        }
+        return getDefaultThemeByAppearance();
+    })();
 
-    return defaultTheme;
+    themeCache.set(teamId || 'default', newTheme);
+    return newTheme;
 };
 
 const ThemeProvider = ({currentTeamId, children, themes}: Props) => {
@@ -69,7 +91,11 @@ const ThemeProvider = ({currentTeamId, children, themes}: Props) => {
         updateThemeIfNeeded(theme);
     }, [theme]);
 
-    useEffect(() => {
+    useDidUpdate(() => {
+        clearThemeCache();
+    }, [themes]);
+
+    useDidUpdate(() => {
         setTheme(getTheme(currentTeamId, themes));
     }, [currentTeamId, themes]);
 
diff --git a/app/screens/settings/display_theme/display_theme.tsx b/app/screens/settings/display_theme/display_theme.tsx
index 19b0a6bdaa..1627ea8c9c 100644
--- a/app/screens/settings/display_theme/display_theme.tsx
+++ b/app/screens/settings/display_theme/display_theme.tsx
@@ -1,7 +1,7 @@
 // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
 // See LICENSE.txt for license information.
 
-import React, {useCallback, useMemo, useState, useEffect} from 'react';
+import React, {useCallback, useMemo, useState} from 'react';
 
 import {savePreference} from '@actions/remote/preference';
 import SettingContainer from '@components/settings/container';
@@ -9,6 +9,7 @@ import {Preferences} from '@constants';
 import {useServerUrl} from '@context/server';
 import {useTheme} from '@context/theme';
 import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
+import useDidUpdate from '@hooks/did_update';
 import {popTopScreen} from '@screens/navigation';
 
 import CustomTheme from './custom_theme';
@@ -30,16 +31,6 @@ const DisplayTheme = ({allowedThemeKeys, componentId, currentTeamId, currentUser
 
     const close = () => popTopScreen(componentId);
 
-    useEffect(() => {
-        const differentTheme = theme.type?.toLowerCase() !== newTheme?.toLowerCase();
-
-        if (!differentTheme) {
-            close();
-            return;
-        }
-        setThemePreference();
-    }, [newTheme]);
-
     const setThemePreference = useCallback(() => {
         const allowedTheme = allowedThemeKeys.find((tk) => tk === newTheme);
         const themeJson = Preferences.THEMES[allowedTheme as ThemeKey] || initialTheme;
@@ -51,7 +42,17 @@ const DisplayTheme = ({allowedThemeKeys, componentId, currentTeamId, currentUser
             value: JSON.stringify(themeJson),
         };
         savePreference(serverUrl, [pref]);
-    }, [allowedThemeKeys, currentTeamId, theme.type, serverUrl, newTheme]);
+    }, [allowedThemeKeys, initialTheme, currentTeamId, currentUserId, serverUrl, newTheme]);
+
+    useDidUpdate(() => {
+        const differentTheme = theme.type?.toLowerCase() !== newTheme?.toLowerCase();
+
+        if (!differentTheme) {
+            close();
+            return;
+        }
+        setThemePreference();
+    }, [close, newTheme, setThemePreference, theme.type]);
 
     const onAndroidBack = () => {
         setThemePreference();


Reviews:
user: login: Rajat-Dabade
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T13:00:16Z
user: login: yasserfaraazkhan
body: Verified that the post containg markdown tables is not flickering on mobile.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T13:00:26Z
user: login: rahimrahman
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T13:15:18Z
user: login: enahum
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T13:16:46Z
user: login: rahimrahman
body: Looks good.

Curious about the use of useDidUpdate.  I feel like it won't execute on initial, and then any changes on deps it will get triggered.  Maybe somewhere in the code the callback has already been called initially and we don't repetition?
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T13:17:19Z
user: login: rahimrahman
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T13:18:24Z
user: login: enahum
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T13:22:30Z

Review comments:
diff_hunk: @@ -33,22 +34,40 @@ export function getDefaultThemeByAppearance(): Theme {
 export const ThemeContext = createContext(getDefaultThemeByAppearance());
 const {Consumer, Provider} = ThemeContext;
 
+const themeCache = new Map<string, Theme>();
+let clearingThemeCache = false;
+const clearThemeCache = () => {
+    if (clearingThemeCache) {
+        return;
+    }
+    clearingThemeCache = true;
+    themeCache.clear();
+    setTimeout(() => {
+        clearingThemeCache = false;
+    }, 300);
path: app/context/theme/index.tsx
user: login: rahimrahman
body: Curious about the need of this timeout.  What kind of race condition are we worried about?
created_at: 2025-03-13T13:15:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -33,22 +34,40 @@ export function getDefaultThemeByAppearance(): Theme {
 export const ThemeContext = createContext(getDefaultThemeByAppearance());
 const {Consumer, Provider} = ThemeContext;
 
+const themeCache = new Map<string, Theme>();
+let clearingThemeCache = false;
+const clearThemeCache = () => {
+    if (clearingThemeCache) {
+        return;
+    }
+    clearingThemeCache = true;
+    themeCache.clear();
+    setTimeout(() => {
+        clearingThemeCache = false;
+    }, 300);
path: app/context/theme/index.tsx
user: login: enahum
body: what happens here is that if you have multiple providers at the same time (we have one per screen in the stack) then what happens without the timeout is that it just clears the cache multiple times (as many screens you have in the stack at that point)
created_at: 2025-03-13T13:16:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -33,22 +34,40 @@ export function getDefaultThemeByAppearance(): Theme {
 export const ThemeContext = createContext(getDefaultThemeByAppearance());
 const {Consumer, Provider} = ThemeContext;
 
+const themeCache = new Map<string, Theme>();
+let clearingThemeCache = false;
+const clearThemeCache = () => {
+    if (clearingThemeCache) {
+        return;
+    }
+    clearingThemeCache = true;
+    themeCache.clear();
+    setTimeout(() => {
+        clearingThemeCache = false;
+    }, 300);
path: app/context/theme/index.tsx
user: login: rahimrahman
body: Ah, this is to avoid being cleared multiple times.

Would that be something worthy of a comment in the code as to the why?
created_at: 2025-03-13T13:18:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -33,22 +34,40 @@ export function getDefaultThemeByAppearance(): Theme {
 export const ThemeContext = createContext(getDefaultThemeByAppearance());
 const {Consumer, Provider} = ThemeContext;
 
+const themeCache = new Map<string, Theme>();
+let clearingThemeCache = false;
+const clearThemeCache = () => {
+    if (clearingThemeCache) {
+        return;
+    }
+    clearingThemeCache = true;
+    themeCache.clear();
+    setTimeout(() => {
+        clearingThemeCache = false;
+    }, 300);
path: app/context/theme/index.tsx
user: login: enahum
body: added
created_at: 2025-03-13T13:22:30Z
author_association: CONTRIBUTOR

PR comments:
user: login: enahum
created_at: 2025-03-13T13:18:24Z
body: > Curious about the use of useDidUpdate. I feel like it won't execute on initial, and then any changes on deps it will get triggered. Maybe somewhere in the code the callback has already been called initially and we don't repetition?

didUpdate is on purpose so that it does not execute when the component first mounts, but only after any other update based on the props we pass as dependencies.
author_association: CONTRIBUTOR
user: login: rahimrahman
created_at: 2025-03-13T13:20:53Z
body: I always thought this is where React 18 feature would shine here with batching.  But I guess it won't do that if the changes are happening in different useEffects.
author_association: CONTRIBUTOR
user: login: rahimrahman
created_at: 2025-03-13T13:22:01Z
body: 
> didUpdate is on purpose so that it does not execute when the component first mounts, but only after any other update based on the props we pass as dependencies.

As long as we're not on strict mode and on development.  Then it'll be off.
author_association: CONTRIBUTOR
user: login: enahum
created_at: 2025-03-13T13:24:50Z
body: @rahimrahman is a hook of ours https://github.com/mattermost/mattermost-mobile/blob/main/app/hooks/did_update.ts so strict mode will have no effect on this.
author_association: CONTRIBUTOR
user: login: mattermost-build
created_at: 2025-03-13T13:28:51Z
body: Cherry pick is scheduled.
author_association: CONTRIBUTOR