From 557bd845a6f09cbd18b48494277af3bbe427b047 Mon Sep 17 00:00:00 2001 From: "Henry Q. Dineen" Date: Fri, 8 May 2026 16:54:25 -0400 Subject: [PATCH 1/6] [babel-plugin] Deterministic ordering for px min/max-width defineConsts breakpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When breakpoints are defined with defineConsts, the Babel transform only sees var(--hash) placeholders at create() call time, so enableMediaQueryOrder cannot apply and ordering falls back to alphabetic — which has nothing to do with breakpoint pixel values. This fixes ordering at CSS generation time (processStylexRules), where constants are already resolved. Constants are now resolved before sorting rather than after, and min-width queries are sorted ascending and max-width queries descending by px value. Only pure min-width or pure max-width queries are sorted; range queries with both dimensions fall through to preserve comparator transitivity. Uses MediaQuery.parser (from style-value-parser) for extraction so that negated queries, screen-and queries, and CSS Level 4 range syntax are all handled correctly. MediaQuery and MediaQueryRule are exported from style-value-parser's public API to support this. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../__tests__/transform-process-test.js | 352 ++++++++++++++++++ packages/@stylexjs/babel-plugin/src/index.js | 171 ++++++--- packages/style-value-parser/src/index.js | 2 + 3 files changed, 477 insertions(+), 48 deletions(-) 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'; From 1b19e75ade42709677e340174be85f39bc3657ad Mon Sep 17 00:00:00 2001 From: "Henry Q. Dineen" Date: Fri, 4 Sep 2026 00:56:43 -0400 Subject: [PATCH 2/6] [babel-plugin] update snapshot for debug class name removal Refresh the legacyDisableLayers inline snapshot to match the class and custom property names produced after #1834 removed property-prefixed debug class names. Ordering intent is unchanged. Co-Authored-By: Claude Opus 5 --- .../__tests__/transform-process-test.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js b/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js index fd7f20bc7..e707b981d 100644 --- a/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js +++ b/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js @@ -1169,13 +1169,13 @@ describe('@stylexjs/babel-plugin', () => { 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}}" + ":root, .xbiwvf9{--x19twipt:2px;--xypjos2:4px;--x1ec7iuc:8px;} + :root, .xsg933n{--xpqh4lw:blue;--x8nt2k2:10px;--xkxfyv:red;} + @media (min-width: 600px){:root, .xsg933n{--x8nt2k2:20px;}} + @media (prefers-color-scheme: dark){:root, .xsg933n{--xkxfyv:lightblue;}} + @supports (color: oklab(0 0 0)){@media (prefers-color-scheme: dark){:root, .xsg933n{--xkxfyv:oklab(0.7 -0.3 -0.4);}}} + @media (max-width: 1000px){.xz4zmo0.xz4zmo0{color:red}} + @media (max-width: 500px){.x100plp.x100plp{color:blue}}" `); }); From 0db4aa1bf5475b7789de2c3ea5aed7e93c76ebea Mon Sep 17 00:00:00 2001 From: "Henry Q. Dineen" Date: Fri, 4 Sep 2026 01:14:32 -0400 Subject: [PATCH 3/6] [babel-plugin] cover logical float vars resolved from constants Hoisting constant substitution above the sort means getLogicalFloatVars inspects resolved values. Pin that: a float arriving via a defineConsts constant now emits the :root block defining --stylex-logical-start, where previously the declaration referenced an undefined custom property and computed to none. Co-Authored-By: Claude Opus 5 --- .../__tests__/transform-process-test.js | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js b/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js index e707b981d..645d65fcc 100644 --- a/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js +++ b/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js @@ -914,6 +914,36 @@ describe('@stylexjs/babel-plugin', () => { `); }); + // The `float` value arrives via a constant, so it is only recognizable as a + // logical float after constants are substituted. + test('logical float vars are emitted when the float comes from a constant', () => { + const rules = [ + [ + 'cHash', + { constKey: 'cHash', constVal: 'var(--stylex-logical-start)' }, + 0, + ], + ['x1', { ltr: '.x1{float:var(--cHash)}', rtl: null }, 3000], + ]; + + expect(stylexPlugin.processStylexRules(rules, true)) + .toMatchInlineSnapshot(` + ":root, [dir="ltr"] { + --stylex-logical-start: left; + --stylex-logical-end: right; + } + [dir="rtl"] { + --stylex-logical-start: right; + --stylex-logical-end: left; + } + + @layer priority1; + @layer priority1{ + .x1{float:var(--stylex-logical-start)} + }" + `); + }); + test('legacy-expand-shorthands duplicates theme selectors for higher precedence', () => { const { _code, metadata } = transform( ` From e3ac0ac32457b190a054fe84899a849999561d2a Mon Sep 17 00:00:00 2001 From: "Henry Q. Dineen" Date: Fri, 4 Sep 2026 02:16:48 -0400 Subject: [PATCH 4/6] [babel-plugin] fix media query sort for negation and nested at-rules Two bugs in the px min/max-width breakpoint sort: A negated width bound inside a conjunction was silently discarded, so "(min-width: 500px) and (not (max-width: 700px))" -- effectively "width > 700px" -- sorted by its 500px bound and lost the cascade to "(min-width: 600px)" above 700px. The walk now bails on any `not`, `or`, negated media type, or non-px bound, and requires exactly one bound. The sort also assumed "@media" started the rule, so a breakpoint nested in another at-rule never sorted. Keys are now built from the whole at-rule chain, and only rules sharing the same surrounding conditions compare against each other. Parsing moves out of the comparator into a chain-keyed cache; it ran O(n log n) times before, which made large sheets pathologically slow. Co-Authored-By: Claude Opus 5 --- .../__tests__/transform-process-test.js | 59 ++++++ packages/@stylexjs/babel-plugin/src/index.js | 191 +++++++++++++----- 2 files changed, 196 insertions(+), 54 deletions(-) diff --git a/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js b/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js index 645d65fcc..f422dde4f 100644 --- a/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js +++ b/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js @@ -1182,6 +1182,65 @@ describe('@stylexjs/babel-plugin', () => { `); }); + test('does not sort a min-width paired with a negated max-width', () => { + // "(min-width: 500px) and (not (max-width: 700px))" is effectively + // "width > 700px", so its 500px bound must not be used as a sort key — + // that would place it before (min-width: 600px) and let the 600px rule + // win at widths above 700px. It falls through to the existing sort. + const mk = (cls, query, decl) => [ + cls, + { ltr: `${query}{.${cls}.${cls}{${decl}}}`, rtl: null }, + 3000, + ]; + const rules = [ + mk( + 'xNarrow', + '@media screen and (min-width: 500px) and (not (max-width: 700px))', + 'color:red', + ), + mk('x600', '@media (min-width: 600px)', 'color:blue'), + ]; + + const css = stylexPlugin.processStylexRules(rules, { + useLayers: false, + legacyDisableLayers: true, + }); + expect(css).toMatchInlineSnapshot(` + "@media (min-width: 600px){.x600.x600{color:blue}} + @media screen and (min-width: 500px) and (not (max-width: 700px)){.xNarrow.xNarrow{color:red}}" + `); + }); + + test('sorts min-width breakpoints nested inside another at-rule', () => { + // The media query is not at the start of the rule, so the sort has to + // find it within the at-rule chain. Breakpoints only sort against rules + // sharing the same surrounding conditions — the differing `@supports` + // pair below must not cross-sort. + const mk = (cls, prelude) => [ + cls, + { ltr: `${prelude}{.${cls}.${cls}{color:red}}}`, rtl: null }, + 3000, + ]; + const rules = [ + mk('xWide', '@supports (display:grid){@media (min-width: 1500px)'), + mk('xNarrow', '@supports (display:grid){@media (min-width: 500px)'), + mk( + 'yOther', + '@supports (color:oklab(0 0 0)){@media (min-width: 900px)', + ), + ]; + + const css = stylexPlugin.processStylexRules(rules, { + useLayers: false, + legacyDisableLayers: true, + }); + expect(css).toMatchInlineSnapshot(` + "@supports (color:oklab(0 0 0)){@media (min-width: 900px){.yOther.yOther{color:red}}} + @supports (display:grid){@media (min-width: 500px){.xNarrow.xNarrow{color:red}}} + @supports (display:grid){@media (min-width: 1500px){.xWide.xWide{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. diff --git a/packages/@stylexjs/babel-plugin/src/index.js b/packages/@stylexjs/babel-plugin/src/index.js index 6720f89c4..885b29d15 100644 --- a/packages/@stylexjs/babel-plugin/src/index.js +++ b/packages/@stylexjs/babel-plugin/src/index.js @@ -505,8 +505,29 @@ function getLogicalFloatVars(rules: Array): string { : ''; } -function findWidthPxInRule(rule: MediaQueryRule, type: string): number | null { - if (rule.type === 'pair' && rule.key === type) { +type WidthBound = $ReadOnly<{ kind: 'min' | 'max', value: number }>; + +// A rule's width bound plus the at-rule context it sits in. Only rules with +// an identical context are comparable: a breakpoint nested under `@supports` +// says nothing about one nested under a different condition. +type WidthSortKey = $ReadOnly<{ context: string, bound: WidthBound }>; + +// The `min-width`/`max-width` px bounds a media query imposes, or null if it +// has none to sort by: `not` and `or` can widen the matched range or split it +// in two, and rem/em bounds are unreadable at build time. +function collectWidthBounds(rule: MediaQueryRule): Array | null { + if (rule.type === 'pair') { + const kind = + rule.key === 'min-width' + ? 'min' + : rule.key === 'max-width' + ? 'max' + : null; + // A non-width feature (e.g. `orientation`) constrains no width. + if (kind == null) { + return []; + } + const v = rule.value; if ( v != null && @@ -514,39 +535,97 @@ function findWidthPxInRule(rule: MediaQueryRule, type: string): number | null { typeof v.value === 'number' && v.unit === 'px' ) { - return v.value; + return [{ kind, value: 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; - } + const bounds: Array = []; for (const r of rule.rules) { - const found = findWidthPxInRule(r, type); - if (found !== null) return found; + const found = collectWidthBounds(r); + if (found == null) { + return null; + } + + bounds.push(...found); } + + return bounds; } - // 'not' and 'or' are intentionally skipped — negated queries have inverted - // semantics and OR queries have no single value to sort by. + + // A negated media type (e.g. `not screen`) inverts the whole expression. + if (rule.type === 'media-keyword') { + return rule.not ? null : []; + } + return null; } -function extractMediaQueryWidthPx( - rule: string, - type: 'min-width' | 'max-width', -): number | null { - const firstBrace = rule.indexOf('{'); - if (firstBrace === -1) return null; +// The single width bound a media query sorts by, or null if it has none. +function mediaQueryWidthSortKey(prelude: string): WidthBound | null { + let parsed; try { - const parsed = MediaQuery.parser.parseToEnd( - rule.slice(0, firstBrace).trimEnd(), - ); - return findWidthPxInRule(parsed.queries, type); + parsed = MediaQuery.parser.parseToEnd(prelude); } catch { return null; } + const bounds = collectWidthBounds(parsed.queries); + // Zero bounds means no width condition; more than one (a range, or a + // redundant pair like `(min-width: 500px) and (min-width: 900px)`) has no + // single value to sort by. + if (bounds == null || bounds.length !== 1) { + return null; + } + + return bounds[0]; +} + +// The leading at-rule preludes of a rule, outermost first, e.g. +// `@supports (x){@media (y){.a{…}}}` -> ['@supports (x)', '@media (y)']. +function atRulePreludes(rule: string): Array { + const preludes = []; + let index = 0; + while (rule[index] === '@') { + const brace = rule.indexOf('{', index); + if (brace === -1) { + break; + } + + preludes.push(rule.slice(index, brace).trimEnd()); + index = brace + 1; + } + + return preludes; +} + +// The sort key for a rule's at-rule chain, or null if it has no single +// `@media` to sort by. Media queries nested in other at-rules still sort, but +// only against rules sharing the same surrounding conditions. +function widthSortKeyForChain(preludes: Array): WidthSortKey | null { + const mediaIndexes = []; + preludes.forEach((prelude, i) => { + if (prelude.startsWith('@media ')) { + mediaIndexes.push(i); + } + }); + // Zero means nothing to sort by; more than one means nested media queries + // whose combined bound isn't a single value. + if (mediaIndexes.length !== 1) { + return null; + } + + const mediaIndex = mediaIndexes[0]; + const bound = mediaQueryWidthSortKey(preludes[mediaIndex]); + if (bound == null) { + return null; + } + + // Blank out the media query itself so the context captures the surrounding + // at-rules and the query's depth among them, but not its breakpoint. + const context = preludes + .map((p, i) => (i === mediaIndex ? '@media' : p)) + .join('{'); + return { context, bound }; } function processStylexRules( @@ -633,6 +712,27 @@ function processStylexRules( constsMap.set(key, resolveConstant(value)); } + // Parsing is expensive and the comparator runs O(n log n) times. Key on the + // at-rule chain, not the whole rule — every rule has a distinct class name, + // so a full-rule key would never hit. + const widthSortKeys: Map = new Map(); + const getWidthSortKey = (rule: string): WidthSortKey | null => { + if (rule[0] !== '@') { + return null; + } + + const preludes = atRulePreludes(rule); + const chain = preludes.join('{'); + const cached = widthSortKeys.get(chain); + if (cached !== undefined) { + return cached; + } + + const key = widthSortKeyForChain(preludes); + widthSortKeys.set(chain, key); + return key; + }; + const sortedRules: Array = nonConstantRules .map(([key, { ...styleObj }, priority]): Rule => { Object.keys(styleObj).forEach((dir) => { @@ -666,39 +766,22 @@ function processStylexRules( 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; - } + // min-width ascending, max-width descending, so the + // narrower-matching rule comes later and wins the cascade. Queries + // with no bound, or bounded on opposite sides, fall through below. + const widthKey1 = getWidthSortKey(rule1); + const widthKey2 = getWidthSortKey(rule2); + if ( + widthKey1 != null && + widthKey2 != null && + widthKey1.context === widthKey2.context && + widthKey1.bound.kind === widthKey2.bound.kind + ) { + const mqComparison = + widthKey1.bound.kind === 'min' + ? widthKey1.bound.value - widthKey2.bound.value + : widthKey2.bound.value - widthKey1.bound.value; + if (mqComparison !== 0) return mqComparison; } const property1 = rule1.slice(rule1.lastIndexOf('{')); const property2 = rule2.slice(rule2.lastIndexOf('{')); From e31dd19f10f00b6f291709ec22250f12d3938fc5 Mon Sep 17 00:00:00 2001 From: "Henry Q. Dineen" Date: Fri, 4 Sep 2026 02:31:20 -0400 Subject: [PATCH 5/6] [babel-plugin] make the media query sort a total order The breakpoint order only applied to pairs of rules bounded on the same side, so some pairs were decided by width and others by declaration text. That is not a consistent comparator, and it produced a cycle: a min-width 500px z-index rule, a max-width 300px margin rule, and a min-width 900px align-items rule ordered a < c < b < a, so the output depended on input order. Six permutations gave three different results. Comparison is now lexicographic over property name, then width, then declaration text, with every width pair getting an answer: bounded rules ahead of unbounded, grouped by at-rule context, then min-width ascending before max-width descending -- the mobile-first order used by sort-css-media-queries. Grouping by property name rather than the whole declaration is what makes this work. Two breakpoints for one property differ in value by definition, so comparing declaration text first would preempt the breakpoint order entirely. Co-Authored-By: Claude Opus 5 --- .../__tests__/transform-process-test.js | 116 ++++++++++++++++++ packages/@stylexjs/babel-plugin/src/index.js | 71 ++++++++--- 2 files changed, 170 insertions(+), 17 deletions(-) diff --git a/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js b/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js index f422dde4f..ac29744e4 100644 --- a/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js +++ b/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js @@ -1501,6 +1501,122 @@ describe('@stylexjs/babel-plugin', () => { `); }); + test('sort is a total order across px min- and max-width rules', () => { + // Three rules with different properties and mixed bound directions. The + // width order applies to some pairs and the declaration order to others, + // so an inconsistent comparator produces a cycle here and lets the + // output depend on input order. + const mk = (cls, query, decl) => [ + cls, + { ltr: `${query}{.${cls}.${cls}{${decl}}}`, rtl: null }, + 3000, + ]; + const a = mk('a', '@media (min-width: 500px)', 'z-index:1'); + const b = mk('b', '@media (max-width: 300px)', 'margin:0'); + const c = mk('c', '@media (min-width: 900px)', 'align-items:start'); + + const outputs = [ + [a, b, c], + [a, c, b], + [b, a, c], + [b, c, a], + [c, a, b], + [c, b, a], + ].map((permutation) => + stylexPlugin.processStylexRules( + permutation.map(([key, styleObj, priority]) => [ + key, + { ...styleObj }, + priority, + ]), + { useLayers: false, legacyDisableLayers: true }, + ), + ); + + expect(new Set(outputs).size).toBe(1); + expect(outputs[0]).toMatchInlineSnapshot(` + "@media (min-width: 900px){.c.c{align-items:start}} + @media (max-width: 300px){.b.b{margin:0}} + @media (min-width: 500px){.a.a{z-index:1}}" + `); + }); + + test('orders the min-width group before the max-width group', () => { + // Matches the conventional mobile-first order used by + // sort-css-media-queries: min-width ascending, then max-width + // descending. + const mk = (cls, query) => [ + cls, + { ltr: `${query}{.${cls}.${cls}{color:red}}`, rtl: null }, + 3000, + ]; + const rules = [ + mk('xMaxNarrow', '@media (max-width: 300px)'), + mk('xMinWide', '@media (min-width: 900px)'), + mk('xMaxWide', '@media (max-width: 800px)'), + mk('xMinNarrow', '@media (min-width: 400px)'), + ]; + + const css = stylexPlugin.processStylexRules(rules, { + useLayers: false, + legacyDisableLayers: true, + }); + expect(css).toMatchInlineSnapshot(` + "@media (min-width: 400px){.xMinNarrow.xMinNarrow{color:red}} + @media (min-width: 900px){.xMinWide.xMinWide{color:red}} + @media (max-width: 800px){.xMaxWide.xMaxWide{color:red}} + @media (max-width: 300px){.xMaxNarrow.xMaxNarrow{color:red}}" + `); + }); + + test('does not sort rem breakpoints', () => { + // A rem bound needs a root font size that is unknown at build time, so + // these fall through to the existing sort rather than being guessed at. + const mk = (cls, query) => [ + cls, + { ltr: `${query}{.${cls}.${cls}{color:red}}`, rtl: null }, + 3000, + ]; + // 100rem vs 48rem: numerically ascending order would put 48rem first, + // so the fall-through order is distinguishable from a width sort. + const rules = [ + mk('xWide', '@media (min-width: 100rem)'), + mk('xNarrow', '@media (min-width: 48rem)'), + ]; + + const css = stylexPlugin.processStylexRules(rules, { + useLayers: false, + legacyDisableLayers: true, + }); + expect(css).toMatchInlineSnapshot(` + "@media (min-width: 100rem){.xWide.xWide{color:red}} + @media (min-width: 48rem){.xNarrow.xNarrow{color:red}}" + `); + }); + + test('does not sort a query with two bounds on the same side', () => { + // "(min-width: 500px) and (min-width: 900px)" is really a 900px bound, + // but taking either value alone would be wrong, so it falls through. + const mk = (cls, query) => [ + cls, + { ltr: `${query}{.${cls}.${cls}{color:red}}`, rtl: null }, + 3000, + ]; + const rules = [ + mk('xBoth', '@media (min-width: 500px) and (min-width: 900px)'), + mk('xSingle', '@media (min-width: 700px)'), + ]; + + const css = stylexPlugin.processStylexRules(rules, { + useLayers: false, + legacyDisableLayers: true, + }); + expect(css).toMatchInlineSnapshot(` + "@media (min-width: 700px){.xSingle.xSingle{color:red}} + @media (min-width: 500px) and (min-width: 900px){.xBoth.xBoth{color:red}}" + `); + }); + 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 885b29d15..e09a98b0b 100644 --- a/packages/@stylexjs/babel-plugin/src/index.js +++ b/packages/@stylexjs/babel-plugin/src/index.js @@ -628,6 +628,45 @@ function widthSortKeyForChain(preludes: Array): WidthSortKey | null { return { context, bound }; } +// The property name a rule declares, e.g. `.a{width:500px}` -> `width`. +// Breakpoints are ordered within a property, so the property has to be +// compared without its value — two breakpoints for the same property differ +// in value by definition, and comparing that first would preempt the order. +function declaredPropertyName(rule: string): string { + const declaration = rule.slice(rule.lastIndexOf('{') + 1); + const colon = declaration.indexOf(':'); + return colon === -1 ? declaration : declaration.slice(0, colon); +} + +// A total ordering over width sort keys, so the sort stays consistent no +// matter which pairs get compared: rules with a bound first, then grouped by +// at-rule context, then `min-width` ascending ahead of `max-width` descending +// — the conventional mobile-first order. +function compareWidthSortKeys( + a: WidthSortKey | null, + b: WidthSortKey | null, +): number { + if (a == null || b == null) { + if (a == null && b == null) { + return 0; + } + + return a == null ? 1 : -1; + } + + if (a.context !== b.context) { + return a.context.localeCompare(b.context); + } + + if (a.bound.kind !== b.bound.kind) { + return a.bound.kind === 'min' ? -1 : 1; + } + + return a.bound.kind === 'min' + ? a.bound.value - b.bound.value + : b.bound.value - a.bound.value; +} + function processStylexRules( rules: Array, config?: @@ -766,23 +805,21 @@ function processStylexRules( if (useLegacyClassnamesSort) { return classname1.localeCompare(classname2); } else { - // min-width ascending, max-width descending, so the - // narrower-matching rule comes later and wins the cascade. Queries - // with no bound, or bounded on opposite sides, fall through below. - const widthKey1 = getWidthSortKey(rule1); - const widthKey2 = getWidthSortKey(rule2); - if ( - widthKey1 != null && - widthKey2 != null && - widthKey1.context === widthKey2.context && - widthKey1.bound.kind === widthKey2.bound.kind - ) { - const mqComparison = - widthKey1.bound.kind === 'min' - ? widthKey1.bound.value - widthKey2.bound.value - : widthKey2.bound.value - widthKey1.bound.value; - if (mqComparison !== 0) return mqComparison; - } + const nameComparison = declaredPropertyName(rule1).localeCompare( + declaredPropertyName(rule2), + ); + if (nameComparison !== 0) return nameComparison; + + // Only rules for the same property compete in the cascade, so the + // breakpoint order applies within a property, after it. Ordering by + // width first would decide some pairs by width and others by + // declaration text, which is not a consistent ordering. + const mqComparison = compareWidthSortKeys( + getWidthSortKey(rule1), + getWidthSortKey(rule2), + ); + if (mqComparison !== 0) return mqComparison; + const property1 = rule1.slice(rule1.lastIndexOf('{')); const property2 = rule2.slice(rule2.lastIndexOf('{')); const propertyComparison = property1.localeCompare(property2); From 5001691453c69a3d26b3ce916b9fbcd6a754871b Mon Sep 17 00:00:00 2001 From: "Henry Q. Dineen" Date: Fri, 4 Sep 2026 12:42:53 -0400 Subject: [PATCH 6/6] [babel-plugin] sort unitless zero and case-insensitive width bounds Media feature names and units are both ASCII case-insensitive, so "(MIN-WIDTH: 700Px)" and "(min-width: 900PX)" were parsed but never sorted. Zero is also the one length valid without a unit, and the parser reports it as a bare number rather than a length, so "(min-width: 0)" was skipped too. Zero now counts as a bound in any unit, or none. Unitless non-zero values are still skipped: "(min-width: 700)" is invalid CSS and the browser drops the query, so treating it as 700px would order the sheet by a rule that never matches. A ratio on a width feature ("(min-width: 16/9)") is array-shaped rather than a length, and now bails explicitly instead of relying on a missing property read -- and, as with a negated bound, cannot let a sibling px bound stand in for it. Co-Authored-By: Claude Opus 5 --- .../__tests__/transform-process-test.js | 76 +++++++++++++++++++ packages/@stylexjs/babel-plugin/src/index.js | 23 ++++-- 2 files changed, 92 insertions(+), 7 deletions(-) diff --git a/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js b/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js index ac29744e4..67c514514 100644 --- a/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js +++ b/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js @@ -1569,6 +1569,82 @@ describe('@stylexjs/babel-plugin', () => { `); }); + test('sorts unitless zero, any-unit zero, and mixed-case px', () => { + // Media feature names and units are both ASCII case-insensitive, and + // zero is the one length valid without a unit (and zero in any unit). + const mk = (cls, query) => [ + cls, + { ltr: `${query}{.${cls}.${cls}{color:red}}`, rtl: null }, + 3000, + ]; + const rules = [ + mk('xUpper', '@media (min-width: 900PX)'), + mk('xZero', '@media (min-width: 0)'), + mk('xMixed', '@media (MIN-WIDTH: 700Px)'), + mk('xZeroEm', '@media (max-width: 0em)'), + ]; + + const css = stylexPlugin.processStylexRules(rules, { + useLayers: false, + legacyDisableLayers: true, + }); + expect(css).toMatchInlineSnapshot(` + "@media (min-width: 0){.xZero.xZero{color:red}} + @media (MIN-WIDTH: 700Px){.xMixed.xMixed{color:red}} + @media (min-width: 900PX){.xUpper.xUpper{color:red}} + @media (max-width: 0em){.xZeroEm.xZeroEm{color:red}}" + `); + }); + + test('does not sort a width whose value is a ratio', () => { + // "(min-width: 16/9)" is invalid CSS but does parse, as a ratio rather + // than a length. It must not sort, and — as with a negated bound — must + // not let a sibling px bound stand in for it. + const mk = (cls, query) => [ + cls, + { ltr: `${query}{.${cls}.${cls}{color:red}}`, rtl: null }, + 3000, + ]; + const rules = [ + mk('xRatio', '@media (min-width: 16/9)'), + mk('xPaired', '@media (min-width: 16/9) and (min-width: 400px)'), + mk('xReal', '@media (min-width: 900px)'), + ]; + + const css = stylexPlugin.processStylexRules(rules, { + useLayers: false, + legacyDisableLayers: true, + }); + expect(css).toMatchInlineSnapshot(` + "@media (min-width: 900px){.xReal.xReal{color:red}} + @media (min-width: 16/9) and (min-width: 400px){.xPaired.xPaired{color:red}} + @media (min-width: 16/9){.xRatio.xRatio{color:red}}" + `); + }); + + test('does not sort a unitless non-zero width', () => { + // "(min-width: 700)" is invalid CSS — the browser drops the query — so + // it must not be treated as 700px. + const mk = (cls, query) => [ + cls, + { ltr: `${query}{.${cls}.${cls}{color:red}}`, rtl: null }, + 3000, + ]; + const rules = [ + mk('xBare', '@media (min-width: 700)'), + mk('xReal', '@media (min-width: 900px)'), + ]; + + const css = stylexPlugin.processStylexRules(rules, { + useLayers: false, + legacyDisableLayers: true, + }); + expect(css).toMatchInlineSnapshot(` + "@media (min-width: 900px){.xReal.xReal{color:red}} + @media (min-width: 700){.xBare.xBare{color:red}}" + `); + }); + test('does not sort rem breakpoints', () => { // A rem bound needs a root font size that is unknown at build time, so // these fall through to the existing sort rather than being guessed at. diff --git a/packages/@stylexjs/babel-plugin/src/index.js b/packages/@stylexjs/babel-plugin/src/index.js index e09a98b0b..b092b5bf6 100644 --- a/packages/@stylexjs/babel-plugin/src/index.js +++ b/packages/@stylexjs/babel-plugin/src/index.js @@ -514,26 +514,35 @@ type WidthSortKey = $ReadOnly<{ context: string, bound: WidthBound }>; // The `min-width`/`max-width` px bounds a media query imposes, or null if it // has none to sort by: `not` and `or` can widen the matched range or split it -// in two, and rem/em bounds are unreadable at build time. +// in two, and rem/em bounds are unreadable at build time. Zero counts as a +// bound whatever unit it carries, or none at all. function collectWidthBounds(rule: MediaQueryRule): Array | null { if (rule.type === 'pair') { + // Media feature names are ASCII case-insensitive. + const key = rule.key.toLowerCase(); const kind = - rule.key === 'min-width' - ? 'min' - : rule.key === 'max-width' - ? 'max' - : null; + key === 'min-width' ? 'min' : key === 'max-width' ? 'max' : null; // A non-width feature (e.g. `orientation`) constrains no width. if (kind == null) { return []; } const v = rule.value; + // Zero is the one length that needs no unit. Any other unitless number is + // invalid CSS for a media feature — the browser drops the whole query — so + // leave those unsorted rather than assume px. + if (v === 0) { + return [{ kind, value: 0 }]; + } + if ( v != null && typeof v === 'object' && + // A `Fraction` value (e.g. `aspect-ratio`) is array-shaped, not a length. + !Array.isArray(v) && typeof v.value === 'number' && - v.unit === 'px' + // Units are ASCII case-insensitive, and zero is zero in any unit. + (v.value === 0 || String(v.unit).toLowerCase() === 'px') ) { return [{ kind, value: v.value }]; }