Skip to content
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
4735d90
feat: Render theme CSS variables in new SSR style slot
ling1726 Mar 21, 2023
008b7bb
changefile
ling1726 Mar 21, 2023
564366c
revert
ling1726 Mar 21, 2023
1f9ca81
remove mock
ling1726 Mar 21, 2023
73c2314
add explainer
ling1726 Mar 21, 2023
12696d3
update md
ling1726 Mar 21, 2023
cc23599
add nonce
ling1726 Mar 21, 2023
5a5d447
make slot private
ling1726 Mar 22, 2023
6a5ad4b
Remove SSR element on first render
ling1726 Mar 22, 2023
2bea363
remove changefile
ling1726 Mar 22, 2023
f795b08
pr feedback
ling1726 Mar 22, 2023
bba6470
update changfile
ling1726 Mar 22, 2023
4954b9e
Merge branch 'master' into feat/ssr-css-variables
ling1726 Mar 22, 2023
e06aef8
Merge branch 'master' into feat/ssr-css-variables
ling1726 Mar 23, 2023
41751a0
stop using query selector
ling1726 Mar 23, 2023
d12535a
pass renderer to useFluentpRoviderThemeStyleTag hook
ling1726 Mar 23, 2023
53f6ad0
rename rendererAttributes to attributes
ling1726 Mar 23, 2023
be47770
simplify logic
ling1726 Mar 23, 2023
a38edf8
remove data attribute
ling1726 Mar 23, 2023
4bfde2d
use PartialTheme type
ling1726 Mar 23, 2023
c184f6c
remove SSRPRovider from tests
ling1726 Mar 23, 2023
8ee281a
remove cruft
ling1726 Mar 23, 2023
cc056f7
use ternary render
ling1726 Mar 23, 2023
46f2b0e
Merge branch 'master' into feat/ssr-css-variables
ling1726 Mar 23, 2023
a110b5c
update md
ling1726 Mar 23, 2023
db16298
only pass attributes
ling1726 Mar 23, 2023
094b7f7
remove ssr provider
ling1726 Mar 23, 2023
6c04a92
fix tests
ling1726 Mar 23, 2023
3b7afda
update md
ling1726 Mar 23, 2023
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "feat: Render theme CSS variables in SSR style element",
"packageName": "@fluentui/react-provider",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { ComponentProps } from '@fluentui/react-utilities';
import type { ComponentState } from '@fluentui/react-utilities';
import { CustomStyleHooksContextValue_unstable } from '@fluentui/react-shared-contexts';
import { GriffelRenderer } from '@griffel/react';
import { OverridesContextValue_unstable } from '@fluentui/react-shared-contexts';
import type { PartialTheme } from '@fluentui/react-theme';
import type { ProviderContextValue_unstable } from '@fluentui/react-shared-contexts';
Expand Down Expand Up @@ -61,6 +62,10 @@ export type FluentProviderSlots = {
export type FluentProviderState = ComponentState<FluentProviderSlots> & Pick<FluentProviderProps, 'targetDocument'> & Required<Pick<FluentProviderProps, 'applyStylesToPortals' | 'customStyleHooks_unstable' | 'dir' | 'overrides_unstable'>> & {
theme: ThemeContextValue_unstable;
themeClassName: string;
serverStyleProps: {
cssRule: string;
attributes: Record<string, string>;
};
};

// @public
Expand All @@ -75,8 +80,13 @@ export function useFluentProviderContextValues_unstable(state: FluentProviderSta
// @public
export const useFluentProviderStyles_unstable: (state: FluentProviderState) => FluentProviderState;

// @public
export const useFluentProviderThemeStyleTag: (options: Pick<FluentProviderState, 'theme' | 'targetDocument'>) => string;
// @internal
export const useFluentProviderThemeStyleTag: (options: Pick<FluentProviderState, 'theme' | 'targetDocument'> & {
renderer: GriffelRenderer;
}) => {
styleTagId: string;
rule: string;
};

// (No @packageDocumentation comment for this package)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* @jest-environment node
*/

// 👆 this is intentionally to test in SSR like environment

import * as React from 'react';
import * as ReactDOM from 'react-dom/server';
import { resetIdsForTests, SSRProvider } from '@fluentui/react-utilities';
import { FluentProvider } from './FluentProvider';
import * as prettier from 'prettier';
import { createDOMRenderer } from '@griffel/core';
import { RendererProvider } from '@griffel/react';
import { PartialTheme } from '@fluentui/react-theme';

const parseHTMLString = (html: string) => {
return prettier.format(html, { parser: 'html' });
};

describe('FluentProvider (node)', () => {
const testTheme: PartialTheme = {
colorNeutralForeground1: 'black',
colorNeutralBackground1: 'white',
};

afterEach(() => {
resetIdsForTests();
});

it('should render CSS variables as inline style', () => {
const html = ReactDOM.renderToStaticMarkup(<FluentProvider theme={testTheme} />);

expect(parseHTMLString(html)).toMatchInlineSnapshot(`
"<div
dir="ltr"
class="fui-FluentProvider fui-FluentProvider1 "
>
<style id="fui-FluentProvider1">
.fui-FluentProvider1 {
--colorNeutralForeground1: black;
--colorNeutralBackground1: white;
}
</style>
</div>"
`);
});

it('renders nonce with SSR style element', () => {
const nonce = 'random';
const renderer = createDOMRenderer(undefined, {
styleElementAttributes: { nonce },
});

const html = ReactDOM.renderToStaticMarkup(
<SSRProvider>
<RendererProvider renderer={renderer}>
<FluentProvider theme={testTheme} />
</RendererProvider>
</SSRProvider>,
);

expect(parseHTMLString(html)).toMatchInlineSnapshot(`
"<div
dir="ltr"
class="fui-FluentProvider fui-FluentProvider1 "
>
<style nonce="random" id="fui-FluentProvider1">
.fui-FluentProvider1 {
--colorNeutralForeground1: black;
--colorNeutralBackground1: white;
}
</style>
</div>"
`);
});
});
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { resetIdsForTests } from '@fluentui/react-utilities';
import { render } from '@testing-library/react';
import * as React from 'react';
import * as renderer from 'react-test-renderer';
import * as reactTestRenderer from 'react-test-renderer';

import { FluentProvider } from './FluentProvider';
import { isConformant } from '../../testing/isConformant';
import { teamsLightTheme } from '@fluentui/react-theme';

describe('FluentProvider', () => {
// eslint-disable-next-line @typescript-eslint/no-empty-function
Expand All @@ -28,13 +29,18 @@ describe('FluentProvider', () => {
* Note: see more visual regression tests for FluentProvider in /apps/vr-tests.
*/
it('renders a default state', () => {
const component = renderer.create(
const component = reactTestRenderer.create(
<FluentProvider theme={{ colorBrandBackground2: '#fff' }}>Default FluentProvider</FluentProvider>,
);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});

it('does not render style element when not in SSR', () => {
const { container } = render(<FluentProvider theme={teamsLightTheme} />);
expect(container.querySelector('style')).toBeNull();
});

describe('applies "dir" attribute', () => {
it('ltr', () => {
const { getByText } = render(<FluentProvider dir="ltr">Test</FluentProvider>);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,19 @@ export type FluentProviderState = ComponentState<FluentProviderSlots> &
> & {
theme: ThemeContextValue;
themeClassName: string;
/**
* Props used to render SSR theme variables style element
*/
serverStyleProps: {
/**
* CSS rule containing CSS variables
*/
cssRule: string;
/**
* Additional attributes applied to the style element
*/
attributes: Record<string, string>;
};
};

export type FluentProviderContextValues = Pick<
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import {
CustomStyleHooksProvider_unstable as CustomStyleHooksProvider,
CustomStyleHooksContextValue_unstable as CustomStyleHooksContextValue,
} from '@fluentui/react-shared-contexts';
import { getSlots } from '@fluentui/react-utilities';
import type { FluentProviderSlots, FluentProviderContextValues, FluentProviderState } from './FluentProvider.types';
import { canUseDOM, getSlots } from '@fluentui/react-utilities';
import type { FluentProviderContextValues, FluentProviderState, FluentProviderSlots } from './FluentProvider.types';

/**
* Render the final JSX of FluentProvider
Expand All @@ -35,7 +35,18 @@ export const renderFluentProvider_unstable = (
<TooltipVisibilityProvider value={contextValues.tooltip}>
<TextDirectionProvider dir={contextValues.textDirection}>
<OverridesProvider value={contextValues.overrides_unstable}>
<slots.root {...slotProps.root}>{state.root.children}</slots.root>
<slots.root {...slotProps.root}>
{canUseDOM() ? null : (
<style
// Using dangerous HTML because react can escape characters
// which can lead to invalid CSS.
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: state.serverStyleProps.cssRule }}
{...state.serverStyleProps.attributes}
/>
)}
{slotProps.root.children}
</slots.root>
</OverridesProvider>
</TextDirectionProvider>
</TooltipVisibilityProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { getNativeElementProps, useMergedRefs } from '@fluentui/react-utilities'
import * as React from 'react';
import { useFluentProviderThemeStyleTag } from './useFluentProviderThemeStyleTag';
import type { FluentProviderProps, FluentProviderState } from './FluentProvider.types';
import { useRenderer_unstable } from '@griffel/react';

/**
* Create the state required to render FluentProvider.
Expand Down Expand Up @@ -69,6 +70,8 @@ export const useFluentProvider_unstable = (
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const renderer = useRenderer_unstable();
const { styleTagId, rule } = useFluentProviderThemeStyleTag({ theme: mergedTheme, targetDocument, renderer });
return {
applyStylesToPortals,
// eslint-disable-next-line @typescript-eslint/naming-convention
Expand All @@ -78,7 +81,7 @@ export const useFluentProvider_unstable = (
theme: mergedTheme,
// eslint-disable-next-line @typescript-eslint/naming-convention
overrides_unstable: mergedOverrides,
themeClassName: useFluentProviderThemeStyleTag({ theme: mergedTheme, targetDocument }),
themeClassName: styleTagId,

components: {
root: 'div',
Expand All @@ -89,6 +92,14 @@ export const useFluentProvider_unstable = (
dir,
ref: useMergedRefs(ref, useFocusVisible<HTMLDivElement>({ targetDocument })),
}),

serverStyleProps: {
cssRule: rule,
attributes: {
...renderer.styleElementAttributes,
id: styleTagId,
},
},
};
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { createDOMRenderer, RendererProvider } from '@griffel/react';
import { createDOMRenderer } from '@griffel/react';
import type { Theme } from '@fluentui/react-theme';
import { resetIdsForTests } from '@fluentui/react-utilities';
import { renderHook } from '@testing-library/react-hooks';
import * as React from 'react';

import { useFluentProviderThemeStyleTag } from './useFluentProviderThemeStyleTag';

jest.mock('@fluentui/react-theme');
const createDocumentMock = (): Document => {
const externalDocument = document.implementation.createDocument('http://www.w3.org/1999/xhtml', 'html', null);
const body = document.createElement('body');
const head = document.createElement('head');
externalDocument.documentElement.appendChild(head);
externalDocument.documentElement.appendChild(body);
return externalDocument;
};

describe('useFluentProviderThemeStyleTag', () => {
const defaultTheme = {
Expand All @@ -20,56 +27,62 @@ describe('useFluentProviderThemeStyleTag', () => {

it('should render style tag', () => {
// Act
const renderer = createDOMRenderer(document);
const { result } = renderHook(() =>
useFluentProviderThemeStyleTag({ theme: defaultTheme, targetDocument: document }),
useFluentProviderThemeStyleTag({ theme: defaultTheme, targetDocument: document, renderer }),
);

// Assert
expect(document.getElementById(result.current)).not.toBeNull();
expect(document.getElementById(result.current.styleTagId)).not.toBeNull();
});

it('should remove style tag on unmount', () => {
// Arrange
const renderer = createDOMRenderer(document);
const { result, unmount } = renderHook(() =>
useFluentProviderThemeStyleTag({ theme: defaultTheme, targetDocument: document }),
useFluentProviderThemeStyleTag({ theme: defaultTheme, targetDocument: document, renderer }),
);

// Act
unmount();

// Assert
expect(document.getElementById(result.current)).toBeNull();
expect(document.getElementById(result.current.styleTagId)).toBeNull();
});

it('should render css variables in theme', () => {
// Act
const renderer = createDOMRenderer(document);
const { result } = renderHook(() =>
useFluentProviderThemeStyleTag({ theme: defaultTheme, targetDocument: document }),
useFluentProviderThemeStyleTag({ theme: defaultTheme, targetDocument: document, renderer }),
);

// Assert
const tag = document.getElementById(result.current) as HTMLStyleElement;
const tag = document.getElementById(result.current.styleTagId) as HTMLStyleElement;
const sheet = tag.sheet as CSSStyleSheet;
const rule = sheet.cssRules[0] as CSSStyleRule;

expect(rule.selectorText).toEqual(`.${result.current}`);
expect(rule.selectorText).toEqual(`.${result.current.styleTagId}`);
expect(rule.cssText).toMatchInlineSnapshot(`".fui-FluentProvider1 {--css-variable-1: 1; --css-variable-2: 2;}"`);
});

it('should update style tag on theme change', () => {
// Arrange
let theme = defaultTheme;
const { result, rerender } = renderHook(() => useFluentProviderThemeStyleTag({ theme, targetDocument: document }));
const renderer = createDOMRenderer(document);
const { result, rerender } = renderHook(() =>
useFluentProviderThemeStyleTag({ theme, targetDocument: document, renderer }),
);

// Act
theme = { 'css-variable-update': 'xxx' } as unknown as Theme;
rerender();

// Assert
const tag = document.getElementById(result.current) as HTMLStyleElement;
const tag = document.getElementById(result.current.styleTagId) as HTMLStyleElement;
const sheet = tag.sheet as CSSStyleSheet;
const rule = sheet.cssRules[0] as CSSStyleRule;
expect(rule.selectorText).toEqual(`.${result.current}`);
expect(rule.selectorText).toEqual(`.${result.current.styleTagId}`);
expect(rule.cssText).toMatchInlineSnapshot(`".fui-FluentProvider1 {--css-variable-update: xxx;}"`);
});

Expand All @@ -78,13 +91,29 @@ describe('useFluentProviderThemeStyleTag', () => {
styleElementAttributes: { nonce: 'random' },
});

const { result } = renderHook(
() => useFluentProviderThemeStyleTag({ theme: defaultTheme, targetDocument: document }),
{ wrapper: props => <RendererProvider renderer={renderer}>{props.children}</RendererProvider> },
const { result } = renderHook(() =>
useFluentProviderThemeStyleTag({ theme: defaultTheme, targetDocument: document, renderer }),
);
const tag = document.getElementById(result.current) as HTMLStyleElement;
const tag = document.getElementById(result.current.styleTagId) as HTMLStyleElement;

expect(tag.getAttribute('id')).toBe('fui-FluentProvider1');
expect(tag.getAttribute('nonce')).toBe('random');
});

it('should move style tags in body to head on first render', () => {
const targetDocument = createDocumentMock();
const ssrStyleElement = targetDocument.createElement('style');
// Kinda hacky - assume the useId call returns as expected (ids are reset after each test)
ssrStyleElement.setAttribute('id', 'fui-FluentProvider1');
targetDocument.body.append(ssrStyleElement);

jest.spyOn(targetDocument, 'createElement');
const renderer = createDOMRenderer(targetDocument);
renderHook(() => useFluentProviderThemeStyleTag({ theme: defaultTheme, targetDocument, renderer }));

expect(targetDocument.body.querySelector('style')).toBeNull();
expect(targetDocument.head.querySelectorAll('style').length).toBe(1);
// eslint-disable-next-line deprecation/deprecation
expect(targetDocument.createElement).toHaveBeenCalledTimes(0);
});
});
Loading