From 5558e03c4ffdc62f01f6bba45d54564f8ca3a3cb Mon Sep 17 00:00:00 2001 From: Maximilian Schoell Date: Mon, 22 Jun 2026 15:56:56 +0200 Subject: [PATCH 1/3] feat: introduce theme helpers --- src/internal/index.ts | 1 + .../use-runtime-visual-refresh.test.tsx | 45 +++++++- src/internal/visual-mode/index.ts | 102 +++++++++--------- 3 files changed, 93 insertions(+), 55 deletions(-) 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/visual-mode/__tests__/use-runtime-visual-refresh.test.tsx b/src/internal/visual-mode/__tests__/use-runtime-visual-refresh.test.tsx index e6aef54..1109008 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, clearVisualRefreshState, 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; } @@ -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,45 @@ 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'); + }); }); }); diff --git a/src/internal/visual-mode/index.ts b/src/internal/visual-mode/index.ts index 745a85b..e831927 100644 --- a/src/internal/visual-mode/index.ts +++ b/src/internal/visual-mode/index.ts @@ -92,63 +92,17 @@ 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 { +interface ThemeDefinition { className: string; isFlagActive: () => boolean; } -const THEMES: Record = { +const themeDefinitions: Record = { [Theme.VisualRefresh]: { className: 'awsui-visual-refresh', isFlagActive: () => !!getGlobal()?.[awsuiVisualRefreshFlag]?.(), @@ -159,10 +113,54 @@ 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() { + allThemes.forEach(applyThemeClassName); +} + +let runtimeVisualRefresh: undefined | boolean = undefined; + +// for testing +export function clearVisualRefreshState() { + runtimeVisualRefresh = undefined; + if (typeof document !== 'undefined') { + for (const theme of allThemes) { + document.body.classList.remove(themeDefinitions[theme].className); + } + } +} + +export function useRuntimeVisualRefresh() { + if (runtimeVisualRefresh === undefined) { + initThemes(); + // Visual refresh is active when either the visual refresh theme or the One Theme variant is active. + runtimeVisualRefresh = isThemeActive(Theme.VisualRefresh) || isThemeActive(Theme.OneTheme); + } + if (isDevelopment) { + const visualRefreshActive = isThemeActive(Theme.VisualRefresh) || isThemeActive(Theme.OneTheme); + if (visualRefreshActive !== runtimeVisualRefresh) { + 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 config.isFlagActive(); + return runtimeVisualRefresh; } From dd8c8175e1a77e6a87f1eb4c0e2fcb73d0371095 Mon Sep 17 00:00:00 2001 From: Maximilian Schoell Date: Mon, 22 Jun 2026 18:24:51 +0200 Subject: [PATCH 2/3] refactor: introduce clearThemeState helper --- src/internal/testing.ts | 2 +- .../use-runtime-visual-refresh.ssr.test.tsx | 2 +- .../use-runtime-visual-refresh.test.tsx | 6 +++--- src/internal/visual-mode/index.ts | 17 ++++++++++++----- 4 files changed, 17 insertions(+), 10 deletions(-) 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 1109008..b6abf6b 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,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import { useRuntimeVisualRefresh, clearVisualRefreshState, initThemes } from '../index'; +import { useRuntimeVisualRefresh, clearThemeState, initThemes } from '../index'; import { render, screen } from '@testing-library/react'; import { clearMessageCache } from '../../logging'; @@ -20,7 +20,7 @@ describe('useVisualRefresh', () => { } afterEach(() => { - clearVisualRefreshState(); + clearThemeState(); expect(document.querySelector('.awsui-visual-refresh')).toBeFalsy(); expect(document.querySelector('.awsui-one-theme')).toBeFalsy(); }); @@ -54,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'); }); diff --git a/src/internal/visual-mode/index.ts b/src/internal/visual-mode/index.ts index e831927..a758263 100644 --- a/src/internal/visual-mode/index.ts +++ b/src/internal/visual-mode/index.ts @@ -136,8 +136,8 @@ export function initThemes() { let runtimeVisualRefresh: undefined | boolean = undefined; -// for testing -export function clearVisualRefreshState() { +// 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) { @@ -146,10 +146,16 @@ export function clearVisualRefreshState() { } } +// @deprecated Use `clearThemeState` instead. +export function clearVisualRefreshState() { + clearThemeState(); +} + export function useRuntimeVisualRefresh() { if (runtimeVisualRefresh === undefined) { initThemes(); - // Visual refresh is active when either the visual refresh theme or the One Theme variant is active. + // 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) { @@ -157,8 +163,9 @@ export function useRuntimeVisualRefresh() { if (visualRefreshActive !== runtimeVisualRefresh) { 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' + '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.' ); } } From 6378064866ef51aa5bb94af78dd75402d26e3f22 Mon Sep 17 00:00:00 2001 From: Maximilian Schoell Date: Tue, 23 Jun 2026 11:44:32 +0200 Subject: [PATCH 3/3] feat: add theme precedence list --- .../use-runtime-visual-refresh.test.tsx | 16 ++++++++++++++++ src/internal/visual-mode/index.ts | 14 +++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) 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 b6abf6b..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 @@ -142,5 +142,21 @@ describe('useVisualRefresh', () => { 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 a758263..10cfa6f 100644 --- a/src/internal/visual-mode/index.ts +++ b/src/internal/visual-mode/index.ts @@ -97,6 +97,8 @@ export enum Theme { OneTheme = 'one-theme', } +// 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; @@ -131,7 +133,17 @@ function applyThemeClassName(theme: Theme) { } export function initThemes() { - allThemes.forEach(applyThemeClassName); + 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;