From e705ad74383994777755eeaf2c38e31f099bf305 Mon Sep 17 00:00:00 2001 From: Amit Gangwar Date: Tue, 27 Oct 2026 15:33:46 +0530 Subject: [PATCH] [babel-plugin] Polyfill logical border radius properties Signed-off-by: Amit Gangwar --- .../transform-logical-properties-test.js | 51 ++++++++++-- .../__tests__/transform-process-test.js | 37 +++++++++ packages/@stylexjs/babel-plugin/src/index.js | 47 ++++++++++- .../src/shared/physical-rtl/generate-ltr.js | 4 - .../src/shared/physical-rtl/generate-rtl.js | 4 - .../physical-rtl/logical-border-radius.js | 29 +++++++ .../src/shared/utils/generate-css-rule.js | 80 +++++++++++++++++-- .../@stylexjs/stylex/__tests__/inject-test.js | 10 +++ .../@stylexjs/stylex/src/stylesheet/utils.js | 43 ++++++++++ 9 files changed, 280 insertions(+), 25 deletions(-) create mode 100644 packages/@stylexjs/babel-plugin/src/shared/physical-rtl/logical-border-radius.js diff --git a/packages/@stylexjs/babel-plugin/__tests__/legacy/transform-logical-properties-test.js b/packages/@stylexjs/babel-plugin/__tests__/legacy/transform-logical-properties-test.js index cc5e13633..af4a6fba2 100644 --- a/packages/@stylexjs/babel-plugin/__tests__/legacy/transform-logical-properties-test.js +++ b/packages/@stylexjs/babel-plugin/__tests__/legacy/transform-logical-properties-test.js @@ -1443,8 +1443,7 @@ describe('@stylexjs/babel-plugin', () => { var _inject2 = _inject; import stylex from 'stylex'; _inject2({ - ltr: ".x13t61ll{border-top-left-radius:5px}", - rtl: ".x13t61ll{border-top-right-radius:5px}", + ltr: "@media all{@supports not (border-start-start-radius: 0){html:not([dir='rtl']) .x13t61ll{border-top-left-radius:5px}html[dir='rtl'] .x13t61ll{border-top-right-radius:5px}}.x13t61ll{border-start-start-radius:5px}}", priority: 3000 }); const classnames = "x13t61ll";" @@ -1469,8 +1468,7 @@ describe('@stylexjs/babel-plugin', () => { var _inject2 = _inject; import stylex from 'stylex'; _inject2({ - ltr: ".xbxn0j6{border-bottom-left-radius:5px}", - rtl: ".xbxn0j6{border-bottom-right-radius:5px}", + ltr: "@media all{@supports not (border-end-start-radius: 0){html:not([dir='rtl']) .xbxn0j6{border-bottom-left-radius:5px}html[dir='rtl'] .xbxn0j6{border-bottom-right-radius:5px}}.xbxn0j6{border-end-start-radius:5px}}", priority: 3000 }); const classnames = "xbxn0j6";" @@ -1495,14 +1493,52 @@ describe('@stylexjs/babel-plugin', () => { var _inject2 = _inject; import stylex from 'stylex'; _inject2({ - ltr: ".x1kchd1x{border-top-right-radius:5px}", - rtl: ".x1kchd1x{border-top-left-radius:5px}", + ltr: "@media all{@supports not (border-start-end-radius: 0){html:not([dir='rtl']) .x1kchd1x{border-top-right-radius:5px}html[dir='rtl'] .x1kchd1x{border-top-left-radius:5px}}.x1kchd1x{border-start-end-radius:5px}}", priority: 3000 }); const classnames = "x1kchd1x";" `); }); + test('borderRadius with pseudo selectors and media queries', () => { + expect( + transform( + ` + import stylex from 'stylex'; + const styles = stylex.create({ + x: { + borderTopStartRadius: 5, + ':hover': { borderBottomEndRadius: 10 }, + '@media (min-width: 600px)': { borderTopEndRadius: 15 } + } + }); + const classnames = stylex(styles.x); + `, + { + styleResolution: 'legacy-expand-shorthands', + enableLogicalStylesPolyfill: true, + }, + ), + ).toMatchInlineSnapshot(` + "import _inject from "@stylexjs/stylex/lib/stylex-inject"; + var _inject2 = _inject; + import stylex from 'stylex'; + _inject2({ + ltr: "@media all{@supports not (border-start-start-radius: 0){html:not([dir='rtl']) .x13t61ll{border-top-left-radius:5px}html[dir='rtl'] .x13t61ll{border-top-right-radius:5px}}.x13t61ll{border-start-start-radius:5px}}", + priority: 3000 + }); + _inject2({ + ltr: "@media all{@supports not (border-end-end-radius: 0){html:not([dir='rtl']) .xomk977:hover{border-bottom-right-radius:10px}html[dir='rtl'] .xomk977:hover{border-bottom-left-radius:10px}}.xomk977:hover{border-end-end-radius:10px}}", + priority: 3130 + }); + _inject2({ + ltr: "@media (min-width: 600px){@media all{@supports not (border-start-end-radius: 0){html:not([dir='rtl']) .xw0ibk9{border-top-right-radius:15px}html[dir='rtl'] .xw0ibk9{border-top-left-radius:15px}}.xw0ibk9{border-start-end-radius:15px}}}", + priority: 3200 + }); + const classnames = "x13t61ll xomk977 xw0ibk9";" + `); + }); + test('borderBottomEndRadius', () => { expect( transform( @@ -1521,8 +1557,7 @@ describe('@stylexjs/babel-plugin', () => { var _inject2 = _inject; import stylex from 'stylex'; _inject2({ - ltr: ".x1u0fnx4{border-bottom-right-radius:5px}", - rtl: ".x1u0fnx4{border-bottom-left-radius:5px}", + ltr: "@media all{@supports not (border-end-end-radius: 0){html:not([dir='rtl']) .x1u0fnx4{border-bottom-right-radius:5px}html[dir='rtl'] .x1u0fnx4{border-bottom-left-radius:5px}}.x1u0fnx4{border-end-end-radius:5px}}", priority: 3000 }); const classnames = "x1u0fnx4";" diff --git a/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js b/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js index 6ec1fca5b..439c35ae6 100644 --- a/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js +++ b/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js @@ -995,6 +995,43 @@ describe('@stylexjs/babel-plugin', () => { `); }); + test('logical border radius fallback stays a single insertable rule', () => { + const rules = [ + [ + 'x1', + { + ltr: "@media all{@supports not (border-start-start-radius: 0){html:not([dir='rtl']) .x1{border-top-left-radius:5px}html[dir='rtl'] .x1{border-top-right-radius:5px}}.x1{border-start-start-radius:5px}}", + rtl: null, + }, + 3000, + ], + [ + 'x2', + { + ltr: "@media (min-width: 600px){@media all{@supports not (border-end-end-radius: 0){html:not([dir='rtl']) .x2{border-bottom-right-radius:10px}html[dir='rtl'] .x2{border-bottom-left-radius:10px}}.x2{border-end-end-radius:10px}}}", + rtl: null, + }, + 3000, + ], + ]; + + expect(stylexPlugin.processStylexRules(rules, { useLayers: false })) + .toMatchInlineSnapshot(` + "@media (min-width: 600px){@media all{@supports not (border-end-end-radius: 0){html:not([dir='rtl']) .x2{border-bottom-right-radius:10px}html[dir='rtl'] .x2{border-bottom-left-radius:10px}}.x2{border-end-end-radius:10px}}} + @media all{@supports not (border-start-start-radius: 0){html:not([dir='rtl']) .x1{border-top-left-radius:5px}html[dir='rtl'] .x1{border-top-right-radius:5px}}.x1{border-start-start-radius:5px}}" + `); + + expect(stylexPlugin.processStylexRules(rules, true)) + .toMatchInlineSnapshot(` + " + @layer priority1; + @layer priority1{ + @media (min-width: 600px){@media all{@supports not (border-end-end-radius: 0){html:not([dir='rtl']) .x2{border-bottom-right-radius:10px}html[dir='rtl'] .x2{border-bottom-left-radius:10px}}.x2{border-end-end-radius:10px}}} + @media all{@supports not (border-start-start-radius: 0){html:not([dir='rtl']) .x1{border-top-left-radius:5px}html[dir='rtl'] .x1{border-top-right-radius:5px}}.x1{border-start-start-radius:5px}} + }" + `); + }); + // 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', () => { diff --git a/packages/@stylexjs/babel-plugin/src/index.js b/packages/@stylexjs/babel-plugin/src/index.js index b4c4b7fd9..73fae4445 100644 --- a/packages/@stylexjs/babel-plugin/src/index.js +++ b/packages/@stylexjs/babel-plugin/src/index.js @@ -614,7 +614,9 @@ function atRulePreludes(rule: string): Array { function widthSortKeyForChain(preludes: Array): WidthSortKey | null { const mediaIndexes = []; preludes.forEach((prelude, i) => { - if (prelude.startsWith('@media ')) { + // `@media all` is used to keep a logical-property fallback and its native + // rule in one insertable block. It imposes no width bound. + if (prelude.startsWith('@media ') && prelude.trim() !== '@media all') { mediaIndexes.push(i); } }); @@ -1023,6 +1025,49 @@ function addSpecificityLevel(selector: string, index: number): string { .map(() => ':not(#\\#)') .join(''); + if (index === 0) { + return selector; + } + + // Radius fallbacks contain several nested rules in one insertable block, so + // every selector in the block needs the specificity bump. + if (selector.includes('@media all{')) { + let result = ''; + let cursor = 0; + while (cursor < selector.length) { + const openCurly = selector.indexOf('{', cursor); + if (openCurly === -1) { + result += selector.slice(cursor); + break; + } + + const prelude = selector.slice(cursor, openCurly); + const boundary = Math.max( + prelude.lastIndexOf('{'), + prelude.lastIndexOf('}'), + ); + const beforeSelector = prelude.slice(0, boundary + 1); + const selectorText = prelude.slice(boundary + 1); + + if (selectorText.trimStart().startsWith('@')) { + result += `${prelude}{`; + } else { + const pseudoIndex = selectorText.includes('::') + ? selectorText.indexOf('::') + : selectorText.length; + result += + beforeSelector + + selectorText.slice(0, pseudoIndex) + + pseudo + + selectorText.slice(pseudoIndex) + + '{'; + } + + cursor = openCurly + 1; + } + return result; + } + const lastOpenCurly = selector.includes('::') ? selector.indexOf('::') : selector.lastIndexOf('{'); diff --git a/packages/@stylexjs/babel-plugin/src/shared/physical-rtl/generate-ltr.js b/packages/@stylexjs/babel-plugin/src/shared/physical-rtl/generate-ltr.js index 30fb8328b..c17f0cdc8 100644 --- a/packages/@stylexjs/babel-plugin/src/shared/physical-rtl/generate-ltr.js +++ b/packages/@stylexjs/babel-plugin/src/shared/physical-rtl/generate-ltr.js @@ -33,10 +33,6 @@ const inlinePropertyToLTR: $ReadOnly<{ 'border-inline-end-color': ([_key, val]) => ['border-right-color', val], 'border-inline-start-style': ([_key, val]) => ['border-left-style', val], 'border-inline-end-style': ([_key, val]) => ['border-right-style', val], - 'border-start-start-radius': ([_key, val]) => ['border-top-left-radius', val], - 'border-end-start-radius': ([_k, val]) => ['border-bottom-left-radius', val], - 'border-start-end-radius': ([_key, val]) => ['border-top-right-radius', val], - 'border-end-end-radius': ([_key, val]) => ['border-bottom-right-radius', val], 'inset-inline-start': ([_key, val]) => ['left', val], 'inset-inline-end': ([_key, val]) => ['right', val], }; diff --git a/packages/@stylexjs/babel-plugin/src/shared/physical-rtl/generate-rtl.js b/packages/@stylexjs/babel-plugin/src/shared/physical-rtl/generate-rtl.js index 72f576538..2e68351ea 100644 --- a/packages/@stylexjs/babel-plugin/src/shared/physical-rtl/generate-rtl.js +++ b/packages/@stylexjs/babel-plugin/src/shared/physical-rtl/generate-rtl.js @@ -99,10 +99,6 @@ const inlinePropertyToRTL: $ReadOnly<{ 'border-inline-end-color': ([_key, val]) => ['border-left-color', val], 'border-inline-start-style': ([_key, val]) => ['border-right-style', val], 'border-inline-end-style': ([_key, val]) => ['border-left-style', val], - 'border-start-start-radius': ([_k, val]) => ['border-top-right-radius', val], - 'border-end-start-radius': ([_k, val]) => ['border-bottom-right-radius', val], - 'border-start-end-radius': ([_key, val]) => ['border-top-left-radius', val], - 'border-end-end-radius': ([_key, val]) => ['border-bottom-left-radius', val], 'inset-inline-start': ([_key, val]) => ['right', val], 'inset-inline-end': ([_key, val]) => ['left', val], }; diff --git a/packages/@stylexjs/babel-plugin/src/shared/physical-rtl/logical-border-radius.js b/packages/@stylexjs/babel-plugin/src/shared/physical-rtl/logical-border-radius.js new file mode 100644 index 000000000..a4c6905ac --- /dev/null +++ b/packages/@stylexjs/babel-plugin/src/shared/physical-rtl/logical-border-radius.js @@ -0,0 +1,29 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +export const LOGICAL_BORDER_RADIUS_TO_PHYSICAL: $ReadOnly<{ + [key: string]: $ReadOnly<{ ltr: string, rtl: string }>, +}> = { + 'border-start-start-radius': { + ltr: 'border-top-left-radius', + rtl: 'border-top-right-radius', + }, + 'border-start-end-radius': { + ltr: 'border-top-right-radius', + rtl: 'border-top-left-radius', + }, + 'border-end-start-radius': { + ltr: 'border-bottom-left-radius', + rtl: 'border-bottom-right-radius', + }, + 'border-end-end-radius': { + ltr: 'border-bottom-right-radius', + rtl: 'border-bottom-left-radius', + }, +}; diff --git a/packages/@stylexjs/babel-plugin/src/shared/utils/generate-css-rule.js b/packages/@stylexjs/babel-plugin/src/shared/utils/generate-css-rule.js index bb6e487fd..9685af0f4 100644 --- a/packages/@stylexjs/babel-plugin/src/shared/utils/generate-css-rule.js +++ b/packages/@stylexjs/babel-plugin/src/shared/utils/generate-css-rule.js @@ -14,6 +14,7 @@ import { defaultOptions } from './default-options'; import generateLtr from '../physical-rtl/generate-ltr'; import generateRtl from '../physical-rtl/generate-rtl'; +import { LOGICAL_BORDER_RADIUS_TO_PHYSICAL } from '../physical-rtl/logical-border-radius'; import { getPriority } from '@stylexjs/shared'; const THUMB_VARIANTS = [ @@ -28,6 +29,7 @@ function buildNestedCSSRule( pseudos: $ReadOnlyArray, atRules: $ReadOnlyArray, constRules: $ReadOnlyArray, + ancestorSelector: string = '', ): string { // Pseudo-elements (::before, ::after, etc.) must come after pseudo-classes // in the selector. e.g. `.class:hover::before` not `.class::before:hover` @@ -52,12 +54,17 @@ function buildNestedCSSRule( const hasWhere = pseudo.includes(':where('); const extraClassForWhere = hasWhere ? `.${className}` : ''; - let selectorForAtRules = + const classNameSelector = `.${className}` + extraClassForWhere + combinedAtRules.map(() => `.${className}`).join('') + pseudo; + let selectorForAtRules = + ancestorSelector === '' + ? classNameSelector + : `${ancestorSelector} ${classNameSelector}`; + if (pseudos.includes('::thumb')) { selectorForAtRules = THUMB_VARIANTS.map( (suffix) => selectorForAtRules + suffix, @@ -70,6 +77,53 @@ function buildNestedCSSRule( ); } +function wrapInAtRules(css: string, atRules: $ReadOnlyArray): string { + return atRules.reduce((acc, atRule) => `${atRule}{${acc}}`, css); +} + +function buildLogicalRadiusFallback( + className: string, + key: string, + pairs: $ReadOnlyArray<[string, string]>, + ltrDecls: string, + pseudos: $ReadOnlyArray, + atRules: $ReadOnlyArray, + constRules: $ReadOnlyArray, +): string | null { + const physical = LOGICAL_BORDER_RADIUS_TO_PHYSICAL[key]; + if (physical == null) { + return null; + } + + const ltrFallbackDecls = pairs + .map(([, value]) => `${physical.ltr}:${value}`) + .join(';'); + const rtlFallbackDecls = pairs + .map(([, value]) => `${physical.rtl}:${value}`) + .join(';'); + + const ltrFallback = buildNestedCSSRule( + className, + ltrFallbackDecls, + pseudos, + [], + [], + "html:not([dir='rtl'])", + ); + const rtlFallback = buildNestedCSSRule( + className, + rtlFallbackDecls, + pseudos, + [], + [], + "html[dir='rtl']", + ); + const nativeRule = buildNestedCSSRule(className, ltrDecls, pseudos, [], []); + + const wrapped = `@media all{@supports not (${key}: 0){${ltrFallback}${rtlFallback}}${nativeRule}}`; + return wrapInAtRules(wrapped, atRules.concat(constRules)); +} + export function generateCSSRule( className: string, key: string, @@ -92,13 +146,23 @@ export function generateCSSRule( .map((pair) => pair.join(':')) .join(';'); - const ltrRule = buildNestedCSSRule( - className, - ltrDecls, - pseudos, - atRules, - constRules, - ); + const logicalRadiusFallback = + options.styleResolution === 'legacy-expand-shorthands' && + options.enableLogicalStylesPolyfill + ? buildLogicalRadiusFallback( + className, + key, + pairs, + ltrDecls, + pseudos, + atRules, + constRules, + ) + : null; + + const ltrRule = + logicalRadiusFallback ?? + buildNestedCSSRule(className, ltrDecls, pseudos, atRules, constRules); const rtlRule = !rtlDecls ? null : buildNestedCSSRule(className, rtlDecls, pseudos, atRules, constRules); diff --git a/packages/@stylexjs/stylex/__tests__/inject-test.js b/packages/@stylexjs/stylex/__tests__/inject-test.js index 618f4f94f..4de9a621a 100644 --- a/packages/@stylexjs/stylex/__tests__/inject-test.js +++ b/packages/@stylexjs/stylex/__tests__/inject-test.js @@ -156,6 +156,16 @@ describe('inject', () => { ); }); + test('handles a media block containing logical radius fallbacks', () => { + const cssText = + '@media all{@supports not (border-start-start-radius: 0){html:not([dir=rtl]) .radius{border-top-left-radius:5px}html[dir=rtl] .radius{border-top-right-radius:5px}}.radius{border-start-start-radius:5px}}'; + const result = inject({ ltr: cssText, priority: 3000 }); + + expect(result).toMatchInlineSnapshot( + '"@media all{@supports not (border-start-start-radius: 0){html:not([dir=rtl]) .radius:not(#\\#):not(#\\#):not(#\\#){border-top-left-radius:5px}html[dir=rtl] .radius:not(#\\#):not(#\\#):not(#\\#){border-top-right-radius:5px}}.radius:not(#\\#):not(#\\#):not(#\\#){border-start-start-radius:5px}}"', + ); + }); + test('handles constants for container queries', () => { inject({ ltr: '', diff --git a/packages/@stylexjs/stylex/src/stylesheet/utils.js b/packages/@stylexjs/stylex/src/stylesheet/utils.js index d342ae10b..38ac9f8cb 100644 --- a/packages/@stylexjs/stylex/src/stylesheet/utils.js +++ b/packages/@stylexjs/stylex/src/stylesheet/utils.js @@ -26,6 +26,49 @@ export function addSpecificityLevel(cssText: string, index: number): string { .map(() => ':not(#\\#)') .join(''); + if (index === 0) { + return cssText; + } + + // Radius fallbacks contain several nested rules in one insertable block, so + // every selector in the block needs the specificity bump. + if (cssText.includes('@media all{')) { + let result = ''; + let cursor = 0; + while (cursor < cssText.length) { + const openCurly = cssText.indexOf('{', cursor); + if (openCurly === -1) { + result += cssText.slice(cursor); + break; + } + + const prelude = cssText.slice(cursor, openCurly); + const boundary = Math.max( + prelude.lastIndexOf('{'), + prelude.lastIndexOf('}'), + ); + const beforeSelector = prelude.slice(0, boundary + 1); + const selectorText = prelude.slice(boundary + 1); + + if (selectorText.trimStart().startsWith('@')) { + result += `${prelude}{`; + } else { + const pseudoIndex = selectorText.includes('::') + ? selectorText.indexOf('::') + : selectorText.length; + result += + beforeSelector + + selectorText.slice(0, pseudoIndex) + + pseudoSelector + + selectorText.slice(pseudoIndex) + + '{'; + } + + cursor = openCurly + 1; + } + return result; + } + const lastOpenCurly = cssText.includes('::') ? cssText.indexOf('::') : cssText.lastIndexOf('{');