Skip to content

Commit 0ddeea9

Browse files
feat(theming) add custom component style rendering (#5812)
Co-authored-by: Caleb Pollman <cpollman@amazon.com>
1 parent 557c08e commit 0ddeea9

File tree

15 files changed

+374
-102
lines changed

15 files changed

+374
-102
lines changed

.changeset/few-houses-grow.md

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@aws-amplify/ui-react": minor
3+
"@aws-amplify/ui": minor
4+
---
5+
6+
feat(theming) add custom component style rendering
7+
8+
```jsx
9+
const customComponentTheme = defineComponentTheme({
10+
name: 'custom-component',
11+
theme(tokens) {
12+
return {
13+
color: tokens.colors.red[10]
14+
}
15+
}
16+
});
17+
18+
export function CustomComponent() {
19+
return (
20+
<>
21+
<View className={customComponentTheme.className()}>
22+
</View>
23+
// This will create a style tag with only the styles in the component theme
24+
// the styles are scoped to the global theme
25+
<ComponentStyle theme={theme} componentThemes=[customComponentTheme] />
26+
</>
27+
)
28+
}
29+
```

packages/react/__tests__/__snapshots__/exports.ts.snap

+1
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ exports[`@aws-amplify/ui-react/internal exports should match snapshot 1`] = `
122122

123123
exports[`@aws-amplify/ui-react/server exports should match snapshot 1`] = `
124124
[
125+
"ComponentStyle",
125126
"ThemeStyle",
126127
"createComponentClasses",
127128
"createTheme",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import * as React from 'react';
2+
import { WebTheme, createComponentCSS } from '@aws-amplify/ui';
3+
import {
4+
BaseComponentProps,
5+
ElementType,
6+
ForwardRefPrimitive,
7+
Primitive,
8+
PrimitiveProps,
9+
} from '../../primitives/types';
10+
import { primitiveWithForwardRef } from '../../primitives/utils/primitiveWithForwardRef';
11+
import { BaseComponentTheme } from '@aws-amplify/ui';
12+
import { Style } from './Style';
13+
14+
interface BaseComponentStyleProps extends BaseComponentProps {
15+
/**
16+
* Provide a server generated nonce which matches your CSP `style-src` rule.
17+
* This will be attached to the generated <style> tag.
18+
* @see: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/style-src
19+
*/
20+
nonce?: string;
21+
theme: Pick<WebTheme, 'name' | 'breakpoints' | 'tokens'>;
22+
componentThemes: BaseComponentTheme[];
23+
}
24+
25+
export type ComponentStyleProps<Element extends ElementType = 'style'> =
26+
PrimitiveProps<BaseComponentStyleProps, Element>;
27+
28+
const ComponentStylePrimitive: Primitive<ComponentStyleProps, 'style'> = (
29+
{ theme, componentThemes = [], ...rest },
30+
ref
31+
) => {
32+
if (!theme || !componentThemes.length) {
33+
return null;
34+
}
35+
36+
const cssText = createComponentCSS({
37+
theme,
38+
components: componentThemes,
39+
});
40+
41+
return <Style {...rest} ref={ref} cssText={cssText} />;
42+
};
43+
44+
/**
45+
* @experimental
46+
* [📖 Docs](https://ui.docs.amplify.aws/react/components/theme)
47+
*/
48+
export const ComponentStyle: ForwardRefPrimitive<
49+
BaseComponentStyleProps,
50+
'style'
51+
> = primitiveWithForwardRef(ComponentStylePrimitive);
52+
53+
ComponentStyle.displayName = 'ComponentStyle';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import * as React from 'react';
2+
import {
3+
BaseComponentProps,
4+
ElementType,
5+
ForwardRefPrimitive,
6+
Primitive,
7+
PrimitiveProps,
8+
} from '../../primitives/types';
9+
import { primitiveWithForwardRef } from '../../primitives/utils/primitiveWithForwardRef';
10+
11+
interface BaseStyleProps extends BaseComponentProps {
12+
cssText?: string;
13+
}
14+
15+
export type StyleProps<Element extends ElementType = 'style'> = PrimitiveProps<
16+
BaseStyleProps,
17+
Element
18+
>;
19+
20+
const StylePrimitive: Primitive<StyleProps, 'style'> = (
21+
{ cssText, ...rest },
22+
ref
23+
) => {
24+
/*
25+
Only inject theme CSS variables if given a theme.
26+
The CSS file users import already has the default theme variables in it.
27+
This will allow users to use the provider and theme with CSS variables
28+
without having to worry about specificity issues because this stylesheet
29+
will likely come after a user's defined CSS.
30+
31+
Q: Why are we using dangerouslySetInnerHTML?
32+
A: We need to directly inject the theme's CSS string into the <style> tag without typical HTML escaping.
33+
For example, JSX would escape characters meaningful in CSS such as ', ", < and >, thus breaking the CSS.
34+
Q: Why not use a sanitization library such as DOMPurify?
35+
A: For our use case, we specifically want to purify CSS text, *not* HTML.
36+
DOMPurify, as well as any other HTML sanitization library, would escape/encode meaningful CSS characters
37+
and break our CSS in the same way that JSX would.
38+
39+
Q: Are there any security risks in this particular use case?
40+
A: Anything set inside of a <style> tag is always interpreted as CSS text, *not* HTML.
41+
Reference: “Restrictions on the content of raw text elements” https://html.spec.whatwg.org/dev/syntax.html#cdata-rcdata-restrictions
42+
And in our case, we are using dangerouslySetInnerHTML to set CSS text inside of a <style> tag.
43+
44+
Thus, it really comes down to the question: Could a malicious user escape from the context of the <style> tag?
45+
For example, when inserting HTML into the DOM, could someone prematurely close the </style> tag and add a <script> tag?
46+
e.g., </style><script>alert('hello')</script>
47+
The answer depends on whether the code is rendered on the client or server side.
48+
49+
Client side
50+
- To prevent XSS inside of the <style> tag, we need to make sure it's not closed prematurely.
51+
- This is prevented by React because React creates a style DOM node (e.g., React.createElement(‘style’, ...)), and directly sets innerHTML as a string.
52+
- Even if the string contains a closing </style> tag, it will still be interpreted as CSS text by the browser.
53+
- Therefore, there is not an XSS vulnerability on the client side.
54+
55+
Server side
56+
- When React code is rendered on the server side (e.g., NextJS), the code is sent to the browser as HTML text.
57+
- Therefore, it *IS* possible to insert a closing </style> tag and escape the CSS context, which opens an XSS vulnerability.
58+
59+
Q: How are we mitigating the potential attack vector?
60+
A: To fix this potential attack vector on the server side, we need to filter out any closing </style> tags,
61+
as this the only way to escape from the context of the browser interpreting the text as CSS.
62+
We also need to catch cases where there is any kind of whitespace character </style[HERE]>, such as tabs, carriage returns, etc:
63+
</style
64+
65+
>
66+
Therefore, by only rendering CSS text which does not include a closing '</style>' tag,
67+
we ensure that the browser will correctly interpret all the text as CSS.
68+
*/
69+
if (cssText === undefined || /<\/style/i.test(cssText)) {
70+
return null;
71+
}
72+
73+
return (
74+
<style
75+
{...rest}
76+
ref={ref}
77+
// eslint-disable-next-line react/no-danger
78+
dangerouslySetInnerHTML={{ __html: cssText }}
79+
/>
80+
);
81+
};
82+
83+
/**
84+
* @experimental
85+
* [📖 Docs](https://ui.docs.amplify.aws/react/components/theme)
86+
*/
87+
export const Style: ForwardRefPrimitive<BaseStyleProps, 'style'> =
88+
primitiveWithForwardRef(StylePrimitive);
89+
90+
Style.displayName = 'Style';

packages/react/src/components/ThemeProvider/ThemeStyle.tsx

+10-59
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
PrimitiveProps,
99
} from '../../primitives/types';
1010
import { primitiveWithForwardRef } from '../../primitives/utils/primitiveWithForwardRef';
11+
import { Style } from './Style';
1112

1213
interface BaseStyleThemeProps extends BaseComponentProps {
1314
/**
@@ -29,65 +30,15 @@ const ThemeStylePrimitive: Primitive<ThemeStyleProps, 'style'> = (
2930
if (!theme) return null;
3031

3132
const { name, cssText } = theme;
32-
/*
33-
Only inject theme CSS variables if given a theme.
34-
The CSS file users import already has the default theme variables in it.
35-
This will allow users to use the provider and theme with CSS variables
36-
without having to worry about specificity issues because this stylesheet
37-
will likely come after a user's defined CSS.
38-
39-
Q: Why are we using dangerouslySetInnerHTML?
40-
A: We need to directly inject the theme's CSS string into the <style> tag without typical HTML escaping.
41-
For example, JSX would escape characters meaningful in CSS such as ', ", < and >, thus breaking the CSS.
42-
Q: Why not use a sanitization library such as DOMPurify?
43-
A: For our use case, we specifically want to purify CSS text, *not* HTML.
44-
DOMPurify, as well as any other HTML sanitization library, would escape/encode meaningful CSS characters
45-
and break our CSS in the same way that JSX would.
46-
47-
Q: Are there any security risks in this particular use case?
48-
A: Anything set inside of a <style> tag is always interpreted as CSS text, *not* HTML.
49-
Reference: “Restrictions on the content of raw text elements” https://html.spec.whatwg.org/dev/syntax.html#cdata-rcdata-restrictions
50-
And in our case, we are using dangerouslySetInnerHTML to set CSS text inside of a <style> tag.
51-
52-
Thus, it really comes down to the question: Could a malicious user escape from the context of the <style> tag?
53-
For example, when inserting HTML into the DOM, could someone prematurely close the </style> tag and add a <script> tag?
54-
e.g., </style><script>alert('hello')</script>
55-
The answer depends on whether the code is rendered on the client or server side.
56-
57-
Client side
58-
- To prevent XSS inside of the <style> tag, we need to make sure it's not closed prematurely.
59-
- This is prevented by React because React creates a style DOM node (e.g., React.createElement(‘style’, ...)), and directly sets innerHTML as a string.
60-
- Even if the string contains a closing </style> tag, it will still be interpreted as CSS text by the browser.
61-
- Therefore, there is not an XSS vulnerability on the client side.
62-
63-
Server side
64-
- When React code is rendered on the server side (e.g., NextJS), the code is sent to the browser as HTML text.
65-
- Therefore, it *IS* possible to insert a closing </style> tag and escape the CSS context, which opens an XSS vulnerability.
66-
67-
Q: How are we mitigating the potential attack vector?
68-
A: To fix this potential attack vector on the server side, we need to filter out any closing </style> tags,
69-
as this the only way to escape from the context of the browser interpreting the text as CSS.
70-
We also need to catch cases where there is any kind of whitespace character </style[HERE]>, such as tabs, carriage returns, etc:
71-
</style
72-
73-
>
74-
Therefore, by only rendering CSS text which does not include a closing '</style>' tag,
75-
we ensure that the browser will correctly interpret all the text as CSS.
76-
*/
77-
if (/<\/style/i.test(cssText)) {
78-
return null;
79-
} else {
80-
return (
81-
<style
82-
{...rest}
83-
ref={ref}
84-
id={`amplify-theme-${name}`}
85-
// eslint-disable-next-line react/no-danger
86-
dangerouslySetInnerHTML={{ __html: cssText }}
87-
nonce={nonce}
88-
/>
89-
);
90-
}
33+
return (
34+
<Style
35+
{...rest}
36+
ref={ref}
37+
cssText={cssText}
38+
nonce={nonce}
39+
id={`amplify-theme-${name}`}
40+
/>
41+
);
9142
};
9243

9344
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { render } from '@testing-library/react';
2+
import * as React from 'react';
3+
4+
import { ComponentStyle } from '../ComponentStyle';
5+
import { createTheme, defineComponentTheme } from '@aws-amplify/ui';
6+
7+
describe('ComponentStyle', () => {
8+
it('does not render anything if no theme is passed', async () => {
9+
// @ts-expect-error - missing props
10+
const { container } = render(<ComponentStyle />);
11+
12+
const styleTag = container.querySelector(`style`);
13+
expect(styleTag).toBe(null);
14+
});
15+
16+
it('does not render anything if no component themes are passed', async () => {
17+
// @ts-expect-error - missing props
18+
const { container } = render(<ComponentStyle theme={createTheme()} />);
19+
20+
const styleTag = container.querySelector(`style`);
21+
expect(styleTag).toBe(null);
22+
});
23+
24+
it('renders a style tag if theme and component themes are passed', async () => {
25+
const testComponentTheme = defineComponentTheme({
26+
name: 'test',
27+
theme(tokens) {
28+
return {
29+
color: tokens.colors.red[100],
30+
};
31+
},
32+
});
33+
const { container } = render(
34+
<ComponentStyle
35+
theme={createTheme()}
36+
componentThemes={[testComponentTheme]}
37+
/>
38+
);
39+
40+
const styleTag = container.querySelector(`style`);
41+
expect(styleTag).toBeInTheDocument();
42+
});
43+
});

packages/react/src/server.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { ThemeStyle } from './components/ThemeProvider/ThemeStyle';
2+
export { ComponentStyle } from './components/ThemeProvider/ComponentStyle';
23
export {
34
createTheme,
45
defineComponentTheme,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`@aws-amplify/ui defineComponentTheme should return a cssText function 1`] = `
4+
"[data-amplify-theme="default-theme"] .amplify-test { background-color:pink; border-radius:var(--amplify-radii-small); }
5+
[data-amplify-theme="default-theme"] .amplify-test--small { border-radius:0; }
6+
"
7+
`;
8+
9+
exports[`@aws-amplify/ui defineComponentTheme should return a cssText function that works with custom tokens 1`] = `
10+
"[data-amplify-theme="test"] .amplify-test { background-color:var(--amplify-colors-hot-pink-10); }
11+
"
12+
`;

0 commit comments

Comments
 (0)