Repository: microsoft/griffel
Pull request #80 (2022-03-22 11:54:27)

feat: add CSS extraction
This PR is the last one to support CSS extraction.

The PR:
- add sorting of CSS rules and implements the same logic as #141 with Stylis
- adds new fixtures and tests
- cleanup `@internal` modifiers and adds necessary exports from `@griffel/core`


Patch:
diff --git a/change/@griffel-core-136e2d80-22ac-475b-b95d-7608a762fa81.json b/change/@griffel-core-136e2d80-22ac-475b-b95d-7608a762fa81.json
new file mode 100644
index 0000000..15399e1
--- /dev/null
+++ b/change/@griffel-core-136e2d80-22ac-475b-b95d-7608a762fa81.json
@@ -0,0 +1,7 @@
+{
+  "type": "patch",
+  "comment": "chore: export getStyleBucketName & normalizeCSSBucketEntry functions for internal usage",
+  "packageName": "@griffel/core",
+  "email": "olfedias@microsoft.com",
+  "dependentChangeType": "patch"
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index cf7b0e9..39624f6 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -40,7 +40,6 @@ export const shorthands = {
 
 export { createDOMRenderer } from './renderer/createDOMRenderer';
 export type { CreateDOMRendererOptions } from './renderer/createDOMRenderer';
-export { styleBucketOrdering } from './renderer/getStyleSheetForBucket';
 export { rehydrateRendererCache } from './renderer/rehydrateRendererCache';
 
 export { mergeClasses } from './mergeClasses';
@@ -48,8 +47,12 @@ export { makeStaticStyles } from './makeStaticStyles';
 export { makeStyles } from './makeStyles';
 export { resolveStyleRulesForSlots } from './resolveStyleRulesForSlots';
 
-// Private exports, are used by build time transforms
+// Private exports, are used by build time transforms or other tools
 export { __css } from './__css';
+export { normalizeCSSBucketEntry } from './runtime/utils/normalizeCSSBucketEntry';
+export { styleBucketOrdering } from './renderer/getStyleSheetForBucket';
+export { defaultCompareMediaQueries } from './renderer/createDOMRenderer';
+export { getStyleBucketName } from './runtime/getStyleBucketName';
 export { reduceToClassNameForSlots } from './runtime/reduceToClassNameForSlots';
 export { resolveStyleRules } from './runtime/resolveStyleRules';
 export { __styles } from './__styles';
diff --git a/packages/core/src/renderer/createDOMRenderer.ts b/packages/core/src/renderer/createDOMRenderer.ts
index cb12d0e..805edd1 100644
--- a/packages/core/src/renderer/createDOMRenderer.ts
+++ b/packages/core/src/renderer/createDOMRenderer.ts
@@ -31,7 +31,8 @@ export interface CreateDOMRendererOptions {
   compareMediaQueries?: (a: string, b: string) => number;
 }
 
-const defaultCompareMediaQueries = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
+/** @internal */
+export const defaultCompareMediaQueries = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
 
 /**
  * Creates a new instances of a renderer.
diff --git a/packages/core/src/renderer/getStyleSheetForBucket.ts b/packages/core/src/renderer/getStyleSheetForBucket.ts
index 631d0fc..3abc69c 100644
--- a/packages/core/src/renderer/getStyleSheetForBucket.ts
+++ b/packages/core/src/renderer/getStyleSheetForBucket.ts
@@ -5,7 +5,7 @@ import { createIsomorphicStyleSheet } from './createIsomorphicStyleSheet';
 /**
  * Ordered style buckets using their short pseudo name.
  *
- * @private
+ * @internal
  */
 export const styleBucketOrdering: StyleBucketName[] = [
   // catch-all
diff --git a/packages/core/src/runtime/getStyleBucketName.ts b/packages/core/src/runtime/getStyleBucketName.ts
index cebd6bd..108906a 100644
--- a/packages/core/src/runtime/getStyleBucketName.ts
+++ b/packages/core/src/runtime/getStyleBucketName.ts
@@ -39,6 +39,8 @@ const pseudosMap: Record<string, StyleBucketName | undefined> = {
  * "h"
  * "f"
  * ```
+ *
+ * @internal
  */
 export function getStyleBucketName(pseudo: string, layer: string, media: string, support: string): StyleBucketName {
   if (media) {
diff --git a/packages/core/src/runtime/reduceToClassNameForSlots.ts b/packages/core/src/runtime/reduceToClassNameForSlots.ts
index f96997f..270e7c8 100644
--- a/packages/core/src/runtime/reduceToClassNameForSlots.ts
+++ b/packages/core/src/runtime/reduceToClassNameForSlots.ts
@@ -32,7 +32,7 @@ export function reduceToClassName(classMap: CSSClassesMap, dir: 'ltr' | 'rtl'):
  * Reduces classname maps for slots to classname strings. Registers them in a definition cache to be used by
  * `mergeClasses()`.
  *
- * @private
+ * @internal
  */
 export function reduceToClassNameForSlots<Slots extends string | number>(
   classesMapBySlot: CSSClassesMapBySlot<Slots>,
diff --git a/packages/core/src/runtime/utils/normalizeCSSBucketEntry.ts b/packages/core/src/runtime/utils/normalizeCSSBucketEntry.ts
index bd68176..91958b4 100644
--- a/packages/core/src/runtime/utils/normalizeCSSBucketEntry.ts
+++ b/packages/core/src/runtime/utils/normalizeCSSBucketEntry.ts
@@ -1,6 +1,8 @@
 import { CSSBucketEntry } from '../../types';
 
 /**
+ * @internal
+ *
  * @param entry - CSS bucket entry that can be either a string or an array
  * @returns An array where the first element is the CSS rule
  */
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 77c87c2..fd3c8ec 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -138,7 +138,7 @@ export type CSSClasses = /* ltrClassName */ string | [/* ltrClassName */ string,
 export type CSSClassesMap = Record<PropertyHash, CSSClasses>;
 export type CSSClassesMapBySlot<Slots extends string | number> = Record<Slots, CSSClassesMap>;
 
-export interface CSSRulesByBucket {
+export type CSSRulesByBucket = {
   // default
   d?: CSSBucketEntry[];
   // link
@@ -161,7 +161,7 @@ export interface CSSRulesByBucket {
   t?: CSSBucketEntry[];
   // @media rules
   m?: CSSBucketEntry[];
-}
+};
 
 export type CSSBucketEntry = string | [string, Record<string, unknown>];
 
diff --git a/packages/devtools/package.json b/packages/devtools/package.json
index 6708bdd..e5e065c 100644
--- a/packages/devtools/package.json
+++ b/packages/devtools/package.json
@@ -10,6 +10,7 @@
   },
   "sideEffects": false,
   "dependencies": {
+    "@emotion/hash": "^0.8.0",
     "@griffel/react": "^1.2.0",
     "js-beautify": "^1.14.0",
     "prism-react-renderer": "1.2.1",
diff --git a/packages/devtools/src/FlattenView.tsx b/packages/devtools/src/FlattenView.tsx
index c60cfbd..278abff 100644
--- a/packages/devtools/src/FlattenView.tsx
+++ b/packages/devtools/src/FlattenView.tsx
@@ -1,4 +1,5 @@
 import * as React from 'react';
+import hash from '@emotion/hash';
 import { makeStaticStyles, makeStyles, shorthands } from '@griffel/react';
 
 import { SlotCSSRules } from './SlotCSSRules';
@@ -19,6 +20,11 @@ const useStyles = makeStyles({
     columnGap: '4px',
     fontFamily: 'system-ui',
     fontSize: '12px',
+
+    position: 'sticky',
+    top: 0,
+    zIndex: 1,
+    backgroundColor: tokens.background,
   },
   input: {
     color: 'inherit',
@@ -92,9 +98,10 @@ export const FlattenView: React.FC<FlattenViewProps> = props => {
       </div>
       <div className={classes.rules}>
         <ViewContext.Provider value={contextValue}>
-          {filteredSlots.map(({ slot, rules }) => (
-            <SlotCSSRules key={slot} slot={slot} atomicRules={rules} />
-          ))}
+          {filteredSlots.map(({ slot, rules }) => {
+            const key = slot + rules.map(rule => rule.cssRule).join('');
+            return <SlotCSSRules key={key} slot={slot} atomicRules={rules} />;
+          })}
         </ViewContext.Provider>
       </div>
     </div>
diff --git a/packages/devtools/src/getMonolithicCSSRules.ts b/packages/devtools/src/getMonolithicCSSRules.ts
index ca4fcc6..be43cbb 100644
--- a/packages/devtools/src/getMonolithicCSSRules.ts
+++ b/packages/devtools/src/getMonolithicCSSRules.ts
@@ -1,16 +1,28 @@
 import * as stylis from 'stylis';
 import type { AtomicRules, MonolithicAtRules, MonolithicRules, RuleDetail } from './types';
 
+const griffelClassNameRegex = /^\.f[0-9a-z]+$/;
+
 function parseRuleElement(element: stylis.Element, overriddenBy?: string) {
   // example of `value`: `.f3xbvq9:hover`
   // `children` contains all CSS rules under the `value` selector
   const { value: classNameSelector, children } = element;
 
   if (Array.isArray(children)) {
-    const selector = stylis.tokenize(classNameSelector).slice(1).join('');
+    let className: string | undefined;
+    const selector = stylis
+      .tokenize(classNameSelector)
+      .filter(token => {
+        if (griffelClassNameRegex.test(token)) {
+          className = token.slice(1);
+          return false;
+        }
+        return true;
+      })
+      .join('');
     const rules: RuleDetail[] = children.map(child => ({
       css: child.value,
-      className: stylis.tokenize(classNameSelector)[0].slice(1),
+      className: className ?? stylis.tokenize(classNameSelector)[0].slice(1),
       overriddenBy,
     }));
     return {
diff --git a/packages/devtools/src/stories/FlattenView.stories.tsx b/packages/devtools/src/stories/FlattenView.stories.tsx
index 7949b18..864e295 100644
--- a/packages/devtools/src/stories/FlattenView.stories.tsx
+++ b/packages/devtools/src/stories/FlattenView.stories.tsx
@@ -79,7 +79,7 @@ const debugResultRoot: DebugResult = {
         f7wpa5l: '@media screen and (max-width: 992px){.f7wpa5l:hover{color:red;}}',
         fy4xd57: '@media screen and (max-width: 992px){.fy4xd57:hover{background-color:blue;}}',
       },
-      slot: 'primaryRed',
+      slot: 'primary',
     },
   ],
   debugClassNames: [
@@ -105,6 +105,8 @@ export const Default: Story<{ theme: 'dark' | 'light' }> = ({ theme }) => {
         ...tokens,
         border: '3px solid gray',
         width: 400,
+        height: 600,
+        overflowY: 'auto',
       }}
     >
       <FlattenView debugResultRoot={debugResultRoot} />
diff --git a/packages/webpack-extraction-plugin/.eslintrc.json b/packages/webpack-extraction-plugin/.eslintrc.json
index 02850a5..712601a 100644
--- a/packages/webpack-extraction-plugin/.eslintrc.json
+++ b/packages/webpack-extraction-plugin/.eslintrc.json
@@ -1,6 +1,6 @@
 {
   "extends": ["../../.eslintrc.json"],
-  "ignorePatterns": ["!**/*", "__fixtures__/*/output.ts"],
+  "ignorePatterns": ["!**/*", "__fixtures__/**/*/output.ts"],
   "overrides": [
     {
       "files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
diff --git a/packages/webpack-extraction-plugin/__fixtures__/webpack/multiple/fs.json b/packages/webpack-extraction-plugin/__fixtures__/webpack/multiple/fs.json
new file mode 100644
index 0000000..966fca3
--- /dev/null
+++ b/packages/webpack-extraction-plugin/__fixtures__/webpack/multiple/fs.json
@@ -0,0 +1,1 @@
+["bundle.js", "griffel.css"]
diff --git a/packages/webpack-extraction-plugin/__fixtures__/webpack/multiple/output.css b/packages/webpack-extraction-plugin/__fixtures__/webpack/multiple/output.css
new file mode 100644
index 0000000..1bbd349
--- /dev/null
+++ b/packages/webpack-extraction-plugin/__fixtures__/webpack/multiple/output.css
@@ -0,0 +1,6 @@
+.fe3e8s9 {
+  color: red;
+}
+.fcnqdeg {
+  background-color: green;
+}
diff --git a/packages/webpack-extraction-plugin/__fixtures__/webpack/rules-deduplication/code.ts b/packages/webpack-extraction-plugin/__fixtures__/webpack/rules-deduplication/code.ts
new file mode 100644
index 0000000..b1c9aca
--- /dev/null
+++ b/packages/webpack-extraction-plugin/__fixtures__/webpack/rules-deduplication/code.ts
@@ -0,0 +1,27 @@
+import { __styles } from '@griffel/react';
+
+const styles1 = __styles(
+  {
+    root: {
+      sj55zd: 'fe3e8s9',
+      uwmqm3: ['fycuoez', 'f8wuabp'],
+    },
+  },
+  {
+    d: ['.fe3e8s9{color:red;}', '.fcnqdeg{background-color:green;}'],
+  },
+);
+
+const styles2 = __styles(
+  {
+    root: {
+      sj55zd: 'fe3e8s9',
+      uwmqm3: ['fycuoez', 'f8wuabp'],
+    },
+  },
+  {
+    d: ['.fe3e8s9{color:red;}', '.fcnqdeg{background-color:green;}'],
+  },
+);
+
+console.log(styles1, styles2);
diff --git a/packages/webpack-extraction-plugin/__fixtures__/webpack/rules-deduplication/fs.json b/packages/webpack-extraction-plugin/__fixtures__/webpack/rules-deduplication/fs.json
new file mode 100644
index 0000000..966fca3
--- /dev/null
+++ b/packages/webpack-extraction-plugin/__fixtures__/webpack/rules-deduplication/fs.json
@@ -0,0 +1,1 @@
+["bundle.js", "griffel.css"]
diff --git a/packages/webpack-extraction-plugin/__fixtures__/webpack/rules-deduplication/output.css b/packages/webpack-extraction-plugin/__fixtures__/webpack/rules-deduplication/output.css
new file mode 100644
index 0000000..1bbd349
--- /dev/null
+++ b/packages/webpack-extraction-plugin/__fixtures__/webpack/rules-deduplication/output.css
@@ -0,0 +1,6 @@
+.fe3e8s9 {
+  color: red;
+}
+.fcnqdeg {
+  background-color: green;
+}
diff --git a/packages/webpack-extraction-plugin/__fixtures__/webpack/style-buckets/code.ts b/packages/webpack-extraction-plugin/__fixtures__/webpack/style-buckets/code.ts
new file mode 100644
index 0000000..d60249f
--- /dev/null
+++ b/packages/webpack-extraction-plugin/__fixtures__/webpack/style-buckets/code.ts
@@ -0,0 +1,27 @@
+import { __styles } from '@griffel/react';
+
+const styles = __styles(
+  {},
+  // Classes in this test are intentionally not realistic to simplify snapshots
+  {
+    d: ['.color-red { color: red; }', '.animation-name { animation-name: foo; }'],
+    l: ['.color-orange:link { color: orange; }'],
+    v: ['.color-purple:visited { color: purple; }'],
+    w: ['.color-pink:focus-within { color: pink; }'],
+    f: ['.color-blue:focus { color: blue; }'],
+    i: ['.color-light-blue:focus-visible { color: salmon; }'],
+    h: ['.color-yellow:hover { color: yellow; }'],
+    a: ['.color-black:active { color: black; }'],
+    k: ['@keyframes foo { from{ transform:rotate(0deg); } to { transform:rotate(360deg); } }'],
+    m: [
+      [
+        '@media (forced-colors: active) { .color-magenta { color: magenta; } }',
+        {
+          m: '(forced-colors: active)',
+        },
+      ],
+    ],
+  },
+);
+
+console.log(styles);
diff --git a/packages/webpack-extraction-plugin/__fixtures__/webpack/style-buckets/fs.json b/packages/webpack-extraction-plugin/__fixtures__/webpack/style-buckets/fs.json
new file mode 100644
index 0000000..966fca3
--- /dev/null
+++ b/packages/webpack-extraction-plugin/__fixtures__/webpack/style-buckets/fs.json
@@ -0,0 +1,1 @@
+["bundle.js", "griffel.css"]
diff --git a/packages/webpack-extraction-plugin/__fixtures__/webpack/style-buckets/output.css b/packages/webpack-extraction-plugin/__fixtures__/webpack/style-buckets/output.css
new file mode 100644
index 0000000..432f7ce
--- /dev/null
+++ b/packages/webpack-extraction-plugin/__fixtures__/webpack/style-buckets/output.css
@@ -0,0 +1,40 @@
+.color-red {
+  color: red;
+}
+.animation-name {
+  animation-name: foo;
+}
+.color-orange:link {
+  color: orange;
+}
+.color-purple:visited {
+  color: purple;
+}
+.color-pink:focus-within {
+  color: pink;
+}
+.color-blue:focus {
+  color: blue;
+}
+.color-light-blue:focus-visible {
+  color: salmon;
+}
+.color-yellow:hover {
+  color: yellow;
+}
+.color-black:active {
+  color: black;
+}
+@keyframes foo {
+  from {
+    transform: rotate(0deg);
+  }
+  to {
+    transform: rotate(360deg);
+  }
+}
+@media (forced-colors: active) {
+  .color-magenta {
+    color: magenta;
+  }
+}
diff --git a/packages/webpack-extraction-plugin/package.json b/packages/webpack-extraction-plugin/package.json
index 0877c8e..1b40471 100644
--- a/packages/webpack-extraction-plugin/package.json
+++ b/packages/webpack-extraction-plugin/package.json
@@ -13,6 +13,7 @@
     "@babel/helper-plugin-utils": "^7.12.13",
     "loader-utils": "^2.0.0",
     "schema-utils": "^3.1.1",
+    "stylis": "^4.0.13",
     "tslib": "^2.1.0"
   },
   "peerDependencies": {
diff --git a/packages/webpack-extraction-plugin/src/GriffelCSSExtractionPlugin.ts b/packages/webpack-extraction-plugin/src/GriffelCSSExtractionPlugin.ts
index 2c83383..d25617a 100644
--- a/packages/webpack-extraction-plugin/src/GriffelCSSExtractionPlugin.ts
+++ b/packages/webpack-extraction-plugin/src/GriffelCSSExtractionPlugin.ts
@@ -1,6 +1,13 @@
+import { defaultCompareMediaQueries, GriffelRenderer } from '@griffel/core';
 import { Compilation } from 'webpack';
 import type { Compiler, sources } from 'webpack';
 
+import { sortCSSRules } from './sortCSSRules';
+
+type GriffelCSSExtractionPluginOptions = {
+  compareMediaQueries?: GriffelRenderer['compareMediaQueries'];
+};
+
 /**
  * Forces all files with `griffel.css` be concatenated into a single asset.
  */
@@ -31,6 +38,12 @@ function getAssetSourceContents(assetSource: sources.Source): string {
 export class GriffelCSSExtractionPlugin {
   static loader = require.resolve('./webpackLoader');
 
+  private readonly compareMediaQueries: NonNullable<GriffelCSSExtractionPluginOptions['compareMediaQueries']>;
+
+  constructor(options?: GriffelCSSExtractionPluginOptions) {
+    this.compareMediaQueries = options?.compareMediaQueries ?? defaultCompareMediaQueries;
+  }
+
   apply(compiler: Compiler): void {
     forceCSSIntoOneStyleSheet(compiler);
 
@@ -47,12 +60,10 @@ export class GriffelCSSExtractionPlugin {
             return;
           }
 
-          // TODO: implement sorting for buckets and media queries
+          const unsortedCSSRules = getAssetSourceContents(griffelAsset);
+          const sortedCSSRules = sortCSSRules(unsortedCSSRules, this.compareMediaQueries);
 
-          compilation.updateAsset(
-            'griffel.css',
-            new compiler.webpack.sources.RawSource(getAssetSourceContents(griffelAsset)),
-          );
+          compilation.updateAsset('griffel.css', new compiler.webpack.sources.RawSource(sortedCSSRules));
         },
       );
     });
diff --git a/packages/webpack-extraction-plugin/src/babelPluginStripGriffelRuntime.ts b/packages/webpack-extraction-plugin/src/babelPluginStripGriffelRuntime.ts
index b50d184..5da2e04 100644
--- a/packages/webpack-extraction-plugin/src/babelPluginStripGriffelRuntime.ts
+++ b/packages/webpack-extraction-plugin/src/babelPluginStripGriffelRuntime.ts
@@ -1,6 +1,6 @@
 import { NodePath, PluginObj, PluginPass, types as t } from '@babel/core';
 import { declare } from '@babel/helper-plugin-utils';
-import type { CSSRulesByBucket } from '@griffel/core';
+import { CSSRulesByBucket, normalizeCSSBucketEntry } from '@griffel/core';
 
 type StripRuntimeBabelPluginOptions = never;
 
@@ -93,7 +93,13 @@ export const babelPluginStripGriffelRuntime = declare<
 
               const cssRulesByBucket = evaluationResult.value as CSSRulesByBucket;
 
-              Object.values(cssRulesByBucket).forEach(cssRules => {
+              Object.values(cssRulesByBucket).forEach(cssBucketEntries => {
+                const cssRules = cssBucketEntries.map(cssBucketEntry => {
+                  const [cssRule] = normalizeCSSBucketEntry(cssBucketEntry);
+
+                  return cssRule;
+                });
+
                 state.cssRules!.push(...cssRules);
               });
 
diff --git a/packages/webpack-extraction-plugin/src/sortCSSRules.ts b/packages/webpack-extraction-plugin/src/sortCSSRules.ts
new file mode 100644
index 0000000..0c93b1e
--- /dev/null
+++ b/packages/webpack-extraction-plugin/src/sortCSSRules.ts
@@ -0,0 +1,82 @@
+import { getStyleBucketName, GriffelRenderer, StyleBucketName, styleBucketOrdering } from '@griffel/core';
+import { compile, Element, KEYFRAMES, MEDIA, RULESET, serialize, stringify, SUPPORTS, tokenize } from 'stylis';
+
+export function getSelectorFromElement(element: Element) {
+  return tokenize(element.value).slice(1).join('');
+}
+
+export function getElementMetadata(element: Element): string {
+  if (element.type === MEDIA) {
+    return element.value.replace(/^@media/, '').trim();
+  }
+
+  return '';
+}
+
+export function getElementReference(element: Element, suffix = ''): string {
+  if (element.type === RULESET || element.type === KEYFRAMES) {
+    return element.value + suffix;
+  }
+
+  if (Array.isArray(element.children) && element.children.length === 1) {
+    return getElementReference(element.children[0], element.value + suffix);
+  }
+
+  function removeRootProperty(element: Element): unknown {
+    return {
+      ...element,
+      children: Array.isArray(element.children)
+        ? element.children.map(child => removeRootProperty(child))
+        : element.children,
+      root: undefined,
+      parent: undefined,
+    };
+  }
+
+  throw new Error(
+    [
+      'getElementReference(): An unhandled case, please report if it happens and provide debug information about an element:',
+      JSON.stringify(removeRootProperty(element), null, 2),
+    ].join('\n'),
+  );
+}
+
+export function getStyleBucketNameFromElement(element: Element): StyleBucketName {
+  if (element.type === KEYFRAMES) {
+    return 'k';
+  }
+
+  return getStyleBucketName(
+    getSelectorFromElement(element),
+    element.type === '@layer' ? element.value : '',
+    element.type === MEDIA ? element.value : '',
+    element.type === SUPPORTS ? element.value : '',
+  );
+}
+
+export function sortCSSRules(css: string, compareMediaQueries: GriffelRenderer['compareMediaQueries']): string {
+  const childElements = compile(css).map(element => ({
+    ...element,
+    bucketName: getStyleBucketNameFromElement(element),
+    metadata: getElementMetadata(element),
+    reference: getElementReference(element),
+  }));
+  const uniqueElements = childElements.reduce<Record<string, typeof childElements[0]>>((acc, element) => {
+    acc[element.reference] = element;
+
+    return acc;
+  }, {});
+  const sortedElements = Object.values(uniqueElements).sort((elementA, elementB) => {
+    if (elementA.bucketName === 'm' && elementB.bucketName === 'm') {
+      return compareMediaQueries(elementA.metadata, elementB.metadata);
+    }
+
+    if (elementA.bucketName === elementB.bucketName) {
+      return 0;
+    }
+
+    return styleBucketOrdering.indexOf(elementA.bucketName) - styleBucketOrdering.indexOf(elementB.bucketName);
+  });
+
+  return serialize(sortedElements, stringify);
+}


Test patch:
diff --git a/packages/devtools/src/getMonolithicCSSRules.test.ts b/packages/devtools/src/getMonolithicCSSRules.test.ts
index be80a8c..4fd5f80 100644
--- a/packages/devtools/src/getMonolithicCSSRules.test.ts
+++ b/packages/devtools/src/getMonolithicCSSRules.test.ts
@@ -2,46 +2,58 @@ import { getMonolithicCSSRules } from './getMonolithicCSSRules';
 
 describe('getMonolithicCSSRules', () => {
   it('group atomic css with class names selector', () => {
-    const rules = ['.abcdef0{background-color:red;}', '.abcdef1{color:red;}'].map(cssRule => ({ cssRule }));
+    const rules = ['.fabcde0{background-color:red;}', '.fabcde1{color:red;}'].map(cssRule => ({ cssRule }));
     expect(getMonolithicCSSRules(rules)).toEqual({
       '': [
         {
-          className: 'abcdef0',
+          className: 'fabcde0',
           css: 'background-color:red;',
         },
         {
-          className: 'abcdef1',
+          className: 'fabcde1',
           css: 'color:red;',
         },
       ],
     });
   });
 
+  it('group atomic css with child class names selector', () => {
+    const rules = ['.f1dx00p .class1{padding-right: 10px;}'].map(cssRule => ({ cssRule }));
+    expect(getMonolithicCSSRules(rules)).toEqual({
+      ' .class1': [
+        {
+          className: 'f1dx00p',
+          css: 'padding-right:10px;',
+        },
+      ],
+    });
+  });
+
   it('separate atomic css with class names selector and pseudo selector', () => {
     const rules = [
-      '.abcdef0{background-color:red;}',
-      '.abcdef1{display:block;}',
-      '.abcdef2:hover{display:none;}',
-      '.abcdef3:hover{color:red;}',
+      '.fabcde0{background-color:red;}',
+      '.fabcde1{display:block;}',
+      '.fabcde2:hover{display:none;}',
+      '.fabcde3:hover{color:red;}',
     ].map(cssRule => ({ cssRule }));
     expect(getMonolithicCSSRules(rules)).toEqual({
       '': [
         {
-          className: 'abcdef0',
+          className: 'fabcde0',
           css: 'background-color:red;',
         },
         {
-          className: 'abcdef1',
+          className: 'fabcde1',
           css: 'display:block;',
         },
       ],
       ':hover': [
         {
-          className: 'abcdef2',
+          className: 'fabcde2',
           css: 'display:none;',
         },
         {
-          className: 'abcdef3',
+          className: 'fabcde3',
           css: 'color:red;',
         },
       ],
@@ -50,30 +62,30 @@ describe('getMonolithicCSSRules', () => {
 
   it('group atomic css with media selector', () => {
     const rules = [
-      '.abcdef0{background-color:red;}',
-      '.abcdef1{display:block;}',
-      '.abcdef2:hover{display:none;}',
-      '.abcdef3:hover{color:red;}',
+      '.fabcde0{background-color:red;}',
+      '.fabcde1{display:block;}',
+      '.fabcde2:hover{display:none;}',
+      '.fabcde3:hover{color:red;}',
     ].map(cssRule => ({ cssRule: `@media screen and (max-width: 992px){${cssRule}}` }));
     expect(getMonolithicCSSRules(rules)).toEqual({
       '@media screen and (max-width: 992px)': {
         '': [
           {
-            className: 'abcdef0',
+            className: 'fabcde0',
             css: 'background-color:red;',
           },
           {
-            className: 'abcdef1',
+            className: 'fabcde1',
             css: 'display:block;',
           },
         ],
         ':hover': [
           {
-            className: 'abcdef2',
+            className: 'fabcde2',
             css: 'display:none;',
           },
           {
-            className: 'abcdef3',
+            className: 'fabcde3',
             css: 'color:red;',
           },
         ],
@@ -83,21 +95,21 @@ describe('getMonolithicCSSRules', () => {
 
   it('separate atomic css with different media selector', () => {
     const rules = [
-      '.abcdef0{background-color:red;}',
-      '@media screen and (max-width: 992px){.abcdef0{background-color:blue;}}',
-      '@media screen and (max-width: 1024px){.abcdef0{background-color:green;}}',
+      '.fabcde0{background-color:red;}',
+      '@media screen and (max-width: 992px){.fabcde0{background-color:blue;}}',
+      '@media screen and (max-width: 1024px){.fabcde0{background-color:green;}}',
     ].map(cssRule => ({ cssRule }));
     expect(getMonolithicCSSRules(rules)).toEqual({
       '': [
         {
-          className: 'abcdef0',
+          className: 'fabcde0',
           css: 'background-color:red;',
         },
       ],
       '@media screen and (max-width: 1024px)': {
         '': [
           {
-            className: 'abcdef0',
+            className: 'fabcde0',
             css: 'background-color:green;',
           },
         ],
@@ -105,7 +117,7 @@ describe('getMonolithicCSSRules', () => {
       '@media screen and (max-width: 992px)': {
         '': [
           {
-            className: 'abcdef0',
+            className: 'fabcde0',
             css: 'background-color:blue;',
           },
         ],
@@ -115,21 +127,21 @@ describe('getMonolithicCSSRules', () => {
 
   it('pass on overriddenBy information from atomic css to monolithic css', () => {
     const rules = [
-      { cssRule: '.abcdef0{background-color:red;}', overriddenBy: 'bcdefg0' },
-      { cssRule: '.abcdef0>div{color:green;}', overriddenBy: 'bcdefg1' },
-      { cssRule: '@media screen and (max-width: 992px){.abcdef0{background-color:blue;}}', overriddenBy: 'bcdefg2' },
+      { cssRule: '.fabcde0{background-color:red;}', overriddenBy: 'bcdefg0' },
+      { cssRule: '.fabcde0>div{color:green;}', overriddenBy: 'bcdefg1' },
+      { cssRule: '@media screen and (max-width: 992px){.fabcde0{background-color:blue;}}', overriddenBy: 'bcdefg2' },
     ];
     expect(getMonolithicCSSRules(rules)).toEqual({
       '': [
         {
-          className: 'abcdef0',
+          className: 'fabcde0',
           css: 'background-color:red;',
           overriddenBy: 'bcdefg0',
         },
       ],
       '>div': [
         {
-          className: 'abcdef0',
+          className: 'fabcde0',
           css: 'color:green;',
           overriddenBy: 'bcdefg1',
         },
@@ -137,7 +149,7 @@ describe('getMonolithicCSSRules', () => {
       '@media screen and (max-width: 992px)': {
         '': [
           {
-            className: 'abcdef0',
+            className: 'fabcde0',
             css: 'background-color:blue;',
             overriddenBy: 'bcdefg2',
           },
@@ -145,4 +157,35 @@ describe('getMonolithicCSSRules', () => {
       },
     });
   });
+
+  it('group atomic css when class name selector has selector in front', () => {
+    const rules = [
+      '[data-keyboard-nav] .fabcde0:focus{background-color:red;}',
+      '[data-keyboard-nav] .fabcde1{color:red;}',
+      '[data-keyboard-nav] .fabcde1 .class1{padding-top:10px;}',
+    ].map(cssRule => ({ cssRule }));
+    expect(getMonolithicCSSRules(rules)).toEqual({
+      '[data-keyboard-nav] ': [
+        {
+          className: 'fabcde1',
+          css: 'color:red;',
+          overriddenBy: undefined,
+        },
+      ],
+      '[data-keyboard-nav]  .class1': [
+        {
+          className: 'fabcde1',
+          css: 'padding-top:10px;',
+          overriddenBy: undefined,
+        },
+      ],
+      '[data-keyboard-nav] :focus': [
+        {
+          className: 'fabcde0',
+          css: 'background-color:red;',
+          overriddenBy: undefined,
+        },
+      ],
+    });
+  });
 });
diff --git a/packages/webpack-extraction-plugin/src/GriffelCSSExtractionPlugin.test.ts b/packages/webpack-extraction-plugin/src/GriffelCSSExtractionPlugin.test.ts
index af2d145..228d6e3 100644
--- a/packages/webpack-extraction-plugin/src/GriffelCSSExtractionPlugin.test.ts
+++ b/packages/webpack-extraction-plugin/src/GriffelCSSExtractionPlugin.test.ts
@@ -196,4 +196,13 @@ function testFixture(fixtureName: string, options: CompileOptions = {}) {
 describe('webpackLoader', () => {
   // Basic assertions
   testFixture('basic-rules');
+
+  // Multiple calls of __styles
+  testFixture('multiple');
+
+  // Deduplicate rules in stylesheet
+  testFixture('rules-deduplication');
+
+  // Sorting rules by buckets
+  testFixture('style-buckets');
 });
diff --git a/packages/webpack-extraction-plugin/src/sortCSSRules.test.ts b/packages/webpack-extraction-plugin/src/sortCSSRules.test.ts
new file mode 100644
index 0000000..58f8ab8
--- /dev/null
+++ b/packages/webpack-extraction-plugin/src/sortCSSRules.test.ts
@@ -0,0 +1,138 @@
+import { GriffelRenderer } from '@griffel/core';
+import * as prettier from 'prettier';
+import { compile } from 'stylis';
+
+import { getElementReference, getElementMetadata, sortCSSRules } from './sortCSSRules';
+
+export const cssSerializer: jest.SnapshotSerializerPlugin = {
+  test(value) {
+    return typeof value === 'string';
+  },
+  print(value) {
+    /**
+     * test function makes sure that value is the guarded type
+     */
+    const _value = value as string;
+
+    return prettier.format(_value, { parser: 'css' }).trim();
+  },
+};
+
+expect.addSnapshotSerializer(cssSerializer);
+
+describe('getElementReference', () => {
+  it.each`
+    css                                                                                       | reference
+    ${'.foo { color: red; }'}                                                                 | ${'.foo'}
+    ${'.foo:hover { color: red; }'}                                                           | ${'.foo:hover'}
+    ${'@media (max-width: 2px) { .foo { color: blue; } }'}                                    | ${'.foo@media (max-width: 2px)'}
+    ${'@keyframes foo { from { transform:rotate(0deg); } to { transform:rotate(360deg); } }'} | ${'@keyframes foo'}
+  `('returns "$reference" for "$css"', ({ css, reference }: { css: string; reference: string }) => {
+    const element = compile(css)[0];
+
+    expect(getElementReference(element)).toBe(reference);
+  });
+});
+
+describe('getElementMetadata', () => {
+  it.each`
+    css                                                    | metadata
+    ${'.foo { color: red; }'}                              | ${''}
+    ${'.foo:hover { color: red; }'}                        | ${''}
+    ${'@media (max-width: 2px) { .foo { color: blue; } }'} | ${'(max-width: 2px)'}
+  `('returns "$reference" for "$css"', ({ css, metadata }: { css: string; metadata: string }) => {
+    const element = compile(css)[0];
+
+    expect(getElementMetadata(element)).toBe(metadata);
+  });
+});
+
+describe('sortCSSRules', () => {
+  it('removes duplicate rules', () => {
+    const css = `
+      .baz { color: orange; }
+      .foo { color: red; }
+      .baz { color: orange; }
+
+      @media (max-width: 2px) { .foo { color: blue; } }
+      @media (max-width: 2px) { .yellow { color: blue; } }
+    `;
+
+    expect(sortCSSRules(css, () => 0)).toMatchInlineSnapshot(`
+      .baz {
+        color: orange;
+      }
+      .foo {
+        color: red;
+      }
+      @media (max-width: 2px) {
+        .foo {
+          color: blue;
+        }
+      }
+      @media (max-width: 2px) {
+        .yellow {
+          color: blue;
+        }
+      }
+    `);
+  });
+
+  it('sorts rules by buckets order', () => {
+    const css = `
+      .foo:focus { color: pink; }
+      .baz { color: orange; }
+      .foo:hover { color: yellow; }
+      .foo { color: red; }
+    `;
+
+    expect(sortCSSRules(css, () => 0)).toMatchInlineSnapshot(`
+      .baz {
+        color: orange;
+      }
+      .foo {
+        color: red;
+      }
+      .foo:focus {
+        color: pink;
+      }
+      .foo:hover {
+        color: yellow;
+      }
+    `);
+  });
+
+  it('sorts media queries', () => {
+    const css = `
+      @media (max-width: 2px) { .foo { color: blue; } }
+      @media (max-width: 1px) { .foo { color: red; } }
+      @media (max-width: 3px) { .foo { color: red; } }
+      .foo { color: green; }
+    `;
+
+    const mediaQueryOrder = ['(max-width: 1px)', '(max-width: 2px)', '(max-width: 3px)', '(max-width: 4px)'];
+    const compareMediaQueries: GriffelRenderer['compareMediaQueries'] = (a: string, b: string) =>
+      mediaQueryOrder.indexOf(a) - mediaQueryOrder.indexOf(b);
+
+    expect(sortCSSRules(css, compareMediaQueries)).toMatchInlineSnapshot(`
+      .foo {
+        color: green;
+      }
+      @media (max-width: 1px) {
+        .foo {
+          color: red;
+        }
+      }
+      @media (max-width: 2px) {
+        .foo {
+          color: blue;
+        }
+      }
+      @media (max-width: 3px) {
+        .foo {
+          color: red;
+        }
+      }
+    `);
+  });
+});