From 166d4414599ff611325ddd2e3d5eace505c38896 Mon Sep 17 00:00:00 2001 From: Melissa Liu Date: Wed, 3 Sep 2025 01:18:03 -0700 Subject: [PATCH] [babel-plugin] group css rules by media query --- .../__tests__/transform-process-test.js | 115 +++++++++++++++- packages/@stylexjs/babel-plugin/src/index.js | 125 ++++++++++++------ 2 files changed, 196 insertions(+), 44 deletions(-) diff --git a/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js b/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js index bf9aaad74..0d261ce44 100644 --- a/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js +++ b/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js @@ -222,14 +222,14 @@ describe('@stylexjs/babel-plugin', () => { .margin-xymmreb:not(#\\#){margin:10px 20px} .padding-xss17vw:not(#\\#){padding:var(--large-x1ec7iuc)} .borderColor-x1bg2uv5:not(#\\#):not(#\\#){border-color:green} - @media (max-width: 1000px){.borderColor-x5ugf7c.borderColor-x5ugf7c:not(#\\#):not(#\\#){border-color:var(--blue-xpqh4lw)}} - @media (max-width: 500px){@media (max-width: 1000px){.borderColor-xqiy1ys.borderColor-xqiy1ys.borderColor-xqiy1ys:not(#\\#):not(#\\#){border-color:yellow}}} .animationName-xckgs0v:not(#\\#):not(#\\#):not(#\\#){animation-name:xi07kvp-B} .backgroundColor-xrkmrrc:not(#\\#):not(#\\#):not(#\\#){background-color:red} .color-x14rh7hd:not(#\\#):not(#\\#):not(#\\#){color:var(--x-color)} html:not([dir='rtl']) .float-x1kmio9f:not(#\\#):not(#\\#):not(#\\#){float:left} html[dir='rtl'] .float-x1kmio9f:not(#\\#):not(#\\#):not(#\\#){float:right} .textShadow-x1skrh0i:not(#\\#):not(#\\#):not(#\\#){text-shadow:1px 2px 3px 4px red} + @media (max-width: 1000px){.borderColor-x5ugf7c.borderColor-x5ugf7c:not(#\\#):not(#\\#){border-color:var(--blue-xpqh4lw)}} + @media (max-width: 500px){@media (max-width: 1000px){.borderColor-xqiy1ys.borderColor-xqiy1ys.borderColor-xqiy1ys:not(#\\#):not(#\\#){border-color:yellow}}} @media (min-width:320px){.textShadow-x1cmij7u.textShadow-x1cmij7u:not(#\\#):not(#\\#):not(#\\#){text-shadow:10px 20px 30px 40px green}}" `); }); @@ -425,5 +425,116 @@ describe('@stylexjs/babel-plugin', () => { .x57uvma.x57uvma, .x57uvma.x57uvma:root{--large-x1ec7iuc:20px;--medium-xypjos2:10px;--small-x19twipt:5px;}" `); }); + + test('media query grouping - rules with same media query are grouped together', () => { + const { _code, metadata } = transform( + ` + import * as stylex from '@stylexjs/stylex'; + export const styles = stylex.create({ + container: { + '@media (max-width: 768px)': { + width: '10px', + height: '20px', + }, + color: { + default: 'black', + '@media (max-width: 308px)': 'white', + '@media (max-width: 768px)': 'red', + }, + backgroundColor: { + default: 'white', + '@media (max-width: 768px)': 'blue', + '@media (min-width: 1024px)': 'yellow', + }, + fontSize: { + default: '16px', + '@media (max-width: 768px)': '14px', + }, + padding: { + default: '10px', + '@media (min-width: 1024px)': '20px', + }, + margin: { + default: '5px', + '@media (min-width: 1024px)': '10px', + } + } + }); + `, + ); + + const css = stylexPlugin.processStylexRules(metadata); + + expect(css).toMatchInlineSnapshot(` + ":root, .xsg933n{--blue-xpqh4lw:blue;} + :root, .xbiwvf9{--small-x19twipt:2px;--medium-xypjos2:4px;--large-x1ec7iuc:8px;} + .margin-x16zck5j:not(#\\#){margin:5px} + .padding-x7z7khe:not(#\\#){padding:10px} + .backgroundColor-x12peec7:not(#\\#):not(#\\#){background-color:white} + .color-x1mqxbix:not(#\\#):not(#\\#){color:black} + .fontSize-x1j61zf2:not(#\\#):not(#\\#){font-size:16px} + @media (min-width: 1024px){ + .margin-x1nff4mz.margin-x1nff4mz:not(#\\#){margin:10px} + .padding-x1glw0n9.padding-x1glw0n9:not(#\\#){padding:20px} + .backgroundColor-xkbfoqe.backgroundColor-xkbfoqe:not(#\\#):not(#\\#){background-color:yellow} + } + @media (max-width: 768px){ + .backgroundColor-xycim1f.backgroundColor-xycim1f:not(#\\#):not(#\\#){background-color:blue} + .color-x9i7o1z.color-x9i7o1z:not(#\\#):not(#\\#){color:red} + .fontSize-xt5ov9y.fontSize-xt5ov9y:not(#\\#):not(#\\#){font-size:14px} + .height-x12z8348.height-x12z8348:not(#\\#):not(#\\#):not(#\\#){height:20px} + .width-x7lwmry.width-x7lwmry:not(#\\#):not(#\\#):not(#\\#){width:10px} + } + @media (max-width: 308px){.color-x1760m8v.color-x1760m8v:not(#\\#):not(#\\#){color:white}}" + `); + }); + + test('media query grouping with layers - rules with same media query are grouped together', () => { + const { _code, metadata } = transform( + ` + import * as stylex from '@stylexjs/stylex'; + export const styles = stylex.create({ + container: { + color: { + default: 'black', + '@media (max-width: 768px)': 'red', + }, + backgroundColor: { + default: 'white', + '@media (max-width: 768px)': 'blue', + }, + fontSize: { + default: '16px', + '@media (max-width: 768px)': '14px', + } + } + }); + `, + { + useLayers: true, + }, + ); + + const css = stylexPlugin.processStylexRules(metadata, true); + + expect(css).toMatchInlineSnapshot(` + " + @layer priority1, priority2; + @layer priority1{ + :root, .xsg933n{--blue-xpqh4lw:blue;} + :root, .xbiwvf9{--small-x19twipt:2px;--medium-xypjos2:4px;--large-x1ec7iuc:8px;} + } + @layer priority2{ + .backgroundColor-x12peec7{background-color:white} + .color-x1mqxbix{color:black} + .fontSize-x1j61zf2{font-size:16px} + @media (max-width: 768px){ + .backgroundColor-xycim1f.backgroundColor-xycim1f{background-color:blue} + .color-x9i7o1z.color-x9i7o1z{color:red} + .fontSize-xt5ov9y.fontSize-xt5ov9y{font-size:14px} + } + }" + `); + }); }); }); diff --git a/packages/@stylexjs/babel-plugin/src/index.js b/packages/@stylexjs/babel-plugin/src/index.js index 8eecfb2bd..a9bc386a5 100644 --- a/packages/@stylexjs/babel-plugin/src/index.js +++ b/packages/@stylexjs/babel-plugin/src/index.js @@ -470,58 +470,99 @@ function processStylexRules( ';\n' : ''; - const collectedCSS = grouped + const globalMediaQueryGroups: Map> = new Map(); + const globalNonMediaRules: Array = []; + + const perGroupOutput = grouped .map((group, index) => { const pri = group[0][2]; - const collectedCSS = Array.from( - new Map(group.map(([a, b]) => [a, b])).values(), - ) - .flatMap((rule) => { - const { ltr, rtl } = rule; - let ltrRule = ltr, - rtlRule = rtl; - - if (!useLayers) { - ltrRule = addSpecificityLevel(ltrRule, index); - rtlRule = rtlRule && addSpecificityLevel(rtlRule, index); - } + const rules = Array.from(new Map(group.map(([a, b]) => [a, b])).values()); + + const mediaQueryGroups = useLayers ? new Map() : globalMediaQueryGroups; + const nonMediaRules = useLayers ? [] : globalNonMediaRules; + + rules.forEach((rule) => { + const { ltr, rtl } = rule; + let ltrRule = ltr, + rtlRule = rtl; - // check if the selector looks like .xtrlmmh, .xtrlmmh:root - // if so, turn it into .xtrlmmh.xtrlmmh, .xtrlmmh.xtrlmmh:root - // This is to ensure the themes always have precedence over the - // default variable values - ltrRule = ltrRule.replace( + if (!useLayers) { + ltrRule = addSpecificityLevel(ltrRule, index); + rtlRule = rtlRule && addSpecificityLevel(rtlRule, index); + } + + ltrRule = ltrRule.replace( + /\.([a-zA-Z0-9]+), \.([a-zA-Z0-9]+):root/g, + '.$1.$1, .$1.$1:root', + ); + if (rtlRule) { + rtlRule = rtlRule.replace( /\.([a-zA-Z0-9]+), \.([a-zA-Z0-9]+):root/g, '.$1.$1, .$1.$1:root', ); - if (rtlRule) { - rtlRule = rtlRule.replace( - /\.([a-zA-Z0-9]+), \.([a-zA-Z0-9]+):root/g, - '.$1.$1, .$1.$1:root', - ); - } + } - return rtlRule - ? enableLTRRTLComments - ? [ - `/* @ltr begin */${ltrRule}/* @ltr end */`, - `/* @rtl begin */${rtlRule}/* @rtl end */`, - ] - : [ - addAncestorSelector(ltrRule, "html:not([dir='rtl'])"), - addAncestorSelector(rtlRule, "html[dir='rtl']"), - ] - : [ltrRule]; - }) - .join('\n'); - - // Don't put @property, @keyframe, @position-try in layers - return useLayers && pri > 0 - ? `@layer priority${index + 1}{\n${collectedCSS}\n}` - : collectedCSS; + const processedRules = rtlRule + ? enableLTRRTLComments + ? [ + `/* @ltr begin */${ltrRule}/* @ltr end */`, + `/* @rtl begin */${rtlRule}/* @rtl end */`, + ] + : [ + addAncestorSelector(ltrRule, "html:not([dir='rtl'])"), + addAncestorSelector(rtlRule, "html[dir='rtl']"), + ] + : [ltrRule]; + + processedRules.forEach((processedRule) => { + const mediaQueryMatch = processedRule.match( + /^(@media[^{]+)\{([\s\S]*)\}$/m, + ); + if (mediaQueryMatch) { + const [, rawMQ, innerContent] = mediaQueryMatch; + const mq = rawMQ.trim().replace(/\s+/g, ' '); + if (!mediaQueryGroups.has(mq)) mediaQueryGroups.set(mq, []); + const arr = mediaQueryGroups.get(mq); + if (arr) arr.push(innerContent.trim()); + } else { + nonMediaRules.push(processedRule); + } + }); + }); + + if (useLayers) { + const allRules = [ + ...nonMediaRules, + ...Array.from(mediaQueryGroups.entries()) + .filter(([, inner]) => inner.length > 0) + .map(([mq, inner]) => + inner.length === 1 + ? `${mq}{${inner[0]}}` + : `${mq}{\n${inner.join('\n')}\n}`, + ), + ]; + const collected = allRules.join('\n'); + return pri > 0 + ? `@layer priority${index + 1}{\n${collected}\n}` + : collected; + } + return ''; }) .join('\n'); + const collectedCSS = useLayers + ? perGroupOutput + : [ + ...globalNonMediaRules, + ...Array.from(globalMediaQueryGroups.entries()) + .filter(([, inner]) => inner.length > 0) + .map(([mq, inner]) => + inner.length === 1 + ? `${mq}{${inner[0]}}` + : `${mq}{\n${inner.join('\n')}\n}`, + ), + ].join('\n'); + return header + collectedCSS; }