theme: stack old theme attributes

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-09-14 02:12:42 +02:00
parent 92c412e07c
commit 3963632b2b
5 changed files with 141 additions and 22 deletions
@@ -96,6 +96,7 @@ describe('createPublicSignInApp', () => {
<body
data-theme-mode="light"
data-theme-name="backstage"
data-unified-theme-stack="[{"mode":"light","name":"backstage"}]"
>
<div>
<form
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { ReactNode, useEffect } from 'react';
import { ReactNode } from 'react';
import CssBaseline from '@material-ui/core/CssBaseline';
import {
ThemeProvider,
@@ -58,6 +58,8 @@ const generateV4ClassName = createGenerateClassName({
productionPrefix: 'jss4-',
});
import { useApplyThemeAttributes } from './useApplyThemeAttributes';
/**
* Provides themes for all Material UI versions supported by the provided unified theme.
*
@@ -70,28 +72,11 @@ export function UnifiedThemeProvider(
const v4Theme = theme.getTheme('v4') as Mui4Theme;
const v5Theme = theme.getTheme('v5') as Mui5Theme;
const themeMode = v4Theme ? v4Theme.palette.type : v5Theme?.palette.mode;
const themeName = 'backstage';
useEffect(() => {
const oldMode = document.body.getAttribute('data-theme-mode');
const oldName = document.body.getAttribute('data-theme-name');
document.body.setAttribute('data-theme-mode', themeMode);
document.body.setAttribute('data-theme-name', themeName);
return () => {
if (oldMode) {
document.body.setAttribute('data-theme-mode', oldMode);
} else {
document.body.removeAttribute('data-theme-mode');
}
if (oldName) {
document.body.setAttribute('data-theme-name', oldName);
} else {
document.body.removeAttribute('data-theme-name');
}
};
}, [themeMode, themeName]);
useApplyThemeAttributes(
v4Theme ? v4Theme.palette.type : v5Theme?.palette.mode,
'backstage',
);
let cssBaseline: JSX.Element | undefined = undefined;
if (!noCssBaseline) {
@@ -0,0 +1,60 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '@testing-library/jest-dom';
import { renderHook } from '@testing-library/react';
import { useApplyThemeAttributes } from './useApplyThemeAttributes';
describe('useApplyThemeAttributes', () => {
beforeEach(() => {
document.body.removeAttribute('data-unified-theme-stack');
document.body.removeAttribute('data-theme-mode');
document.body.removeAttribute('data-theme-name');
});
it('pushes attributes on mount and pops on unmount', () => {
const { unmount } = renderHook(() =>
useApplyThemeAttributes('light', 'one'),
);
expect(document.body.getAttribute('data-theme-mode')).toBe('light');
expect(document.body.getAttribute('data-theme-name')).toBe('one');
expect(
JSON.parse(document.body.getAttribute('data-unified-theme-stack') || '[]')
.length,
).toBe(1);
unmount();
expect(document.body.getAttribute('data-theme-mode')).toBeNull();
expect(document.body.getAttribute('data-theme-name')).toBeNull();
expect(document.body.getAttribute('data-unified-theme-stack')).toBeNull();
});
it('stacks multiple mounts and applies top-most', () => {
const r1 = renderHook(() => useApplyThemeAttributes('light', 'one'));
const r2 = renderHook(() => useApplyThemeAttributes('dark', 'two'));
expect(document.body.getAttribute('data-theme-mode')).toBe('dark');
expect(document.body.getAttribute('data-theme-name')).toBe('two');
r2.unmount();
expect(document.body.getAttribute('data-theme-mode')).toBe('light');
expect(document.body.getAttribute('data-theme-name')).toBe('one');
r1.unmount();
expect(document.body.getAttribute('data-theme-mode')).toBeNull();
expect(document.body.getAttribute('data-theme-name')).toBeNull();
});
});
@@ -0,0 +1,72 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect } from 'react';
type ThemeFrame = { mode?: string; name?: string };
const STACK_ATTR = 'data-unified-theme-stack';
const MODE_ATTR = 'data-theme-mode';
const NAME_ATTR = 'data-theme-name';
function mutateAttrStack(mutator: (stack: ThemeFrame[]) => void) {
const stack = (() => {
const raw = document.body.getAttribute(STACK_ATTR);
if (!raw) {
return [] as ThemeFrame[];
}
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as ThemeFrame[]) : [];
} catch {
return [] as ThemeFrame[];
}
})();
mutator(stack);
if (stack.length === 0) {
document.body.removeAttribute(STACK_ATTR);
} else {
document.body.setAttribute(STACK_ATTR, JSON.stringify(stack));
}
const top = stack[stack.length - 1];
if (top?.mode) {
document.body.setAttribute(MODE_ATTR, String(top.mode));
} else {
document.body.removeAttribute(MODE_ATTR);
}
if (top?.name) {
document.body.setAttribute(NAME_ATTR, String(top.name));
} else {
document.body.removeAttribute(NAME_ATTR);
}
}
export function useApplyThemeAttributes(themeMode: string, themeName: string) {
useEffect(() => {
mutateAttrStack(stack => {
stack.push({ mode: themeMode, name: themeName });
});
return () => {
mutateAttrStack(stack => {
stack.pop();
});
};
}, [themeMode, themeName]);
}
@@ -99,6 +99,7 @@ describe('appModulePublicSignIn', () => {
<body
data-theme-mode="light"
data-theme-name="backstage"
data-unified-theme-stack="[{"mode":"light","name":"backstage"}]"
>
<div>
<form