From 3bc8c17b927f545828608d33404d06c5616c8a43 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 23 Jul 2026 17:10:38 +0300 Subject: [PATCH 1/2] fix(transforms): treat scale percentage values as unitless Tailwind v4 `scale-*` utilities crash React Native's transform validator with `Transform with key of "scale" must be a number: {"scale":"75%"}`. CSS `scale` accepts percentages (75% is a 0.75 factor) but RN's transform only accepts unitless numbers, and the value reached the validator as the string "75%" through two independent code paths -- both now fixed: 1. Compile-time (parseScaleValue): delegated unconditionally to parseLength, which formats a lightningcss { type: "percentage", value: 0.75 } back into the string "75%" (correct for layout props, wrong for transforms). Short-circuit percentages to return the already-normalised decimal. 2. Runtime (scale() resolver): accepted string args as valid because its type guard is `typeof x === "string" || "number"`. Tailwind v4 emits `scale: var(--tw-scale-x) var(--tw-scale-y)` whose vars resolve to "75%" at runtime, bypassing (1) entirely. Normalize "N%" -> N/100 before the guards; rotate keeps "Ndeg" and translate keeps "N%" (only scale is unitless). Adds edge-case tests (identity, zero, negative, >100%, fractional, per-axis, both var shapes, unitless-number regression) and corrects the vendor scale tests that had encoded the buggy "N%" output. Fixes #216 --- src/__tests__/native/transform.test.tsx | 80 ++++++++++++++++++- .../vendor/tailwind/transform.test.ts | 8 +- src/compiler/declarations.ts | 12 ++- .../styles/functions/transform-functions.ts | 21 ++++- 4 files changed, 112 insertions(+), 9 deletions(-) diff --git a/src/__tests__/native/transform.test.tsx b/src/__tests__/native/transform.test.tsx index b7a08fca..f7577214 100644 --- a/src/__tests__/native/transform.test.tsx +++ b/src/__tests__/native/transform.test.tsx @@ -55,8 +55,10 @@ describe("scale", () => { , ).getByTestId(testID); + // Scale is unitless in RN — a percentage var resolves to the fraction + // (2% → 0.02), never the string "2%" (which crashes the transform validator). expect(component.props.style).toStrictEqual({ - transform: [{ scaleX: "2%" }, { scaleY: "2%" }], + transform: [{ scaleX: 0.02 }, { scaleY: 0.02 }], }); }); @@ -75,6 +77,82 @@ describe("scale", () => { transform: [{ scaleX: 2 }, { scaleY: 3 }], }); }); + + // nativewind/react-native-css#216 — CSS `scale` is unitless in React Native + // (75% → 0.75), never the "75%" string that crashes the transform validator. + // Both code paths are covered: direct percentages (compile-time + // parseScaleValue) and var()-resolved percentages (runtime scale() resolver, + // the shape Tailwind v4's `scale-*` utilities actually emit). + const scaleStyle = (css: string): unknown => { + registerCSS(`.my-class { ${css} }`); + return render().getByTestId( + testID, + ).props.style; + }; + + test("percentage — single value applies to both axes", () => { + expect(scaleStyle("scale: 75%;")).toStrictEqual({ + transform: [{ scaleX: 0.75 }, { scaleY: 0.75 }], + }); + }); + + test("percentage — identity (100% → 1)", () => { + expect(scaleStyle("scale: 100%;")).toStrictEqual({ + transform: [{ scaleX: 1 }, { scaleY: 1 }], + }); + }); + + test("percentage — zero (0% → 0)", () => { + expect(scaleStyle("scale: 0%;")).toStrictEqual({ + transform: [{ scaleX: 0 }, { scaleY: 0 }], + }); + }); + + test("percentage — negative flips (-50% → -0.5)", () => { + expect(scaleStyle("scale: -50%;")).toStrictEqual({ + transform: [{ scaleX: -0.5 }, { scaleY: -0.5 }], + }); + }); + + test("percentage — greater than 100% (150% → 1.5)", () => { + expect(scaleStyle("scale: 150%;")).toStrictEqual({ + transform: [{ scaleX: 1.5 }, { scaleY: 1.5 }], + }); + }); + + test("percentage — fractional (12.5% → 0.125)", () => { + expect(scaleStyle("scale: 12.5%;")).toStrictEqual({ + transform: [{ scaleX: 0.125 }, { scaleY: 0.125 }], + }); + }); + + test("percentage — different per-axis values (75% 50%)", () => { + expect(scaleStyle("scale: 75% 50%;")).toStrictEqual({ + transform: [{ scaleX: 0.75 }, { scaleY: 0.5 }], + }); + }); + + test("percentage via var() — the Tailwind v4 scale-* shape", () => { + expect( + scaleStyle( + "--tw-scale-x: 75%; --tw-scale-y: 75%; scale: var(--tw-scale-x) var(--tw-scale-y);", + ), + ).toStrictEqual({ transform: [{ scaleX: 0.75 }, { scaleY: 0.75 }] }); + }); + + test("percentage via var() — different per-axis values", () => { + expect( + scaleStyle( + "--tw-scale-x: 50%; --tw-scale-y: 75%; scale: var(--tw-scale-x) var(--tw-scale-y);", + ), + ).toStrictEqual({ transform: [{ scaleX: 0.5 }, { scaleY: 0.75 }] }); + }); + + test("unitless number is unchanged — no regression (2 → 2)", () => { + expect(scaleStyle("scale: 2;")).toStrictEqual({ + transform: [{ scaleX: 2 }, { scaleY: 2 }], + }); + }); }); describe("transform", () => { diff --git a/src/__tests__/vendor/tailwind/transform.test.ts b/src/__tests__/vendor/tailwind/transform.test.ts index a056663f..2a7cf239 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: [{ scale: 0 }], }, }, }); @@ -35,7 +35,7 @@ describe("Transforms - Scale", () => { expect(await renderCurrentTest()).toStrictEqual({ props: { style: { - transform: [{ scaleX: "50%" }, { scaleY: 1 }], + transform: [{ scaleX: 0.5 }, { scaleY: 1 }], }, }, }); @@ -44,7 +44,7 @@ describe("Transforms - Scale", () => { expect(await renderCurrentTest()).toStrictEqual({ props: { style: { - transform: [{ scaleX: 1 }, { scaleY: "50%" }], + transform: [{ scaleX: 1 }, { scaleY: 0.5 }], }, }, }); @@ -53,7 +53,7 @@ describe("Transforms - Scale", () => { expect(await renderCurrentTest()).toStrictEqual({ props: { style: { - transform: [{ scale: "50%" }], + transform: [{ scale: 0.5 }], }, }, }); diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 13013642..6281dda2 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -842,7 +842,17 @@ export function parseScaleValue( return 0; } - return parseLength(translate[prop], builder); + const value = translate[prop]; + // Scale is unitless in React Native's transform API — CSS `scale: 75%` must + // become `{ scale: 0.75 }`, not `{ scale: "75%" }`. lightningcss parses "75%" + // as { type: "percentage", value: 0.75 }, so the decimal is already in + // `value`; parseLength would format it back to the string "75%" (correct for + // layout props, wrong for transforms). Short-circuit percentages here. + if (typeof value === "object" && value.type === "percentage") { + return round(value.value); + } + + return parseLength(value, builder); } function parseLetterSpacing( diff --git a/src/native/styles/functions/transform-functions.ts b/src/native/styles/functions/transform-functions.ts index c9826db6..8d3ad795 100644 --- a/src/native/styles/functions/transform-functions.ts +++ b/src/native/styles/functions/transform-functions.ts @@ -2,15 +2,30 @@ import { isStyleDescriptorArray } from "react-native-css/utilities"; import type { StyleFunctionResolver } from "../resolve"; +// CSS `scale` accepts unitless numbers AND percentage strings per CSSWG, but +// React Native's transform validator only accepts unitless numbers. Tailwind v4 +// emits `scale: var(--tw-scale-x) var(--tw-scale-y)`, whose vars resolve to +// strings like "100%" / "75%" at runtime — normalize "N%" → N/100 before the +// type guards. (rotate keeps "Ndeg", translate keeps "N%"; only scale is unitless.) +const normalizeScaleArg = (value: unknown): unknown => { + if (typeof value === "string" && value.endsWith("%")) { + const fraction = parseFloat(value); + if (!Number.isNaN(fraction)) { + return fraction / 100; + } + } + return value; +}; + export const scale: StyleFunctionResolver = (resolveValue, descriptor) => { const args = descriptor[2]; if (!isStyleDescriptorArray(args)) { - return { scale: resolveValue(args) }; + return { scale: normalizeScaleArg(resolveValue(args)) }; } - const x = resolveValue(args[0]); - const y = resolveValue(args[1]); + const x = normalizeScaleArg(resolveValue(args[0])); + const y = normalizeScaleArg(resolveValue(args[1])); const isXValid = typeof x === "string" || typeof x === "number"; const isYValid = typeof y === "string" || typeof y === "number"; From af0a40e77b22e8922ecf03ff638e11f3d1d07f54 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 23 Jul 2026 17:43:02 +0300 Subject: [PATCH 2/2] test(transforms): cover mixed number/percentage scale (both types coexist) --- src/__tests__/native/transform.test.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/__tests__/native/transform.test.tsx b/src/__tests__/native/transform.test.tsx index f7577214..34af0523 100644 --- a/src/__tests__/native/transform.test.tsx +++ b/src/__tests__/native/transform.test.tsx @@ -132,6 +132,14 @@ describe("scale", () => { }); }); + test("mixed number and percentage per-axis (2 50% → 2, 0.5)", () => { + // Both types coexist: the number stays a number, the percentage becomes + // its unitless fraction — nothing about number handling changes. + expect(scaleStyle("scale: 2 50%;")).toStrictEqual({ + transform: [{ scaleX: 2 }, { scaleY: 0.5 }], + }); + }); + test("percentage via var() — the Tailwind v4 scale-* shape", () => { expect( scaleStyle(