🪶 feat: Support Non-Persistent Controlled Themes (#15170)

This commit is contained in:
Danny Avila 2026-08-24 11:52:30 -04:00 committed by GitHub
parent a997275902
commit f9c051f8ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 153 additions and 57 deletions

View file

@ -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:

View file

@ -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(
<ThemeProvider
initialTheme="light"
persistThemeDefinition={false}
themeDefinition={{
version: 1,
name: 'deployment',
modes: { light: { colors: { 'rgb-accent-primary': '4 5 6' } } },
}}
>
<Controls />
</ThemeProvider>,
);
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(
<ThemeProvider
initialTheme="light"
persistThemeDefinition={false}
themeDefinition={{
version: 1,
name: 'deployment-next',
modes: { light: { colors: { 'rgb-accent-primary': '7 8 9' } } },
}}
>
<Controls />
</ThemeProvider>,
);
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(
<ThemeProvider initialTheme="light">
<Controls />
</ThemeProvider>,
);
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(
<ThemeProvider

View file

@ -66,6 +66,8 @@ export interface ThemeProviderProps {
children: React.ReactNode;
themeRGB?: IThemeRGB;
themeDefinition?: ThemeDefinition;
/** Whether theme definition, color, name, and source changes should be persisted. */
persistThemeDefinition?: boolean;
themeName?: string;
initialTheme?: string;
}
@ -222,6 +224,7 @@ export function ThemeProvider({
children,
themeRGB: propThemeRGB,
themeDefinition: propThemeDefinition,
persistThemeDefinition = true,
themeName: propThemeName,
initialTheme,
}: ThemeProviderProps): JSX.Element {
@ -285,6 +288,16 @@ export function ThemeProvider({
const themeDOMSnapshot = useRef<ThemeDOMSnapshot | undefined>(undefined);
const themeClassSnapshot = useRef<ThemeClassSnapshot | undefined>(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(