Repository: microsoft/griffel (ts)

Issue:
react: Better support for React 18
RC for React 18 is already published:

![image](https://user-images.githubusercontent.com/14183168/154933630-e23a7ce7-04ee-488e-a130-8637249bd890.png)

---

> **NOTE: Make sure you read the section "When to Insert <style> on The Client". If you currently inject style rules "during render", it could make your library VERY slow in concurrent rendering.**
> https://github.com/reactwg/react-18/discussions/110

In short we should use `useInsertionEffect` to apply our styles to DOM.

For reference:
- changes in Emotion https://github.com/emotion-js/emotion/pull/2600
- issue with bundling https://github.com/emotion-js/emotion/pull/2651

- [x] Types in React 18 does not allow `children` by default in `React.FC`, check components and update (#214)

Pull request description:
chore: improve React 18 support
Fixes #40.

This PR improves compatibility of `@griffel/react` by using a custom insertion factory that uses [`useInsertionEffect`](https://react.dev/reference/react/useInsertionEffect) when it's available. 

Patch:
diff --git a/change/@griffel-react-13bf5fac-a88b-41ad-98fc-a8df3be96e6d.json b/change/@griffel-react-13bf5fac-a88b-41ad-98fc-a8df3be96e6d.json
new file mode 100644
index 0000000..e485dc0
--- /dev/null
+++ b/change/@griffel-react-13bf5fac-a88b-41ad-98fc-a8df3be96e6d.json
@@ -0,0 +1,7 @@
+{
+  "type": "patch",
+  "comment": "chore: improve React 18 support by using useInsertionEffect",
+  "packageName": "@griffel/react",
+  "email": "olfedias@microsoft.com",
+  "dependentChangeType": "patch"
+}
diff --git a/packages/react/src/RendererContext.tsx b/packages/react/src/RendererContext.tsx
index 62e470e..dcf53d5 100644
--- a/packages/react/src/RendererContext.tsx
+++ b/packages/react/src/RendererContext.tsx
@@ -1,6 +1,8 @@
 import { createDOMRenderer, rehydrateRendererCache } from '@griffel/core';
-import * as React from 'react';
 import type { GriffelRenderer } from '@griffel/core';
+import * as React from 'react';
+
+import { canUseDOM } from './utils/canUseDOM';
 
 export interface RendererProviderProps {
   /** An instance of Griffel renderer. */
@@ -17,13 +19,6 @@ export interface RendererProviderProps {
   children: React.ReactNode;
 }
 
-/**
- * Verifies if an application can use DOM.
- */
-function canUseDOM(): boolean {
-  return typeof window !== 'undefined' && !!(window.document && window.document.createElement);
-}
-
 /**
  * @private
  */
diff --git a/packages/react/src/__resetStyles.ts b/packages/react/src/__resetStyles.ts
index 574a998..c337206 100644
--- a/packages/react/src/__resetStyles.ts
+++ b/packages/react/src/__resetStyles.ts
@@ -1,6 +1,7 @@
 import { __resetStyles as vanillaResetStyles } from '@griffel/core';
 import type { CSSRulesByBucket } from '@griffel/core';
 
+import { insertionFactory } from './insertionFactory';
 import { useRenderer } from './RendererContext';
 import { useTextDirection } from './TextDirectionContext';
 
@@ -15,7 +16,7 @@ export function __resetStyles(
   rtlClassName: string | null,
   cssRules: CSSRulesByBucket | string[],
 ) {
-  const getStyles = vanillaResetStyles(ltrClassName, rtlClassName, cssRules);
+  const getStyles = vanillaResetStyles(ltrClassName, rtlClassName, cssRules, insertionFactory);
 
   return function useClasses(): string {
     const dir = useTextDirection();
diff --git a/packages/react/src/__styles.ts b/packages/react/src/__styles.ts
index 345627e..bcfb237 100644
--- a/packages/react/src/__styles.ts
+++ b/packages/react/src/__styles.ts
@@ -1,6 +1,7 @@
 import { __styles as vanillaStyles } from '@griffel/core';
 import type { CSSClassesMapBySlot, CSSRulesByBucket } from '@griffel/core';
 
+import { insertionFactory } from './insertionFactory';
 import { useRenderer } from './RendererContext';
 import { useTextDirection } from './TextDirectionContext';
 
@@ -14,7 +15,7 @@ export function __styles<Slots extends string>(
   classesMapBySlot: CSSClassesMapBySlot<Slots>,
   cssRules: CSSRulesByBucket,
 ) {
-  const getStyles = vanillaStyles(classesMapBySlot, cssRules);
+  const getStyles = vanillaStyles(classesMapBySlot, cssRules, insertionFactory);
 
   return function useClasses(): Record<Slots, string> {
     const dir = useTextDirection();
diff --git a/packages/react/src/insertionFactory.ts b/packages/react/src/insertionFactory.ts
new file mode 100644
index 0000000..c4ad719
--- /dev/null
+++ b/packages/react/src/insertionFactory.ts
@@ -0,0 +1,25 @@
+import type { CSSRulesByBucket, GriffelInsertionFactory, GriffelRenderer } from '@griffel/core';
+
+import { canUseDOM } from './utils/canUseDOM';
+import { useInsertionEffect } from './useInsertionEffect';
+
+export const insertionFactory: GriffelInsertionFactory = () => {
+  const insertionCache: Record<string, boolean> = {};
+
+  return function insert(renderer: GriffelRenderer, cssRules: CSSRulesByBucket) {
+    // Even if `useInsertionEffect` is available, we can use it on a client only as it will not be executed in SSR
+    if (useInsertionEffect && canUseDOM()) {
+      // eslint-disable-next-line react-hooks/rules-of-hooks
+      useInsertionEffect(() => {
+        renderer.insertCSSRules(cssRules!);
+      }, [renderer, cssRules]);
+
+      return;
+    }
+
+    if (insertionCache[renderer.id] === undefined) {
+      renderer.insertCSSRules(cssRules!);
+      insertionCache[renderer.id] = true;
+    }
+  };
+};
diff --git a/packages/react/src/makeResetStyles.ts b/packages/react/src/makeResetStyles.ts
index a42b980..7ebf072 100644
--- a/packages/react/src/makeResetStyles.ts
+++ b/packages/react/src/makeResetStyles.ts
@@ -1,12 +1,13 @@
 import { makeResetStyles as vanillaMakeResetStyles } from '@griffel/core';
 import type { GriffelResetStyle } from '@griffel/core';
 
-import { isInsideComponent } from './utils/isInsideComponent';
+import { insertionFactory } from './insertionFactory';
 import { useRenderer } from './RendererContext';
 import { useTextDirection } from './TextDirectionContext';
+import { isInsideComponent } from './utils/isInsideComponent';
 
 export function makeResetStyles(styles: GriffelResetStyle) {
-  const getStyles = vanillaMakeResetStyles(styles);
+  const getStyles = vanillaMakeResetStyles(styles, insertionFactory);
 
   if (process.env.NODE_ENV !== 'production') {
     if (isInsideComponent()) {
diff --git a/packages/react/src/makeStaticStyles.ts b/packages/react/src/makeStaticStyles.ts
index dd3bc29..e60611b 100644
--- a/packages/react/src/makeStaticStyles.ts
+++ b/packages/react/src/makeStaticStyles.ts
@@ -1,10 +1,11 @@
 import { makeStaticStyles as vanillaMakeStaticStyles } from '@griffel/core';
+import type { GriffelStaticStyles, MakeStaticStylesOptions } from '@griffel/core';
 
+import { insertionFactory } from './insertionFactory';
 import { useRenderer } from './RendererContext';
-import type { GriffelStaticStyles, MakeStaticStylesOptions } from '@griffel/core';
 
 export function makeStaticStyles(styles: GriffelStaticStyles | GriffelStaticStyles[]) {
-  const getStyles = vanillaMakeStaticStyles(styles);
+  const getStyles = vanillaMakeStaticStyles(styles, insertionFactory);
 
   if (process.env.NODE_ENV === 'test') {
     // eslint-disable-next-line @typescript-eslint/no-empty-function
diff --git a/packages/react/src/makeStyles.ts b/packages/react/src/makeStyles.ts
index f999675..e4090d5 100644
--- a/packages/react/src/makeStyles.ts
+++ b/packages/react/src/makeStyles.ts
@@ -1,12 +1,13 @@
 import { makeStyles as vanillaMakeStyles } from '@griffel/core';
 import type { GriffelStyle } from '@griffel/core';
 
-import { isInsideComponent } from './utils/isInsideComponent';
+import { insertionFactory } from './insertionFactory';
 import { useRenderer } from './RendererContext';
 import { useTextDirection } from './TextDirectionContext';
+import { isInsideComponent } from './utils/isInsideComponent';
 
 export function makeStyles<Slots extends string | number>(stylesBySlots: Record<Slots, GriffelStyle>) {
-  const getStyles = vanillaMakeStyles(stylesBySlots);
+  const getStyles = vanillaMakeStyles(stylesBySlots, insertionFactory);
 
   if (process.env.NODE_ENV !== 'production') {
     if (isInsideComponent()) {
diff --git a/packages/react/src/useInsertionEffect.ts b/packages/react/src/useInsertionEffect.ts
new file mode 100644
index 0000000..fe5e8c5
--- /dev/null
+++ b/packages/react/src/useInsertionEffect.ts
@@ -0,0 +1,6 @@
+import * as React from 'react';
+
+export const useInsertionEffect: typeof React.useInsertionEffect | undefined =
+  // @ts-expect-error Hack to make sure that `useInsertionEffect` will not cause bundling issues in older React versions
+  // eslint-disable-next-line no-useless-concat
+  React['useInsertion' + 'Effect'] ? React['useInsertion' + 'Effect'] : undefined;
diff --git a/packages/react/src/utils/canUseDOM.ts b/packages/react/src/utils/canUseDOM.ts
new file mode 100644
index 0000000..d11d3ff
--- /dev/null
+++ b/packages/react/src/utils/canUseDOM.ts
@@ -0,0 +1,6 @@
+/**
+ * Verifies if an application can use DOM.
+ */
+export function canUseDOM(): boolean {
+  return typeof window !== 'undefined' && !!(window.document && window.document.createElement);
+}


Test patch:
diff --git a/packages/react/src/createDOMRenderer.test.tsx b/packages/react/src/createDOMRenderer.test.tsx
index 89b6fa0..611b4e0 100644
--- a/packages/react/src/createDOMRenderer.test.tsx
+++ b/packages/react/src/createDOMRenderer.test.tsx
@@ -8,6 +8,13 @@ import { makeStyles } from './makeStyles';
 import { makeResetStyles } from './makeResetStyles';
 import { RendererProvider } from './RendererContext';
 import { renderToStyleElements } from './renderToStyleElements';
+import { useInsertionEffect as _useInsertionEffect } from './useInsertionEffect';
+
+jest.mock('./useInsertionEffect', () => ({
+  useInsertionEffect: jest.fn(),
+}));
+
+const useInsertionEffect = _useInsertionEffect as jest.MockedFunction<typeof React.useInsertionEffect>;
 
 describe('createDOMRenderer', () => {
   it('rehydrateCache() avoids double insertion', () => {
@@ -46,6 +53,11 @@ describe('createDOMRenderer', () => {
     // A "server" renders components to static HTML that will be transferred to a client
     //
 
+    // Heads up!
+    // Mock there is need as this test is executed in DOM environment and uses "useInsertionEffect".
+    // However, "useInsertionEffect" will not be called in "renderToStaticMarkup()".
+    useInsertionEffect.mockImplementation(fn => fn());
+
     const componentHTML = renderToStaticMarkup(
       <RendererProvider renderer={serverRenderer}>
         <ExampleComponent />
@@ -53,6 +65,8 @@ describe('createDOMRenderer', () => {
     );
     const stylesHTML = renderToStaticMarkup(<>{renderToStyleElements(serverRenderer)}</>);
 
+    useInsertionEffect.mockImplementation(React.useInsertionEffect);
+
     // Ensure that all styles are inserted into the cache
     expect(serverRenderer.insertionCache).toMatchInlineSnapshot(`
       Object {
diff --git a/packages/react/src/insertionFactory-node.test.ts b/packages/react/src/insertionFactory-node.test.ts
new file mode 100644
index 0000000..298c4b8
--- /dev/null
+++ b/packages/react/src/insertionFactory-node.test.ts
@@ -0,0 +1,24 @@
+/*
+ * @jest-environment node
+ */
+
+// 👆 this is intentionally to test in SSR like environment
+
+import type { GriffelRenderer } from '@griffel/core';
+import * as React from 'react';
+
+import { insertionFactory } from './insertionFactory';
+
+describe('insertionFactory (node)', () => {
+  it('does not use insertionEffect', () => {
+    const useInsertionEffect = jest.spyOn(React, 'useInsertionEffect');
+
+    const renderer: Partial<GriffelRenderer> = { id: 'a', insertCSSRules: jest.fn() };
+    const insertStyles = insertionFactory();
+
+    insertStyles(renderer as GriffelRenderer, { d: ['a'] });
+
+    expect(useInsertionEffect).not.toHaveBeenCalled();
+    expect(renderer.insertCSSRules).toHaveBeenCalledTimes(1);
+  });
+});
diff --git a/packages/react/src/insertionFactory.test.ts b/packages/react/src/insertionFactory.test.ts
new file mode 100644
index 0000000..1cd6486
--- /dev/null
+++ b/packages/react/src/insertionFactory.test.ts
@@ -0,0 +1,27 @@
+import type { GriffelRenderer } from '@griffel/core';
+
+import { insertionFactory } from './insertionFactory';
+import { useInsertionEffect as _useInsertionEffect } from './useInsertionEffect';
+import * as React from 'react';
+
+jest.mock('./useInsertionEffect', () => ({
+  useInsertionEffect: jest.fn().mockImplementation(fn => fn()),
+}));
+
+const useInsertionEffect = _useInsertionEffect as jest.MockedFunction<typeof React.useInsertionEffect>;
+
+describe('canUseDOM', () => {
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
+  it('uses "useInsertionEffect" when available', () => {
+    const renderer: Partial<GriffelRenderer> = { insertCSSRules: jest.fn() };
+    const insertStyles = insertionFactory();
+
+    insertStyles(renderer as GriffelRenderer, { d: ['a'] });
+
+    expect(useInsertionEffect).toHaveBeenCalledTimes(1);
+    expect(renderer.insertCSSRules).toHaveBeenCalledTimes(1);
+  });
+});
diff --git a/packages/react/src/utils/canUseDOM-node.test.ts b/packages/react/src/utils/canUseDOM-node.test.ts
new file mode 100644
index 0000000..2e2290f
--- /dev/null
+++ b/packages/react/src/utils/canUseDOM-node.test.ts
@@ -0,0 +1,13 @@
+/*
+ * @jest-environment node
+ */
+
+// 👆 this is intentionally to test in SSR like environment
+
+import { canUseDOM } from './canUseDOM';
+
+describe('canUseDOM (node)', () => {
+  it('returns "false"', () => {
+    expect(canUseDOM()).toBe(false);
+  });
+});
diff --git a/packages/react/src/utils/canUseDOM.test.tsx b/packages/react/src/utils/canUseDOM.test.tsx
new file mode 100644
index 0000000..98e18d2
--- /dev/null
+++ b/packages/react/src/utils/canUseDOM.test.tsx
@@ -0,0 +1,7 @@
+import { canUseDOM } from './canUseDOM';
+
+describe('canUseDOM', () => {
+  it('returns "true"', () => {
+    expect(canUseDOM()).toBe(true);
+  });
+});