MM-69131: Keep app__body off backstage routes to fix dark-theme styling (#36928)

* MM-69131: Keep app__body off backstage routes to fix dark-theme styling

Centralizing app__body ownership on WithUserTheme (MM-67913) made the class
persist on backstage routes (integrations, custom emoji), which render a
static light surface. Under dark themes this leaked themed colors in, making
text and form inputs illegible.

useAppBodyClass now takes the current pathname and omits app__body on
backstage routes via isBackstageRoute, while keeping it persisted across
channels, products, and plugins so the original white-flash fix still holds.

* MM-69131: Address theme provider review feedback

Keep backstage route detection local to the theme provider and let the app body class hook read routing context directly.

---------

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
Nick Misasi
2026-06-05 12:37:09 -04:00
committed by GitHub
co-authored by Hanzo Dev
parent 91669295da
commit bcd703977f
2 changed files with 97 additions and 12 deletions
@@ -1,9 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createMemoryHistory} from 'history';
import React from 'react';
import {Router} from 'react-router-dom';
import {render, renderHook} from 'tests/react_testing_utils';
import {act, render, renderHook} from 'tests/react_testing_utils';
import {useAppBodyClass, WithUserTheme} from './theme_context';
@@ -12,8 +14,30 @@ describe('useAppBodyClass', () => {
document.body.classList.remove('app__body');
});
it('adds app__body to the document body while mounted', () => {
const {unmount} = renderHook(() => useAppBodyClass());
function renderUseAppBodyClass(pathname: string) {
const history = createMemoryHistory({initialEntries: [pathname]});
const view = renderHook(() => useAppBodyClass(), {
wrapper: ({children}) => (
<Router history={history}>
{children}
</Router>
),
});
return {...view, history};
}
it.each([
'/myteam/channels/town-square',
'/myteam/messages/@someone',
'/myteam/channels/integrations',
'/admin_console/integrations/bot_accounts',
'/create_team',
'/select_team',
'/plug/some-plugin',
'/',
])('adds app__body to the document body for themed route %s while mounted', (pathname) => {
const {unmount} = renderUseAppBodyClass(pathname);
expect(document.body.classList.contains('app__body')).toBe(true);
@@ -21,6 +45,36 @@ describe('useAppBodyClass', () => {
expect(document.body.classList.contains('app__body')).toBe(false);
});
it.each([
'/myteam/integrations',
'/myteam/integrations/',
'/myteam/integrations/bots',
'/myteam/integrations/incoming_webhooks',
'/myteam/emoji',
'/myteam/emoji/add',
'/my-team_1/integrations',
])('does not add app__body on backstage route %s', (pathname) => {
renderUseAppBodyClass(pathname);
expect(document.body.classList.contains('app__body')).toBe(false);
});
it('toggles app__body as the route changes', () => {
const {history} = renderUseAppBodyClass('/myteam/channels/town-square');
expect(document.body.classList.contains('app__body')).toBe(true);
act(() => {
history.push('/myteam/integrations');
});
expect(document.body.classList.contains('app__body')).toBe(false);
act(() => {
history.push('/myteam/channels/town-square');
});
expect(document.body.classList.contains('app__body')).toBe(true);
});
});
describe('WithUserTheme', () => {
@@ -28,12 +82,20 @@ describe('WithUserTheme', () => {
document.body.classList.remove('app__body');
});
it('adds app__body to the document body while mounted', () => {
const {unmount} = render(
<WithUserTheme>
<div>{'Themed content'}</div>
</WithUserTheme>,
function renderWithUserTheme(pathname: string) {
const history = createMemoryHistory({initialEntries: [pathname]});
return render(
<Router history={history}>
<WithUserTheme>
<div>{'Themed content'}</div>
</WithUserTheme>
</Router>,
);
}
it('adds app__body to the document body for a themed route while mounted', () => {
const {unmount} = renderWithUserTheme('/myteam/channels/town-square');
expect(document.body.classList.contains('app__body')).toBe(true);
@@ -41,4 +103,10 @@ describe('WithUserTheme', () => {
expect(document.body.classList.contains('app__body')).toBe(false);
});
it('does not add app__body on a backstage route', () => {
renderWithUserTheme('/myteam/integrations');
expect(document.body.classList.contains('app__body')).toBe(false);
});
});
@@ -2,12 +2,22 @@
// See LICENSE.txt for license information.
import React, {useContext, useEffect} from 'react';
import {useLocation} from 'react-router-dom';
import {TEAM_NAME_PATH_PATTERN} from 'utils/path';
export const ThemeContext = React.createContext({
startUsingUserTheme: () => {},
stopUsingUserTheme: () => {},
});
// Exclude System Console's integrations pages from team backstage routes.
const BACKSTAGE_ROUTE_PATTERN = new RegExp(`^/(?!admin_console)${TEAM_NAME_PATH_PATTERN}/(?:integrations|emoji)(?:/|$)`);
function isBackstageRoute(pathname: string): boolean {
return BACKSTAGE_ROUTE_PATTERN.test(pathname);
}
/**
* useUserTheme makes it so that the app will apply the user's theme instead of the default one for as long as the
* calling component remains mounted.
@@ -25,18 +35,25 @@ export function useUserTheme() {
}
/**
* useAppBodyClass manages the `app__body` class on the document body for as long as the calling component
* remains mounted. That class is required for much of our CSS to apply the user's theme, so it should be used
* wherever those should be used.
* useAppBodyClass manages the `app__body` class on the document body for the current route while the calling
* component remains mounted. That class is required for much of our CSS to apply the user's theme, so it is
* omitted on backstage routes (e.g. integrations, custom emoji) that intentionally render a static light theme.
*/
export function useAppBodyClass() {
const {pathname} = useLocation();
const themed = !isBackstageRoute(pathname);
useEffect(() => {
if (!themed) {
return undefined;
}
document.body.classList.add('app__body');
return () => {
document.body.classList.remove('app__body');
};
}, []);
}, [themed]);
}
/**