diff --git a/src/internal/index.ts b/src/internal/index.ts
index 1223f27..49d1ea9 100644
--- a/src/internal/index.ts
+++ b/src/internal/index.ts
@@ -14,6 +14,7 @@ export {
useDensityMode,
useReducedMotion,
useRuntimeVisualRefresh,
+ initThemes,
isThemeActive,
Theme,
} from './visual-mode/index.js';
diff --git a/src/internal/testing.ts b/src/internal/testing.ts
index 12af494..6e67ee2 100644
--- a/src/internal/testing.ts
+++ b/src/internal/testing.ts
@@ -4,7 +4,7 @@
export { clearOneTimeMetricsCache } from './base-component/metrics/metrics.js';
export { clearMessageCache } from './logging.js';
export { setGlobalFlag } from './global-flags/index.js';
-export { clearVisualRefreshState } from './visual-mode/index.js';
+export { clearThemeState, clearVisualRefreshState } from './visual-mode/index.js';
export {
TestSingleTabStopNavigationProvider,
setTestSingleTabStopNavigationTarget,
diff --git a/src/internal/visual-mode/__tests__/use-runtime-visual-refresh.ssr.test.tsx b/src/internal/visual-mode/__tests__/use-runtime-visual-refresh.ssr.test.tsx
index 4477048..8f049f6 100644
--- a/src/internal/visual-mode/__tests__/use-runtime-visual-refresh.ssr.test.tsx
+++ b/src/internal/visual-mode/__tests__/use-runtime-visual-refresh.ssr.test.tsx
@@ -41,5 +41,5 @@ test('prints a warning when feature flag changes between renders', () => {
renderToStaticMarkup();
globalWithFlags[awsuiVisualRefreshFlag] = () => false;
renderToStaticMarkup();
- expect(console.warn).toHaveBeenCalledWith(expect.stringMatching(/Dynamic visual refresh change detected/));
+ expect(console.warn).toHaveBeenCalledWith(expect.stringMatching(/Dynamic theme change detected/));
});
diff --git a/src/internal/visual-mode/__tests__/use-runtime-visual-refresh.test.tsx b/src/internal/visual-mode/__tests__/use-runtime-visual-refresh.test.tsx
index e6aef54..3702430 100644
--- a/src/internal/visual-mode/__tests__/use-runtime-visual-refresh.test.tsx
+++ b/src/internal/visual-mode/__tests__/use-runtime-visual-refresh.test.tsx
@@ -2,11 +2,12 @@
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
-import { useRuntimeVisualRefresh, clearVisualRefreshState } from '../index';
+import { useRuntimeVisualRefresh, clearThemeState, initThemes } from '../index';
import { render, screen } from '@testing-library/react';
import { clearMessageCache } from '../../logging';
const awsuiVisualRefreshFlag = Symbol.for('awsui-visual-refresh-flag');
+const awsuiGlobalFlagsSymbol = Symbol.for('awsui-global-flags');
interface ExtendedWindow extends Window {
[awsuiVisualRefreshFlag]?: () => boolean;
}
@@ -19,7 +20,7 @@ describe('useVisualRefresh', () => {
}
afterEach(() => {
- clearVisualRefreshState();
+ clearThemeState();
expect(document.querySelector('.awsui-visual-refresh')).toBeFalsy();
expect(document.querySelector('.awsui-one-theme')).toBeFalsy();
});
@@ -53,7 +54,7 @@ describe('useVisualRefresh', () => {
document.body.classList.add('awsui-visual-refresh');
rerender();
- expect(console.warn).toHaveBeenCalledWith(expect.stringMatching(/Dynamic visual refresh change detected/));
+ expect(console.warn).toHaveBeenCalledWith(expect.stringMatching(/Dynamic theme change detected/));
expect(screen.getByTestId('current-mode')).toHaveTextContent('false');
});
@@ -92,8 +93,6 @@ describe('useVisualRefresh', () => {
});
describe('oneTheme global flag', () => {
- const awsuiGlobalFlagsSymbol = Symbol.for('awsui-global-flags');
-
afterEach(() => {
delete (window as any)[awsuiGlobalFlagsSymbol];
});
@@ -103,5 +102,61 @@ describe('useVisualRefresh', () => {
render();
expect(screen.getByTestId('current-mode')).toHaveTextContent('true');
});
+
+ test('should add the awsui-one-theme body class when oneTheme global flag is set', () => {
+ (window as any)[awsuiGlobalFlagsSymbol] = { oneTheme: true };
+ render();
+ expect(document.body).toHaveClass('awsui-one-theme');
+ });
+
+ test('should not add the awsui-one-theme body class when oneTheme global flag is not set', () => {
+ window[awsuiVisualRefreshFlag] = () => true;
+ render();
+ expect(document.body).toHaveClass('awsui-visual-refresh');
+ expect(document.body).not.toHaveClass('awsui-one-theme');
+ window[awsuiVisualRefreshFlag] = undefined;
+ });
+ });
+
+ describe('initThemes', () => {
+ afterEach(() => {
+ delete (window as any)[awsuiGlobalFlagsSymbol];
+ window[awsuiVisualRefreshFlag] = undefined;
+ });
+
+ test('adds the visual refresh body class when its flag is set', () => {
+ window[awsuiVisualRefreshFlag] = () => true;
+ initThemes();
+ expect(document.body).toHaveClass('awsui-visual-refresh');
+ expect(document.body).not.toHaveClass('awsui-one-theme');
+ });
+
+ test('adds the one-theme body class when its flag is set', () => {
+ (window as any)[awsuiGlobalFlagsSymbol] = { oneTheme: true };
+ initThemes();
+ expect(document.body).toHaveClass('awsui-one-theme');
+ });
+
+ test('adds no theme body class when no flag is set', () => {
+ initThemes();
+ expect(document.body).not.toHaveClass('awsui-visual-refresh');
+ expect(document.body).not.toHaveClass('awsui-one-theme');
+ });
+
+ test('applies only the highest-priority theme when multiple flags are set', () => {
+ window[awsuiVisualRefreshFlag] = () => true;
+ (window as any)[awsuiGlobalFlagsSymbol] = { oneTheme: true };
+ initThemes();
+ expect(document.body).toHaveClass('awsui-one-theme');
+ expect(document.body).not.toHaveClass('awsui-visual-refresh');
+ });
+
+ test('warns when multiple theme flags are enabled', () => {
+ jest.spyOn(console, 'warn').mockImplementation(() => {});
+ window[awsuiVisualRefreshFlag] = () => true;
+ (window as any)[awsuiGlobalFlagsSymbol] = { oneTheme: true };
+ initThemes();
+ expect(console.warn).toHaveBeenCalledWith(expect.stringMatching(/Multiple theme flags are enabled/));
+ });
});
});
diff --git a/src/internal/visual-mode/index.ts b/src/internal/visual-mode/index.ts
index 745a85b..10cfa6f 100644
--- a/src/internal/visual-mode/index.ts
+++ b/src/internal/visual-mode/index.ts
@@ -92,63 +92,19 @@ function useMutationObserver(elementRef: React.RefObject, onChange:
}, [handler]);
}
-// We expect VR is to be set only once and before the application is rendered.
-let visualRefreshState: undefined | boolean = undefined;
-
-// for testing
-export function clearVisualRefreshState() {
- visualRefreshState = undefined;
- if (typeof document !== 'undefined') {
- document.body.classList.remove('awsui-visual-refresh');
- document.body.classList.remove('awsui-one-theme');
- }
-}
-
-function detectVisualRefreshClassName() {
- return typeof document !== 'undefined' && !!document.querySelector('.awsui-visual-refresh, .awsui-one-theme');
-}
-
-function detectVisualRefreshFlag() {
- const global = getGlobal();
- return global?.[awsuiVisualRefreshFlag]?.() ?? !!getGlobalFlag('oneTheme');
-}
-
-export function useRuntimeVisualRefresh() {
- if (visualRefreshState === undefined) {
- visualRefreshState = detectVisualRefreshClassName();
- if (!visualRefreshState) {
- if (detectVisualRefreshFlag()) {
- visualRefreshState = true;
- if (typeof document !== 'undefined') {
- document.body.classList.add('awsui-visual-refresh');
- }
- }
- }
- }
- if (isDevelopment) {
- const newVisualRefreshState = detectVisualRefreshClassName() || detectVisualRefreshFlag();
- if (newVisualRefreshState !== visualRefreshState) {
- warnOnce(
- 'Visual Refresh',
- 'Dynamic visual refresh change detected. This is not supported. ' +
- 'Make sure `awsui-visual-refresh` is attached to the `` element before initial React render'
- );
- }
- }
- return visualRefreshState;
-}
-
export enum Theme {
VisualRefresh = 'visual-refresh',
OneTheme = 'one-theme',
}
-interface ThemeConfig {
+// When multiple theme flags are enabled, only the highest-priority (lower index) theme's body class is applied.
+const themePrecedence: Array = [Theme.OneTheme, Theme.VisualRefresh];
+interface ThemeDefinition {
className: string;
isFlagActive: () => boolean;
}
-const THEMES: Record = {
+const themeDefinitions: Record = {
[Theme.VisualRefresh]: {
className: 'awsui-visual-refresh',
isFlagActive: () => !!getGlobal()?.[awsuiVisualRefreshFlag]?.(),
@@ -159,10 +115,71 @@ const THEMES: Record = {
},
};
+const allThemes = Object.values(Theme);
+
+function hasThemeClassName(theme: Theme) {
+ return typeof document !== 'undefined' && !!document.querySelector(`.${themeDefinitions[theme].className}`);
+}
+
+// A theme is active if its body class or its global flag is set.
export function isThemeActive(theme: Theme): boolean {
- const config = THEMES[theme];
- if (typeof document !== 'undefined' && document.querySelector(`.${config.className}`)) {
- return true;
+ return hasThemeClassName(theme) || themeDefinitions[theme].isFlagActive();
+}
+
+function applyThemeClassName(theme: Theme) {
+ if (typeof document !== 'undefined' && !hasThemeClassName(theme) && themeDefinitions[theme].isFlagActive()) {
+ document.body.classList.add(themeDefinitions[theme].className);
+ }
+}
+
+export function initThemes() {
+ const flaggedThemes = themePrecedence.filter(theme => themeDefinitions[theme].isFlagActive());
+ if (isDevelopment && flaggedThemes.length > 1) {
+ warnOnce(
+ 'Theme',
+ `Multiple theme flags are enabled (${flaggedThemes.join(', ')}). ` +
+ `Only the highest-priority theme (${flaggedThemes[0]}) is applied.`
+ );
+ }
+ if (flaggedThemes.length > 0) {
+ applyThemeClassName(flaggedThemes[0]);
+ }
+}
+
+let runtimeVisualRefresh: undefined | boolean = undefined;
+
+// Resets applied theme state: removes every theme's body class and clears the memoized state.
+export function clearThemeState() {
+ runtimeVisualRefresh = undefined;
+ if (typeof document !== 'undefined') {
+ for (const theme of allThemes) {
+ document.body.classList.remove(themeDefinitions[theme].className);
+ }
+ }
+}
+
+// @deprecated Use `clearThemeState` instead.
+export function clearVisualRefreshState() {
+ clearThemeState();
+}
+
+export function useRuntimeVisualRefresh() {
+ if (runtimeVisualRefresh === undefined) {
+ initThemes();
+ // One Theme needs to activate the same visual refresh behavior as the
+ // Visual Refresh theme, so both themes count as visual refresh here.
+ runtimeVisualRefresh = isThemeActive(Theme.VisualRefresh) || isThemeActive(Theme.OneTheme);
+ }
+ if (isDevelopment) {
+ const visualRefreshActive = isThemeActive(Theme.VisualRefresh) || isThemeActive(Theme.OneTheme);
+ if (visualRefreshActive !== runtimeVisualRefresh) {
+ warnOnce(
+ 'Visual Refresh',
+ 'Dynamic theme change detected. This is not supported. ' +
+ 'Make sure the theme class (e.g. `awsui-visual-refresh` or `awsui-one-theme`) is attached to ' +
+ 'the `` element before the initial React render.'
+ );
+ }
}
- return config.isFlagActive();
+ return runtimeVisualRefresh;
}