Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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 new SSR style slot",
"packageName": "@fluentui/react-provider",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "feat: implement useInSSRContext",
"packageName": "@fluentui/react-utilities",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export type FluentProviderProps = Omit<ComponentProps<FluentProviderSlots>, 'dir
// @public (undocumented)
export type FluentProviderSlots = {
root: Slot<'div'>;
serverStyle?: Slot<'style'>;
};

// @public (undocumented)
Expand All @@ -76,7 +77,10 @@ export function useFluentProviderContextValues_unstable(state: FluentProviderSta
export const useFluentProviderStyles_unstable: (state: FluentProviderState) => FluentProviderState;

// @public
export const useFluentProviderThemeStyleTag: (options: Pick<FluentProviderState, 'theme' | 'targetDocument'>) => string;
export const useFluentProviderThemeStyleTag: (options: Pick<FluentProviderState, 'theme' | 'targetDocument'>) => {
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,54 @@
/*
* @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 { SSRProvider } from '@fluentui/react-utilities';
import { FluentProvider } from './FluentProvider';
import * as prettier from 'prettier';

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

describe('FluentProvider (node)', () => {
const testTheme = {
colorNeutralForeground1: 'black',
colorNeutralBackground1: 'white',
};
it('should render CSS variables as inline style when wrapped with SSRPRovider', () => {
const html = ReactDOM.renderToStaticMarkup(
<SSRProvider>
<FluentProvider theme={testTheme} />
</SSRProvider>,
);

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

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

expect(parseHTMLString(html)).toMatchInlineSnapshot(`
"<div
dir="ltr"
class="fui-FluentProvider fui-FluentProvider1 "
></div>"
`);
});
});
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { resetIdsForTests } from '@fluentui/react-utilities';
import { resetIdsForTests, SSRProvider } from '@fluentui/react-utilities';
import { render } from '@testing-library/react';
import * as React from 'react';
import * as renderer 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 @@ -17,6 +18,9 @@ describe('FluentProvider', () => {
isConformant({
disabledTests: ['component-handles-classname'],
Component: FluentProvider,
renderOptions: {
wrapper: SSRProvider,
},
displayName: 'FluentProvider',
});

Expand All @@ -35,6 +39,45 @@ describe('FluentProvider', () => {
expect(tree).toMatchSnapshot();
});

it('renders style element with css variables if wrapped with a SSRProvider', () => {
const { container } = render(
<SSRProvider>
<FluentProvider theme={{ colorNeutralBackground1: 'white', colorNeutralForeground1: 'black' }}>
foo
</FluentProvider>
</SSRProvider>,
);

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

it('does not render style element with css variables if not wrapped with a SSRProvider', () => {
const { container } = render(<FluentProvider theme={teamsLightTheme} />);
expect(container).toMatchInlineSnapshot(`
<div>
<div
class="fui-FluentProvider fui-FluentProvider1"
dir="ltr"
/>
</div>
`);
});

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 @@ -11,6 +11,10 @@ import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utili

export type FluentProviderSlots = {
root: Slot<'div'>;
/**
* HTMLStyleElement rendered during SSR that contains theme CSS variables
*/
serverStyle?: Slot<'style'>;
};

// exported for callers to avoid referencing react-shared-context
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ 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}>
{slots.serverStyle && <slots.serverStyle {...slotProps.serverStyle} />}
{slotProps.root.children}
</slots.root>
</OverridesProvider>
</TextDirectionProvider>
</TooltipVisibilityProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type {
ThemeContextValue_unstable as ThemeContextValue,
} from '@fluentui/react-shared-contexts';

import { getNativeElementProps, useMergedRefs } from '@fluentui/react-utilities';
import { getNativeElementProps, resolveShorthand, useIsInSSRContext, useMergedRefs } from '@fluentui/react-utilities';
import * as React from 'react';
import { useFluentProviderThemeStyleTag } from './useFluentProviderThemeStyleTag';
import type { FluentProviderProps, FluentProviderState } from './FluentProvider.types';
Expand Down Expand Up @@ -69,6 +69,7 @@ export const useFluentProvider_unstable = (
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const { styleTagId, rule } = useFluentProviderThemeStyleTag({ theme: mergedTheme, targetDocument });
return {
applyStylesToPortals,
// eslint-disable-next-line @typescript-eslint/naming-convention
Expand All @@ -78,17 +79,26 @@ 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',
serverStyle: 'style',
},

root: getNativeElementProps('div', {
...props,
dir,
ref: useMergedRefs(ref, useFocusVisible<HTMLDivElement>({ targetDocument })),
}),

serverStyle: resolveShorthand(props.serverStyle, {
required: useIsInSSRContext(),
defaultProps: {
id: styleTagId,
dangerouslySetInnerHTML: { __html: rule },
},
}),
};
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { SlotClassNames } from '@fluentui/react-utilities';

export const fluentProviderClassNames: SlotClassNames<FluentProviderSlots> = {
root: 'fui-FluentProvider',
serverStyle: 'fui-FluentProvider__serverStyle',
};

const useStyles = makeStyles({
Expand All @@ -29,5 +30,9 @@ export const useFluentProviderStyles_unstable = (state: FluentProviderState) =>
state.root.className,
);

if (state.serverStyle) {
state.serverStyle.className = mergeClasses(fluentProviderClassNames.serverStyle, state.serverStyle.className);
}

return state;
};
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ describe('useFluentProviderThemeStyleTag', () => {
);

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

it('should remove style tag on unmount', () => {
Expand All @@ -38,7 +38,7 @@ describe('useFluentProviderThemeStyleTag', () => {
unmount();

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

it('should render css variables in theme', () => {
Expand All @@ -48,11 +48,11 @@ describe('useFluentProviderThemeStyleTag', () => {
);

// 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;}"`);
});

Expand All @@ -66,10 +66,10 @@ describe('useFluentProviderThemeStyleTag', () => {
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 @@ -82,7 +82,7 @@ describe('useFluentProviderThemeStyleTag', () => {
() => useFluentProviderThemeStyleTag({ theme: defaultTheme, targetDocument: document }),
{ wrapper: props => <RendererProvider renderer={renderer}>{props.children}</RendererProvider> },
);
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');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useId, useIsomorphicLayoutEffect } from '@fluentui/react-utilities';
import { useId, useIsInSSRContext, useIsomorphicLayoutEffect } from '@fluentui/react-utilities';
import { useRenderer_unstable } from '@griffel/react';
import * as React from 'react';

Expand Down Expand Up @@ -46,6 +46,7 @@ const insertSheet = (tag: HTMLStyleElement, rule: string) => {
*/
export const useFluentProviderThemeStyleTag = (options: Pick<FluentProviderState, 'theme' | 'targetDocument'>) => {
const { targetDocument, theme } = options;
const isInSSRContext = useIsInSSRContext();

const renderer = useRenderer_unstable();
const styleTag = React.useRef<HTMLStyleElement>();
Expand All @@ -64,17 +65,22 @@ export const useFluentProviderThemeStyleTag = (options: Pick<FluentProviderState

const rule = `.${styleTagId} { ${cssVarsAsString} }`;

useInsertionEffect(() => {
styleTag.current = createStyleTag(targetDocument, { ...styleElementAttributes, id: styleTagId });

if (styleTag.current) {
insertSheet(styleTag.current, rule);

return () => {
styleTag.current?.remove();
};
}
}, [styleTagId, targetDocument, rule, styleElementAttributes]);
if (!isInSSRContext) {
// Not really breaking the rules of hooks here, this condition will only change if the parent
// tree is different (i.e. an SSRProvider is added/removed)
// eslint-disable-next-line react-hooks/rules-of-hooks
useInsertionEffect(() => {
styleTag.current = createStyleTag(targetDocument, { ...styleElementAttributes, id: styleTagId });

if (styleTag.current) {
insertSheet(styleTag.current, rule);

return () => {
styleTag.current?.remove();
};
}
}, [styleTagId, targetDocument, rule, styleElementAttributes]);
}

return styleTagId;
return { styleTagId, rule };
};
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,9 @@ export function useForceUpdate(): DispatchWithoutAction;
// @public
export function useId(prefix?: string, providedId?: string): string;

// @internal (undocumented)
export function useIsInSSRContext(): boolean;

// @public
export const useIsomorphicLayoutEffect: typeof React_2.useEffect;

Expand Down
2 changes: 1 addition & 1 deletion packages/react-components/react-utilities/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export {
} from './hooks/index';
export type { RefObjectFunction, UseControllableStateOptions, UseOnClickOrScrollOutsideOptions } from './hooks/index';

export { canUseDOM, useIsSSR, SSRProvider } from './ssr/index';
export { canUseDOM, useIsSSR, useIsInSSRContext, SSRProvider } from './ssr/index';

export {
clamp,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// 👆 this is intentionally to test in SSR like environment

import { renderHook } from '@testing-library/react-hooks';
import { SSRProvider, useIsSSR } from './SSRContext';
import { SSRProvider, useIsSSR, useIsInSSRContext } from './SSRContext';

describe('useIsSSR (node)', () => {
afterEach(() => {
Expand All @@ -27,3 +27,15 @@ describe('useIsSSR (node)', () => {
expect(result.current).toBe(true);
});
});

describe('useIsInSSRContext (node)', () => {
it('returns true if wrapped by an SSRProvider', () => {
const { result } = renderHook(() => useIsInSSRContext(), { wrapper: SSRProvider });
expect(result.current).toBe(true);
});

it('returns false if not wrapped by an SSRProvider', () => {
const { result } = renderHook(() => useIsInSSRContext());
expect(result.current).toBe(false);
});
});
Loading