Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 87 additions & 1 deletion src/__tests__/native/transform.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,10 @@ describe("scale", () => {
<View testID={testID} className="my-class" />,
).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 }],
});
});

Expand All @@ -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(<View testID={testID} className="my-class" />).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", () => {
Expand Down
8 changes: 4 additions & 4 deletions src/__tests__/vendor/tailwind/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ describe("Transforms - Scale", () => {
expect(await renderCurrentTest()).toStrictEqual({
props: {
style: {
transform: [{ scale: "0%" }],
transform: [{ scale: 0 }],
},
},
});
Expand All @@ -35,7 +35,7 @@ describe("Transforms - Scale", () => {
expect(await renderCurrentTest()).toStrictEqual({
props: {
style: {
transform: [{ scaleX: "50%" }, { scaleY: 1 }],
transform: [{ scaleX: 0.5 }, { scaleY: 1 }],
},
},
});
Expand All @@ -44,7 +44,7 @@ describe("Transforms - Scale", () => {
expect(await renderCurrentTest()).toStrictEqual({
props: {
style: {
transform: [{ scaleX: 1 }, { scaleY: "50%" }],
transform: [{ scaleX: 1 }, { scaleY: 0.5 }],
},
},
});
Expand All @@ -53,7 +53,7 @@ describe("Transforms - Scale", () => {
expect(await renderCurrentTest()).toStrictEqual({
props: {
style: {
transform: [{ scale: "50%" }],
transform: [{ scale: 0.5 }],
},
},
});
Expand Down
12 changes: 11 additions & 1 deletion src/compiler/declarations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
21 changes: 18 additions & 3 deletions src/native/styles/functions/transform-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down