diff --git a/packages/client/src/theme/README.md b/packages/client/src/theme/README.md
index 64f171e0d8..46e74e4113 100644
--- a/packages/client/src/theme/README.md
+++ b/packages/client/src/theme/README.md
@@ -545,6 +545,11 @@ function App() {
remain synchronized when they change. Only pass theme props when the parent should control those
values; otherwise use the context setters and allow stored preferences to remain authoritative.
+Set `persistThemeDefinition={false}` when a parent controls a deployment or embedded theme that
+must not replace the user's stored theme definition, legacy colors, name, or source. Appearance
+mode changes remain independently persisted through `color-theme`; leave `initialTheme` undefined
+when the stored light, dark, or system preference should remain authoritative.
+
## Contributing
When adding new theme colors:
diff --git a/packages/client/src/theme/context/ThemeProvider.spec.tsx b/packages/client/src/theme/context/ThemeProvider.spec.tsx
index 10b0660302..48991884b5 100644
--- a/packages/client/src/theme/context/ThemeProvider.spec.tsx
+++ b/packages/client/src/theme/context/ThemeProvider.spec.tsx
@@ -106,6 +106,74 @@ describe('ThemeProvider', () => {
expect(localStorage.getItem('theme-source')).toBe('definition');
});
+ it('applies a controlled definition without replacing stored theme preferences', async () => {
+ const storedDefinition = {
+ version: 1 as const,
+ name: 'stored',
+ modes: { light: { colors: { 'rgb-accent-primary': '1 2 3' } } },
+ };
+ const storedColors = { 'rgb-accent-primary': '1 2 3' };
+ localStorage.setItem('theme-definition', JSON.stringify(storedDefinition));
+ localStorage.setItem('theme-colors', JSON.stringify(storedColors));
+ localStorage.setItem('theme-name', 'stored');
+ localStorage.setItem('theme-source', 'legacy');
+
+ const { rerender, unmount } = render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(document.documentElement.dataset.theme).toBe('deployment');
+ });
+ expect(document.documentElement.style.getPropertyValue('--accent-primary')).toBe('4 5 6');
+ expect(JSON.parse(localStorage.getItem('theme-definition') ?? '{}')).toEqual(storedDefinition);
+ expect(JSON.parse(localStorage.getItem('theme-colors') ?? '{}')).toEqual(storedColors);
+ expect(localStorage.getItem('theme-name')).toBe('stored');
+ expect(localStorage.getItem('theme-source')).toBe('legacy');
+
+ rerender(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(document.documentElement.dataset.theme).toBe('deployment-next');
+ });
+ expect(document.documentElement.style.getPropertyValue('--accent-primary')).toBe('7 8 9');
+ expect(JSON.parse(localStorage.getItem('theme-definition') ?? '{}')).toEqual(storedDefinition);
+
+ unmount();
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(document.documentElement.dataset.theme).toBe('stored');
+ });
+ expect(document.documentElement.style.getPropertyValue('--accent-primary')).toBe('1 2 3');
+ });
+
it('keeps valid legacy overrides when another token is malformed', async () => {
render(
(undefined);
const themeClassSnapshot = useRef(undefined);
+ const writeThemeStorage = useCallback(
+ (key: string, value?: string) => {
+ if (!persistThemeDefinition) {
+ return;
+ }
+ writeStorage(key, value);
+ },
+ [persistThemeDefinition],
+ );
+
const restoreAppliedTheme = useCallback((root = window.document.documentElement) => {
if (!themeDOMSnapshot.current) {
return;
@@ -326,19 +339,19 @@ export function ThemeProvider({
const definition = validPropDefinition ?? legacyDefinition;
if (!definition) {
if (propThemeName !== undefined && themeDefinition) {
- writeStorage(THEME_DEFINITION_KEY, JSON.stringify(themeDefinition));
- writeStorage(THEME_NAME_KEY, themeDefinition.name);
- writeStorage(THEME_SOURCE_KEY, legacyThemeRGB ? 'legacy' : 'definition');
+ writeThemeStorage(THEME_DEFINITION_KEY, JSON.stringify(themeDefinition));
+ writeThemeStorage(THEME_NAME_KEY, themeDefinition.name);
+ writeThemeStorage(THEME_SOURCE_KEY, legacyThemeRGB ? 'legacy' : 'definition');
} else if (propThemeName && !themeDefinition) {
- writeStorage(THEME_NAME_KEY, propThemeName);
+ writeThemeStorage(THEME_NAME_KEY, propThemeName);
}
return;
}
- writeStorage(THEME_DEFINITION_KEY, JSON.stringify(definition));
- writeStorage(THEME_NAME_KEY, definition.name);
- writeStorage(THEME_SOURCE_KEY, legacyDefinition ? 'legacy' : 'definition');
- writeStorage(
+ writeThemeStorage(THEME_DEFINITION_KEY, JSON.stringify(definition));
+ writeThemeStorage(THEME_NAME_KEY, definition.name);
+ writeThemeStorage(THEME_SOURCE_KEY, legacyDefinition ? 'legacy' : 'definition');
+ writeThemeStorage(
THEME_COLORS_KEY,
!propThemeDefinition && legacyDefinition
? JSON.stringify(legacyDefinition.modes.light?.colors ?? {})
@@ -351,6 +364,7 @@ export function ThemeProvider({
propThemeName,
propThemeRGB,
themeDefinition,
+ writeThemeStorage,
]);
const setTheme = useCallback((newTheme: string) => {
@@ -361,57 +375,66 @@ export function ThemeProvider({
writeStorage(THEME_KEY, newTheme);
}, []);
- const setThemeDefinition = useCallback((definition?: ThemeDefinition) => {
- const errors = definition ? validateThemeDefinition(definition) : [];
- if (errors.length > 0) {
- throw new TypeError(errors.join('\n'));
- }
- themeDefinitionRef.current = definition;
- setThemeDefinitionState(definition);
- legacyThemeRGBRef.current = undefined;
- setLegacyThemeRGB(undefined);
- writeStorage(THEME_DEFINITION_KEY, definition ? JSON.stringify(definition) : undefined);
- writeStorage(THEME_COLORS_KEY);
- writeStorage(THEME_SOURCE_KEY, definition ? 'definition' : undefined);
- setThemeNameState(definition?.name);
- themeNameRef.current = definition?.name;
- writeStorage(THEME_NAME_KEY, definition?.name);
- }, []);
+ const setThemeDefinition = useCallback(
+ (definition?: ThemeDefinition) => {
+ const errors = definition ? validateThemeDefinition(definition) : [];
+ if (errors.length > 0) {
+ throw new TypeError(errors.join('\n'));
+ }
+ themeDefinitionRef.current = definition;
+ setThemeDefinitionState(definition);
+ legacyThemeRGBRef.current = undefined;
+ setLegacyThemeRGB(undefined);
+ writeThemeStorage(THEME_DEFINITION_KEY, definition ? JSON.stringify(definition) : undefined);
+ writeThemeStorage(THEME_COLORS_KEY);
+ writeThemeStorage(THEME_SOURCE_KEY, definition ? 'definition' : undefined);
+ setThemeNameState(definition?.name);
+ themeNameRef.current = definition?.name;
+ writeThemeStorage(THEME_NAME_KEY, definition?.name);
+ },
+ [writeThemeStorage],
+ );
- const setThemeRGB = useCallback((colors?: IThemeRGB) => {
- const definition = colors
- ? fromLegacyTheme(colors, themeDefinitionRef.current?.name ?? themeNameRef.current)
- : undefined;
- const legacyColors = definition?.modes.light?.colors;
- themeDefinitionRef.current = definition;
- setThemeDefinitionState(definition);
- legacyThemeRGBRef.current = legacyColors;
- setLegacyThemeRGB(legacyColors);
- setThemeNameState(definition?.name);
- themeNameRef.current = definition?.name;
- writeStorage(THEME_DEFINITION_KEY, definition ? JSON.stringify(definition) : undefined);
- writeStorage(THEME_NAME_KEY, definition?.name);
- writeStorage(THEME_COLORS_KEY, legacyColors ? JSON.stringify(legacyColors) : undefined);
- writeStorage(THEME_SOURCE_KEY, definition ? 'legacy' : undefined);
- }, []);
+ const setThemeRGB = useCallback(
+ (colors?: IThemeRGB) => {
+ const definition = colors
+ ? fromLegacyTheme(colors, themeDefinitionRef.current?.name ?? themeNameRef.current)
+ : undefined;
+ const legacyColors = definition?.modes.light?.colors;
+ themeDefinitionRef.current = definition;
+ setThemeDefinitionState(definition);
+ legacyThemeRGBRef.current = legacyColors;
+ setLegacyThemeRGB(legacyColors);
+ setThemeNameState(definition?.name);
+ themeNameRef.current = definition?.name;
+ writeThemeStorage(THEME_DEFINITION_KEY, definition ? JSON.stringify(definition) : undefined);
+ writeThemeStorage(THEME_NAME_KEY, definition?.name);
+ writeThemeStorage(THEME_COLORS_KEY, legacyColors ? JSON.stringify(legacyColors) : undefined);
+ writeThemeStorage(THEME_SOURCE_KEY, definition ? 'legacy' : undefined);
+ },
+ [writeThemeStorage],
+ );
- const setThemeName = useCallback((name?: string) => {
- const currentDefinition = themeDefinitionRef.current;
- const nextName = name?.trim() || (currentDefinition ? 'custom' : undefined);
- setThemeNameState(nextName);
- themeNameRef.current = nextName;
- writeStorage(THEME_NAME_KEY, nextName);
+ const setThemeName = useCallback(
+ (name?: string) => {
+ const currentDefinition = themeDefinitionRef.current;
+ const nextName = name?.trim() || (currentDefinition ? 'custom' : undefined);
+ setThemeNameState(nextName);
+ themeNameRef.current = nextName;
+ writeThemeStorage(THEME_NAME_KEY, nextName);
- if (!nextName || !currentDefinition) {
- return;
- }
+ if (!nextName || !currentDefinition) {
+ return;
+ }
- const renamedDefinition = { ...currentDefinition, name: nextName };
- themeDefinitionRef.current = renamedDefinition;
- setThemeDefinitionState(renamedDefinition);
- writeStorage(THEME_DEFINITION_KEY, JSON.stringify(renamedDefinition));
- writeStorage(THEME_SOURCE_KEY, legacyThemeRGBRef.current ? 'legacy' : 'definition');
- }, []);
+ const renamedDefinition = { ...currentDefinition, name: nextName };
+ themeDefinitionRef.current = renamedDefinition;
+ setThemeDefinitionState(renamedDefinition);
+ writeThemeStorage(THEME_DEFINITION_KEY, JSON.stringify(renamedDefinition));
+ writeThemeStorage(THEME_SOURCE_KEY, legacyThemeRGBRef.current ? 'legacy' : 'definition');
+ },
+ [writeThemeStorage],
+ );
useEffect(() => {
if (!synchronizedThemeProps.current) {
@@ -540,9 +563,9 @@ export function ThemeProvider({
const resetTheme = useCallback(() => {
setTheme('system');
setThemeDefinition(undefined);
- writeStorage(THEME_COLORS_KEY);
+ writeThemeStorage(THEME_COLORS_KEY);
restoreAppliedTheme();
- }, [restoreAppliedTheme, setTheme, setThemeDefinition]);
+ }, [restoreAppliedTheme, setTheme, setThemeDefinition, writeThemeStorage]);
const themeRGB = legacyThemeRGB ?? themeDefinition?.modes.light?.colors;
const value = useMemo(