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
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
.DS_Store
1 change: 1 addition & 0 deletions build-tools/tasks/clean.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ module.exports = task('clean', () => {
`${workspace.targetPath}/**`,
`${workspace.staticSitePath}/**`,
`${workspace.generatedTestUtils}/**`,
`${workspace.generatedPath}/custom-css-properties/**`,
Copy link
Member Author

Choose a reason for hiding this comment

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

workspace.generatedPath contains other non-generated files: https://github.com/cloudscape-design/components/tree/main/src/internal/generated

Ideally we should clean up them all (or move to a different path), but not in this PR

`node_modules/.cache`,
],
{ glob: true }
Expand Down
10 changes: 7 additions & 3 deletions build-tools/tasks/generate-custom-css-properties.js
Original file line number Diff line number Diff line change
@@ -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 => {
Expand All @@ -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')}
`
);
Expand Down
1 change: 1 addition & 0 deletions build-tools/tasks/generate-environment.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
1 change: 1 addition & 0 deletions build-tools/utils/workspace.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
8 changes: 8 additions & 0 deletions src/__integ__/__snapshots__/themes.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/__integ__/themes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]+$/, '');
Copy link
Member Author

Choose a reason for hiding this comment

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

This code assumed the hash is always 7 characters, which is not true

result[valueWithoutPostfix] = value[0][0];
}
}
Expand Down
5 changes: 5 additions & 0 deletions src/internal/base-component/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Member Author

Choose a reason for hiding this comment

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

Core of this check. We check presence of this CSS var in Javascript. If not found – the styles are missing

Copy link
Member

Choose a reason for hiding this comment

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

so if an app doesn't bundle and server styles but another app on the same page has a similar git commit. it wouldn't be detected.

but I guess this is acceptable, and better than what we have now.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, there is a chance of missing some cases, if they happen to have the same commit

}
1 change: 1 addition & 0 deletions src/internal/environment.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Original file line number Diff line number Diff line change
@@ -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(<Test key={1} />);

await waitFor(
() => {
expect(consoleMock).toHaveBeenCalledTimes(1);
expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('Missing AWS-UI CSS'));
},
{ timeout: 2000 }
);

consoleMock.mockClear();
rerender(<Test key={2} />);

await new Promise(resolve => setTimeout(resolve, 1100));
expect(consoleMock).toHaveBeenCalledTimes(0);
});
Original file line number Diff line number Diff line change
@@ -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();
});
});
2 changes: 2 additions & 0 deletions src/internal/hooks/use-base-component/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T = any> {
__internalRootRef?: MutableRefObject<T | null> | null;
Expand All @@ -32,6 +33,7 @@ export default function useBaseComponent<T = any>(
const theme = getVisualTheme(THEME, isVisualRefresh);
useComponentMetrics(componentName, { packageSource: PACKAGE_SOURCE, packageVersion: PACKAGE_VERSION, theme }, config);
useFocusVisible();
useMissingStylesCheck();
const elementRef = useComponentMetadata<T>(
componentName,
{ packageName: PACKAGE_SOURCE, version: PACKAGE_VERSION, theme },
Expand Down
52 changes: 52 additions & 0 deletions src/internal/hooks/use-base-component/styles-check.ts
Original file line number Diff line number Diff line change
@@ -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) {
Copy link
Member Author

Choose a reason for hiding this comment

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

Exported checkMissingStyles and idleWithDelay as separate functions to allow write simpler unit tests on each part of the functionality

// 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());
}, []);
}
4 changes: 2 additions & 2 deletions src/internal/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Copy link
Member Author

Choose a reason for hiding this comment

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

Addressing this follow-up cloudscape-design/component-toolkit#136 (comment)

Yes I know this THEME value does not support vr, but it is fine for this use-case

Copy link
Member

Choose a reason for hiding this comment

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

can you explain a bit more?

Copy link
Member Author

Choose a reason for hiding this comment

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

Before it was like this

const metrics = new Metrics(PACKAGE_SOURCE, PACKAGE_VERSION);
metrics.initMetrics(theme);

This is not safe, because it metrics.initMetrics(theme) sets theme as a global variable inside @cloudscape-design/component-toolkit package

Multiple packages may call the same toolkit instance and the same metrics.initMetrics(theme) will overwrite each other theme.

So, now we finally got the theme properly scoped to each bundle

Loading