Repository: microsoft/griffel (ts)

Issue:
core/babel-preset: duplicated rules with at-rules
The following snippet with flipping properties in RTL causes CSS rule duplication:

```ts
import { makeStyles, shorthands } from "@griffel/core";

export default makeStyles({
  root: {
    "@media(forced-colors: active)": {
      paddingLeft: "8px",
      paddingRight: "8px"
    },
    "@supports (display: flex)": {
      paddingLeft: "4px",
      paddingRight: "4px"
    }
  }
});
```

This affects both CSS extraction and transformed code:

```js
// Transformed code
export const useStyles = __styles(
  {
    root: {
      cvzfql: ["f13wceqr", "fmtq1hn"],
      g42fl4: ["fmtq1hn", "f13wceqr"],
      B6rh290: ["f16pi78z", "fem5dos"],
      rth2jb: ["fem5dos", "f16pi78z"]
    }
  },
  {
    m: [
      [
        "@media (min-width: 600px){.f13wceqr{padding-left:4px;}.fmtq1hn{padding-right:4px;}}",
        {
          m: "(min-width: 600px)"
        }
      ],
      [
        "@media (min-width: 600px){.fmtq1hn{padding-right:4px;}.f13wceqr{padding-left:4px;}}",
        {
          m: "(min-width: 600px)"
        }
      ]
    ],
    t: [
      "@supports (display: flex){.f16pi78z{padding-left:4px;}.fem5dos{padding-right:4px;}}",
      "@supports (display: flex){.fem5dos{padding-right:4px;}.f16pi78z{padding-left:4px;}}"
    ]
  }
);
```

```css
// Extracted CSS
@media (forced-colors: active) {
  .f1xnl3gg {
    padding-left: 8px;
  }

  .fyzjxl6 {
    padding-right: 8px;
  }
}

@media (forced-colors: active) {
  .fyzjxl6 {
    padding-right: 8px;
  }

  .f1xnl3gg {
    padding-left: 8px;
  }
}
```

[Playground](https://griffel.js.org/try-it-out/?code=JYWwDg9gTgLgBAbziAhgawKYGUYE8A2GAzgDRxEAW0MFKAdgCZFwC%2BcAZlBCHAOQACAcyjB27DPgD0AY2gZeAbgBQSjAA9IsOAwzsUAV3zxUmHAWIAKBErhwuEGAC5EN2334gMDYCgvto0l4AtLL40ETOKNIwwABuGACUvM7Wbm5gKAzedIIAMrpOfAAcYGq8JK5pGVnAOQBKwIIUhbwlZRVpLB2sFSwJykA)


Pull request:
fix(core|babel-preset): deduplicate CSS rules in output
Fixes #372.

The fix contains two parts:
- **Sorting of classes inside rules (only for Atomic CSS)**
  This mandatory to handle RTL flipping on directional properties. Sorting is done via a Stylis plugin.

  ```css
  /* Before: two identical CSS rules */
  @media {
    .pl { padding-left: 10px }
    .pr { padding-right: 10px }
  }
  @media {
    .pr { padding-right: 10px }
    .pl { padding-left: 10px }
  }

  /* After: single CSS rule */
  @media {
    .pr { padding-right: 10px }
    .pl { padding-left: 10px }
  }
  ```
- **Fixes to deduplication in Babel plugin**
  `dedupeCSSRules()` did not handle rules with meta information i.e. media queries.

Patch:
diff --git a/README.md b/README.md
index a5a6de8..414f06c 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,9 @@ npm install @griffel/react
 yarn add @griffel/react
 ```
 
-[Read more](./packages/react)
+- [Quick start](https://griffel.js.org/react/install)
+- [API guide](https://griffel.js.org/react/api/make-styles)
+- [README](./packages/react)
 
 ## Learn more?
 
diff --git a/change/@griffel-babel-preset-0503a230-165c-4fb8-a3c8-46d1fd1d3211.json b/change/@griffel-babel-preset-0503a230-165c-4fb8-a3c8-46d1fd1d3211.json
new file mode 100644
index 0000000..bb9a9c1
--- /dev/null
+++ b/change/@griffel-babel-preset-0503a230-165c-4fb8-a3c8-46d1fd1d3211.json
@@ -0,0 +1,7 @@
+{
+  "type": "patch",
+  "comment": "fix: deduplicate CSS rules in output",
+  "packageName": "@griffel/babel-preset",
+  "email": "olfedias@microsoft.com",
+  "dependentChangeType": "patch"
+}
diff --git a/change/@griffel-core-ecfd0155-ed16-455d-9c39-2dbb4bb7c6ef.json b/change/@griffel-core-ecfd0155-ed16-455d-9c39-2dbb4bb7c6ef.json
new file mode 100644
index 0000000..4333948
--- /dev/null
+++ b/change/@griffel-core-ecfd0155-ed16-455d-9c39-2dbb4bb7c6ef.json
@@ -0,0 +1,7 @@
+{
+  "type": "patch",
+  "comment": "chore: sort CSS classes inside at rules",
+  "packageName": "@griffel/core",
+  "email": "olfedias@microsoft.com",
+  "dependentChangeType": "patch"
+}
diff --git a/packages/babel-preset/__fixtures__/at-rules/code.ts b/packages/babel-preset/__fixtures__/at-rules/code.ts
new file mode 100644
index 0000000..f2e1e89
--- /dev/null
+++ b/packages/babel-preset/__fixtures__/at-rules/code.ts
@@ -0,0 +1,14 @@
+import { makeStyles } from '@griffel/react';
+
+export const useStyles = makeStyles({
+  root: {
+    '@media (min-width: 600px)': {
+      paddingLeft: '4px',
+      paddingRight: '4px',
+    },
+    '@supports (display: flex)': {
+      paddingLeft: '4px',
+      paddingRight: '4px',
+    },
+  },
+});
diff --git a/packages/babel-preset/__fixtures__/at-rules/output.ts b/packages/babel-preset/__fixtures__/at-rules/output.ts
new file mode 100644
index 0000000..6310605
--- /dev/null
+++ b/packages/babel-preset/__fixtures__/at-rules/output.ts
@@ -0,0 +1,22 @@
+import { __styles } from '@griffel/react';
+export const useStyles = __styles(
+  {
+    root: {
+      cvzfql: ['f13wceqr', 'fmtq1hn'],
+      g42fl4: ['fmtq1hn', 'f13wceqr'],
+      B6rh290: ['f16pi78z', 'fem5dos'],
+      rth2jb: ['fem5dos', 'f16pi78z'],
+    },
+  },
+  {
+    m: [
+      [
+        '@media (min-width: 600px){.f13wceqr{padding-left:4px;}.fmtq1hn{padding-right:4px;}}',
+        {
+          m: '(min-width: 600px)',
+        },
+      ],
+    ],
+    t: ['@supports (display: flex){.f16pi78z{padding-left:4px;}.fem5dos{padding-right:4px;}}'],
+  },
+);
diff --git a/packages/babel-preset/src/transformPlugin.ts b/packages/babel-preset/src/transformPlugin.ts
index c5b6d5b..a1061e1 100644
--- a/packages/babel-preset/src/transformPlugin.ts
+++ b/packages/babel-preset/src/transformPlugin.ts
@@ -2,17 +2,12 @@ import { NodePath, PluginObj, PluginPass, types as t } from '@babel/core';
 import { declare } from '@babel/helper-plugin-utils';
 import { Module } from '@linaria/babel-preset';
 import shakerEvaluator from '@linaria/shaker';
-import {
-  resolveStyleRulesForSlots,
-  CSSRulesByBucket,
-  StyleBucketName,
-  GriffelStyle,
-  resolveResetStyleRules,
-} from '@griffel/core';
+import { resolveStyleRulesForSlots, GriffelStyle, resolveResetStyleRules } from '@griffel/core';
 import * as path from 'path';
 
 import { normalizeStyleRules } from './assets/normalizeStyleRules';
 import { replaceAssetsWithImports } from './assets/replaceAssetsWithImports';
+import { dedupeCSSRules } from './utils/dedupeCSSRules';
 import { evaluatePaths } from './utils/evaluatePaths';
 import { BabelPluginOptions } from './types';
 import { validateOptions } from './validateOptions';
@@ -107,20 +102,6 @@ function isRequireDeclarator(
   return false;
 }
 
-/**
- * Rules that are returned by `resolveStyles()` are not deduplicated.
- * It's critical to filter out duplicates for build-time transform to avoid duplicated rules in a bundle.
- */
-function dedupeCSSRules(cssRules: CSSRulesByBucket): CSSRulesByBucket {
-  (Object.keys(cssRules) as StyleBucketName[]).forEach(styleBucketName => {
-    cssRules[styleBucketName] = cssRules[styleBucketName]!.filter(
-      (rule, index, rules) => rules.indexOf(rule) === index,
-    );
-  });
-
-  return cssRules;
-}
-
 export const transformPlugin = declare<Partial<BabelPluginOptions>, PluginObj<BabelPluginState>>((api, options) => {
   api.assertVersion(7);
 
diff --git a/packages/babel-preset/src/utils/dedupeCSSRules.ts b/packages/babel-preset/src/utils/dedupeCSSRules.ts
new file mode 100644
index 0000000..16ca498
--- /dev/null
+++ b/packages/babel-preset/src/utils/dedupeCSSRules.ts
@@ -0,0 +1,22 @@
+import type { CSSRulesByBucket } from '@griffel/core';
+
+/**
+ * Rules that are returned by `resolveStyles()` are not deduplicated.
+ * It's critical to filter out duplicates for build-time transform to avoid duplicated rules in a bundle.
+ */
+export function dedupeCSSRules(cssRulesByBucket: CSSRulesByBucket): CSSRulesByBucket {
+  return Object.fromEntries(
+    Object.entries(cssRulesByBucket).map(([styleBucketName, cssBucketEntries]) => {
+      if (styleBucketName === 'm') {
+        return [
+          styleBucketName,
+          cssBucketEntries.filter(
+            (entryA, index, entries) => entries.findIndex(entryB => entryA[0] === entryB[0]) === index,
+          ),
+        ];
+      }
+
+      return [styleBucketName, [...new Set(cssBucketEntries)]];
+    }),
+  );
+}
diff --git a/packages/core/src/runtime/compileCSS.ts b/packages/core/src/runtime/compileAtomicCSSRule.ts
similarity index 75%
rename from packages/core/src/runtime/compileCSS.ts
rename to packages/core/src/runtime/compileAtomicCSSRule.ts
index ab33106..11962f1 100644
--- a/packages/core/src/runtime/compileCSS.ts
+++ b/packages/core/src/runtime/compileAtomicCSSRule.ts
@@ -1,10 +1,8 @@
-import { compile, middleware, prefixer, rulesheet, serialize, stringify } from 'stylis';
-
-import { globalPlugin } from './stylis/globalPlugin';
 import { hyphenateProperty } from './utils/hyphenateProperty';
 import { normalizeNestedProperty } from './utils/normalizeNestedProperty';
+import { compileCSSRules } from './compileCSSRules';
 
-export interface CompileCSSOptions {
+export interface CompileAtomicCSSOptions {
   className: string;
 
   selectors: string[];
@@ -43,26 +41,6 @@ export function normalizePseudoSelector(pseudoSelector: string): string {
   );
 }
 
-export function compileCSSRules(cssRules: string): string[] {
-  const rules: string[] = [];
-
-  serialize(
-    compile(cssRules),
-    middleware([
-      globalPlugin,
-      prefixer,
-      stringify,
-
-      // 💡 we are using `.insertRule()` API for DOM operations, which does not support
-      // insertion of multiple CSS rules in a single call. `rulesheet` plugin extracts
-      // individual rules to be used with this API
-      rulesheet(rule => rules.push(rule)),
-    ]),
-  );
-
-  return rules;
-}
-
 function createCSSRule(classNameSelector: string, cssDeclaration: string, pseudos: string[]): string {
   let cssRule = cssDeclaration;
 
@@ -75,7 +53,9 @@ function createCSSRule(classNameSelector: string, cssDeclaration: string, pseudo
   return `${classNameSelector}{${cssRule}}`;
 }
 
-export function compileCSS(options: CompileCSSOptions): [string? /* ltr definition */, string? /* rtl definition */] {
+export function compileAtomicCSSRule(
+  options: CompileAtomicCSSOptions,
+): [string? /* ltr definition */, string? /* rtl definition */] {
   const {
     className,
     media,
@@ -122,5 +102,5 @@ export function compileCSS(options: CompileCSSOptions): [string? /* ltr definiti
     cssRule = `@container ${container} { ${cssRule} }`;
   }
 
-  return compileCSSRules(cssRule) as [string?, string?];
+  return compileCSSRules(cssRule, true) as [string?, string?];
 }
diff --git a/packages/core/src/runtime/compileCSSRules.ts b/packages/core/src/runtime/compileCSSRules.ts
new file mode 100644
index 0000000..ab5f350
--- /dev/null
+++ b/packages/core/src/runtime/compileCSSRules.ts
@@ -0,0 +1,28 @@
+import { compile, middleware, prefixer, rulesheet, serialize, stringify } from 'stylis';
+
+import { globalPlugin } from './stylis/globalPlugin';
+import { sortClassesInAtRulesPlugin } from './stylis/sortClassesInAtRulesPlugin';
+
+// eslint-disable-next-line @typescript-eslint/no-empty-function
+function noop() {}
+
+export function compileCSSRules(cssRules: string, sortClassesInAtRules: boolean): string[] {
+  const rules: string[] = [];
+
+  serialize(
+    compile(cssRules),
+    middleware([
+      globalPlugin,
+      sortClassesInAtRules ? sortClassesInAtRulesPlugin : noop,
+      prefixer,
+      stringify,
+
+      // 💡 we are using `.insertRule()` API for DOM operations, which does not support
+      // insertion of multiple CSS rules in a single call. `rulesheet` plugin extracts
+      // individual rules to be used with this API
+      rulesheet(rule => rules.push(rule)),
+    ]),
+  );
+
+  return rules;
+}
diff --git a/packages/core/src/runtime/compileStaticCSS.ts b/packages/core/src/runtime/compileStaticCSS.ts
index 23ae0c4..0f557a4 100644
--- a/packages/core/src/runtime/compileStaticCSS.ts
+++ b/packages/core/src/runtime/compileStaticCSS.ts
@@ -1,8 +1,8 @@
-import { GriffelStaticStyle } from '../types';
-import { compileCSSRules } from './compileCSS';
+import type { GriffelStaticStyle } from '../types';
 import { cssifyObject } from './utils/cssifyObject';
+import { compileCSSRules } from './compileCSSRules';
 
 export function compileStaticCSS(property: string, value: GriffelStaticStyle): string {
   const cssRule = `${property} {${cssifyObject(value)}}`;
-  return compileCSSRules(cssRule)[0];
+  return compileCSSRules(cssRule, false)[0];
 }
diff --git a/packages/core/src/runtime/resolveResetStyleRules.ts b/packages/core/src/runtime/resolveResetStyleRules.ts
index aef486e..5c26dcd 100644
--- a/packages/core/src/runtime/resolveResetStyleRules.ts
+++ b/packages/core/src/runtime/resolveResetStyleRules.ts
@@ -9,7 +9,8 @@ import { isNestedSelector } from './utils/isNestedSelector';
 import { isSupportQuerySelector } from './utils/isSupportQuerySelector';
 import { isObject } from './utils/isObject';
 import { hyphenateProperty } from './utils/hyphenateProperty';
-import { compileCSSRules, normalizePseudoSelector } from './compileCSS';
+import { normalizePseudoSelector } from './compileAtomicCSSRule';
+import { compileCSSRules } from './compileCSSRules';
 import { compileKeyframeRule, compileKeyframesCSS } from './compileKeyframeCSS';
 import { isContainerQuerySelector } from './utils/isContainerQuerySelector';
 import { warnAboutUnresolvedRule } from './warnings/warnAboutUnresolvedRule';
@@ -138,14 +139,14 @@ export function resolveResetStyleRules(styles: GriffelResetStyle): [string, stri
   const [ltrRule, rtlRule] = createStringFromStyles(styles);
 
   const ltrClassName = RESET_HASH_PREFIX + hashString(ltrRule);
-  const ltrCSS = compileCSSRules(`.${ltrClassName}{${ltrRule}}`);
+  const ltrCSS = compileCSSRules(`.${ltrClassName}{${ltrRule}}`, false);
 
   if (ltrRule === rtlRule) {
     return [ltrClassName, null, ltrCSS];
   }
 
   const rtlClassName = RESET_HASH_PREFIX + hashString(rtlRule);
-  const rtlCSS = compileCSSRules(`.${rtlClassName}{${rtlRule}}`);
+  const rtlCSS = compileCSSRules(`.${rtlClassName}{${rtlRule}}`, false);
 
   return [ltrClassName, rtlClassName, ltrCSS.concat(rtlCSS)];
 }
diff --git a/packages/core/src/runtime/resolveStaticStyleRules.ts b/packages/core/src/runtime/resolveStaticStyleRules.ts
index 1960192..04b3328 100644
--- a/packages/core/src/runtime/resolveStaticStyleRules.ts
+++ b/packages/core/src/runtime/resolveStaticStyleRules.ts
@@ -1,10 +1,10 @@
-import { GriffelStaticStyles, CSSRulesByBucket } from '../types';
+import type { GriffelStaticStyles, CSSRulesByBucket } from '../types';
+import { compileCSSRules } from './compileCSSRules';
 import { compileStaticCSS } from './compileStaticCSS';
-import { compileCSSRules } from './compileCSS';
 
 export function resolveStaticStyleRules(styles: GriffelStaticStyles, result: CSSRulesByBucket = {}): CSSRulesByBucket {
   if (typeof styles === 'string') {
-    const cssRules = compileCSSRules(styles);
+    const cssRules = compileCSSRules(styles, false);
 
     for (const rule of cssRules) {
       addResolvedStyles(rule, result);
diff --git a/packages/core/src/runtime/resolveStyleRules.ts b/packages/core/src/runtime/resolveStyleRules.ts
index 6d191f8..83efd9e 100644
--- a/packages/core/src/runtime/resolveStyleRules.ts
+++ b/packages/core/src/runtime/resolveStyleRules.ts
@@ -10,7 +10,7 @@ import {
   GriffelAnimation,
   CSSBucketEntry,
 } from '../types';
-import { compileCSS, CompileCSSOptions } from './compileCSS';
+import { compileAtomicCSSRule, CompileAtomicCSSOptions } from './compileAtomicCSSRule';
 import { compileKeyframeRule, compileKeyframesCSS } from './compileKeyframeCSS';
 import { generateCombinedQuery } from './utils/generateCombinedMediaQuery';
 import { isMediaQuerySelector } from './utils/isMediaQuerySelector';
@@ -122,7 +122,7 @@ export function resolveStyleRules(
             support,
           })
         : undefined;
-      const rtlCompileOptions: Partial<CompileCSSOptions> | undefined = flippedInRtl
+      const rtlCompileOptions: Partial<CompileAtomicCSSOptions> | undefined = flippedInRtl
         ? {
             rtlClassName,
             rtlProperty: rtlDefinition.key,
@@ -131,7 +131,7 @@ export function resolveStyleRules(
         : undefined;
 
       const styleBucketName = getStyleBucketName(selectors, layer, media, support, container);
-      const [ltrCSS, rtlCSS] = compileCSS({
+      const [ltrCSS, rtlCSS] = compileAtomicCSSRule({
         className,
         media,
         layer,
@@ -242,7 +242,7 @@ export function resolveStyleRules(
           })
         : undefined;
 
-      const rtlCompileOptions: Partial<CompileCSSOptions> | undefined = flippedInRtl
+      const rtlCompileOptions: Partial<CompileAtomicCSSOptions> | undefined = flippedInRtl
         ? {
             rtlClassName,
             rtlProperty: rtlDefinitions[0].key,
@@ -251,7 +251,7 @@ export function resolveStyleRules(
         : undefined;
 
       const styleBucketName = getStyleBucketName(selectors, layer, media, support, container);
-      const [ltrCSS, rtlCSS] = compileCSS({
+      const [ltrCSS, rtlCSS] = compileAtomicCSSRule({
         className,
         media,
         layer,
diff --git a/packages/core/src/runtime/stylis/sortClassesInAtRulesPlugin.ts b/packages/core/src/runtime/stylis/sortClassesInAtRulesPlugin.ts
new file mode 100644
index 0000000..c6a1094
--- /dev/null
+++ b/packages/core/src/runtime/stylis/sortClassesInAtRulesPlugin.ts
@@ -0,0 +1,18 @@
+import {
+  // @ts-expect-error https://github.com/DefinitelyTyped/DefinitelyTyped/pull/65517
+  LAYER,
+  MEDIA,
+  SUPPORTS,
+} from 'stylis';
+import type { Middleware } from 'stylis';
+
+export const sortClassesInAtRulesPlugin: Middleware = element => {
+  switch (element.type) {
+    case MEDIA:
+    case SUPPORTS:
+    case LAYER:
+      if (Array.isArray(element.children)) {
+        element.children.sort((a, b) => (a.props[0] > b.props[0] ? 1 : -1));
+      }
+  }
+};


Test patch:
diff --git a/e2e/typescript/src/test.ts b/e2e/typescript/src/test.ts
index a6cbb02..74b6957 100644
--- a/e2e/typescript/src/test.ts
+++ b/e2e/typescript/src/test.ts
@@ -68,4 +68,6 @@ async function performTest(tsVersion: string) {
   await performTest('3.9');
   await performTest('4.1');
   await performTest('4.4');
+  await performTest('4.9');
+  await performTest('5.0');
 })();
diff --git a/packages/babel-preset/src/transformPlugin.test.ts b/packages/babel-preset/src/transformPlugin.test.ts
index c99708b..f2ed1a3 100644
--- a/packages/babel-preset/src/transformPlugin.test.ts
+++ b/packages/babel-preset/src/transformPlugin.test.ts
@@ -43,6 +43,11 @@ pluginTester({
         modules: [{ moduleSource: 'custom-package', importName: 'createStylesA' }],
       },
     },
+    {
+      title: 'at rules',
+      fixture: path.resolve(fixturesDir, 'at-rules', 'code.ts'),
+      outputFixture: path.resolve(fixturesDir, 'at-rules', 'output.ts'),
+    },
     {
       title: 'multiple declarations',
       fixture: path.resolve(fixturesDir, 'multiple-declarations', 'code.ts'),
diff --git a/packages/babel-preset/src/utils/dedupeCSSRules.test.ts b/packages/babel-preset/src/utils/dedupeCSSRules.test.ts
new file mode 100644
index 0000000..6ccb026
--- /dev/null
+++ b/packages/babel-preset/src/utils/dedupeCSSRules.test.ts
@@ -0,0 +1,37 @@
+import type { CSSRulesByBucket } from '@griffel/core';
+import { dedupeCSSRules } from './dedupeCSSRules';
+
+describe('dedupeCSSRules', () => {
+  it('dedupes simple rules', () => {
+    const rules: CSSRulesByBucket = {
+      d: ['.foo { color: red; }', '.foo { color: red; }', '.baz { color: pink }'],
+    };
+
+    expect(dedupeCSSRules(rules)).toMatchObject({
+      d: ['.foo { color: red; }', '.baz { color: pink }'],
+    });
+  });
+
+  it('dedupes rules with metadata', () => {
+    const rules: CSSRulesByBucket = {
+      m: [
+        ['@media (min-width: 480px) { .foo { color: red; } }', { m: '(min-width: 480px)' }],
+        ['@media (min-width: 480px) { .baz { color: pink; } }', { m: '(min-width: 480px)' }],
+
+        ['@media (min-width: 600px) { .foo { color: red; } }', { m: '(min-width: 600px)' }],
+        ['@media (min-width: 600px) { .foo { color: red; } }', { m: '(min-width: 600px)' }],
+        ['@media (min-width: 600px) { .baz { color: pink; } }', { m: '(min-width: 600px)' }],
+      ],
+    };
+
+    expect(dedupeCSSRules(rules)).toMatchObject({
+      m: [
+        ['@media (min-width: 480px) { .foo { color: red; } }', { m: '(min-width: 480px)' }],
+        ['@media (min-width: 480px) { .baz { color: pink; } }', { m: '(min-width: 480px)' }],
+
+        ['@media (min-width: 600px) { .foo { color: red; } }', { m: '(min-width: 600px)' }],
+        ['@media (min-width: 600px) { .baz { color: pink; } }', { m: '(min-width: 600px)' }],
+      ],
+    });
+  });
+});
diff --git a/packages/core/src/runtime/compileCSS.test.ts b/packages/core/src/runtime/compileAtomicCSSRule.test.ts
similarity index 90%
rename from packages/core/src/runtime/compileCSS.test.ts
rename to packages/core/src/runtime/compileAtomicCSSRule.test.ts
index 50b6011..f5f6560 100644
--- a/packages/core/src/runtime/compileCSS.test.ts
+++ b/packages/core/src/runtime/compileAtomicCSSRule.test.ts
@@ -1,7 +1,7 @@
-import { compileCSS, CompileCSSOptions, normalizePseudoSelector } from './compileCSS';
+import { compileAtomicCSSRule, CompileAtomicCSSOptions, normalizePseudoSelector } from './compileAtomicCSSRule';
 
 const defaultOptions: Pick<
-  CompileCSSOptions,
+  CompileAtomicCSSOptions,
   'rtlClassName' | 'className' | 'media' | 'selectors' | 'support' | 'layer' | 'container'
 > = {
   className: 'foo',
@@ -13,10 +13,10 @@ const defaultOptions: Pick<
   container: '',
 };
 
-describe('compileCSS', () => {
+describe('compileAtomicCSSRule', () => {
   it('handles pseudo', () => {
     expect(
-      compileCSS({
+      compileAtomicCSSRule({
         ...defaultOptions,
         selectors: [':hover'],
         property: 'color',
@@ -28,7 +28,7 @@ describe('compileCSS', () => {
       ]
     `);
     expect(
-      compileCSS({
+      compileAtomicCSSRule({
         ...defaultOptions,
         selectors: [':focus:hover'],
         property: 'color',
@@ -43,7 +43,7 @@ describe('compileCSS', () => {
 
   it('handles array of values', () => {
     expect(
-      compileCSS({
+      compileAtomicCSSRule({
         ...defaultOptions,
         property: 'color',
         value: ['red', 'blue'],
@@ -57,7 +57,7 @@ describe('compileCSS', () => {
 
   it('handles at-rules', () => {
     expect(
-      compileCSS({
+      compileAtomicCSSRule({
         ...defaultOptions,
         media: '(max-width: 100px)',
         property: 'color',
@@ -69,7 +69,7 @@ describe('compileCSS', () => {
       ]
     `);
     expect(
-      compileCSS({
+      compileAtomicCSSRule({
         ...defaultOptions,
         support: '(display: table-cell)',
         property: 'color',
@@ -84,7 +84,7 @@ describe('compileCSS', () => {
 
   it('handles rtl properties', () => {
     expect(
-      compileCSS({
+      compileAtomicCSSRule({
         ...defaultOptions,
 
         property: 'paddingLeft',
@@ -103,7 +103,7 @@ describe('compileCSS', () => {
 
   it('handles rtl properties with pseudo selectors', () => {
     expect(
-      compileCSS({
+      compileAtomicCSSRule({
         ...defaultOptions,
         selectors: [':before'],
 
@@ -123,7 +123,7 @@ describe('compileCSS', () => {
 
   it('handles rtl properties with fallback values', () => {
     expect(
-      compileCSS({
+      compileAtomicCSSRule({
         ...defaultOptions,
         property: 'paddingLeft',
         value: [0, '10px'],
@@ -141,7 +141,7 @@ describe('compileCSS', () => {
   describe('global', () => {
     it('compiles global rules', () => {
       expect(
-        compileCSS({
+        compileAtomicCSSRule({
           ...defaultOptions,
           selectors: [':global(body)'],
           property: 'color',
@@ -153,7 +153,7 @@ describe('compileCSS', () => {
       ]
     `);
       expect(
-        compileCSS({
+        compileAtomicCSSRule({
           ...defaultOptions,
           selectors: [':global(.fui-FluentProvider)', '& .focus:hover'],
           property: 'color',
@@ -168,7 +168,7 @@ describe('compileCSS', () => {
 
     it('compiles global rules with RTL', () => {
       expect(
-        compileCSS({
+        compileAtomicCSSRule({
           ...defaultOptions,
           selectors: [':global(body)'],
           property: 'paddingLeft',
diff --git a/packages/core/src/runtime/resolveStyleRules.test.ts b/packages/core/src/runtime/resolveStyleRules.test.ts
index c40bac7..8fde01c 100644
--- a/packages/core/src/runtime/resolveStyleRules.test.ts
+++ b/packages/core/src/runtime/resolveStyleRules.test.ts
@@ -272,12 +272,12 @@ describe('resolveStyleRules', () => {
         }),
       ).toMatchInlineSnapshot(`
         @media screen and (max-width: 992px) {
-          .f1f6067w {
-            text-align: left;
-          }
           .f133ge8t {
             text-align: right;
           }
+          .f1f6067w {
+            text-align: left;
+          }
         }
       `);
     });
diff --git a/packages/core/src/runtime/stylis/sortClassesInAtRulesPlugin.test.ts b/packages/core/src/runtime/stylis/sortClassesInAtRulesPlugin.test.ts
new file mode 100644
index 0000000..1d197e7
--- /dev/null
+++ b/packages/core/src/runtime/stylis/sortClassesInAtRulesPlugin.test.ts
@@ -0,0 +1,131 @@
+import { compile, middleware, serialize, stringify } from 'stylis';
+import { sortClassesInAtRulesPlugin } from './sortClassesInAtRulesPlugin';
+import * as prettier from 'prettier';
+
+const cssFormatSerializer: 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();
+  },
+};
+
+function compileRule(rule: string) {
+  return serialize(compile(rule), middleware([sortClassesInAtRulesPlugin, stringify]));
+}
+
+expect.addSnapshotSerializer(cssFormatSerializer);
+
+describe('sortClassesInAtRulesPlugin', () => {
+  describe('at-rules', () => {
+    it('handles @media', () => {
+      const css = `
+        @media (min-width: 480px) {
+          .b { padding-right: 1px; }
+          .a { padding-left: 1px; }
+        }
+      `;
+
+      expect(compileRule(css)).toMatchInlineSnapshot(`
+        @media (min-width: 480px) {
+          .a {
+            padding-left: 1px;
+          }
+          .b {
+            padding-right: 1px;
+          }
+        }
+      `);
+    });
+
+    it('handles @supports', () => {
+      const css = `
+        @supports (display: flex) {
+          .b { padding-right: 1px; }
+          .a { padding-left: 1px; }
+        }
+      `;
+
+      expect(compileRule(css)).toMatchInlineSnapshot(`
+        @supports (display: flex) {
+          .a {
+            padding-left: 1px;
+          }
+          .b {
+            padding-right: 1px;
+          }
+        }
+      `);
+    });
+
+    it('handles @layer', () => {
+      const css = `
+        @layer utilities {
+          .b { padding-right: 1px; }
+          .a { padding-left: 1px; }
+        }
+      `;
+
+      expect(compileRule(css)).toMatchInlineSnapshot(`
+        @layer utilities {
+          .a {
+            padding-left: 1px;
+          }
+          .b {
+            padding-right: 1px;
+          }
+        }
+      `);
+    });
+  });
+
+  it('handles nested at-rules', () => {
+    const css = `
+      @supports (display: flex) {
+        @media (min-width: 480px) {
+          .b { padding-right: 1px; }
+          .a { padding-left: 1px; }
+        }
+      }
+    `;
+
+    expect(compileRule(css)).toMatchInlineSnapshot(`
+      @supports (display: flex) {
+        @media (min-width: 480px) {
+          .a {
+            padding-left: 1px;
+          }
+          .b {
+            padding-right: 1px;
+          }
+        }
+      }
+    `);
+  });
+
+  it('handles selectors', () => {
+    const css = `
+      @media (min-width: 480px) {
+        .b:hover { padding-right: 1px; }
+        .a:hover { padding-left: 1px; }
+      }
+    `;
+
+    expect(compileRule(css)).toMatchInlineSnapshot(`
+      @media (min-width: 480px) {
+        .a:hover {
+          padding-left: 1px;
+        }
+        .b:hover {
+          padding-right: 1px;
+        }
+      }
+    `);
+  });
+});