Repository: microsoft/griffel (ts)

Issue:
core: allow to control style tag insertion location
[Internally Griffel ensures its style tags are inserted in the correct order to provide deterministic results](https://github.com/microsoft/griffel/blob/main/packages/core/src/renderer/getStyleSheetForBucket.ts#L65). However, when used in an application that lazy loads code and styles, Griffel's style tags may become interleaved with the application's style tags, resulting in non-deterministic results.

This manifests in real-world applications that use lazy loading of React components (and CSS modules for application styles in this case but I don't think that's necessarily relevant) and Fluent UI React v9 for controls. In this case application authors want to override default Fluent styles with their own CSS but because their components are lazily loaded and they cannot control where Griffel styles are loaded in the document they cannot be guaranteed their styles will have greater specificity than Fluent's built in Griffel styles.

I've created a rough demo of this on Codesandbox: [Griffel Lazy Load](https://codesandbox.io/s/griffel-lazy-load-example-j53dfe)

With the default example, by inspecting the source, you can see how some application CSS is loaded in the document first, then Griffel styles, then more app CSS.

I'd like an API for Griffel that allows me to control where Griffel's style tags are inserted so I can ensure they are in the correct place relative to my application's styles.

Pull request:
feat: add "insertionPoint" option to createDOMRenderer()
Fixes #332.

This PR adds `insertionPoint` option. If specified, a renderer will insert created style tags after this element:

```js
import { createDOMRenderer } from '@griffel/react';

const insertionPoint = document.head.querySelector('#foo');
const renderer = createDOMRenderer(document, {
  insertionPoint,
});
```

```html
<html>
  <head>
    <style id="foo" />
    <!-- Style elements created by Griffel will be inserted after "#foo" element -->
    <style data-make-styles-bucket="d" />
    <style id="bar" />
  </head>
</html>
```

Patch:
diff --git a/apps/website/docs/react/api/create-dom-renderer.md b/apps/website/docs/react/api/create-dom-renderer.md
index 6921cca..71e950a 100644
--- a/apps/website/docs/react/api/create-dom-renderer.md
+++ b/apps/website/docs/react/api/create-dom-renderer.md
@@ -22,6 +22,30 @@ function App(props) {
 }
 ```
 
+### insertionPoint
+
+If specified, a renderer will insert created style tags after this element:
+
+```js
+import { createDOMRenderer } from '@griffel/react';
+
+const insertionPoint = document.head.querySelector('#foo');
+const renderer = createDOMRenderer(document, {
+  insertionPoint,
+});
+```
+
+```html
+<html>
+  <head>
+    <style id="foo" />
+    <!-- Style elements created by Griffel will be inserted after "#foo" element -->
+    <style data-make-styles-bucket="d" />
+    <style id="bar" />
+  </head>
+</html>
+```
+
 ### styleElementAttributes
 
 A map of attributes that's passed to the generated style elements. For example, is useful to set ["nonce" attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/nonce).
@@ -29,7 +53,7 @@ A map of attributes that's passed to the generated style elements. For example,
 ```js
 import { createDOMRenderer } from '@griffel/react';
 
-const renderer = createDOMRenderer(targetDocument, {
+const renderer = createDOMRenderer(document, {
   styleElementAttributes: {
     nonce: 'random',
   },
diff --git a/change/@griffel-core-78fefbae-6966-49fb-b7cb-7f29a5dd05c7.json b/change/@griffel-core-78fefbae-6966-49fb-b7cb-7f29a5dd05c7.json
new file mode 100644
index 0000000..f5d487b
--- /dev/null
+++ b/change/@griffel-core-78fefbae-6966-49fb-b7cb-7f29a5dd05c7.json
@@ -0,0 +1,7 @@
+{
+  "type": "minor",
+  "comment": "feat: add \"insertionPoint\" option to createDOMRenderer()",
+  "packageName": "@griffel/core",
+  "email": "olfedias@microsoft.com",
+  "dependentChangeType": "patch"
+}
diff --git a/packages/core/src/renderer/createDOMRenderer.ts b/packages/core/src/renderer/createDOMRenderer.ts
index 59c0709..cd2d361 100644
--- a/packages/core/src/renderer/createDOMRenderer.ts
+++ b/packages/core/src/renderer/createDOMRenderer.ts
@@ -6,6 +6,11 @@ import { getStyleSheetForBucket } from './getStyleSheetForBucket';
 let lastIndex = 0;
 
 export interface CreateDOMRendererOptions {
+  /**
+   * If specified, a renderer will insert created style tags after this element.
+   */
+  insertionPoint?: HTMLElement;
+
   /**
    * A map of attributes that's passed to the generated style elements. Is useful to set "nonce" attribute.
    *
@@ -40,10 +45,15 @@ export const defaultCompareMediaQueries = (a: string, b: string) => (a < b ? -1
  * @public
  */
 export function createDOMRenderer(
-  target: Document | undefined = typeof document === 'undefined' ? undefined : document,
+  targetDocument: Document | undefined = typeof document === 'undefined' ? undefined : document,
   options: CreateDOMRendererOptions = {},
 ): GriffelRenderer {
-  const { unstable_filterCSSRule, styleElementAttributes, compareMediaQueries = defaultCompareMediaQueries } = options;
+  const {
+    unstable_filterCSSRule,
+    insertionPoint,
+    styleElementAttributes,
+    compareMediaQueries = defaultCompareMediaQueries,
+  } = options;
   const renderer: GriffelRenderer = {
     insertionCache: {},
     stylesheets: {},
@@ -60,13 +70,20 @@ export function createDOMRenderer(
         // This is a hot path in rendering styles: ".length" is cached in "l" var to avoid accesses the property
         for (let i = 0, l = cssRulesForBucket.length; i < l; i++) {
           const [ruleCSS, metadata] = normalizeCSSBucketEntry(cssRulesForBucket[i]);
-          const sheet = getStyleSheetForBucket(styleBucketName as StyleBucketName, target, renderer, metadata);
+          const sheet = getStyleSheetForBucket(
+            styleBucketName as StyleBucketName,
+            targetDocument,
+            insertionPoint || null,
+            renderer,
+            metadata,
+          );
 
           if (renderer.insertionCache[ruleCSS]) {
             continue;
           }
 
           renderer.insertionCache[ruleCSS] = styleBucketName as StyleBucketName;
+
           if (process.env.NODE_ENV !== 'production' && isDevToolsEnabled) {
             debugData.addCSSRule(ruleCSS);
           }
@@ -91,8 +108,8 @@ export function createDOMRenderer(
     },
   };
 
-  if (target && process.env.NODE_ENV !== 'production' && isDevToolsEnabled) {
-    injectDevTools(target);
+  if (targetDocument && process.env.NODE_ENV !== 'production' && isDevToolsEnabled) {
+    injectDevTools(targetDocument);
   }
 
   return renderer;
diff --git a/packages/core/src/renderer/getStyleSheetForBucket.ts b/packages/core/src/renderer/getStyleSheetForBucket.ts
index 2b92f46..1b104eb 100644
--- a/packages/core/src/renderer/getStyleSheetForBucket.ts
+++ b/packages/core/src/renderer/getStyleSheetForBucket.ts
@@ -45,7 +45,8 @@ const styleBucketOrderingMap = styleBucketOrdering.reduce((acc, cur, j) => {
  */
 export function getStyleSheetForBucket(
   bucketName: StyleBucketName,
-  target: Document | undefined,
+  targetDocument: Document | undefined,
+  insertionPoint: HTMLElement | null,
   renderer: GriffelRenderer,
   metadata: Record<string, unknown> = {},
 ): IsomorphicStyleSheet {
@@ -53,7 +54,7 @@ export function getStyleSheetForBucket(
   const stylesheetKey: StyleBucketName | string = isMediaBucket ? ((bucketName + metadata['m']) as string) : bucketName;
 
   if (!renderer.stylesheets[stylesheetKey]) {
-    const tag: HTMLStyleElement | undefined = target && target.createElement('style');
+    const tag: HTMLStyleElement | undefined = targetDocument && targetDocument.createElement('style');
     const stylesheet = createIsomorphicStyleSheet(tag, bucketName, {
       ...renderer.styleElementAttributes,
       ...(isMediaBucket && { media: metadata['m'] as string }),
@@ -61,9 +62,11 @@ export function getStyleSheetForBucket(
 
     renderer.stylesheets[stylesheetKey] = stylesheet;
 
-    if (target && tag) {
-      const elementSibling = findElementSibling(target, bucketName, renderer, metadata);
-      target.head.insertBefore(tag, elementSibling);
+    if (targetDocument && tag) {
+      targetDocument.head.insertBefore(
+        tag,
+        findInsertionPoint(targetDocument, insertionPoint, bucketName, renderer, metadata),
+      );
     }
   }
 
@@ -71,33 +74,37 @@ export function getStyleSheetForBucket(
 }
 
 /**
- * Finds an element before which the new bucket style element should be inserted following the
- * bucket sort order
+ * Finds an element before which the new bucket style element should be inserted following the bucket sort order.
  *
- * @param target - document
+ * @param targetDocument - A document
+ * @param insertionPoint - An element that will be used as an initial insertion point
  * @param targetBucket - The bucket that should be inserted to DOM
  * @param renderer - Griffel renderer
  * @param metadata - metadata for CSS rule
  * @returns - Smallest style element with greater sort order than the current bucket
  */
-function findElementSibling(
-  target: Document,
+function findInsertionPoint(
+  targetDocument: Document,
+  insertionPoint: HTMLElement | null,
   targetBucket: StyleBucketName,
   renderer: GriffelRenderer,
   metadata?: Record<string, unknown>,
-) {
+): Node | null {
   const targetOrder = styleBucketOrderingMap[targetBucket];
 
   // Similar to javascript sort comparators where
   // a positive value is increasing sort order
   // a negative value is decreasing sort order
-  let comparer: (el: HTMLStyleElement) => number = (el: HTMLStyleElement) =>
+  let comparer: (el: HTMLStyleElement) => number = el =>
     targetOrder - styleBucketOrderingMap[el.getAttribute(DATA_BUCKET_ATTR) as StyleBucketName];
 
-  let styleElements = target.head.querySelectorAll<HTMLStyleElement>(`[${DATA_BUCKET_ATTR}]`);
+  let styleElements = targetDocument.head.querySelectorAll<HTMLStyleElement>(`[${DATA_BUCKET_ATTR}]`);
 
   if (targetBucket === 'm' && metadata) {
-    const mediaElements = target.head.querySelectorAll<HTMLStyleElement>(`[${DATA_BUCKET_ATTR}="${targetBucket}"]`);
+    const mediaElements = targetDocument.head.querySelectorAll<HTMLStyleElement>(
+      `[${DATA_BUCKET_ATTR}="${targetBucket}"]`,
+    );
+
     // only reduce the scope of the search and change comparer
     // if there are other media buckets already on the page
     if (mediaElements.length) {
@@ -106,11 +113,22 @@ function findElementSibling(
     }
   }
 
-  for (const styleElement of styleElements) {
-    if (comparer(styleElement) < 0) {
-      return styleElement;
+  const length = styleElements.length;
+  let index = length - 1;
+
+  while (index >= 0) {
+    const styleElement = styleElements.item(index);
+
+    if (comparer(styleElement) > 0) {
+      return styleElement.nextSibling;
     }
+
+    index--;
+  }
+
+  if (length > 0) {
+    return styleElements.item(0);
   }
 
-  return null;
+  return insertionPoint ? insertionPoint.nextSibling : null;
 }
diff --git a/packages/react/README.md b/packages/react/README.md
index f8d7144..1e5e8f4 100644
--- a/packages/react/README.md
+++ b/packages/react/README.md
@@ -15,6 +15,7 @@ A package with wrappers and APIs to be used with React.js.
 - [`makeStaticStyles()`](#makestaticstyles)
 - [`makeResetStyles()`](#makeresetstyles)
 - [`createDOMRenderer()`, `RendererProvider`](#createdomrenderer-rendererprovider)
+  - [insertionPoint](#insertionpoint)
   - [styleElementAttributes](#styleelementattributes)
 - [`TextDirectionProvider`](#textdirectionprovider)
 - [Shorthands](#shorthands)
@@ -375,6 +376,30 @@ function App(props) {
 }
 ```
 
+### insertionPoint
+
+If specified, a renderer will insert created style tags after this element:
+
+```js
+import { createDOMRenderer } from '@griffel/react';
+
+const insertionPoint = document.head.querySelector('#foo');
+const renderer = createDOMRenderer(document, {
+  insertionPoint,
+});
+```
+
+```html
+<html>
+  <head>
+    <style id="foo" />
+    <!-- Style elements created by Griffel will be inserted after "#foo" element -->
+    <style data-make-styles-bucket="d" />
+    <style id="bar" />
+  </head>
+</html>
+```
+
 ### styleElementAttributes
 
 A map of attributes that's passed to the generated style elements. For example, is useful to set ["nonce" attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/nonce).
@@ -382,7 +407,7 @@ A map of attributes that's passed to the generated style elements. For example,
 ```js
 import { createDOMRenderer } from '@griffel/react';
 
-const renderer = createDOMRenderer(targetDocument, {
+const renderer = createDOMRenderer(document, {
   styleElementAttributes: {
     nonce: 'random',
   },


Test patch:
diff --git a/packages/core/src/renderer/createDOMRenderer.test.ts b/packages/core/src/renderer/createDOMRenderer.test.ts
index b2a3aef..b477687 100644
--- a/packages/core/src/renderer/createDOMRenderer.test.ts
+++ b/packages/core/src/renderer/createDOMRenderer.test.ts
@@ -81,4 +81,44 @@ describe('createDOMRenderer', () => {
       ]
     `);
   });
+
+  it('handles "insertionPoint"', () => {
+    const elementA = document.createElement('style');
+    const elementB = document.createElement('style');
+
+    elementA.setAttribute('data-test', 'A');
+    elementB.setAttribute('data-test', 'B');
+
+    document.head.appendChild(elementA);
+    document.head.appendChild(elementB);
+
+    const renderer = createDOMRenderer(document, {
+      insertionPoint: elementA,
+    });
+
+    // CSS rules are redundant for this test, but they are necessary as they trigger style nodes creation
+    const cssRules: CSSRulesByBucket = {
+      d: ['.foo { color: red; }'],
+      h: ['.foo:hover { color: blue; }'],
+    };
+
+    renderer.insertCSSRules(cssRules);
+
+    expect(document.head.children).toMatchInlineSnapshot(`
+      HTMLCollection [
+        <style
+          data-test="A"
+        />,
+        <style
+          data-make-styles-bucket="d"
+        />,
+        <style
+          data-make-styles-bucket="h"
+        />,
+        <style
+          data-test="B"
+        />,
+      ]
+    `);
+  });
 });
diff --git a/packages/core/src/renderer/getStyleSheetForBucket.test.ts b/packages/core/src/renderer/getStyleSheetForBucket.test.ts
index 05888b8..abf4186 100644
--- a/packages/core/src/renderer/getStyleSheetForBucket.test.ts
+++ b/packages/core/src/renderer/getStyleSheetForBucket.test.ts
@@ -1,6 +1,6 @@
-import { getStyleSheetForBucket, styleBucketOrdering } from './getStyleSheetForBucket';
-import { createDOMRenderer } from './createDOMRenderer';
 import { DATA_BUCKET_ATTR } from '../constants';
+import { createDOMRenderer } from './createDOMRenderer';
+import { getStyleSheetForBucket, styleBucketOrdering } from './getStyleSheetForBucket';
 
 function createFakeDocument(): Document {
   const doc = document.implementation.createDocument('http://www.w3.org/1999/xhtml', 'html', null);
@@ -10,22 +10,49 @@ function createFakeDocument(): Document {
 }
 
 describe('getStyleSheetForBucket', () => {
+  it('creates elements order', () => {
+    const target = createFakeDocument();
+    const renderer = createDOMRenderer();
+
+    getStyleSheetForBucket('l', target, null, renderer);
+    getStyleSheetForBucket('r', target, null, renderer);
+    getStyleSheetForBucket('a', target, null, renderer);
+    getStyleSheetForBucket('d', target, null, renderer);
+
+    expect(target.head.children).toMatchInlineSnapshot(`
+      HTMLCollection [
+        <style
+          data-make-styles-bucket="r"
+        />,
+        <style
+          data-make-styles-bucket="d"
+        />,
+        <style
+          data-make-styles-bucket="l"
+        />,
+        <style
+          data-make-styles-bucket="a"
+        />,
+      ]
+    `);
+  });
+
   it('sets "data-make-styles-bucket" attribute in order', () => {
     const target = createFakeDocument();
     const renderer = createDOMRenderer();
 
-    getStyleSheetForBucket('r', target, renderer);
-    getStyleSheetForBucket('l', target, renderer);
-    getStyleSheetForBucket('d', target, renderer);
-    getStyleSheetForBucket('v', target, renderer);
-    getStyleSheetForBucket('a', target, renderer);
-    getStyleSheetForBucket('i', target, renderer);
-    getStyleSheetForBucket('h', target, renderer);
-    getStyleSheetForBucket('m', target, renderer);
-    getStyleSheetForBucket('w', target, renderer);
-    getStyleSheetForBucket('t', target, renderer);
-    getStyleSheetForBucket('k', target, renderer);
-    getStyleSheetForBucket('f', target, renderer);
+    getStyleSheetForBucket('r', target, null, renderer);
+    getStyleSheetForBucket('l', target, null, renderer);
+    getStyleSheetForBucket('d', target, null, renderer);
+    getStyleSheetForBucket('v', target, null, renderer);
+    getStyleSheetForBucket('a', target, null, renderer);
+    getStyleSheetForBucket('i', target, null, renderer);
+    getStyleSheetForBucket('h', target, null, renderer);
+    getStyleSheetForBucket('m', target, null, renderer);
+    getStyleSheetForBucket('w', target, null, renderer);
+    getStyleSheetForBucket('t', target, null, renderer);
+    getStyleSheetForBucket('k', target, null, renderer);
+    getStyleSheetForBucket('f', target, null, renderer);
 
     const styleElements = target.head.querySelectorAll(`[${DATA_BUCKET_ATTR}]`);
     const styleElementOrder = Array.from(styleElements).map(styleElement =>
@@ -43,27 +70,27 @@ describe('getStyleSheetForBucket', () => {
       },
     });
 
-    getStyleSheetForBucket('l', target, renderer);
-    getStyleSheetForBucket('d', target, renderer);
-    getStyleSheetForBucket('v', target, renderer);
+    getStyleSheetForBucket('l', target, null, renderer);
+    getStyleSheetForBucket('d', target, null, renderer);
+    getStyleSheetForBucket('v', target, null, renderer);
 
-    getStyleSheetForBucket('m', target, renderer, { m: '(max-width: 3px)' });
+    getStyleSheetForBucket('m', target, null, renderer, { m: '(max-width: 3px)' });
 
-    getStyleSheetForBucket('a', target, renderer);
-    getStyleSheetForBucket('i', target, renderer);
+    getStyleSheetForBucket('a', target, null, renderer);
+    getStyleSheetForBucket('i', target, null, renderer);
 
-    getStyleSheetForBucket('m', target, renderer, { m: '(max-width: 1px)' });
+    getStyleSheetForBucket('m', target, null, renderer, { m: '(max-width: 1px)' });
 
-    getStyleSheetForBucket('h', target, renderer);
+    getStyleSheetForBucket('h', target, null, renderer);
 
-    getStyleSheetForBucket('m', target, renderer, { m: '(max-width: 4px)' });
+    getStyleSheetForBucket('m', target, null, renderer, { m: '(max-width: 4px)' });
 
-    getStyleSheetForBucket('w', target, renderer);
-    getStyleSheetForBucket('t', target, renderer);
-    getStyleSheetForBucket('k', target, renderer);
-    getStyleSheetForBucket('f', target, renderer);
+    getStyleSheetForBucket('w', target, null, renderer);
+    getStyleSheetForBucket('t', target, null, renderer);
+    getStyleSheetForBucket('k', target, null, renderer);
+    getStyleSheetForBucket('f', target, null, renderer);
 
-    getStyleSheetForBucket('m', target, renderer, { m: '(max-width: 2px)' });
+    getStyleSheetForBucket('m', target, null, renderer, { m: '(max-width: 2px)' });
 
     const styleElements = target.head.querySelectorAll(`[${DATA_BUCKET_ATTR}]`);
     const styleElementOrder = Array.from(styleElements).map(styleElement =>
@@ -93,4 +120,44 @@ describe('getStyleSheetForBucket', () => {
       .filter(Boolean);
     expect(actualMediaQueryOrder).toEqual(mediaQueryOrder);
   });
+
+  it('handles "insertionPoint"', () => {
+    const target = createFakeDocument();
+    const renderer = createDOMRenderer();
+
+    const elementA = target.createElement('style');
+    const elementB = target.createElement('style');
+    const elementC = target.createElement('style');
+
+    elementA.setAttribute('data-test', 'A');
+    elementB.setAttribute('data-test', 'B');
+    elementC.setAttribute('data-test', 'C');
+
+    target.head.appendChild(elementA);
+    target.head.appendChild(elementB);
+    target.head.appendChild(elementC);
+
+    getStyleSheetForBucket('r', target, elementB, renderer);
+    getStyleSheetForBucket('d', target, elementB, renderer);
+
+    expect(target.head.children).toMatchInlineSnapshot(`
+      HTMLCollection [
+        <style
+          data-test="A"
+        />,
+        <style
+          data-test="B"
+        />,
+        <style
+          data-make-styles-bucket="r"
+        />,
+        <style
+          data-make-styles-bucket="d"
+        />,
+        <style
+          data-test="C"
+        />,
+      ]
+    `);
+  });
 });