diff --git a/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js b/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js index 48b06af23..67c514514 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( ` @@ -1060,6 +1090,609 @@ 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('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. + // 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, .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}}" + `); + }); + + 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 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('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. + 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 c49e06257..b092b5bf6 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, @@ -503,6 +505,177 @@ function getLogicalFloatVars(rules: Array): string { : ''; } +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. 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 = + 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' && + // 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 }]; + } + return null; + } + if (rule.type === 'and') { + const bounds: Array = []; + for (const r of rule.rules) { + const found = collectWidthBounds(r); + if (found == null) { + return null; + } + + bounds.push(...found); + } + + return bounds; + } + + // A negated media type (e.g. `not screen`) inverts the whole expression. + if (rule.type === 'media-keyword') { + return rule.not ? null : []; + } + + 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 { + 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 }; +} + +// 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?: @@ -587,57 +760,88 @@ 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); - } - }, - ); - - let lastKPri = -1; - const grouped = sortedRules.reduce((acc: Array>, 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; + // 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 replacement = String(constValue); + const preludes = atRulePreludes(rule); + const chain = preludes.join('{'); + const cached = widthSortKeys.get(chain); + if (cached !== undefined) { + return cached; + } - original = original.replaceAll(varRef, replacement); + const key = widthSortKeyForChain(preludes); + widthSortKeys.set(chain, key); + return key; + }; - // 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 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 { + 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 constName = varRef.slice(4, -1); - original = original.replaceAll(`${constName}:`, `${targetName}:`); + 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); } + }, + ); - styleObj[dir] = original; - } - }); + let lastKPri = -1; + const grouped = sortedRules.reduce((acc: Array>, rule) => { + const [key, styleObj, priority] = rule; + const priorityLevel = Math.floor(priority / 1000); if (priorityLevel === lastKPri) { acc[acc.length - 1].push([key, styleObj, priority]); @@ -649,7 +853,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';