diff --git a/src/__tests__/native/transform.test.tsx b/src/__tests__/native/transform.test.tsx index b7a08fc..34af052 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,90 @@ 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("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( + "--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 a056663..2a7cf23 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 1301364..6281dda 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 c9826db..8d3ad79 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";