From 4566ce1b2bebd23ff0f1b8189960662da15d362b Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 21:03:43 +0300 Subject: [PATCH 1/8] fix(transforms): give every transform entry one property React Native's transform validator requires exactly one property per entry and throws otherwise. A resolver returns a group for a two-operand scale() or translate(), the compiler emits one for translate(10px), and an unsupported function such as scale3d() leaves an empty one, so each reached React Native as a nested array. The transform shorthand now spreads a group into the list, which also drops the empty one and keeps its neighbours. --- src/__tests__/native/transform.test.tsx | 60 +++++++++++++++++++++++ src/native/styles/shorthands/transform.ts | 9 ++-- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/__tests__/native/transform.test.tsx b/src/__tests__/native/transform.test.tsx index 7a59b816..a9b0c70b 100644 --- a/src/__tests__/native/transform.test.tsx +++ b/src/__tests__/native/transform.test.tsx @@ -154,3 +154,63 @@ describe("transform", () => { }); }); }); + +function renderStyle(css: string): unknown { + registerCSS(css); + + return render( + , + ).getByTestId(testID).props.style; +} + +// A second definition keeps each variable from being inlined, so it resolves at runtime +const decoy = `.decoy { --sx: 9; --sy: 9; --t: 9px; }`; + +// React Native requires exactly one property per transform object, and its validator throws otherwise +describe("one property per entry", () => { + // Chromium 153, Firefox 155 and WebKit 26.6 scale each axis by its own operand + test.each([ + ["75%", "50%", [{ scaleX: 0.75 }, { scaleY: 0.5 }]], + ["2", "3", [{ scaleX: 2 }, { scaleY: 3 }]], + ])( + "transform: scale(var(--sx), var(--sy)) with %s and %s renders an entry per axis", + (sx, sy, transform) => { + expect( + renderStyle( + `${decoy} .my-class { --sx: ${sx}; --sy: ${sy}; transform: scale(var(--sx), var(--sy)); }`, + ), + ).toStrictEqual({ transform }); + }, + ); + + test("a two-operand translate() renders an entry per axis, beside a sibling", () => { + expect( + renderStyle( + `${decoy} .my-class { --t: 10px; transform: translate(var(--t), var(--t)) rotate(45deg); }`, + ), + ).toStrictEqual({ + transform: [{ translateX: 10 }, { translateY: 10 }, { rotate: "45deg" }], + }); + }); + + test.each([ + "transform: scale3d(1, 2, 3);", + "transform: scaleZ(2);", + "transform: matrix(1, 0, 0, 1, 0, 0);", + ])( + "%s, which React Native has no form for, renders no entry", + (declaration) => { + expect(renderStyle(`.my-class { ${declaration} }`)).toStrictEqual({ + transform: [], + }); + }, + ); + + test("a function React Native has no form for leaves its neighbour", () => { + expect( + renderStyle( + `.my-class { transform: translateX(10px) scale3d(1, 2, 3); }`, + ), + ).toStrictEqual({ transform: [{ translateX: 10 }] }); + }); +}); diff --git a/src/native/styles/shorthands/transform.ts b/src/native/styles/shorthands/transform.ts index ef603dd8..49a32369 100644 --- a/src/native/styles/shorthands/transform.ts +++ b/src/native/styles/shorthands/transform.ts @@ -11,9 +11,12 @@ export const transform: StyleFunctionResolver = ( const transforms = resolveValue(transformDescriptor[2]); if (Array.isArray(transforms)) { - return transforms.filter( - (transform) => transform !== undefined && transform !== "initial", - ) as unknown; + // React Native takes one property per transform object, so a resolver's group of entries is spread into the list + return transforms + .flat() + .filter( + (transform) => transform !== undefined && transform !== "initial", + ) as unknown; } else if (transforms) { // If it's a single transform, wrap it in an array return [transforms]; From b0c48cf46973fb878a2ab5769dbb7996dd6f5424 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 21:04:11 +0300 Subject: [PATCH 2/8] fix(transforms): a transform value React Native cannot take leaves none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A transform function or longhand whose var() leaves it no valid value reached React Native as an entry it rejects: `scaleX(var(--missing))` and `rotate: var(--missing)` as an undefined value, `scale(var(--s))` with `--s: none` as the string "none", and `scale: initial`, `translate: initial` or a longhand with two missing operands as the placeholder `true`. Each throws in the transform validator. Browsers treat each declaration as invalid at computed-value time (css-variables-1 §3.1): a function without a valid argument leaves no transform, and a longhand without a value is none. A resolver now returns null for an operand it cannot use, the transform shorthand turns that into an empty list, and a longhand that resolves to nothing removes its entries. A scale factor is a number or nothing, and a one-operand translate() translates along x, as translate(x) is translate(x, 0). --- src/__tests__/native/transform.test.tsx | 59 ++++++++++++++++ src/native/objects.ts | 44 +++++++----- .../styles/functions/transform-functions.ts | 69 ++++++++----------- src/native/styles/resolve.ts | 18 ++--- src/native/styles/scale-factor.ts | 13 ++-- src/native/styles/shorthands/transform.ts | 13 ++-- 6 files changed, 140 insertions(+), 76 deletions(-) diff --git a/src/__tests__/native/transform.test.tsx b/src/__tests__/native/transform.test.tsx index a9b0c70b..21ad6eb2 100644 --- a/src/__tests__/native/transform.test.tsx +++ b/src/__tests__/native/transform.test.tsx @@ -183,6 +183,31 @@ describe("one property per entry", () => { }, ); + test("transform: scale(var(--sx)) scales both axes by its one operand", () => { + expect( + renderStyle( + `${decoy} .my-class { --sx: 75%; transform: scale(var(--sx)); }`, + ), + ).toStrictEqual({ transform: [{ scale: 0.75 }] }); + }); + + test("scale: var(--sx) scales both axes by its one value", () => { + expect( + renderStyle(`${decoy} .my-class { --sx: 75%; scale: var(--sx); }`), + ).toStrictEqual({ transform: [{ scale: 0.75 }] }); + }); + + // Chromium 153, Firefox 155 and WebKit 26.6 translate a one-operand translate() along x only + test.each([ + "transform: translate(10px);", + "--t: 10px; transform: translate(var(--t));", + "--t: 10px; translate: var(--t);", + ])("%s translates along x only", (declarations) => { + expect(renderStyle(`${decoy} .my-class { ${declarations} }`)).toStrictEqual( + { transform: [{ translateX: 10 }, { translateY: 0 }] }, + ); + }); + test("a two-operand translate() renders an entry per axis, beside a sibling", () => { expect( renderStyle( @@ -214,3 +239,37 @@ describe("one property per entry", () => { ).toStrictEqual({ transform: [{ translateX: 10 }] }); }); }); + +// Chromium 153, Firefox 155 and WebKit 26.6: a function left without a valid argument makes the declaration invalid at computed-value time, so the transform is none (css-variables-1 §3.1) +test.each([ + "transform: scaleX(var(--missing));", + "transform: translateX(10px) scaleX(var(--missing));", + "transform: translate(10px, var(--missing));", + "transform: rotate(var(--missing));", + "--sx: none; transform: scaleX(var(--sx));", + "--sx: none; transform: scale(var(--sx));", +])("%s leaves no transform", (declarations) => { + expect( + renderStyle( + `${decoy} .earlier { transform: rotate(10deg); } .my-class { ${declarations} }`, + ), + ).toStrictEqual({ transform: [] }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6: a scale or translate longhand left without a value is none, and the transform property beside it stands +test.each([ + "scale: var(--missing);", + "scale: var(--missing) var(--missing);", + "translate: var(--missing) var(--missing);", + "--sx: none; scale: var(--sx);", + "--sx: none; --sy: 2; scale: var(--sx) var(--sy);", + "scale: unset;", + "scale: initial;", + "translate: initial;", +])("%s leaves the transform property's entries", (declarations) => { + expect( + renderStyle( + `${decoy} .earlier { transform: rotate(10deg); } .my-class { ${declarations} }`, + ), + ).toStrictEqual({ transform: [{ rotate: "10deg" }] }); +}); diff --git a/src/native/objects.ts b/src/native/objects.ts index 12e69ccd..f555101e 100644 --- a/src/native/objects.ts +++ b/src/native/objects.ts @@ -53,6 +53,11 @@ export function applyValue( prop: string, value: any, ) { + if (transformKeys.has(prop)) { + applyTransformValue(target, prop, value); + return; + } + // This is confusing. // An undefined value means "don't set anything" (something failed while parsing) // While a null value means "remove this value", which in React Native means "set to undefined" @@ -62,23 +67,7 @@ export function applyValue( value = undefined; } - if (transformKeys.has(prop)) { - if (!Array.isArray(target.transform)) { - target.transform = []; - } - - const transformArray: Record[] = target.transform; - - // Remove any existing values - target.transform = transformArray.filter((t) => !(prop in t)); - - if (Array.isArray(value)) { - target.transform.push(...value); - } else { - target.transform.push(value); - } - return; - } else if (typeof value === "object" && value && ShortHandSymbol in value) { + if (typeof value === "object" && value && ShortHandSymbol in value) { delete value[ShortHandSymbol]; Object.assign(target, value); return; @@ -87,6 +76,27 @@ export function applyValue( target[prop] = value; } +// A transform longhand that resolves to no value is `none`, so its entries go and nothing takes their place +function applyTransformValue( + target: Record, + prop: string, + value: unknown, +) { + const transforms: Record[] = Array.isArray(target.transform) + ? target.transform + : []; + + target.transform = transforms.filter((transform) => !(prop in transform)); + + if (value === undefined || value === null) { + return; + } else if (Array.isArray(value)) { + target.transform.push(...value); + } else { + target.transform.push(value); + } +} + export function setDeepPath( target: Record, paths: string | string[] | readonly string[], diff --git a/src/native/styles/functions/transform-functions.ts b/src/native/styles/functions/transform-functions.ts index 9a38d15e..c795c591 100644 --- a/src/native/styles/functions/transform-functions.ts +++ b/src/native/styles/functions/transform-functions.ts @@ -1,46 +1,38 @@ +import type { StyleDescriptor } from "react-native-css/compiler"; import { isStyleDescriptorArray } from "react-native-css/utilities"; import type { StyleFunctionResolver } from "../resolve"; import { scaleFactor } from "../scale-factor"; +// A component left without a valid value makes the declaration invalid at computed-value time (css-variables-1 §3.1), which null carries export const scale: StyleFunctionResolver = (resolveValue, descriptor) => { - const args = descriptor[2]; - - if (!isStyleDescriptorArray(args)) { - return { scale: scaleFactor(resolveValue(args)) }; - } - - const x = scaleFactor(resolveValue(args[0])); - const y = scaleFactor(resolveValue(args[1])); + const [first, second] = operands(descriptor[2]); + const x = scaleFactor(resolveValue(first)); + const y = second === undefined ? x : scaleFactor(resolveValue(second)); - const isXValid = typeof x === "string" || typeof x === "number"; - const isYValid = typeof y === "string" || typeof y === "number"; - - if (isXValid && isYValid) { - return x === y ? { scale: x } : [{ scaleX: x }, { scaleY: y }]; - } else if (isXValid) { - return { scale: x }; - } else if (isYValid) { - return { scaleY: y }; + if (x === null || y === null) { + return null; } - return; + return x === y ? { scale: x } : [{ scaleX: x }, { scaleY: y }]; }; export const rotate: StyleFunctionResolver = (resolveValue, descriptor) => { const args = descriptor[2]; if (!isStyleDescriptorArray(args)) { - return { rotate: resolveValue(args) }; + const angle = resolveValue(args); + + return isTransformComponent(angle) ? { rotate: angle } : null; } const x = resolveValue(args[0]); const y = resolveValue(args[1]); const z = resolveValue(args[2]); - const isXValid = typeof x === "string" || typeof x === "number"; - const isYValid = typeof y === "string" || typeof y === "number"; - const isZValid = typeof z === "string" || typeof z === "number"; + const isXValid = isTransformComponent(x); + const isYValid = isTransformComponent(y); + const isZValid = isTransformComponent(z); if (isXValid && isYValid && isZValid) { return [{ rotateX: x }, { rotateY: y }, { rotateZ: z }]; @@ -58,29 +50,26 @@ export const rotate: StyleFunctionResolver = (resolveValue, descriptor) => { return { rotateZ: z }; } - return; + return null; }; +// `translate(x)` is `translate(x, 0)` (css-transforms-1 §9) export const translate: StyleFunctionResolver = (resolveValue, descriptor) => { - const args = descriptor[2]; + const [first, second] = operands(descriptor[2]); + const x = resolveValue(first); + const y = second === undefined ? 0 : resolveValue(second); - if (!isStyleDescriptorArray(args)) { - return; + if (!isTransformComponent(x) || !isTransformComponent(y)) { + return null; } - const x = resolveValue(args[0]); - const y = resolveValue(args[1]); + return [{ translateX: x }, { translateY: y }]; +}; - const isXValid = typeof x === "string" || typeof x === "number"; - const isYValid = typeof y === "string" || typeof y === "number"; +function operands(args: StyleDescriptor): StyleDescriptor[] { + return isStyleDescriptorArray(args) ? args : [args]; +} - if (isXValid && isYValid) { - return [{ translateX: x }, { translateY: y }]; - } else if (isXValid) { - return { translateX: x }; - } else if (isYValid) { - return { translateY: y }; - } - - return; -}; +function isTransformComponent(value: unknown): value is string | number { + return typeof value === "string" || typeof value === "number"; +} diff --git a/src/native/styles/resolve.ts b/src/native/styles/resolve.ts index 0cb40385..d40d1aae 100644 --- a/src/native/styles/resolve.ts +++ b/src/native/styles/resolve.ts @@ -125,16 +125,16 @@ export function resolveValue( ) as StyleDescriptor; } else if (transformKeys.has(name)) { // translate, rotate, scale, etc. - let resolved = simpleResolve(value[2], castToArray); + const resolved = simpleResolve(value[2], castToArray); // CSS scale percentages describe a factor, unlike translation percentages. - if ( - (name === "scaleX" || name === "scaleY" || name === "scale") && - typeof resolved === "string" && - resolved.endsWith("%") - ) { - resolved = scaleFactor(resolved); - } - return { [name]: resolved }; + const component = + name === "scaleX" || name === "scaleY" || name === "scale" + ? scaleFactor(resolved) + : resolved; + + return component === undefined || component === null + ? null + : { [name]: component }; } else { let args = simpleResolve(value[2], castToArray); diff --git a/src/native/styles/scale-factor.ts b/src/native/styles/scale-factor.ts index 5d30789a..f8fe9d44 100644 --- a/src/native/styles/scale-factor.ts +++ b/src/native/styles/scale-factor.ts @@ -1,6 +1,9 @@ -export function scaleFactor(value: unknown) { - if (typeof value === "string" && value.endsWith("%")) { - return Number(value.slice(0, -1)) / 100; - } - return value; +// React Native takes a scale only as a number, and its transform validator throws on anything else +export function scaleFactor(value: unknown): number | null { + const factor = + typeof value === "string" && value.endsWith("%") + ? Number(value.slice(0, -1)) / 100 + : value; + + return typeof factor === "number" ? factor : null; } diff --git a/src/native/styles/shorthands/transform.ts b/src/native/styles/shorthands/transform.ts index 49a32369..bad77f86 100644 --- a/src/native/styles/shorthands/transform.ts +++ b/src/native/styles/shorthands/transform.ts @@ -12,11 +12,14 @@ export const transform: StyleFunctionResolver = ( if (Array.isArray(transforms)) { // React Native takes one property per transform object, so a resolver's group of entries is spread into the list - return transforms - .flat() - .filter( - (transform) => transform !== undefined && transform !== "initial", - ) as unknown; + const entries: unknown[] = transforms.flat(); + + // A function left without a valid argument makes the declaration invalid at computed-value time, so the transform is none (css-variables-1 §3.1) + return entries.includes(null) + ? [] + : entries.filter( + (transform) => transform !== undefined && transform !== "initial", + ); } else if (transforms) { // If it's a single transform, wrap it in an array return [transforms]; From c8451095b55201a32a3adf495016fd7f71e40f43 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 21:08:57 +0300 Subject: [PATCH 3/8] fix(transforms): a transform list holding anything but entries is none A var() fallback holding a translate() pair nests a level deeper than a resolver's group, and a var() holding bare lengths (`--offset: 10px 20px; transform: var(--offset)`) resolves to numbers rather than entries; both reached React Native's validator. The shorthand now spreads every level, and a list with any item that is not a single-key entry is none, as browsers treat the declaration as invalid at computed-value time. --- src/__tests__/native/transform.test.tsx | 12 ++++++++- .../styles/functions/transform-functions.ts | 8 +++--- src/native/styles/shorthands/transform.ts | 26 ++++++++----------- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/__tests__/native/transform.test.tsx b/src/__tests__/native/transform.test.tsx index 21ad6eb2..cb64e520 100644 --- a/src/__tests__/native/transform.test.tsx +++ b/src/__tests__/native/transform.test.tsx @@ -164,7 +164,7 @@ function renderStyle(css: string): unknown { } // A second definition keeps each variable from being inlined, so it resolves at runtime -const decoy = `.decoy { --sx: 9; --sy: 9; --t: 9px; }`; +const decoy = `.decoy { --sx: 9; --sy: 9; --t: 9px; --offset: 1px; }`; // React Native requires exactly one property per transform object, and its validator throws otherwise describe("one property per entry", () => { @@ -231,6 +231,14 @@ describe("one property per entry", () => { }, ); + test("a translate() pair in a var() fallback renders an entry per axis", () => { + expect( + renderStyle( + `.my-class { transform: var(--missing, translate(10px, 20px)); }`, + ), + ).toStrictEqual({ transform: [{ translateX: 10 }, { translateY: 20 }] }); + }); + test("a function React Native has no form for leaves its neighbour", () => { expect( renderStyle( @@ -246,6 +254,8 @@ test.each([ "transform: translateX(10px) scaleX(var(--missing));", "transform: translate(10px, var(--missing));", "transform: rotate(var(--missing));", + "--offset: 10px 20px; transform: var(--offset);", + "--offset: 10px 20px; transform: translateX(5px) var(--offset);", "--sx: none; transform: scaleX(var(--sx));", "--sx: none; transform: scale(var(--sx));", ])("%s leaves no transform", (declarations) => { diff --git a/src/native/styles/functions/transform-functions.ts b/src/native/styles/functions/transform-functions.ts index c795c591..0b454788 100644 --- a/src/native/styles/functions/transform-functions.ts +++ b/src/native/styles/functions/transform-functions.ts @@ -30,9 +30,9 @@ export const rotate: StyleFunctionResolver = (resolveValue, descriptor) => { const y = resolveValue(args[1]); const z = resolveValue(args[2]); - const isXValid = isTransformComponent(x); - const isYValid = isTransformComponent(y); - const isZValid = isTransformComponent(z); + const isXValid = typeof x === "string" || typeof x === "number"; + const isYValid = typeof y === "string" || typeof y === "number"; + const isZValid = typeof z === "string" || typeof z === "number"; if (isXValid && isYValid && isZValid) { return [{ rotateX: x }, { rotateY: y }, { rotateZ: z }]; @@ -50,7 +50,7 @@ export const rotate: StyleFunctionResolver = (resolveValue, descriptor) => { return { rotateZ: z }; } - return null; + return; }; // `translate(x)` is `translate(x, 0)` (css-transforms-1 §9) diff --git a/src/native/styles/shorthands/transform.ts b/src/native/styles/shorthands/transform.ts index bad77f86..2622859f 100644 --- a/src/native/styles/shorthands/transform.ts +++ b/src/native/styles/shorthands/transform.ts @@ -10,20 +10,16 @@ export const transform: StyleFunctionResolver = ( ) => { const transforms = resolveValue(transformDescriptor[2]); - if (Array.isArray(transforms)) { - // React Native takes one property per transform object, so a resolver's group of entries is spread into the list - const entries: unknown[] = transforms.flat(); + // A group from a resolver and the list a var() fallback holds each nest a level, while React Native takes a flat list of single-key objects + const resolved: unknown[] = [transforms].flat(Number.POSITIVE_INFINITY); + const entries = resolved.filter( + (transform) => transform !== undefined && transform !== "initial", + ); - // A function left without a valid argument makes the declaration invalid at computed-value time, so the transform is none (css-variables-1 §3.1) - return entries.includes(null) - ? [] - : entries.filter( - (transform) => transform !== undefined && transform !== "initial", - ); - } else if (transforms) { - // If it's a single transform, wrap it in an array - return [transforms]; - } else { - return; - } + // Anything but a transform entry, null included, leaves the declaration invalid at computed-value time, so the transform is none (css-variables-1 §3.1) + return entries.every(isTransformEntry) ? entries : []; }; + +function isTransformEntry(value: unknown): boolean { + return typeof value === "object" && value !== null; +} From 1595d150ca8f9406c00c30d1d92f1bacd7bbc867 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 21:12:15 +0300 Subject: [PATCH 4/8] fix(transforms): read an operand list a var() supplies whole `translate: var(--offset)` with `--offset: 10px 20px`, `transform: translate(var(--offset))` with `--offset: 10px, 20px` and `scale: var(--s)` with `--s: 2 3` each resolve their single var() to the whole operand list, which the resolvers read as one operand and rejected. A missing second operand, as in `scale(2, var(--missing))`, still leaves the declaration invalid rather than falling back to the first. --- src/__tests__/native/transform.test.tsx | 20 ++++++++++++- .../styles/functions/transform-functions.ts | 28 +++++++++++++------ 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/__tests__/native/transform.test.tsx b/src/__tests__/native/transform.test.tsx index cb64e520..950f1f3a 100644 --- a/src/__tests__/native/transform.test.tsx +++ b/src/__tests__/native/transform.test.tsx @@ -164,7 +164,7 @@ function renderStyle(css: string): unknown { } // A second definition keeps each variable from being inlined, so it resolves at runtime -const decoy = `.decoy { --sx: 9; --sy: 9; --t: 9px; --offset: 1px; }`; +const decoy = `.decoy { --sx: 9; --sy: 9; --t: 9px; --offset: 1px; --s: 1; }`; // React Native requires exactly one property per transform object, and its validator throws otherwise describe("one property per entry", () => { @@ -208,6 +208,23 @@ describe("one property per entry", () => { ); }); + // Chromium 153, Firefox 155 and WebKit 26.6 read an operand list a var() supplies whole + test.each([ + [ + "--offset: 10px 20px; translate: var(--offset);", + [{ translateX: 10 }, { translateY: 20 }], + ], + [ + "--offset: 10px, 20px; transform: translate(var(--offset));", + [{ translateX: 10 }, { translateY: 20 }], + ], + ["--s: 2 3; scale: var(--s);", [{ scaleX: 2 }, { scaleY: 3 }]], + ] as const)("%s reads both operands", (declarations, transform) => { + expect(renderStyle(`${decoy} .my-class { ${declarations} }`)).toStrictEqual( + { transform }, + ); + }); + test("a two-operand translate() renders an entry per axis, beside a sibling", () => { expect( renderStyle( @@ -254,6 +271,7 @@ test.each([ "transform: translateX(10px) scaleX(var(--missing));", "transform: translate(10px, var(--missing));", "transform: rotate(var(--missing));", + "transform: scale(2, var(--missing));", "--offset: 10px 20px; transform: var(--offset);", "--offset: 10px 20px; transform: translateX(5px) var(--offset);", "--sx: none; transform: scaleX(var(--sx));", diff --git a/src/native/styles/functions/transform-functions.ts b/src/native/styles/functions/transform-functions.ts index 0b454788..c8ef4bdd 100644 --- a/src/native/styles/functions/transform-functions.ts +++ b/src/native/styles/functions/transform-functions.ts @@ -1,14 +1,14 @@ import type { StyleDescriptor } from "react-native-css/compiler"; import { isStyleDescriptorArray } from "react-native-css/utilities"; -import type { StyleFunctionResolver } from "../resolve"; +import type { SimpleResolveValue, StyleFunctionResolver } from "../resolve"; import { scaleFactor } from "../scale-factor"; // A component left without a valid value makes the declaration invalid at computed-value time (css-variables-1 §3.1), which null carries export const scale: StyleFunctionResolver = (resolveValue, descriptor) => { - const [first, second] = operands(descriptor[2]); - const x = scaleFactor(resolveValue(first)); - const y = second === undefined ? x : scaleFactor(resolveValue(second)); + const operands = resolveOperands(resolveValue, descriptor[2]); + const x = scaleFactor(operands[0]); + const y = operands.length > 1 ? scaleFactor(operands[1]) : x; if (x === null || y === null) { return null; @@ -55,9 +55,9 @@ export const rotate: StyleFunctionResolver = (resolveValue, descriptor) => { // `translate(x)` is `translate(x, 0)` (css-transforms-1 §9) export const translate: StyleFunctionResolver = (resolveValue, descriptor) => { - const [first, second] = operands(descriptor[2]); - const x = resolveValue(first); - const y = second === undefined ? 0 : resolveValue(second); + const operands = resolveOperands(resolveValue, descriptor[2]); + const x = operands[0]; + const y = operands.length > 1 ? operands[1] : 0; if (!isTransformComponent(x) || !isTransformComponent(y)) { return null; @@ -66,8 +66,18 @@ export const translate: StyleFunctionResolver = (resolveValue, descriptor) => { return [{ translateX: x }, { translateY: y }]; }; -function operands(args: StyleDescriptor): StyleDescriptor[] { - return isStyleDescriptorArray(args) ? args : [args]; +// A var() can supply the whole operand list, which resolves to an array in place of one operand +function resolveOperands( + resolveValue: SimpleResolveValue, + args: StyleDescriptor, +): unknown[] { + if (isStyleDescriptorArray(args)) { + return args.map((arg) => resolveValue(arg)); + } + + const value = resolveValue(args); + + return Array.isArray(value) ? value : [value]; } function isTransformComponent(value: unknown): value is string | number { From de15c6e8b07a3a7a3caf728eadadaaa5a38929c0 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 21:34:27 +0300 Subject: [PATCH 5/8] test(transforms): run each invalid function beside a valid one too On its own, a function left without a valid argument gives the same empty transform whether it is read as invalid or dropped. Next to a valid translateX(5px) only the invalid reading leaves no transform, which is what browsers compute. --- src/__tests__/native/transform.test.tsx | 27 +++++++++++++++---------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/__tests__/native/transform.test.tsx b/src/__tests__/native/transform.test.tsx index 950f1f3a..2d4ff225 100644 --- a/src/__tests__/native/transform.test.tsx +++ b/src/__tests__/native/transform.test.tsx @@ -266,17 +266,22 @@ describe("one property per entry", () => { }); // Chromium 153, Firefox 155 and WebKit 26.6: a function left without a valid argument makes the declaration invalid at computed-value time, so the transform is none (css-variables-1 §3.1) -test.each([ - "transform: scaleX(var(--missing));", - "transform: translateX(10px) scaleX(var(--missing));", - "transform: translate(10px, var(--missing));", - "transform: rotate(var(--missing));", - "transform: scale(2, var(--missing));", - "--offset: 10px 20px; transform: var(--offset);", - "--offset: 10px 20px; transform: translateX(5px) var(--offset);", - "--sx: none; transform: scaleX(var(--sx));", - "--sx: none; transform: scale(var(--sx));", -])("%s leaves no transform", (declarations) => { +test.each( + ( + [ + ["", "scaleX(var(--missing))"], + ["", "translate(10px, var(--missing))"], + ["", "rotate(var(--missing))"], + ["", "scale(2, var(--missing))"], + ["--offset: 10px 20px; ", "var(--offset)"], + ["--sx: none; ", "scaleX(var(--sx))"], + ["--sx: none; ", "scale(var(--sx))"], + ] as const + ).flatMap(([customProperties, transform]) => [ + `${customProperties}transform: ${transform};`, + `${customProperties}transform: translateX(5px) ${transform};`, + ]), +)("%s leaves no transform", (declarations) => { expect( renderStyle( `${decoy} .earlier { transform: rotate(10deg); } .my-class { ${declarations} }`, From e5c430dc5050a56d00073462a532610fc7e2ef44 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 23:16:24 +0300 Subject: [PATCH 6/8] fix(transforms): give each transform property a cascade slot, composed in CSS order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit translate, rotate, scale and transform each cascade on their own, and the element's transform composes them in that order (css-transforms-2 §6). The runtime appended a longhand to whatever transform array it found, and a later transform replaced the longhands. Each property now records its cascaded value per style target, and the slots compose into one transform after every rule. The compiler emits each longhand as one declaration under its own name, and carries it through the first pass unparsed, because lightningcss before parcel-bundler/lightningcss#1340 folds a parsed longhand into transform. A transform argument the compiler cannot convert now ignores the declaration with a warning (CSS 2.2 §4.2), as width: 1cap already does, instead of rendering an empty entry React Native refuses. A typed calc() warns too. --- src/__tests__/compiler/compiler.test.tsx | 40 ++++ src/__tests__/compiler/declarations.test.tsx | 4 +- src/__tests__/native/transform.test.tsx | 112 ++++++++- .../vendor/tailwind/transform.test.ts | 3 +- src/compiler/calculation.ts | 145 ++++++++++++ src/compiler/compiler.ts | 46 ++-- src/compiler/declarations.ts | 219 ++++++++---------- src/compiler/individual-transforms.ts | 94 ++++++++ src/compiler/tokens.ts | 60 +++++ src/native/objects.ts | 31 --- src/native/styles/calculate-props.ts | 149 +++++++++--- 11 files changed, 692 insertions(+), 211 deletions(-) create mode 100644 src/compiler/calculation.ts create mode 100644 src/compiler/individual-transforms.ts create mode 100644 src/compiler/tokens.ts diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index b08e24c3..49b4c38f 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -333,6 +333,46 @@ test("warnings", () => { }); }); +test("a transform value the compiler cannot convert is ignored, with a warning", () => { + const compiled = compile(` +.my-class { + transform: translate(10px, 1cap); + translate: calc(1em + 5px) 5px; + rotate: 1grad; + scale: 2; +}`); + + expect(compiled.stylesheet()).toStrictEqual({ + s: [ + [ + "my-class", + [ + { + d: [ + [ + [ + [{}, "scaleX", 2], + [{}, "scaleY", 2], + ], + "scale", + ], + ], + s: [1, 1], + }, + ], + ], + ], + }); + + expect(compiled.warnings()).toStrictEqual({ + values: { + transform: ["1cap"], + translate: ["calc()"], + rotate: ["1 grad"], + }, + }); +}); + test("simplifies rem", () => { const compiled = compile(`.test { border-width: calc(10rem + 2px); diff --git a/src/__tests__/compiler/declarations.test.tsx b/src/__tests__/compiler/declarations.test.tsx index c2e18378..4dbd378f 100644 --- a/src/__tests__/compiler/declarations.test.tsx +++ b/src/__tests__/compiler/declarations.test.tsx @@ -8,8 +8,8 @@ const tests = [ ["-rn-ripple-layer: foreground;", [{ d: [[true, ["android_ripple", "foreground"]]], s: [1, 1] }]], ["caret-color: black", [{ d: [["#000", ["cursorColor"]]], s: [1, 1] }]], ["fill: black;", [{ d: [["#000", ["fill"]]], s: [1, 1] }]], - ["rotate: 3deg;", [{ d: [[[{}, "rotateZ", "3deg"], "rotateZ"]], s: [1, 1] }]], - ["rotate: x 3deg;", [{ d: [[[{}, "rotateX", "3deg"], "rotateX"]], s: [1, 1] }]], + ["rotate: 3deg;", [{ d: [[[{}, "rotateZ", "3deg"], "rotate"]], s: [1, 1] }]], + ["rotate: x 3deg;", [{ d: [[[{}, "rotateX", "3deg"], "rotate"]], s: [1, 1] }]], ["stroke-width: 1px;", [{ d: [[1, ["strokeWidth"]]], s: [1, 1] }]], ["stroke: black;", [{ d: [["#000", ["stroke"]]], s: [1, 1] }]], ] as const; diff --git a/src/__tests__/native/transform.test.tsx b/src/__tests__/native/transform.test.tsx index 2d4ff225..a6b2d6a2 100644 --- a/src/__tests__/native/transform.test.tsx +++ b/src/__tests__/native/transform.test.tsx @@ -164,7 +164,7 @@ function renderStyle(css: string): unknown { } // A second definition keeps each variable from being inlined, so it resolves at runtime -const decoy = `.decoy { --sx: 9; --sy: 9; --t: 9px; --offset: 1px; --s: 1; }`; +const decoy = `.decoy { --sx: 9; --sy: 9; --t: 9px; --offset: 1px; --s: 1; --x: 9px; --y: 9px; }`; // React Native requires exactly one property per transform object, and its validator throws otherwise describe("one property per entry", () => { @@ -235,18 +235,16 @@ describe("one property per entry", () => { }); }); + // React Native refuses a transform entry without a property, so a function the compiler does not convert contributes none test.each([ "transform: scale3d(1, 2, 3);", "transform: scaleZ(2);", "transform: matrix(1, 0, 0, 1, 0, 0);", - ])( - "%s, which React Native has no form for, renders no entry", - (declaration) => { - expect(renderStyle(`.my-class { ${declaration} }`)).toStrictEqual({ - transform: [], - }); - }, - ); + ])("%s renders no entry", (declaration) => { + expect(renderStyle(`.my-class { ${declaration} }`)).toStrictEqual({ + transform: [], + }); + }); test("a translate() pair in a var() fallback renders an entry per axis", () => { expect( @@ -306,3 +304,99 @@ test.each([ ), ).toStrictEqual({ transform: [{ rotate: "10deg" }] }); }); + +// Chromium 153, Firefox 155 and WebKit 26.6 cascade each transform property on its own, and compose translate, rotate, scale, then the transform list (css-transforms-2 §6) +test.each([ + [ + ".earlier { translate: 10px 20px; } .my-class { --x: 1px; --y: 2px; translate: var(--x) var(--y); }", + [{ translateX: 1 }, { translateY: 2 }], + ], + [ + ".earlier { --x: 1px; --y: 2px; translate: var(--x) var(--y); } .my-class { translate: 10px 20px; }", + [{ translateX: 10 }, { translateY: 20 }], + ], + [ + ".earlier { scale: 2; } .my-class { scale: 3; }", + [{ scaleX: 3 }, { scaleY: 3 }], + ], + [ + ".earlier { transform: rotate(10deg); } .my-class { rotate: 20deg; }", + [{ rotateZ: "20deg" }, { rotate: "10deg" }], + ], + [ + ".earlier { translate: 10px 20px; } .my-class { --t: 5px; transform: translateX(var(--t)); }", + [{ translateX: 10 }, { translateY: 20 }, { translateX: 5 }], + ], + [ + ".earlier { --t: 5px; transform: translateX(var(--t)); } .my-class { translate: 10px 20px; }", + [{ translateX: 10 }, { translateY: 20 }, { translateX: 5 }], + ], + [ + ".my-class { translate: 10px 20px; rotate: 30deg; scale: 2; transform: translateX(5px); }", + [ + { translateX: 10 }, + { translateY: 20 }, + { rotateZ: "30deg" }, + { scaleX: 2 }, + { scaleY: 2 }, + { translateX: 5 }, + ], + ], + [ + ".earlier { translate: 10px 20px !important; } .my-class { translate: 1px 2px; }", + [{ translateX: 10 }, { translateY: 20 }], + ], + [ + ".earlier { transform: rotate(10deg) !important; } .my-class { translate: 1px 2px; }", + [{ translateX: 1 }, { translateY: 2 }, { rotate: "10deg" }], + ], +] as const)("%s composes in CSS order", (css, transform) => { + expect(renderStyle(`${decoy} ${css}`)).toStrictEqual({ transform }); +}); + +// A unit React Native has no equivalent for leaves the declaration invalid there, and an invalid declaration is ignored (CSS 2.2 §4.2) +test.each([ + [ + ".earlier { translate: 5px 6px; } .my-class { translate: 10px 1cap; }", + [{ translateX: 5 }, { translateY: 6 }], + ], + [ + ".earlier { transform: rotate(5deg); } .my-class { translate: 10px 1cap; }", + [{ rotate: "5deg" }], + ], + [ + ".earlier { transform: rotate(5deg); } .my-class { transform: translate(10px, 1cap); }", + [{ rotate: "5deg" }], + ], +] as const)( + "%s ignores the declaration it cannot convert", + (css, transform) => { + expect(renderStyle(css)).toStrictEqual({ transform }); + }, +); + +// Chromium 153, Firefox 155 and WebKit 26.6 resolve an em length against the element's font size, whichever rule sets it (css-values-4 §6.1.1) +test.each([ + [ + ".earlier { translate: 1em 5px; } .my-class { font-size: 20px; }", + [{ translateX: 20 }, { translateY: 5 }], + ], + [ + ".earlier { transform: translateX(1em); } .my-class { font-size: 20px; }", + [{ translateX: 20 }], + ], +] as const)( + "%s resolves em against the element's font size", + (css, transform) => { + expect(renderStyle(css)).toStrictEqual({ fontSize: 20, transform }); + }, +); + +// Chromium 153, Firefox 155 and WebKit 26.6: a later transform without a value replaces an earlier one, and is none (css-variables-1 §3.1) +test("transform: var(--missing) replaces an earlier rule's transform", () => { + expect( + renderStyle( + `${decoy} .earlier { transform: rotate(10deg); } .my-class { transform: var(--missing); }`, + ), + ).toStrictEqual({ transform: [] }); +}); diff --git a/src/__tests__/vendor/tailwind/transform.test.ts b/src/__tests__/vendor/tailwind/transform.test.ts index 2a7cf239..6655504d 100644 --- a/src/__tests__/vendor/tailwind/transform.test.ts +++ b/src/__tests__/vendor/tailwind/transform.test.ts @@ -233,11 +233,12 @@ describe("Transforms - Mixed", () => { ).toStrictEqual({ props: { style: { + // css-transforms-2 §6: translate, then rotate, then the transform list transform: [ - { skewY: "1deg" }, { translateX: 3.5 }, { translateY: 0 }, { rotateZ: "90deg" }, + { skewY: "1deg" }, ], }, }, diff --git a/src/compiler/calculation.ts b/src/compiler/calculation.ts new file mode 100644 index 00000000..3f7cc715 --- /dev/null +++ b/src/compiler/calculation.ts @@ -0,0 +1,145 @@ +import type { + DimensionPercentageFor_LengthValue, + Length, + RoundingStrategy, + TokenOrValue, +} from "lightningcss"; + +import { + commaSeparated, + delimiterToken, + functionToken, + identifierToken, + lengthToken, + numberToken, + percentageToken, + WHITESPACE_TOKEN, +} from "./tokens"; + +export type LengthLeaf = Length | DimensionPercentageFor_LengthValue; + +/** lightningcss's calculation tree for a length, as it holds one it could not fold. */ +export type LengthCalculation = + | { readonly type: "value"; readonly value: LengthLeaf } + | { readonly type: "number"; readonly value: number } + | { + readonly type: "sum"; + readonly value: readonly [LengthCalculation, LengthCalculation]; + } + | { + readonly type: "product"; + readonly value: readonly [number, LengthCalculation]; + } + | { readonly type: "function"; readonly value: LengthMathFunction }; + +export type LengthMathFunction = + | { + readonly type: "calc" | "abs" | "sign"; + readonly value: LengthCalculation; + } + | { + readonly type: "min" | "max" | "hypot"; + readonly value: readonly LengthCalculation[]; + } + | { + readonly type: "clamp"; + readonly value: readonly [ + LengthCalculation, + LengthCalculation, + LengthCalculation, + ]; + } + | { + readonly type: "round"; + readonly value: readonly [ + RoundingStrategy, + LengthCalculation, + LengthCalculation, + ]; + } + | { + readonly type: "rem" | "mod"; + readonly value: readonly [LengthCalculation, LengthCalculation]; + }; + +export function lengthLeafTokens(leaf: LengthLeaf): TokenOrValue[] { + switch (leaf.type) { + case "value": + case "dimension": + return [lengthToken(leaf.value)]; + case "percentage": + return [percentageToken(leaf.value)]; + case "calc": + return [calculationToken(leaf.value)]; + } +} + +/** The one token a calculation is written as: its own math function, or `calc()` around anything else. */ +export function calculationToken(calculation: LengthCalculation): TokenOrValue { + return calculation.type === "function" + ? mathFunctionToken(calculation.value) + : functionToken("calc", calculationTokens(calculation)); +} + +function calculationTokens(calculation: LengthCalculation): TokenOrValue[] { + switch (calculation.type) { + case "value": + return lengthLeafTokens(calculation.value); + case "number": + return [numberToken(calculation.value)]; + case "sum": + return [ + ...calculationTokens(calculation.value[0]), + WHITESPACE_TOKEN, + delimiterToken("+"), + WHITESPACE_TOKEN, + ...calculationTokens(calculation.value[1]), + ]; + case "product": { + const [factor, operand] = calculation.value; + + return [ + numberToken(factor), + WHITESPACE_TOKEN, + delimiterToken("*"), + WHITESPACE_TOKEN, + ...(operand.type === "sum" + ? [calculationToken(operand)] + : calculationTokens(operand)), + ]; + } + case "function": + return [mathFunctionToken(calculation.value)]; + } +} + +function mathFunctionToken(math: LengthMathFunction): TokenOrValue { + switch (math.type) { + case "calc": + case "abs": + case "sign": + return functionToken(math.type, calculationTokens(math.value)); + case "min": + case "max": + case "hypot": + case "clamp": + case "rem": + case "mod": + return functionToken( + math.type, + commaSeparated(math.value.map(calculationTokens)), + ); + case "round": { + const [strategy, value, interval] = math.value; + + return functionToken( + "round", + commaSeparated([ + [identifierToken(strategy)], + calculationTokens(value), + calculationTokens(interval), + ]), + ); + } + } +} diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 363ded58..6229a2f5 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -33,6 +33,7 @@ import { reduceParseUnparsed, round, } from "./declarations"; +import { unparseIndividualTransform } from "./individual-transforms"; import { inlineVariables } from "./inline-variables"; import { extractKeyFrames } from "./keyframes"; import { lightningcssLoader } from "./lightningcss-loader"; @@ -145,26 +146,33 @@ export function compile(code: Buffer | string, options: CompilerOptions = {}) { }; } - if (options.inlineVariables !== false) { - const exclusionList: string[] = options.inlineVariables?.exclude ?? []; + const exclusionList: string[] = + options.inlineVariables === false + ? [] + : (options.inlineVariables?.exclude ?? []); + + firstPassVisitor.Declaration = (decl) => { + if ( + options.inlineVariables !== false && + decl.property === "custom" && + decl.value.name.startsWith("--") && + !exclusionList.includes(decl.value.name) + ) { + const entry = vars.get(decl.value.name) ?? { + count: 0, + value: [ + ...decl.value.value, + { type: "token", value: { type: "white-space", value: " " } }, + ], + }; + entry.count++; + vars.set(decl.value.name, entry); + } - firstPassVisitor.Declaration = (decl) => { - if ( - decl.property === "custom" && - decl.value.name.startsWith("--") && - !exclusionList.includes(decl.value.name) - ) { - const entry = vars.get(decl.value.name) ?? { - count: 0, - value: [ - ...decl.value.value, - { type: "token", value: { type: "white-space", value: " " } }, - ], - }; - entry.count++; - vars.set(decl.value.name, entry); - } - }; + return unparseIndividualTransform(decl); + }; + + if (options.inlineVariables !== false) { firstPassVisitor.StyleSheetExit = (sheet) => { return inlineVariables(sheet, vars); }; diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 9ca2aab8..0f4f65e8 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -736,159 +736,144 @@ function parseFont( builder.addDescriptor("font-weight", parseFontWeight(value.weight, builder)); } +type TransformFunction = Extract< + StyleFunction, + [Record, string, StyleDescriptor] +>; + +// An argument React Native has no equivalent for leaves the declaration invalid there, and an invalid declaration is ignored (CSS 2.2 §4.2) +function hasUnconvertibleArgument( + functions: readonly TransformFunction[], +): boolean { + return functions.some(([, , argument]) => argument === undefined); +} + function parseTransform( { value }: DeclarationType<"transform">, builder: StylesheetBuilder, ) { - builder.addDescriptor("transform", [ - {}, - "transform", - value.flatMap((t): StyleDescriptor[] => { - switch (t.type) { - case "perspective": - return [[{}, "perspective", parseLength(t.value, builder)]]; - case "translate": - return [ - [ - {}, - "translateX", - parseLengthOrCoercePercentageToRuntime(t.value[0], builder), - ], - [ - [ - {}, - "translateY", - parseLengthOrCoercePercentageToRuntime(t.value[1], builder), - ], - ], - ]; - case "translateX": - return [ - [ - {}, - "translateX", - parseLengthOrCoercePercentageToRuntime(t.value, builder), - ], - ]; - case "translateY": - return [ - [ - {}, - "translateY", - parseLengthOrCoercePercentageToRuntime(t.value, builder), - ], - ]; - case "rotate": - return [[{}, "rotate", parseAngle(t.value, builder)]]; - case "rotateX": - return [[{}, "rotateX", parseAngle(t.value, builder)]]; - case "rotateY": - return [[{}, "rotateY", parseAngle(t.value, builder)]]; - case "rotateZ": - return [[{}, "rotateZ", parseAngle(t.value, builder)]]; - case "scale": - return [ - [{}, "scaleX", parseLength(t.value[0], builder)], - [{}, "scaleY", parseLength(t.value[1], builder)], - ]; - case "scaleX": - return [[{}, "scaleX", parseLength(t.value, builder)]]; - case "scaleY": - return [[{}, "scaleY", parseLength(t.value, builder)]]; - case "skew": - return [ - [{}, "skewX", parseAngle(t.value[0], builder)], - [{}, "skewY", parseAngle(t.value[1], builder)], - ]; - case "skewX": - return [[{}, "skewX", parseAngle(t.value, builder)]]; - case "skewY": - return [[{}, "skewY", parseAngle(t.value, builder)]]; - case "translateZ": - case "translate3d": - case "scaleZ": - case "scale3d": - case "rotate3d": - case "matrix": - case "matrix3d": - return [[]]; - } - }), - ]); - return; + const functions = value.flatMap((t): TransformFunction[] => { + switch (t.type) { + case "perspective": + return [[{}, "perspective", parseLength(t.value, builder)]]; + case "translate": + return [ + [{}, "translateX", parseLength(t.value[0], builder)], + [{}, "translateY", parseLength(t.value[1], builder)], + ]; + case "translateX": + return [[{}, "translateX", parseLength(t.value, builder)]]; + case "translateY": + return [[{}, "translateY", parseLength(t.value, builder)]]; + case "rotate": + return [[{}, "rotate", parseAngle(t.value, builder)]]; + case "rotateX": + return [[{}, "rotateX", parseAngle(t.value, builder)]]; + case "rotateY": + return [[{}, "rotateY", parseAngle(t.value, builder)]]; + case "rotateZ": + return [[{}, "rotateZ", parseAngle(t.value, builder)]]; + case "scale": + return [ + [{}, "scaleX", parseLength(t.value[0], builder)], + [{}, "scaleY", parseLength(t.value[1], builder)], + ]; + case "scaleX": + return [[{}, "scaleX", parseLength(t.value, builder)]]; + case "scaleY": + return [[{}, "scaleY", parseLength(t.value, builder)]]; + case "skew": + return [ + [{}, "skewX", parseAngle(t.value[0], builder)], + [{}, "skewY", parseAngle(t.value[1], builder)], + ]; + case "skewX": + return [[{}, "skewX", parseAngle(t.value, builder)]]; + case "skewY": + return [[{}, "skewY", parseAngle(t.value, builder)]]; + case "translateZ": + case "translate3d": + case "scaleZ": + case "scale3d": + case "rotate3d": + case "matrix": + case "matrix3d": + return []; + } + }); + + if (hasUnconvertibleArgument(functions)) { + return; + } + + builder.addDescriptor("transform", [{}, "transform", functions]); } function parseTranslate( { value }: DeclarationType<"translate">, builder: StylesheetBuilder, ) { - builder.addDescriptor("translateX", [ - {}, - "translateX", - parseTranslateProp(value, "x", builder), - ]); - builder.addDescriptor("translateY", [ - {}, - "translateY", - parseTranslateProp(value, "y", builder), - ]); + const translations: TransformFunction[] = [ + [{}, "translateX", parseTranslateProp(value, "x", builder)], + [{}, "translateY", parseTranslateProp(value, "y", builder)], + ]; + + if (hasUnconvertibleArgument(translations)) { + return; + } + + builder.addDescriptor("translate", translations); } function parseRotate( { value }: DeclarationType<"rotate">, builder: StylesheetBuilder, ) { + const angle = parseAngle(value.angle, builder); + const rotations: TransformFunction[] = []; + if (value.x) { - builder.addDescriptor("rotateX", [ - {}, - "rotateX", - parseAngle(value.angle, builder), - ]); + rotations.push([{}, "rotateX", angle]); } if (value.y) { - builder.addDescriptor("rotateY", [ - {}, - "rotateY", - parseAngle(value.angle, builder), - ]); + rotations.push([{}, "rotateY", angle]); } if (value.z) { - builder.addDescriptor("rotateZ", [ - {}, - "rotateZ", - parseAngle(value.angle, builder), - ]); + rotations.push([{}, "rotateZ", angle]); } + + if (hasUnconvertibleArgument(rotations)) { + return; + } + + builder.addDescriptor( + "rotate", + rotations.length === 1 ? rotations[0] : rotations, + ); } function parseScale( { value }: DeclarationType<"scale">, builder: StylesheetBuilder, ) { - builder.addDescriptor("scaleX", [ - {}, - "scaleX", - parseScaleValue(value, "x", builder), - ]); - builder.addDescriptor("scaleY", [ - {}, - "scaleY", - parseScaleValue(value, "y", builder), + builder.addDescriptor("scale", [ + [{}, "scaleX", parseScaleValue(value, "x", builder)], + [{}, "scaleY", parseScaleValue(value, "y", builder)], ]); } export function parseScaleValue( - translate: Scale, + scale: Scale, prop: keyof Extract, builder: StylesheetBuilder, ): StyleDescriptor { - if (translate === "none") { + if (scale === "none") { return 1; } - return parseLength(translate[prop], builder); + return parseLength(scale[prop], builder); } function parseLetterSpacing( @@ -1509,6 +1494,7 @@ export function parseLength( switch (length.type) { case "calc": { // TODO: Add the calc polyfill + builder.addWarning("value", "calc()"); return undefined; } case "number": { @@ -2401,13 +2387,6 @@ export function parseFontVariantCaps( return undefined; } -export function parseLengthOrCoercePercentageToRuntime( - value: Length | DimensionPercentageFor_LengthValue | NumberOrPercentage, - builder: StylesheetBuilder, -): StyleDescriptor { - return parseLength(value, builder); -} - export function parseGap( declaration: DeclarationType<"gap" | "column-gap" | "row-gap">, builder: StylesheetBuilder, diff --git a/src/compiler/individual-transforms.ts b/src/compiler/individual-transforms.ts new file mode 100644 index 00000000..0086b5bf --- /dev/null +++ b/src/compiler/individual-transforms.ts @@ -0,0 +1,94 @@ +import type { + Declaration, + NumberOrPercentage, + Rotate, + Scale, + TokenOrValue, + Translate, +} from "lightningcss"; + +import { lengthLeafTokens } from "./calculation"; +import { + identifierToken, + numberToken, + percentageToken, + spaceSeparated, +} from "./tokens"; + +/** + * Carries `translate`, `rotate` and `scale` across the first pass as the tokens + * they were written as. lightningcss before + * https://github.com/parcel-bundler/lightningcss/pull/1340 folds a parsed one + * into `transform` when it serialises the rule, and passes an unparsed one + * through in place. + */ +export function unparseIndividualTransform( + declaration: Declaration, +): Declaration | undefined { + switch (declaration.property) { + case "translate": + return { + property: "unparsed", + value: { + propertyId: { property: "translate" }, + value: translateTokens(declaration.value), + }, + }; + case "rotate": + return { + property: "unparsed", + value: { + propertyId: { property: "rotate" }, + value: rotateTokens(declaration.value), + }, + }; + case "scale": + return { + property: "unparsed", + value: { + propertyId: { property: "scale" }, + value: scaleTokens(declaration.value), + }, + }; + default: + return undefined; + } +} + +function numberOrPercentageTokens(value: NumberOrPercentage): TokenOrValue[] { + switch (value.type) { + case "number": + return [numberToken(value.value)]; + case "percentage": + return [percentageToken(value.value)]; + } +} + +function translateTokens(value: Translate): TokenOrValue[] { + return value === "none" + ? [identifierToken("none")] + : spaceSeparated([ + lengthLeafTokens(value.x), + lengthLeafTokens(value.y), + lengthLeafTokens(value.z), + ]); +} + +function rotateTokens(value: Rotate): TokenOrValue[] { + return spaceSeparated([ + [numberToken(value.x)], + [numberToken(value.y)], + [numberToken(value.z)], + [{ type: "angle", value: value.angle }], + ]); +} + +function scaleTokens(value: Scale): TokenOrValue[] { + return value === "none" + ? [identifierToken("none")] + : spaceSeparated([ + numberOrPercentageTokens(value.x), + numberOrPercentageTokens(value.y), + numberOrPercentageTokens(value.z), + ]); +} diff --git a/src/compiler/tokens.ts b/src/compiler/tokens.ts new file mode 100644 index 00000000..1ca251b9 --- /dev/null +++ b/src/compiler/tokens.ts @@ -0,0 +1,60 @@ +import type { LengthValue, TokenOrValue } from "lightningcss"; + +export const WHITESPACE_TOKEN: TokenOrValue = { + type: "token", + value: { type: "white-space", value: " " }, +}; + +export const COMMA_TOKEN: TokenOrValue = { + type: "token", + value: { type: "comma" }, +}; + +export function delimiterToken(value: string): TokenOrValue { + return { type: "token", value: { type: "delim", value } }; +} + +export function identifierToken(value: string): TokenOrValue { + return { type: "token", value: { type: "ident", value } }; +} + +export function numberToken(value: number): TokenOrValue { + return { type: "token", value: { type: "number", value } }; +} + +/** lightningcss holds a percentage as its fraction, in a token as in a value: 50% is 0.5. */ +export function percentageToken(fraction: number): TokenOrValue { + return { type: "token", value: { type: "percentage", value: fraction } }; +} + +export function lengthToken(value: LengthValue): TokenOrValue { + return { type: "length", value }; +} + +export function functionToken( + name: string, + argumentTokens: TokenOrValue[], +): TokenOrValue { + return { type: "function", value: { name, arguments: argumentTokens } }; +} + +function joinTokens( + groups: readonly TokenOrValue[][], + separator: readonly TokenOrValue[], +): TokenOrValue[] { + return groups.flatMap((group, index) => + index === 0 ? group : [...separator, ...group], + ); +} + +export function spaceSeparated( + groups: readonly TokenOrValue[][], +): TokenOrValue[] { + return joinTokens(groups, [WHITESPACE_TOKEN]); +} + +export function commaSeparated( + groups: readonly TokenOrValue[][], +): TokenOrValue[] { + return joinTokens(groups, [COMMA_TOKEN, WHITESPACE_TOKEN]); +} diff --git a/src/native/objects.ts b/src/native/objects.ts index f555101e..c6bb7f9a 100644 --- a/src/native/objects.ts +++ b/src/native/objects.ts @@ -1,6 +1,5 @@ /* eslint-disable */ import { ShortHandSymbol } from "../native/styles/constants"; -import { transformKeys } from "../native/styles/defaults"; export function getDeepPath(source: any, paths: string | string[] | false) { if (!source) { @@ -23,10 +22,6 @@ export function getDeepPath(source: any, paths: string | string[] | false) { } return target; - } else if (transformKeys.has(paths)) { - return Array.isArray(source?.transform) - ? source.transform.find((t: any) => t[paths] !== undefined) - : source.transform; } else { return source?.[paths]; } @@ -53,11 +48,6 @@ export function applyValue( prop: string, value: any, ) { - if (transformKeys.has(prop)) { - applyTransformValue(target, prop, value); - return; - } - // This is confusing. // An undefined value means "don't set anything" (something failed while parsing) // While a null value means "remove this value", which in React Native means "set to undefined" @@ -76,27 +66,6 @@ export function applyValue( target[prop] = value; } -// A transform longhand that resolves to no value is `none`, so its entries go and nothing takes their place -function applyTransformValue( - target: Record, - prop: string, - value: unknown, -) { - const transforms: Record[] = Array.isArray(target.transform) - ? target.transform - : []; - - target.transform = transforms.filter((transform) => !(prop in transform)); - - if (value === undefined || value === null) { - return; - } else if (Array.isArray(value)) { - target.transform.push(...value); - } else { - target.transform.push(value); - } -} - export function setDeepPath( target: Record, paths: string | string[] | readonly string[], diff --git a/src/native/styles/calculate-props.ts b/src/native/styles/calculate-props.ts index 28b9ed36..c8afe058 100644 --- a/src/native/styles/calculate-props.ts +++ b/src/native/styles/calculate-props.ts @@ -2,6 +2,7 @@ import type { InlineVariable, StyleDeclaration, + StyleDescriptor, StyleRule, } from "react-native-css/compiler"; import { Specificity as S } from "react-native-css/utilities"; @@ -13,7 +14,6 @@ import { type Getter, type VariableContextValue, } from "../reactivity"; -import { transformKeys } from "./defaults"; import { resolveValue } from "./resolve"; export function calculateProps( @@ -31,7 +31,7 @@ export function calculateProps( let important: Record | undefined; const delayedStyles: (() => void)[] = []; - const transformStyles: (() => void)[] = []; + const transforms = createTransformComposition(); for (const rule of rules) { if (VAR_SYMBOL in rule) { @@ -50,9 +50,8 @@ export function calculateProps( } if (rule.d) { - let topLevelTarget = rule.s?.[S.Important] - ? (important ??= {}) - : (normal ??= {}); + const isImportant = Boolean(rule.s?.[S.Important]); + let topLevelTarget = isImportant ? (important ??= {}) : (normal ??= {}); let target = topLevelTarget; const ruleTarget = rule.target || "style"; @@ -71,7 +70,7 @@ export function calculateProps( inlineVariables, inheritedVariables, delayedStyles, - transformStyles, + transforms.recorderFor(target, ruleTarget, isImportant), guards, target, topLevelTarget, @@ -83,9 +82,14 @@ export function calculateProps( delayedStyle(); } - for (const transformStyle of transformStyles) { - transformStyle(); - } + transforms.compose((value) => + resolveValue(value, get, { + inlineVariables, + inheritedVariables, + renderGuards: guards, + calculateProps, + }), + ); return { normal, @@ -94,13 +98,108 @@ export function calculateProps( }; } +// css-transforms-2 §6: an element's transform is its `translate`, then its `rotate`, then its `scale`, then its `transform` list +const TRANSFORM_PROPERTIES = [ + "translate", + "rotate", + "scale", + "transform", +] as const; + +type TransformProperty = (typeof TRANSFORM_PROPERTIES)[number]; + +function isTransformProperty(prop: string | number): prop is TransformProperty { + return (TRANSFORM_PROPERTIES as readonly (string | number)[]).includes(prop); +} + +type TransformSlots = Partial>; + +interface TransformTarget { + readonly target: Record; + readonly slots: TransformSlots; +} + +type RecordTransform = ( + property: TransformProperty, + value: StyleDescriptor, +) => void; + +/** Each transform property of each style target is a cascade slot of its own, and the slots compose into one `transform` once every declaration has been walked. */ +function createTransformComposition() { + const byPath = new Map< + string, + { normal?: TransformTarget; important?: TransformTarget } + >(); + + return { + recorderFor( + target: Record, + path: string | readonly string[], + important: boolean, + ): RecordTransform { + return (property, value) => { + const pathKey = typeof path === "string" ? path : path.join("\0"); + const pair = byPath.get(pathKey) ?? {}; + const side = important ? "important" : "normal"; + const recorded = pair[side] ?? { target, slots: {} }; + + recorded.slots[property] = value; + pair[side] = recorded; + byPath.set(pathKey, pair); + }; + }, + + compose(resolve: (value: StyleDescriptor) => unknown) { + for (const { normal, important } of byPath.values()) { + const destination = important ?? normal; + const entries = composeTransform( + { ...normal?.slots, ...important?.slots }, + resolve, + ); + + if (destination !== undefined && entries !== undefined) { + destination.target.transform = entries; + } + } + }, + }; +} + +function composeTransform( + slots: TransformSlots, + resolve: (value: StyleDescriptor) => unknown, +): unknown[] | undefined { + let composed: unknown[] | undefined; + + for (const property of TRANSFORM_PROPERTIES) { + if (!Object.hasOwn(slots, property)) { + continue; + } + + const entries = transformEntries(resolve(slots[property])); + + if (entries !== undefined) { + composed = [...(composed ?? []), ...entries]; + } + } + + return composed; +} + +// A property that resolves to no value is `none` (css-variables-1 §3.1) +function transformEntries(value: unknown): unknown[] | undefined { + return value === undefined || value === null + ? undefined + : [value].flat(Number.POSITIVE_INFINITY); +} + export function applyDeclarations( get: Getter, declarations: StyleDeclaration[], inlineVariables: InlineVariable, inheritedVariables: VariableContextValue, delayedStyles: (() => void)[] = [], - transformStyles: (() => void)[] = [], + recordTransform: RecordTransform = () => undefined, guards: RenderGuard[] = [], target: Record = {}, topLevelTarget = target, @@ -162,9 +261,14 @@ export function applyDeclarations( prop = propPath; } + if (target === originalTarget && isTransformProperty(prop)) { + recordTransform(prop, value); + continue; + } + const shouldDelay = declaration[2]; - if (shouldDelay || transformKeys.has(prop)) { + if (shouldDelay) { /** * We need to delay the resolution of this value until after all * styles have been calculated. But another style might override @@ -175,11 +279,11 @@ export function applyDeclarations( * mutate the props object and not create a new one */ const originalValue = value; - // This needs to be a object with the [prop] so we can discover in transform arrays value = { [prop]: true }; - if (transformKeys.has(prop)) { - transformStyles.push(() => { + delayedStyles.push(() => { + if (getDeepPath(target, prop) === value) { + delete target[prop]; value = resolveValue(originalValue, get, { inlineVariables, inheritedVariables, @@ -187,21 +291,8 @@ export function applyDeclarations( calculateProps, }); applyValue(target, prop, value); - }); - } else { - delayedStyles.push(() => { - if (getDeepPath(target, prop) === value) { - delete target[prop]; - value = resolveValue(originalValue, get, { - inlineVariables, - inheritedVariables, - renderGuards: guards, - calculateProps, - }); - applyValue(target, prop, value); - } - }); - } + } + }); } else { value = resolveValue(value, get, { inlineVariables, From 708234353f72cbaf6726fde4c973a711dc851087 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 23:32:36 +0300 Subject: [PATCH 7/8] fix(transforms): read translate, rotate and scale by their own grammars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The individual transform properties each have a grammar of their own (css-transforms-2 §3): translate and scale take an optional z, and rotate takes an axis keyword or a vector beside its angle, in either order. A deferred one ran through the transform function of the same name, which takes no z, no axis and no none. Each now resolves through its own grammar on both routes. A rotation turns about the axis its vector names, reversed about a negative axis, and the zero vector rotates by none; the compiler and the runtime share that one decision. React Native's rotate keys turn only about an axis, so a vector off every axis warns and is ignored when written, and is none when substituted. A scale along z warns. A var() can supply part of a property's values, and they splice into the rest. A transform function given more operands than it takes is invalid. --- src/__tests__/compiler/compiler.test.tsx | 67 ++++++ src/__tests__/native/transform.test.tsx | 106 +++++++++- src/compiler/declarations.ts | 73 +++++-- .../styles/functions/transform-functions.ts | 196 ++++++++++++++---- src/utilities/index.ts | 1 + src/utilities/rotation-axis.ts | 37 ++++ 6 files changed, 410 insertions(+), 70 deletions(-) create mode 100644 src/utilities/rotation-axis.ts diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index 49b4c38f..082f9a10 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -373,6 +373,73 @@ test("a transform value the compiler cannot convert is ignored, with a warning", }); }); +test("a scale along z or a rotate about an oblique axis warns", () => { + const compiled = compile(` +.my-class { + scale: 2 3 400%; + rotate: 1 1 0 45deg; +} +.unwarned { + scale: 2 3 100%; + translate: 10px 20px 30px; + rotate: 0 0 0 45deg; +}`); + + expect(compiled.stylesheet()).toStrictEqual({ + s: [ + [ + "my-class", + [ + { + d: [ + [ + [ + [{}, "scaleX", 2], + [{}, "scaleY", 3], + ], + "scale", + ], + ], + s: [1, 1], + }, + ], + ], + [ + "unwarned", + [ + { + d: [ + [ + [ + [{}, "scaleX", 2], + [{}, "scaleY", 3], + ], + "scale", + ], + [ + [ + [{}, "translateX", 10], + [{}, "translateY", 20], + ], + "translate", + ], + [[{}, "rotateZ", "0deg"], "rotate"], + ], + s: [2, 1], + }, + ], + ], + ], + }); + + expect(compiled.warnings()).toStrictEqual({ + values: { + scale: ["400%"], + rotate: ["1 1 0 45deg"], + }, + }); +}); + test("simplifies rem", () => { const compiled = compile(`.test { border-width: calc(10rem + 2px); diff --git a/src/__tests__/native/transform.test.tsx b/src/__tests__/native/transform.test.tsx index a6b2d6a2..4dfe977a 100644 --- a/src/__tests__/native/transform.test.tsx +++ b/src/__tests__/native/transform.test.tsx @@ -164,7 +164,7 @@ function renderStyle(css: string): unknown { } // A second definition keeps each variable from being inlined, so it resolves at runtime -const decoy = `.decoy { --sx: 9; --sy: 9; --t: 9px; --offset: 1px; --s: 1; --x: 9px; --y: 9px; }`; +const decoy = `.decoy { --sx: 9; --sy: 9; --t: 9px; --offset: 1px; --s: 1; --x: 9px; --y: 9px; --r: 9deg; }`; // React Native requires exactly one property per transform object, and its validator throws otherwise describe("one property per entry", () => { @@ -274,6 +274,9 @@ test.each( ["--offset: 10px 20px; ", "var(--offset)"], ["--sx: none; ", "scaleX(var(--sx))"], ["--sx: none; ", "scale(var(--sx))"], + ["--t: 1px, 2px, 3px; ", "translate(var(--t))"], + ["--s: 1, 2, 3; ", "scale(var(--s))"], + ["--r: 10deg, 20deg; ", "rotate(var(--r))"], ] as const ).flatMap(([customProperties, transform]) => [ `${customProperties}transform: ${transform};`, @@ -292,7 +295,6 @@ test.each([ "scale: var(--missing);", "scale: var(--missing) var(--missing);", "translate: var(--missing) var(--missing);", - "--sx: none; scale: var(--sx);", "--sx: none; --sy: 2; scale: var(--sx) var(--sy);", "scale: unset;", "scale: initial;", @@ -392,6 +394,106 @@ test.each([ }, ); +// Chromium 153 and Firefox 155 rotate about the axis a vector names, reversing the angle about a negative axis, and rotate by none about the zero vector; WebKit 26.6 ignores the direction of a z axis +test.each([ + ["rotate: 0 0 -1 45deg;", [{ rotateZ: "-45deg" }]], + ["--r: 0 0 -1 45deg; rotate: var(--r);", [{ rotateZ: "-45deg" }]], + ["rotate: -1 0 0 45deg;", [{ rotateX: "-45deg" }]], + ["--r: -1 0 0 45deg; rotate: var(--r);", [{ rotateX: "-45deg" }]], + ["rotate: 0 0 2 45deg;", [{ rotateZ: "45deg" }]], + ["--r: 0 0 2 45deg; rotate: var(--r);", [{ rotateZ: "45deg" }]], + ["rotate: x 45deg;", [{ rotateX: "45deg" }]], + ["--r: x 45deg; rotate: var(--r);", [{ rotateX: "45deg" }]], + ["rotate: 45deg y;", [{ rotateY: "45deg" }]], + ["--r: 45deg y; rotate: var(--r);", [{ rotateY: "45deg" }]], + ["--r: 20deg; rotate: var(--r);", [{ rotateZ: "20deg" }]], + ["--r: x; rotate: var(--r) 45deg;", [{ rotateX: "45deg" }]], + ["--r: 0 0 -1; rotate: var(--r) 45deg;", [{ rotateZ: "-45deg" }]], + ["rotate: 0 0 0 45deg;", [{ rotateZ: "0deg" }]], + ["--r: 0 0 0 45deg; rotate: var(--r);", [{ rotateZ: "0deg" }]], + ["--r: none; rotate: var(--r);", [{ rotateZ: "0deg" }]], +] as const)("%s rotates about the axis it names", (declarations, transform) => { + expect( + renderStyle( + `${decoy} .earlier { rotate: 10deg; } .my-class { ${declarations} }`, + ), + ).toStrictEqual({ transform }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6 read the third value of translate and scale as its z, which React Native has no axis for +test.each([ + ["translate: 10px 20px 30px;", [{ translateX: 10 }, { translateY: 20 }]], + [ + "--t: 10px 20px 30px; translate: var(--t);", + [{ translateX: 10 }, { translateY: 20 }], + ], + [ + "--t: 20px 30px; translate: 10px var(--t);", + [{ translateX: 10 }, { translateY: 20 }], + ], + ["--t: none; translate: var(--t);", [{ translateX: 0 }, { translateY: 0 }]], + ["--t: NONE; translate: var(--t);", [{ translateX: 0 }, { translateY: 0 }]], + ["scale: 2 3 4;", [{ scaleX: 2 }, { scaleY: 3 }]], + ["--s: 2 3 4; scale: var(--s);", [{ scaleX: 2 }, { scaleY: 3 }]], + ["--s: 3 4; scale: 2 var(--s);", [{ scaleX: 2 }, { scaleY: 3 }]], + ["--s: none; scale: var(--s);", [{ scaleX: 1 }, { scaleY: 1 }]], +] as const)("%s renders its x and y", (declarations, transform) => { + expect( + renderStyle( + `${decoy} .earlier { translate: 1px 2px; scale: 5; } .my-class { translate: none; scale: none; ${declarations} }`, + ), + ).toStrictEqual({ + transform: [ + ...(declarations.includes("translate") + ? transform + : [{ translateX: 0 }, { translateY: 0 }]), + ...(declarations.includes("scale") + ? transform + : [{ scaleX: 1 }, { scaleY: 1 }]), + ], + }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6: a value the property's grammar does not take is invalid at computed-value time, so the property is none (css-variables-1 §3.1) +test.each([ + "--r: x y 45deg; rotate: var(--r);", + "--r: 0 45deg 0 1; rotate: var(--r);", + "--t: 10px 20px 30%; translate: var(--t);", + "--t: 1px 2px 3px 4px; translate: var(--t);", + "--s: 2 3 4 5; scale: var(--s);", + "--s: 2 3 red; scale: var(--s);", + "--s: none 2; scale: var(--s);", +])("%s leaves the property none", (declarations) => { + expect( + renderStyle( + `${decoy} .earlier { translate: 1px 2px; rotate: 10deg; scale: 5; transform: translateX(5px); } .my-class { ${declarations} }`, + ), + ).toStrictEqual({ + transform: [ + ...(declarations.includes("translate:") + ? [] + : [{ translateX: 1 }, { translateY: 2 }]), + ...(declarations.includes("rotate:") ? [] : [{ rotateZ: "10deg" }]), + ...(declarations.includes("scale:") + ? [] + : [{ scaleX: 5 }, { scaleY: 5 }]), + { translateX: 5 }, + ], + }); +}); + +// React Native's rotate keys turn only about an axis, so a rotate about a vector off every axis is ignored when written and none when substituted +test.each([ + ["rotate: 1 1 0 45deg;", [{ rotateZ: "10deg" }]], + ["--r: 1 1 0 45deg; rotate: var(--r);", []], +] as const)("%s rotates by none", (declarations, transform) => { + expect( + renderStyle( + `${decoy} .earlier { rotate: 10deg; } .my-class { ${declarations} }`, + ), + ).toStrictEqual(transform.length === 0 ? {} : { transform }); +}); + // Chromium 153, Firefox 155 and WebKit 26.6: a later transform without a value replaces an earlier one, and is none (css-variables-1 §3.1) test("transform: var(--missing) replaces an earlier rule's transform", () => { expect( diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 0f4f65e8..1d6c7001 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -36,7 +36,7 @@ import type { UnresolvedColor, } from "lightningcss"; -import { isStyleFunction } from "../utilities"; +import { isStyleFunction, negateAngle, rotationAxis } from "../utilities"; import { isMappingProperty } from "./atRules"; import type { StyleDescriptor, @@ -102,6 +102,13 @@ const unparsedRuntimeParsing = new Set([ "translate", ]); +// css-transforms-2 §3: the individual transform properties each have a grammar of their own, which the transform function of the same name does not share +const INDIVIDUAL_TRANSFORM_RESOLVERS: ReadonlyMap = new Map([ + ["rotate", "individualRotate"], + ["scale", "individualScale"], + ["translate", "individualTranslate"], +]); + const parsers: { [K in Declaration["property"]]?: Parser; } = { @@ -829,35 +836,50 @@ function parseRotate( { value }: DeclarationType<"rotate">, builder: StylesheetBuilder, ) { - const angle = parseAngle(value.angle, builder); - const rotations: TransformFunction[] = []; - - if (value.x) { - rotations.push([{}, "rotateX", angle]); - } - - if (value.y) { - rotations.push([{}, "rotateY", angle]); - } - - if (value.z) { - rotations.push([{}, "rotateZ", angle]); - } + const rotation = rotationAxis(value.x, value.y, value.z); - if (hasUnconvertibleArgument(rotations)) { - return; + switch (rotation.type) { + case "none": + builder.addDescriptor("rotate", [{}, "rotateZ", "0deg"]); + return; + case "axis": { + const angle = parseAngle(value.angle, builder); + + if (angle !== undefined) { + builder.addDescriptor("rotate", [ + {}, + rotation.rotation, + rotation.reversed ? negateAngle(angle) : angle, + ]); + } + return; + } + // React Native's rotate keys turn only about an axis, so a vector off every axis has none to take it + case "oblique": + builder.addWarning( + "value", + `${value.x} ${value.y} ${value.z} ${value.angle.value}${value.angle.type}`, + ); + return; + default: + rotation satisfies never; } - - builder.addDescriptor( - "rotate", - rotations.length === 1 ? rotations[0] : rotations, - ); } function parseScale( { value }: DeclarationType<"scale">, builder: StylesheetBuilder, ) { + // React Native has no z axis to scale along, and lightningcss holds a percentage as its fraction + if (value !== "none" && value.z.value !== 1) { + builder.addWarning( + "value", + value.z.type === "percentage" + ? `${round(value.z.value * 100)}%` + : `${round(value.z.value)}`, + ); + } + builder.addDescriptor("scale", [ [{}, "scaleX", parseScaleValue(value, "x", builder)], [{}, "scaleY", parseScaleValue(value, "y", builder)], @@ -974,7 +996,12 @@ export function parseUnparsedDeclaration( if (property === "animation") { builder.addDescriptor("animation", [{}, property, args]); } else { - builder.addDescriptor(property, [{}, toRNProperty(property), args, 1]); + builder.addDescriptor(property, [ + {}, + INDIVIDUAL_TRANSFORM_RESOLVERS.get(property) ?? toRNProperty(property), + args, + 1, + ]); } } else { const value = parseUnparsed(declaration.value.value, builder, property); diff --git a/src/native/styles/functions/transform-functions.ts b/src/native/styles/functions/transform-functions.ts index c8ef4bdd..5d841a0d 100644 --- a/src/native/styles/functions/transform-functions.ts +++ b/src/native/styles/functions/transform-functions.ts @@ -1,83 +1,189 @@ import type { StyleDescriptor } from "react-native-css/compiler"; -import { isStyleDescriptorArray } from "react-native-css/utilities"; +import { + isStyleDescriptorArray, + negateAngle, + rotationAxis, +} from "react-native-css/utilities"; import type { SimpleResolveValue, StyleFunctionResolver } from "../resolve"; import { scaleFactor } from "../scale-factor"; // A component left without a valid value makes the declaration invalid at computed-value time (css-variables-1 §3.1), which null carries export const scale: StyleFunctionResolver = (resolveValue, descriptor) => { + return scalePair(resolveOperands(resolveValue, descriptor[2])); +}; + +export const rotate: StyleFunctionResolver = (resolveValue, descriptor) => { const operands = resolveOperands(resolveValue, descriptor[2]); - const x = scaleFactor(operands[0]); - const y = operands.length > 1 ? scaleFactor(operands[1]) : x; + const [angle] = operands; - if (x === null || y === null) { - return null; + return operands.length === 1 && isTransformComponent(angle) + ? { rotate: angle } + : null; +}; + +export const translate: StyleFunctionResolver = (resolveValue, descriptor) => { + return translatePair(resolveOperands(resolveValue, descriptor[2])); +}; + +// css-transforms-2 §3: `translate: none | [ ?]?`, and React Native has no z axis to move along +export const individualTranslate: StyleFunctionResolver = ( + resolveValue, + descriptor, +) => { + const operands = resolveOperands(resolveValue, descriptor[2]); + + if (operands.length === 1 && isNoneKeyword(operands[0])) { + return [{ translateX: 0 }, { translateY: 0 }]; } - return x === y ? { scale: x } : [{ scaleX: x }, { scaleY: y }]; + const z = operands.length > 2 ? operands[2] : 0; + + return operands.length <= 3 && typeof z === "number" + ? translatePair(operands.slice(0, 2)) + : null; }; -export const rotate: StyleFunctionResolver = (resolveValue, descriptor) => { - const args = descriptor[2]; +// css-transforms-2 §3: `scale: none | [ | ]{1,3}`, and React Native has no z axis to scale along +export const individualScale: StyleFunctionResolver = ( + resolveValue, + descriptor, +) => { + const operands = resolveOperands(resolveValue, descriptor[2]); + + if (operands.length === 1 && isNoneKeyword(operands[0])) { + return [{ scaleX: 1 }, { scaleY: 1 }]; + } + + const z = operands.length > 2 ? operands[2] : 1; + + return operands.length <= 3 && scaleFactor(z) !== null + ? scalePair(operands.slice(0, 2)) + : null; +}; + +// css-transforms-2 §3: `rotate: none | | [x | y | z | {3}] && ` +export const individualRotate: StyleFunctionResolver = ( + resolveValue, + descriptor, +) => { + const operands = resolveOperands(resolveValue, descriptor[2]); + + if (operands.length === 1) { + const [angle] = operands; + + if (isNoneKeyword(angle)) { + return { rotateZ: "0deg" }; + } + + return isTransformComponent(angle) ? { rotateZ: angle } : null; + } + + const axisIndex = operands.findIndex(isAxisKeyword); - if (!isStyleDescriptorArray(args)) { - const angle = resolveValue(args); + if (axisIndex !== -1) { + const axis = operands[axisIndex]; + const angle = operands[axisIndex === 0 ? 1 : 0]; - return isTransformComponent(angle) ? { rotate: angle } : null; + return operands.length === 2 && + isAxisKeyword(axis) && + isTransformComponent(angle) + ? { [AXIS_ROTATIONS[axis]]: angle } + : null; } - const x = resolveValue(args[0]); - const y = resolveValue(args[1]); - const z = resolveValue(args[2]); - - const isXValid = typeof x === "string" || typeof x === "number"; - const isYValid = typeof y === "string" || typeof y === "number"; - const isZValid = typeof z === "string" || typeof z === "number"; - - if (isXValid && isYValid && isZValid) { - return [{ rotateX: x }, { rotateY: y }, { rotateZ: z }]; - } else if (isXValid && isYValid) { - return [{ rotateX: x }, { rotateY: y }]; - } else if (isXValid && isZValid) { - return [{ rotateX: x }, { rotateZ: z }]; - } else if (isYValid && isZValid) { - return [{ rotateY: y }, { rotateZ: z }]; - } else if (isXValid) { - return { rotateX: x }; - } else if (isYValid) { - return { rotateY: y }; - } else if (isZValid) { - return { rotateZ: z }; + const angleIndex = operands.length === 4 ? angleOperandIndex(operands) : -1; + const angle = operands[angleIndex]; + const [x, y, z] = operands.filter((_, index) => index !== angleIndex); + + if ( + angleIndex === -1 || + !isTransformComponent(angle) || + typeof x !== "number" || + typeof y !== "number" || + typeof z !== "number" + ) { + return null; } - return; + const rotation = rotationAxis(x, y, z); + + switch (rotation.type) { + case "none": + return { rotateZ: "0deg" }; + case "axis": + return { + [rotation.rotation]: rotation.reversed ? negateAngle(angle) : angle, + }; + case "oblique": + return null; + default: + rotation satisfies never; + return null; + } }; +const AXIS_ROTATIONS = { + x: "rotateX", + y: "rotateY", + z: "rotateZ", +} as const; + +function isAxisKeyword(value: unknown): value is keyof typeof AXIS_ROTATIONS { + return value === "x" || value === "y" || value === "z"; +} + +// The angle of a vector rotation comes first or last, around three numbers +function angleOperandIndex(operands: readonly unknown[]): number { + const index = operands.findIndex((operand) => typeof operand === "string"); + + return index === 0 || index === operands.length - 1 ? index : -1; +} + +// `none` is the identity transform, and React Native refuses the keyword itself +function isNoneKeyword(value: unknown): boolean { + return typeof value === "string" && value.toLowerCase() === "none"; +} + +function scalePair(operands: readonly unknown[]): unknown { + const x = scaleFactor(operands[0]); + const y = operands.length > 1 ? scaleFactor(operands[1]) : x; + + if (operands.length > 2 || x === null || y === null) { + return null; + } + + return x === y ? { scale: x } : [{ scaleX: x }, { scaleY: y }]; +} + // `translate(x)` is `translate(x, 0)` (css-transforms-1 §9) -export const translate: StyleFunctionResolver = (resolveValue, descriptor) => { - const operands = resolveOperands(resolveValue, descriptor[2]); +function translatePair(operands: readonly unknown[]): unknown { const x = operands[0]; const y = operands.length > 1 ? operands[1] : 0; - if (!isTransformComponent(x) || !isTransformComponent(y)) { + if ( + operands.length > 2 || + !isTransformComponent(x) || + !isTransformComponent(y) + ) { return null; } return [{ translateX: x }, { translateY: y }]; -}; +} -// A var() can supply the whole operand list, which resolves to an array in place of one operand +// A var() substitutes its tokens before the grammar reads them, so it can supply one operand, several, or the whole list (css-variables-1 §3) function resolveOperands( resolveValue: SimpleResolveValue, args: StyleDescriptor, ): unknown[] { - if (isStyleDescriptorArray(args)) { - return args.map((arg) => resolveValue(arg)); - } - - const value = resolveValue(args); + const resolved = isStyleDescriptorArray(args) + ? args.map((arg) => resolveValue(arg)) + : [resolveValue(args)]; - return Array.isArray(value) ? value : [value]; + return resolved.flatMap((operand): readonly unknown[] => + Array.isArray(operand) ? operand : [operand], + ); } function isTransformComponent(value: unknown): value is string | number { diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 0c95da63..40b18563 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -1,3 +1,4 @@ export * from "./specificity"; export * from "./style-descriptor"; export * from "./dot-notation.types"; +export * from "./rotation-axis"; diff --git a/src/utilities/rotation-axis.ts b/src/utilities/rotation-axis.ts new file mode 100644 index 00000000..9a4fa30a --- /dev/null +++ b/src/utilities/rotation-axis.ts @@ -0,0 +1,37 @@ +export type RotationAxis = + | { readonly type: "none" } + | { + readonly type: "axis"; + readonly rotation: "rotateX" | "rotateY" | "rotateZ"; + readonly reversed: boolean; + } + | { readonly type: "oblique" }; + +// css-transforms-2 §3: a rotation turns about the direction its vector names, and the zero vector names none +export function rotationAxis(x: number, y: number, z: number): RotationAxis { + if (x === 0 && y === 0 && z === 0) { + return { type: "none" }; + } + + if (y === 0 && z === 0) { + return { type: "axis", rotation: "rotateX", reversed: x < 0 }; + } + + if (x === 0 && z === 0) { + return { type: "axis", rotation: "rotateY", reversed: y < 0 }; + } + + if (x === 0 && y === 0) { + return { type: "axis", rotation: "rotateZ", reversed: z < 0 }; + } + + return { type: "oblique" }; +} + +export function negateAngle(angle: string | number): string | number { + if (typeof angle === "number") { + return -angle; + } + + return angle.startsWith("-") ? angle.slice(1) : `-${angle}`; +} From ed6a57d8b64efeb93afa046d024b860715f628c9 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 23:33:43 +0300 Subject: [PATCH 8/8] fix(transforms): render a scale as the scaleX / scaleY pair on both routes The compiler writes scale(2) and scale: 2 as the scaleX / scaleY pair, while the runtime wrote the same value as one scale entry when both axes matched. Both routes now write the pair, so one value has one shape whichever route resolves it. --- src/__tests__/native/transform.test.tsx | 4 ++-- src/__tests__/vendor/tailwind/transform.test.ts | 4 ++-- src/native/styles/functions/transform-functions.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/__tests__/native/transform.test.tsx b/src/__tests__/native/transform.test.tsx index 4dfe977a..7b7edeaf 100644 --- a/src/__tests__/native/transform.test.tsx +++ b/src/__tests__/native/transform.test.tsx @@ -188,13 +188,13 @@ describe("one property per entry", () => { renderStyle( `${decoy} .my-class { --sx: 75%; transform: scale(var(--sx)); }`, ), - ).toStrictEqual({ transform: [{ scale: 0.75 }] }); + ).toStrictEqual({ transform: [{ scaleX: 0.75 }, { scaleY: 0.75 }] }); }); test("scale: var(--sx) scales both axes by its one value", () => { expect( renderStyle(`${decoy} .my-class { --sx: 75%; scale: var(--sx); }`), - ).toStrictEqual({ transform: [{ scale: 0.75 }] }); + ).toStrictEqual({ transform: [{ scaleX: 0.75 }, { scaleY: 0.75 }] }); }); // Chromium 153, Firefox 155 and WebKit 26.6 translate a one-operand translate() along x only diff --git a/src/__tests__/vendor/tailwind/transform.test.ts b/src/__tests__/vendor/tailwind/transform.test.ts index 6655504d..7d859046 100644 --- a/src/__tests__/vendor/tailwind/transform.test.ts +++ b/src/__tests__/vendor/tailwind/transform.test.ts @@ -26,7 +26,7 @@ describe("Transforms - Scale", () => { expect(await renderCurrentTest()).toStrictEqual({ props: { style: { - transform: [{ scale: 0 }], + transform: [{ scaleX: 0 }, { scaleY: 0 }], }, }, }); @@ -53,7 +53,7 @@ describe("Transforms - Scale", () => { expect(await renderCurrentTest()).toStrictEqual({ props: { style: { - transform: [{ scale: 0.5 }], + transform: [{ scaleX: 0.5 }, { scaleY: 0.5 }], }, }, }); diff --git a/src/native/styles/functions/transform-functions.ts b/src/native/styles/functions/transform-functions.ts index 5d841a0d..7efe81ab 100644 --- a/src/native/styles/functions/transform-functions.ts +++ b/src/native/styles/functions/transform-functions.ts @@ -153,7 +153,7 @@ function scalePair(operands: readonly unknown[]): unknown { return null; } - return x === y ? { scale: x } : [{ scaleX: x }, { scaleY: y }]; + return [{ scaleX: x }, { scaleY: y }]; } // `translate(x)` is `translate(x, 0)` (css-transforms-1 §9)