diff --git a/.gitignore b/.gitignore
index 79df56493b..db3d62b944 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,9 +9,9 @@ src/index.ts
src/test-utils/dom/index.ts
src/test-utils/selectors
src/icon/generated
-src/internal/generated/custom-css-properties/index.*
+src/internal/generated/custom-css-properties
vendor/generated-*.txt
# IDEs
.vscode
# System
-.DS_Store
\ No newline at end of file
+.DS_Store
diff --git a/build-tools/tasks/clean.js b/build-tools/tasks/clean.js
index bc7c2ac143..d7ded376a9 100644
--- a/build-tools/tasks/clean.js
+++ b/build-tools/tasks/clean.js
@@ -14,6 +14,7 @@ module.exports = task('clean', () => {
`${workspace.targetPath}/**`,
`${workspace.staticSitePath}/**`,
`${workspace.generatedTestUtils}/**`,
+ `${workspace.generatedPath}/custom-css-properties/**`,
`node_modules/.cache`,
],
{ glob: true }
diff --git a/build-tools/tasks/generate-custom-css-properties.js b/build-tools/tasks/generate-custom-css-properties.js
index a0fd2e9d4e..b246cca97f 100644
--- a/build-tools/tasks/generate-custom-css-properties.js
+++ b/build-tools/tasks/generate-custom-css-properties.js
@@ -1,11 +1,12 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
-const customCssPropertiesList = require('../../src/internal/generated/custom-css-properties/list');
const path = require('path');
-const { writeFile } = require('../utils/files');
const { getHashDigest } = require('loader-utils');
+const customCssPropertiesList = require('../utils/custom-css-properties');
+const { writeFile } = require('../utils/files');
+const workspace = require('../utils/workspace');
-const outputBasePath = path.join(__dirname, '../../src/internal/generated/custom-css-properties');
+const outputBasePath = path.join(workspace.generatedPath, 'custom-css-properties');
const hash = getHashDigest(Buffer.from(JSON.stringify(customCssPropertiesList)), 'md5', 'base36', 6);
const getHashedProperty = property => {
@@ -32,6 +33,9 @@ function writeSassFile() {
writeFile(
filepath,
`
+ // Build environment
+ $awsui-commit-hash: ${workspace.gitCommitVersion};
+ // Manually managed CSS-variables
${customCssPropertiesList.map(property => `$${property}: ${getHashedProperty(property)};`).join('\n')}
`
);
diff --git a/build-tools/tasks/generate-environment.js b/build-tools/tasks/generate-environment.js
index 4d28f833e0..f706c039fe 100644
--- a/build-tools/tasks/generate-environment.js
+++ b/build-tools/tasks/generate-environment.js
@@ -10,6 +10,7 @@ function writeEnvironmentFile(theme) {
const values = {
PACKAGE_SOURCE: workspace.packageSource,
PACKAGE_VERSION: workspace.packageVersion,
+ GIT_SHA: workspace.gitCommitVersion,
THEME: theme.name,
ALWAYS_VISUAL_REFRESH: !!theme.alwaysVisualRefresh,
};
diff --git a/src/internal/generated/custom-css-properties/list.js b/build-tools/utils/custom-css-properties.js
similarity index 100%
rename from src/internal/generated/custom-css-properties/list.js
rename to build-tools/utils/custom-css-properties.js
diff --git a/build-tools/utils/workspace.js b/build-tools/utils/workspace.js
index 7283d5b8d7..4c10a2cd75 100644
--- a/build-tools/utils/workspace.js
+++ b/build-tools/utils/workspace.js
@@ -11,6 +11,7 @@ module.exports = {
isProd: process.env.NODE_ENV === 'production',
packageSource,
packageVersion,
+ gitCommitVersion,
sourcePath: 'src',
generatedPath: 'src/internal/generated',
generatedTestUtils: 'src/test-utils/selectors',
diff --git a/src/__integ__/__snapshots__/themes.test.ts.snap b/src/__integ__/__snapshots__/themes.test.ts.snap
index f591c9d5fe..6489bf42b9 100644
--- a/src/__integ__/__snapshots__/themes.test.ts.snap
+++ b/src/__integ__/__snapshots__/themes.test.ts.snap
@@ -2,6 +2,7 @@
exports[`CSS Custom Properties match previous snapshot for mode "compact" 1`] = `
{
+ "awsui-version-info": "true",
"border-active-width": "2px",
"border-code-editor-status-divider-width": "0px",
"border-container-sticky-width": "1px",
@@ -679,6 +680,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "compact" 1`] =
exports[`CSS Custom Properties match previous snapshot for mode "dark" 1`] = `
{
+ "awsui-version-info": "true",
"border-active-width": "2px",
"border-code-editor-status-divider-width": "0px",
"border-container-sticky-width": "1px",
@@ -1356,6 +1358,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "dark" 1`] = `
exports[`CSS Custom Properties match previous snapshot for mode "light" 1`] = `
{
+ "awsui-version-info": "true",
"border-active-width": "2px",
"border-code-editor-status-divider-width": "0px",
"border-container-sticky-width": "1px",
@@ -2033,6 +2036,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "light" 1`] = `
exports[`CSS Custom Properties match previous snapshot for mode "reduced-motion" 1`] = `
{
+ "awsui-version-info": "true",
"border-active-width": "2px",
"border-code-editor-status-divider-width": "0px",
"border-container-sticky-width": "1px",
@@ -2710,6 +2714,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "reduced-motion"
exports[`CSS Custom Properties match previous snapshot for mode "visual-refresh" 1`] = `
{
+ "awsui-version-info": "true",
"border-active-width": "4px",
"border-code-editor-status-divider-width": "1px",
"border-container-sticky-width": "0px",
@@ -3387,6 +3392,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "visual-refresh"
exports[`CSS Custom Properties match previous snapshot for mode "visual-refresh-compact" 1`] = `
{
+ "awsui-version-info": "true",
"border-active-width": "4px",
"border-code-editor-status-divider-width": "1px",
"border-container-sticky-width": "0px",
@@ -4064,6 +4070,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "visual-refresh-
exports[`CSS Custom Properties match previous snapshot for mode "visual-refresh-content-header" 1`] = `
{
+ "awsui-version-info": "true",
"border-active-width": "4px",
"border-code-editor-status-divider-width": "1px",
"border-container-sticky-width": "0px",
@@ -4741,6 +4748,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "visual-refresh-
exports[`CSS Custom Properties match previous snapshot for mode "visual-refresh-dark" 1`] = `
{
+ "awsui-version-info": "true",
"border-active-width": "4px",
"border-code-editor-status-divider-width": "1px",
"border-container-sticky-width": "0px",
diff --git a/src/__integ__/themes.test.ts b/src/__integ__/themes.test.ts
index 08eed7ae33..773571a633 100644
--- a/src/__integ__/themes.test.ts
+++ b/src/__integ__/themes.test.ts
@@ -23,7 +23,7 @@ class CustomPropertyPageObject extends BasePageObject {
for (const [prop, value] of (element as any).computedStyleMap()) {
// Custom Property
if (prop.startsWith('--')) {
- const valueWithoutPostfix = prop.substring(2, prop.length - 7);
+ const valueWithoutPostfix = prop.replace(/^--/, '').replace(/-[\d\w]+$/, '');
result[valueWithoutPostfix] = value[0][0];
}
}
diff --git a/src/internal/base-component/styles.scss b/src/internal/base-component/styles.scss
index 66c1e86fee..158fa6d799 100644
--- a/src/internal/base-component/styles.scss
+++ b/src/internal/base-component/styles.scss
@@ -9,3 +9,8 @@
*/
@use 'awsui:globals';
@use '../styles/global.scss';
+@use '../generated/custom-css-properties' as custom-styles;
+
+:root {
+ --awsui-version-info-#{custom-styles.$awsui-commit-hash}: true;
+}
diff --git a/src/internal/environment.d.ts b/src/internal/environment.d.ts
index 450933dec0..1db77857f7 100644
--- a/src/internal/environment.d.ts
+++ b/src/internal/environment.d.ts
@@ -5,5 +5,6 @@
export const THEME: string;
export const PACKAGE_SOURCE: string;
export const PACKAGE_VERSION: string;
+export const GIT_SHA: string;
/** Indicates that the current theme is always in visual refresh mode. */
export const ALWAYS_VISUAL_REFRESH: boolean;
diff --git a/src/internal/hooks/use-base-component/__tests__/styles-check-hook.test.tsx b/src/internal/hooks/use-base-component/__tests__/styles-check-hook.test.tsx
new file mode 100644
index 0000000000..c1716e1743
--- /dev/null
+++ b/src/internal/hooks/use-base-component/__tests__/styles-check-hook.test.tsx
@@ -0,0 +1,38 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import React from 'react';
+import { render, waitFor } from '@testing-library/react';
+
+import { useMissingStylesCheck } from '../../../../../lib/components/internal/hooks/use-base-component/styles-check';
+
+function Test() {
+ useMissingStylesCheck();
+ return null;
+}
+
+let consoleMock: jest.SpyInstance;
+beforeEach(() => {
+ globalThis.requestIdleCallback = cb => setTimeout(cb, 0);
+ consoleMock = jest.spyOn(console, 'error').mockImplementation(() => {});
+});
+afterEach(() => {
+ jest.restoreAllMocks();
+});
+
+test('emits style check error only once', async () => {
+ const { rerender } = render();
+
+ await waitFor(
+ () => {
+ expect(consoleMock).toHaveBeenCalledTimes(1);
+ expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('Missing AWS-UI CSS'));
+ },
+ { timeout: 2000 }
+ );
+
+ consoleMock.mockClear();
+ rerender();
+
+ await new Promise(resolve => setTimeout(resolve, 1100));
+ expect(consoleMock).toHaveBeenCalledTimes(0);
+});
diff --git a/src/internal/hooks/use-base-component/__tests__/styles-check-pure.test.ts b/src/internal/hooks/use-base-component/__tests__/styles-check-pure.test.ts
new file mode 100644
index 0000000000..34b4412759
--- /dev/null
+++ b/src/internal/hooks/use-base-component/__tests__/styles-check-pure.test.ts
@@ -0,0 +1,111 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { GIT_SHA } from '../../../../../lib/components/internal/environment';
+import { checkMissingStyles } from '../../../../../lib/components/internal/hooks/use-base-component/styles-check';
+import { metrics } from '../../../../../lib/components/internal/metrics';
+import { idleWithDelay } from '../styles-check';
+
+jest.mock('../../../../../lib/components/internal/environment', () => ({
+ ...jest.requireActual('../../../../../lib/components/internal/environment'),
+ PACKAGE_VERSION: '3.0.0 (abc)',
+ GIT_SHA: 'abc',
+}));
+
+afterEach(() => {
+ jest.resetAllMocks();
+});
+
+describe('checkMissingStyles', () => {
+ let consoleWarnSpy: jest.SpyInstance;
+ let sendPanoramaMetricSpy: jest.SpyInstance;
+ const style = document.createElement('style');
+ document.body.append(style);
+
+ beforeEach(() => {
+ style.textContent = ``;
+ consoleWarnSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
+ sendPanoramaMetricSpy = jest.spyOn(metrics, 'sendOpsMetricObject').mockImplementation(() => {});
+ });
+
+ test('should pass the check if styles found', () => {
+ // using :root does not work in JSDOM: https://github.com/jsdom/jsdom/issues/3563
+ style.textContent = `
+ body {
+ --awsui-version-info-${GIT_SHA}: true;
+ }
+ `;
+ checkMissingStyles();
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
+ expect(sendPanoramaMetricSpy).not.toHaveBeenCalled();
+ });
+
+ test('should detect missing styles', () => {
+ checkMissingStyles();
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ 'Missing AWS-UI CSS for theme "default", version "3.0.0 (abc)", and git sha "abc".'
+ );
+ expect(sendPanoramaMetricSpy).toHaveBeenCalledWith('awsui-missing-css-asset', {});
+ });
+
+ test('should report missing styles if a different version found', () => {
+ style.textContent = `
+ body {
+ --awsui-version-info-c4d5e6: true;
+ }
+ `;
+ checkMissingStyles();
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ 'Missing AWS-UI CSS for theme "default", version "3.0.0 (abc)", and git sha "abc".'
+ );
+ expect(sendPanoramaMetricSpy).toHaveBeenCalledWith('awsui-missing-css-asset', {});
+ });
+});
+
+describe('idleWithDelay', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ // simulate requestIdleCallback for JSDOM
+ globalThis.requestIdleCallback = cb => setTimeout(cb, 0);
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ // @ts-expect-error reset to initial state
+ globalThis.requestIdleCallback = undefined;
+ });
+
+ test('does nothing if requestIdleCallback not supported', () => {
+ // @ts-expect-error simulate missing API
+ globalThis.requestIdleCallback = undefined;
+ const cb = jest.fn();
+ expect(requestIdleCallback).toBe(undefined);
+ idleWithDelay(cb);
+ jest.runAllTimers();
+ expect(cb).not.toHaveBeenCalled();
+ });
+
+ test('runs callback after a delay', () => {
+ const cb = jest.fn();
+ idleWithDelay(cb);
+ jest.runAllTimers();
+ expect(cb).toHaveBeenCalled();
+ });
+
+ test('delay can be aborted before setTimeout phase', () => {
+ const cb = jest.fn();
+ const abort = idleWithDelay(cb);
+ abort!();
+ jest.runAllTimers();
+ expect(cb).not.toHaveBeenCalled();
+ });
+
+ test('delay can be aborted before requestIdleCallback phase', () => {
+ const cb = jest.fn();
+ const abort = idleWithDelay(cb);
+ jest.runOnlyPendingTimers(); // flush setTimeout
+ abort!();
+ jest.runOnlyPendingTimers(); // flush following requestIdleCallback
+ expect(cb).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/internal/hooks/use-base-component/index.ts b/src/internal/hooks/use-base-component/index.ts
index 86af7ddc9f..992785b4bf 100644
--- a/src/internal/hooks/use-base-component/index.ts
+++ b/src/internal/hooks/use-base-component/index.ts
@@ -13,6 +13,7 @@ import { AnalyticsMetadata } from '../../analytics/interfaces';
import { PACKAGE_SOURCE, PACKAGE_VERSION, THEME } from '../../environment';
import { getVisualTheme } from '../../utils/get-visual-theme';
import { useVisualRefresh } from '../use-visual-mode';
+import { useMissingStylesCheck } from './styles-check';
export interface InternalBaseComponentProps {
__internalRootRef?: MutableRefObject | null;
@@ -32,6 +33,7 @@ export default function useBaseComponent(
const theme = getVisualTheme(THEME, isVisualRefresh);
useComponentMetrics(componentName, { packageSource: PACKAGE_SOURCE, packageVersion: PACKAGE_VERSION, theme }, config);
useFocusVisible();
+ useMissingStylesCheck();
const elementRef = useComponentMetadata(
componentName,
{ packageName: PACKAGE_SOURCE, version: PACKAGE_VERSION, theme },
diff --git a/src/internal/hooks/use-base-component/styles-check.ts b/src/internal/hooks/use-base-component/styles-check.ts
new file mode 100644
index 0000000000..8d17a19aa8
--- /dev/null
+++ b/src/internal/hooks/use-base-component/styles-check.ts
@@ -0,0 +1,52 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import { useEffect } from 'react';
+
+import { GIT_SHA, PACKAGE_VERSION, THEME } from '../../environment';
+import { metrics } from '../../metrics';
+
+export function checkMissingStyles() {
+ const result = getComputedStyle(document.body).getPropertyValue(`--awsui-version-info-${GIT_SHA}`);
+ if (!result) {
+ console.error(`Missing AWS-UI CSS for theme "${THEME}", version "${PACKAGE_VERSION}", and git sha "${GIT_SHA}".`);
+ metrics.sendOpsMetricObject('awsui-missing-css-asset', {});
+ }
+}
+
+export function idleWithDelay(cb: () => void) {
+ // if idle callbacks not supported, we simply do not collect the metric
+ if (typeof requestIdleCallback !== 'function') {
+ return;
+ }
+ let aborted = false;
+
+ setTimeout(() => {
+ if (aborted) {
+ return;
+ }
+ requestIdleCallback(() => {
+ if (aborted) {
+ return;
+ }
+ cb();
+ });
+ }, 1000);
+
+ return () => {
+ aborted = true;
+ };
+}
+
+let checked = false;
+const checkMissingStylesOnce = () => {
+ if (!checked) {
+ checkMissingStyles();
+ checked = true;
+ }
+};
+
+export function useMissingStylesCheck() {
+ useEffect(() => {
+ return idleWithDelay(() => checkMissingStylesOnce());
+ }, []);
+}
diff --git a/src/internal/metrics.ts b/src/internal/metrics.ts
index 0942aee508..b35d9eea2f 100644
--- a/src/internal/metrics.ts
+++ b/src/internal/metrics.ts
@@ -2,6 +2,6 @@
// SPDX-License-Identifier: Apache-2.0
import { Metrics } from '@cloudscape-design/component-toolkit/internal';
-import { PACKAGE_SOURCE, PACKAGE_VERSION } from './environment';
+import { PACKAGE_SOURCE, PACKAGE_VERSION, THEME } from './environment';
-export const metrics = new Metrics(PACKAGE_SOURCE, PACKAGE_VERSION);
+export const metrics = new Metrics({ packageSource: PACKAGE_SOURCE, packageVersion: PACKAGE_VERSION, theme: THEME });