diff --git a/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js b/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js index 58c30ac1f..2dea96e62 100644 --- a/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js +++ b/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js @@ -1065,6 +1065,358 @@ describe('@stylexjs/babel-plugin', () => { `); }); + test('sorts min-width with screen and media type', () => { + const rules = [ + [ + 'xLg', + { ltr: 'var(--xLgHash){.xLg.xLg{color:blue}}', rtl: null }, + 6000, + ], + [ + 'xSm', + { ltr: 'var(--xSmHash){.xSm.xSm{color:red}}', rtl: null }, + 6000, + ], + [ + 'xLgHash', + { + constKey: 'xLgHash', + constVal: '@media screen and (min-width: 1280px)', + ltr: '', + rtl: null, + }, + 0, + ], + [ + 'xSmHash', + { + constKey: 'xSmHash', + constVal: '@media screen and (min-width: 768px)', + ltr: '', + rtl: null, + }, + 0, + ], + ]; + + const css = stylexPlugin.processStylexRules(rules, { + useLayers: false, + legacyDisableLayers: true, + }); + + expect(css).toMatchInlineSnapshot(` + "@media screen and (min-width: 768px){.xSm.xSm{color:red}} + @media screen and (min-width: 1280px){.xLg.xLg{color:blue}}" + `); + }); + + test('does not misorder negated min-width media queries', () => { + // "@media (not (min-width: 1000px))" means the opposite of min-width — a + // user can produce this directly. We must not sort it as a positive + // min-width query; instead it falls through to the existing property sort. + const rules = [ + [ + 'xNot', + { ltr: 'var(--xNotHash){.xNot.xNot{color:red}}', rtl: null }, + 6000, + ], + [ + 'xPos', + { ltr: 'var(--xPosHash){.xPos.xPos{color:blue}}', rtl: null }, + 6000, + ], + [ + 'xNotHash', + { + constKey: 'xNotHash', + constVal: '@media (not (min-width: 1000px))', + ltr: '', + rtl: null, + }, + 0, + ], + [ + 'xPosHash', + { + constKey: 'xPosHash', + constVal: '@media (min-width: 1000px)', + ltr: '', + rtl: null, + }, + 0, + ], + ]; + + const css = stylexPlugin.processStylexRules(rules, { + useLayers: false, + legacyDisableLayers: true, + }); + expect(css).toMatchInlineSnapshot(` + "@media (min-width: 1000px){.xPos.xPos{color:blue}} + @media (not (min-width: 1000px)){.xNot.xNot{color:red}}" + `); + }); + + test('sorts max-width defineConsts breakpoints using real transform metadata', () => { + // Uses constants.mediaBig = '@media (max-width: 1000px)' and + // constants.mediaSmall = '@media (max-width: 500px)' from the test fixture. + // Two separate namespaces give them equal priority, catching the ordering bug. + const { metadata } = transform(` + import * as stylex from '@stylexjs/stylex'; + export const styles = stylex.create({ + a: { color: { [constants.mediaBig]: 'red' } }, + b: { color: { [constants.mediaSmall]: 'blue' } }, + }); + `); + + const css = stylexPlugin.processStylexRules(metadata, { + useLayers: false, + legacyDisableLayers: true, + }); + expect(css).toMatchInlineSnapshot(` + ":root, .xsg933n{--blue-xpqh4lw:blue;--marginTokens-x8nt2k2:10px;--colorTokens-xkxfyv:red;} + :root, .xbiwvf9{--small-x19twipt:2px;--medium-xypjos2:4px;--large-x1ec7iuc:8px;} + @media (prefers-color-scheme: dark){:root, .xsg933n{--colorTokens-xkxfyv:lightblue;}} + @media (min-width: 600px){:root, .xsg933n{--marginTokens-x8nt2k2:20px;}} + @supports (color: oklab(0 0 0)){@media (prefers-color-scheme: dark){:root, .xsg933n{--colorTokens-xkxfyv:oklab(0.7 -0.3 -0.4);}}} + @media (max-width: 1000px){.color-xz4zmo0.color-xz4zmo0{color:red}} + @media (max-width: 500px){.color-x100plp.color-x100plp{color:blue}}" + `); + }); + + test('sorts min-width defineConsts breakpoints in ascending px order', () => { + const rules = [ + // desktop (1500px) processed first — should appear AFTER tablet in CSS + [ + 'xDesktop', + { + ltr: 'var(--xDesktopHash){.xDesktop.xDesktop{width:200px}}', + rtl: null, + }, + 6000, + ], + [ + 'xTablet', + { + ltr: 'var(--xTabletHash){.xTablet.xTablet{width:500px}}', + rtl: null, + }, + 6000, + ], + [ + 'xDesktopHash', + { + constKey: 'xDesktopHash', + constVal: '@media (min-width: 1500px)', + ltr: '', + rtl: null, + }, + 0, + ], + [ + 'xTabletHash', + { + constKey: 'xTabletHash', + constVal: '@media (min-width: 1000px)', + ltr: '', + rtl: null, + }, + 0, + ], + ]; + + const css = stylexPlugin.processStylexRules(rules, { + useLayers: false, + legacyDisableLayers: true, + }); + expect(css).toMatchInlineSnapshot(` + "@media (min-width: 1000px){.xTablet.xTablet{width:500px}} + @media (min-width: 1500px){.xDesktop.xDesktop{width:200px}}" + `); + }); + + test('sorts min-width breakpoints via template literal partial value', () => { + // defineConsts({ sm: '768px', lg: '1280px' }) used as @media (min-width: ${sm}) + // produces ltr with var() inside the @media condition, not as the whole at-rule + const rules = [ + [ + 'xLg', + { + ltr: '@media (min-width: var(--xLgHash)){.xLg.xLg{display:block}}', + rtl: null, + }, + 6000, + ], + [ + 'xSm', + { + ltr: '@media (min-width: var(--xSmHash)){.xSm.xSm{display:none}}', + rtl: null, + }, + 6000, + ], + [ + 'xLgHash', + { constKey: 'xLgHash', constVal: '1280px', ltr: '', rtl: null }, + 0, + ], + [ + 'xSmHash', + { constKey: 'xSmHash', constVal: '768px', ltr: '', rtl: null }, + 0, + ], + ]; + + const css = stylexPlugin.processStylexRules(rules, { + useLayers: false, + legacyDisableLayers: true, + }); + expect(css).toMatchInlineSnapshot(` + "@media (min-width: 768px){.xSm.xSm{display:none}} + @media (min-width: 1280px){.xLg.xLg{display:block}}" + `); + }); + + test('sorts max-width defineConsts breakpoints in descending px order', () => { + const rules = [ + // small (500px) processed first — should appear AFTER large in CSS + [ + 'xSmall', + { ltr: 'var(--xSmallHash){.xSmall.xSmall{color:blue}}', rtl: null }, + 6000, + ], + [ + 'xLarge', + { ltr: 'var(--xLargeHash){.xLarge.xLarge{color:red}}', rtl: null }, + 6000, + ], + [ + 'xSmallHash', + { + constKey: 'xSmallHash', + constVal: '@media (max-width: 500px)', + ltr: '', + rtl: null, + }, + 0, + ], + [ + 'xLargeHash', + { + constKey: 'xLargeHash', + constVal: '@media (max-width: 1000px)', + ltr: '', + rtl: null, + }, + 0, + ], + ]; + + const css = stylexPlugin.processStylexRules(rules, { + useLayers: false, + legacyDisableLayers: true, + }); + expect(css).toMatchInlineSnapshot(` + "@media (max-width: 1000px){.xLarge.xLarge{color:red}} + @media (max-width: 500px){.xSmall.xSmall{color:blue}}" + `); + }); + + test('sorts CSS Level 4 range syntax (width >= Xpx) as min-width', () => { + // MediaQuery.parser normalises (width >= 768px) to min-width: 768px, + // so Level 4 range syntax gets sorted for free. + const rules = [ + [ + 'xLg', + { ltr: 'var(--xLgHash){.xLg.xLg{color:red}}', rtl: null }, + 6000, + ], + [ + 'xSm', + { ltr: 'var(--xSmHash){.xSm.xSm{color:violet}}', rtl: null }, + 6000, + ], + [ + 'xLgHash', + { + constKey: 'xLgHash', + constVal: '@media (width >= 1280px)', + ltr: '', + rtl: null, + }, + 0, + ], + [ + 'xSmHash', + { + constKey: 'xSmHash', + constVal: '@media (width >= 768px)', + ltr: '', + rtl: null, + }, + 0, + ], + ]; + + const css = stylexPlugin.processStylexRules(rules, { + useLayers: false, + legacyDisableLayers: true, + }); + expect(css).toMatchInlineSnapshot(` + "@media (width >= 768px){.xSm.xSm{color:violet}} + @media (width >= 1280px){.xLg.xLg{color:red}}" + `); + }); + + test('range queries (both min and max-width) fall through to existing sort', () => { + // Range queries like (768px <= width <= 1024px) parse as an and{min-width, + // max-width} pair. Sorting them alongside pure min/max-width queries would + // break comparator transitivity — a range can compare by min-width against + // one rule and by max-width against another, creating a cycle. They fall + // through to the existing property + rule comparison instead. + const rules = [ + [ + 'xLg', + { ltr: 'var(--xLgHash){.xLg.xLg{color:blue}}', rtl: null }, + 6000, + ], + [ + 'xSm', + { ltr: 'var(--xSmHash){.xSm.xSm{color:violet}}', rtl: null }, + 6000, + ], + [ + 'xLgHash', + { + constKey: 'xLgHash', + constVal: '@media (1024px <= width <= 1280px)', + ltr: '', + rtl: null, + }, + 0, + ], + [ + 'xSmHash', + { + constKey: 'xSmHash', + constVal: '@media (768px <= width <= 1024px)', + ltr: '', + rtl: null, + }, + 0, + ], + ]; + + const css = stylexPlugin.processStylexRules(rules, { + useLayers: false, + legacyDisableLayers: true, + }); + expect(css).toMatchInlineSnapshot(` + "@media (1024px <= width <= 1280px){.xLg.xLg{color:blue}} + @media (768px <= width <= 1024px){.xSm.xSm{color:violet}}" + `); + }); + test('sort is deterministic regardless of input order', () => { // These rules mix @media, @container, @starting-style, var()-wrapped, // and plain pseudo-element rules at the same priority. diff --git a/packages/@stylexjs/babel-plugin/src/index.js b/packages/@stylexjs/babel-plugin/src/index.js index 324d22b1d..2fbbd91e6 100644 --- a/packages/@stylexjs/babel-plugin/src/index.js +++ b/packages/@stylexjs/babel-plugin/src/index.js @@ -12,6 +12,8 @@ import type { PluginObj } from '@babel/core'; import type { StyleXOptions } from './utils/state-manager'; import * as t from '@babel/types'; +import { MediaQuery } from 'style-value-parser'; +import type { MediaQueryRule } from 'style-value-parser'; import StateManager from './utils/state-manager'; import { EXTENSIONS, @@ -455,6 +457,50 @@ function getLogicalFloatVars(rules: Array): string { : ''; } +function findWidthPxInRule(rule: MediaQueryRule, type: string): number | null { + if (rule.type === 'pair' && rule.key === type) { + const v = rule.value; + if ( + v != null && + typeof v === 'object' && + typeof v.value === 'number' && + v.unit === 'px' + ) { + return v.value; + } + return null; + } + if (rule.type === 'and') { + // A negated media type (e.g. "not screen") inverts the whole expression + if (rule.rules.some((r) => r.type === 'media-keyword' && r.not)) { + return null; + } + for (const r of rule.rules) { + const found = findWidthPxInRule(r, type); + if (found !== null) return found; + } + } + // 'not' and 'or' are intentionally skipped — negated queries have inverted + // semantics and OR queries have no single value to sort by. + return null; +} + +function extractMediaQueryWidthPx( + rule: string, + type: 'min-width' | 'max-width', +): number | null { + const firstBrace = rule.indexOf('{'); + if (firstBrace === -1) return null; + try { + const parsed = MediaQuery.parser.parseToEnd( + rule.slice(0, firstBrace).trimEnd(), + ); + return findWidthPxInRule(parsed.queries, type); + } catch { + return null; + } +} + function processStylexRules( rules: Array, config?: @@ -539,58 +585,87 @@ function processStylexRules( constsMap.set(key, resolveConstant(value)); } - const sortedRules = nonConstantRules.sort( - ( - [classname1, { ltr: rule1 }, firstPriority]: [string, any, number], - [classname2, { ltr: rule2 }, secondPriority]: [string, any, number], - ) => { - const priorityComparison = firstPriority - secondPriority; - if (priorityComparison !== 0) return priorityComparison; - - if (useLegacyClassnamesSort) { - return classname1.localeCompare(classname2); - } else { - const property1 = rule1.slice(rule1.lastIndexOf('{')); - const property2 = rule2.slice(rule2.lastIndexOf('{')); - const propertyComparison = property1.localeCompare(property2); - if (propertyComparison !== 0) return propertyComparison; - return rule1.localeCompare(rule2); - } - }, - ); + const sortedRules: Array = nonConstantRules + .map(([key, { ...styleObj }, priority]): Rule => { + Object.keys(styleObj).forEach((dir) => { + let original = styleObj[dir]; + for (const [varRef, constValue] of constsMap.entries()) { + if (typeof original !== 'string') continue; + const replacement = String(constValue); + original = original.replaceAll(varRef, replacement); + if (replacement.startsWith('var(') && replacement.endsWith(')')) { + const inside = replacement.slice(4, -1).trim(); + const commaIdx = inside.indexOf(','); + const targetName = ( + commaIdx >= 0 ? inside.slice(0, commaIdx) : inside + ).trim(); + const constName = varRef.slice(4, -1); + original = original.replaceAll(`${constName}:`, `${targetName}:`); + } + styleObj[dir] = original; + } + }); + return [key, styleObj, priority]; + }) + .sort( + ( + [classname1, { ltr: rule1 }, firstPriority]: [string, any, number], + [classname2, { ltr: rule2 }, secondPriority]: [string, any, number], + ) => { + const priorityComparison = firstPriority - secondPriority; + if (priorityComparison !== 0) return priorityComparison; + + if (useLegacyClassnamesSort) { + return classname1.localeCompare(classname2); + } else { + // Deterministic ordering for px min/max-width media queries. + // Sorts min-width ascending and max-width descending so that larger + // breakpoints appear later in the sheet and win the cascade. + // rem/em and negated/complex queries fall through to the existing + // property + rule comparison. + if (rule1.startsWith('@media ') && rule2.startsWith('@media ')) { + const minWidth1 = extractMediaQueryWidthPx(rule1, 'min-width'); + const minWidth2 = extractMediaQueryWidthPx(rule2, 'min-width'); + const maxWidth1 = extractMediaQueryWidthPx(rule1, 'max-width'); + const maxWidth2 = extractMediaQueryWidthPx(rule2, 'max-width'); + // Only sort queries of the same shape. Mixing pure min-width, pure + // max-width, and range queries (which have both) in a single + // comparator can produce non-transitive orderings — a range query + // can compare by min-width against one rule and by max-width against + // another, creating a cycle. Range queries fall through to the + // existing property + rule comparison. + if ( + minWidth1 !== null && + minWidth2 !== null && + maxWidth1 === null && + maxWidth2 === null + ) { + const mqComparison = minWidth1 - minWidth2; + if (mqComparison !== 0) return mqComparison; + } else if ( + maxWidth1 !== null && + maxWidth2 !== null && + minWidth1 === null && + minWidth2 === null + ) { + const mqComparison = maxWidth2 - maxWidth1; + if (mqComparison !== 0) return mqComparison; + } + } + const property1 = rule1.slice(rule1.lastIndexOf('{')); + const property2 = rule2.slice(rule2.lastIndexOf('{')); + const propertyComparison = property1.localeCompare(property2); + if (propertyComparison !== 0) return propertyComparison; + return rule1.localeCompare(rule2); + } + }, + ); let lastKPri = -1; const grouped = sortedRules.reduce((acc: Array>, rule) => { - const [key, { ...styleObj }, priority] = rule; + const [key, styleObj, priority] = rule; const priorityLevel = Math.floor(priority / 1000); - Object.keys(styleObj).forEach((dir) => { - let original = styleObj[dir]; - - for (const [varRef, constValue] of constsMap.entries()) { - if (typeof original !== 'string') continue; - - const replacement = String(constValue); - - original = original.replaceAll(varRef, replacement); - - // When the replacement is a variable, we need to replace the key to allow variable overrides - if (replacement.startsWith('var(') && replacement.endsWith(')')) { - const inside = replacement.slice(4, -1).trim(); - // Account for fallback variables - const commaIdx = inside.indexOf(','); - const targetName = ( - commaIdx >= 0 ? inside.slice(0, commaIdx) : inside - ).trim(); - - const constName = varRef.slice(4, -1); - original = original.replaceAll(`${constName}:`, `${targetName}:`); - } - - styleObj[dir] = original; - } - }); - if (priorityLevel === lastKPri) { acc[acc.length - 1].push([key, styleObj, priority]); return acc; @@ -601,7 +676,7 @@ function processStylexRules( return acc; }, []); - const logicalFloatVars = getLogicalFloatVars(nonConstantRules); + const logicalFloatVars = getLogicalFloatVars(sortedRules); const layerName = (index: number): string => layerPrefix diff --git a/packages/style-value-parser/src/index.js b/packages/style-value-parser/src/index.js index 4a8cdf428..60bc64f18 100644 --- a/packages/style-value-parser/src/index.js +++ b/packages/style-value-parser/src/index.js @@ -10,3 +10,5 @@ export * as tokenParser from './token-parser'; export * as properties from './properties'; export { lastMediaQueryWinsTransform } from './at-queries/media-query-transform.js'; +export { MediaQuery } from './at-queries/media-query.js'; +export type { MediaQueryRule } from './at-queries/media-query.js';