Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/internal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export {
useDensityMode,
useReducedMotion,
useRuntimeVisualRefresh,
initThemes,
isThemeActive,
Theme,
} from './visual-mode/index.js';
Expand Down
2 changes: 1 addition & 1 deletion src/internal/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,5 @@ test('prints a warning when feature flag changes between renders', () => {
renderToStaticMarkup(<App />);
globalWithFlags[awsuiVisualRefreshFlag] = () => false;
renderToStaticMarkup(<App />);
expect(console.warn).toHaveBeenCalledWith(expect.stringMatching(/Dynamic visual refresh change detected/));
expect(console.warn).toHaveBeenCalledWith(expect.stringMatching(/Dynamic theme change detected/));
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -19,7 +20,7 @@ describe('useVisualRefresh', () => {
}

afterEach(() => {
clearVisualRefreshState();
clearThemeState();
expect(document.querySelector('.awsui-visual-refresh')).toBeFalsy();
expect(document.querySelector('.awsui-one-theme')).toBeFalsy();
});
Expand Down Expand Up @@ -53,7 +54,7 @@ describe('useVisualRefresh', () => {

document.body.classList.add('awsui-visual-refresh');
rerender(<App />);
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');
});

Expand Down Expand Up @@ -92,8 +93,6 @@ describe('useVisualRefresh', () => {
});

describe('oneTheme global flag', () => {
const awsuiGlobalFlagsSymbol = Symbol.for('awsui-global-flags');

afterEach(() => {
delete (window as any)[awsuiGlobalFlagsSymbol];
});
Expand All @@ -103,5 +102,61 @@ describe('useVisualRefresh', () => {
render(<App />);
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(<App />);
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(<App />);
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/));
});
});
});
121 changes: 69 additions & 52 deletions src/internal/visual-mode/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,63 +92,19 @@ function useMutationObserver(elementRef: React.RefObject<HTMLElement>, 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 `<body>` 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> = [Theme.OneTheme, Theme.VisualRefresh];
interface ThemeDefinition {
className: string;
isFlagActive: () => boolean;
}

const THEMES: Record<Theme, ThemeConfig> = {
const themeDefinitions: Record<Theme, ThemeDefinition> = {
[Theme.VisualRefresh]: {
className: 'awsui-visual-refresh',
isFlagActive: () => !!getGlobal()?.[awsuiVisualRefreshFlag]?.(),
Expand All @@ -159,10 +115,71 @@ const THEMES: Record<Theme, ThemeConfig> = {
},
};

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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if multiple flags are active?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's ok, it will just add the class to the body no matter if there is another flag present.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If components use both classnames, the winning styles depend on stylesheet source order? It is implicit. What about having a precedence list, add only the highest-priority one?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m not sure that’s ideal, as it could hide the fact that other services are having multiple flags enabled instead of cleaning them up.

That said, if you feel strongly about it, I’m happy to add it, it’s a small change.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe another solution is having that priority list, but show a dev warning if multiple flags are enabled.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think themes are mutually exclusive. Rather than silently applying 2 themes and rely on CSS order, I prefer to apply priority list, also console.warn if 2 themes are active.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we allow both classes to coexist, we'd need tests ensuring the CSS precedence holds.

}
}

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',
Comment thread
YueyingLu marked this conversation as resolved.
'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 `<body>` element before the initial React render.'
);
}
}
return config.isFlagActive();
return runtimeVisualRefresh;
}
Loading