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(