From 3bc8c17b927f545828608d33404d06c5616c8a43 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 23 Jul 2026 17:10:38 +0300 Subject: [PATCH 1/6] 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/6] 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( From b88b0e1ef8940ded0510715868f04a3f60a243c3 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 14 Aug 2026 23:58:35 +0300 Subject: [PATCH 3/6] fix(transforms): parse every scale component through one unitless parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React Native's transform API is unitless for scale and enforces it by crashing the screen: Invariant Violation: Transform with key of "scale" must be a number: {"scale":"75%"} Four separate places decided how a scale component is parsed, and only one of them collapsed a percentage. `transform: scaleX(75%)` and `transform: scaleY(75%)` emitted the string "75%" straight into the transform array, and a `var()` resolving to a percentage escaped the runtime the same way, because scaleX/scaleY are transform keys rather than resolver functions and never reached the scale() resolver. Compiler: `parseScaleComponent` is now the single parser, used by the `scale` longhand and by `scale()` / `scaleX()` / `scaleY()` in the `transform` shorthand. `transform: scale(75%)` only worked before because lightningcss's serialiser normalises that one function to a number between the compiler's two passes — nothing in this repo did it. Runtime: `normalizeScaleValue` and `scaleTransformKeys` move to `src/native/styles/scale-value.ts` and now guard both boundaries a percentage can leave from — the scale() resolver and the transform-key passthrough in `resolveValue`. The set is deliberately narrower than `transformKeys`: React Native accepts a percentage for translate and a `deg` string for rotate and skew, so those stay untouched. Also fixes `scale: none`, which returned 0 and collapsed the element. "Do not scale" is the identity transform, and `defaultValues.scale` is already 1. Tests are split by the plane that can observe them. The eight percentage-census tests asserted compiler output from a runtime file and move to `src/__tests__/compiler/transform-scale.test.ts`. The two tests claiming to cover the runtime resolver did not reach it — the compiler inlines a `var()` with a single definition — so the runtime census is rebuilt behind a competing definition, which is what a real Tailwind v4 stylesheet has once more than one `scale-*` utility is present. A decoy rule supplying a numeric scale sits beside the subject so the suite cannot pass by dropping the percentage declaration entirely. --- .../compiler/transform-scale.test.ts | 155 ++++++++++++ src/__tests__/native/transform.test.tsx | 220 ++++++++++++------ src/compiler/declarations.ts | 52 +++-- .../styles/functions/transform-functions.ts | 25 +- src/native/styles/resolve.ts | 12 +- src/native/styles/scale-value.ts | 37 +++ 6 files changed, 394 insertions(+), 107 deletions(-) create mode 100644 src/__tests__/compiler/transform-scale.test.ts create mode 100644 src/native/styles/scale-value.ts diff --git a/src/__tests__/compiler/transform-scale.test.ts b/src/__tests__/compiler/transform-scale.test.ts new file mode 100644 index 00000000..a307ac89 --- /dev/null +++ b/src/__tests__/compiler/transform-scale.test.ts @@ -0,0 +1,155 @@ +import { compileWithAutoDebug } from "react-native-css/jest"; + +/** + * React Native's transform validator rejects a non-numeric scale component: + * + * Invariant Violation: Transform with key of "scale" must be a number: {"scale":"75%"} + * + * CSS allows a percentage everywhere a scale component is accepted, so every + * emitter that can produce a scale component has to collapse it to the unitless + * fraction. These tests pin the compiler plane: what lands in the stylesheet IR. + * `src/__tests__/native/transform.test.tsx` pins the same census after the + * runtime has resolved it. + */ +const scaleKeys = new Set(["scale", "scaleX", "scaleY"]); + +type ScaleComponent = [key: string, value: unknown]; + +/** + * Walks the emitted IR and collects every `[{}, , value]` descriptor + * triple, wherever it is nested. Asserting on the collected components rather + * than on the exact IR shape pins the property that keeps React Native alive — + * no scale component is ever a string — instead of the nesting of the day. + */ +function collectScaleComponents( + node: unknown, + found: ScaleComponent[] = [], +): ScaleComponent[] { + if (typeof node !== "object" || node === null) { + return found; + } + + if (Array.isArray(node)) { + const [, key, value] = node; + + if ( + node.length === 3 && + typeof key === "string" && + scaleKeys.has(key) && + !Array.isArray(value) + ) { + found.push([key, value]); + } + } + + for (const child of Object.values(node)) { + collectScaleComponents(child, found); + } + + return found; +} + +function scaleComponentsFor(declarations: string): ScaleComponent[] { + const stylesheet = compileWithAutoDebug( + `.my-class { ${declarations} }`, + ).stylesheet(); + + const rule = stylesheet.s?.find(([name]) => name === "my-class")?.[1]; + + if (!rule) { + throw new Error(`No rule compiled for: ${declarations}`); + } + + return collectScaleComponents(rule); +} + +/** + * The input census. Every row is a CSS spelling that reaches a scale emitter, + * paired with the components the compiler must emit for it. + * + * `transform: scale3d(...)` is deliberately absent from this table — the + * compiler drops 3d transforms entirely, so it emits no scale component at all. + * `dropsEveryScaleComponent` below pins that instead. + */ +// prettier-ignore +const census: [declarations: string, components: ScaleComponent[]][] = [ + // `scale` longhand — a percentage is the fraction, never the "N%" string. + ["scale: 75%;", [["scaleX", 0.75], ["scaleY", 0.75]]], + ["scale: 0.75;", [["scaleX", 0.75], ["scaleY", 0.75]]], + ["scale: 100%;", [["scaleX", 1], ["scaleY", 1]]], + ["scale: 0%;", [["scaleX", 0], ["scaleY", 0]]], + ["scale: -50%;", [["scaleX", -0.5], ["scaleY", -0.5]]], + ["scale: 150%;", [["scaleX", 1.5], ["scaleY", 1.5]]], + ["scale: 12.5%;", [["scaleX", 0.125], ["scaleY", 0.125]]], + ["scale: 75% 50%;", [["scaleX", 0.75], ["scaleY", 0.5]]], + // Mixed: the number is untouched, the percentage becomes its fraction. + ["scale: 2 50%;", [["scaleX", 2], ["scaleY", 0.5]]], + ["scale: 2;", [["scaleX", 2], ["scaleY", 2]]], + // `scale: none` means "do not scale", which is identity — not zero. + ["scale: none;", [["scaleX", 1], ["scaleY", 1]]], + + // `transform` shorthand — a separate emitter per function, same requirement. + ["transform: scale(75%);", [["scaleX", 0.75], ["scaleY", 0.75]]], + ["transform: scale(75%, 50%);", [["scaleX", 0.75], ["scaleY", 0.5]]], + ["transform: scale(0.75);", [["scaleX", 0.75], ["scaleY", 0.75]]], + ["transform: scaleX(75%);", [["scaleX", 0.75]]], + ["transform: scaleY(75%);", [["scaleY", 0.75]]], + ["transform: scaleX(0.75);", [["scaleX", 0.75]]], + ["transform: scaleY(0.75);", [["scaleY", 0.75]]], + ["transform: scaleX(-50%);", [["scaleX", -0.5]]], + ["transform: scaleY(0%);", [["scaleY", 0]]], + ["transform: scaleX(100%);", [["scaleX", 1]]], + // Coexisting in one shorthand: neither emitter interferes with the other. + ["transform: scaleX(75%) scaleY(2);", [["scaleX", 0.75], ["scaleY", 2]]], + + // Supplied through a CSS variable. A variable the compiler can resolve to a + // single value is inlined here, so it lands on the same emitters above rather + // than reaching the runtime — the runtime half of this census lives in + // `src/__tests__/native/transform.test.tsx`, behind a variable that cannot be + // inlined. + ["--s: 75%; scale: var(--s);", [["scaleX", 0.75], ["scaleY", 0.75]]], + ["--sx: 75%; --sy: 50%; scale: var(--sx) var(--sy);", [["scaleX", 0.75], ["scaleY", 0.5]]], + ["--s: 75%; transform: scale(var(--s));", [["scaleX", 0.75], ["scaleY", 0.75]]], + ["--s: 75%; transform: scaleX(var(--s));", [["scaleX", 0.75]]], +]; + +test.each(census)("compiles %s", (declarations, components) => { + expect(scaleComponentsFor(declarations)).toStrictEqual(components); +}); + +test("the census covers every declaration that reaches a scale emitter", () => { + // A census that silently empties makes every `test.each` row vanish while the + // suite stays green. Pin its magnitude, and pin that it spans both emitters. + expect(census.length).toBeGreaterThan(0); + expect(census.some(([css]) => css.startsWith("scale:"))).toBe(true); + expect(census.some(([css]) => css.startsWith("transform:"))).toBe(true); +}); + +test.each(census)( + "every scale component compiled from %s is a number", + (declarations, components) => { + // The class-level invariant behind every row above, stated once: a string + // reaching React Native's transform validator is a hard render crash, so it + // is the TYPE that must hold, not just the value of the cases listed here. + const emitted = scaleComponentsFor(declarations); + + // An emitter that stops emitting makes the loop below iterate nothing and + // pass while asserting nothing. Pin the count first. + expect(emitted).toHaveLength(components.length); + + expect(emitted.map(([key, value]) => [key, typeof value])).toStrictEqual( + components.map(([key]) => [key, "number"]), + ); + }, +); + +test.each([ + "transform: scale3d(75%, 50%, 1);", + "transform: scale3d(0.75, 0.5, 1);", + "transform: scaleZ(75%);", +])("%s emits no scale component at all", (declarations) => { + // React Native has no 3d scale, so the compiler drops these. Pinned because + // "dropped" and "emitted as a string" are indistinguishable from a green + // suite that only ever asserts the rows it happens to list. + expect(scaleComponentsFor(declarations)).toStrictEqual([]); +}); diff --git a/src/__tests__/native/transform.test.tsx b/src/__tests__/native/transform.test.tsx index 34af0523..9d8c509e 100644 --- a/src/__tests__/native/transform.test.tsx +++ b/src/__tests__/native/transform.test.tsx @@ -78,87 +78,167 @@ describe("scale", () => { }); }); - // 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 }], - }); - }); + /** + * nativewind/react-native-css#216 — React Native's transform validator + * rejects a non-numeric scale component, and does it by crashing the screen: + * + * Invariant Violation: Transform with key of "scale" must be a number: {"scale":"75%"} + * + * The compiler collapses every percentage it can see at build time, and + * `src/__tests__/compiler/transform-scale.test.ts` pins that plane. These + * tests pin the other one: what a percentage becomes after the RUNTIME has + * resolved it, which is the only plane that can observe the crash above. + */ + describe("runtime resolver", () => { + const scaleKeys = new Set(["scale", "scaleX", "scaleY"]); + + type ScaleComponent = [key: string, value: unknown]; + + /** + * Every scale component in the rendered `transform` array, in order. + * Collecting them rather than asserting the whole style lets one census + * cover both shapes the runtime produces — `{ scale }` when both axes + * agree, `{ scaleX } { scaleY }` when they do not. + */ + const renderScaleComponents = ( + css: string, + className: string, + ): ScaleComponent[] => { + registerCSS(css); + + const style: unknown = render( + , + ).getByTestId(testID).props.style; + + const { transform } = (style ?? {}) as { transform?: unknown }; + + if (!Array.isArray(transform)) { + throw new Error(`No transform rendered for .${className}`); + } - test("percentage — zero (0% → 0)", () => { - expect(scaleStyle("scale: 0%;")).toStrictEqual({ - transform: [{ scaleX: 0 }, { scaleY: 0 }], + return transform.flatMap((entry: unknown) => + Object.entries(entry as Record).filter(([key]) => + scaleKeys.has(key), + ), + ); + }; + + /** + * Reaches the runtime resolver, which is harder than it looks: the compiler + * INLINES a `var()` it can resolve to a single value, so a fixture with one + * definition never leaves the compiler and silently tests the other plane. + * + * Tailwind v4 emits `--tw-scale-x` / `--tw-scale-y` in every `scale-*` + * utility, so a real stylesheet holds many competing definitions and none + * of them can be inlined — the percentage survives as a string until the + * runtime resolves it. `.competing-definition` reproduces that. + */ + const runtimeScaleComponents = (declarations: string): ScaleComponent[] => + renderScaleComponents( + `.competing-definition { --sx: 999%; --sy: 999%; } + .my-class { ${declarations} }`, + "my-class", + ); + + // prettier-ignore + const census: [declarations: string, components: ScaleComponent[]][] = [ + // `scale` longhand through the runtime scale() resolver. Equal axes + // collapse onto the single `scale` key — the key in the crash above. + ["--sx: 75%; --sy: 75%; scale: var(--sx) var(--sy);", [["scale", 0.75]]], + ["--sx: 100%; --sy: 100%; scale: var(--sx) var(--sy);", [["scale", 1]]], + ["--sx: 0%; --sy: 0%; scale: var(--sx) var(--sy);", [["scale", 0]]], + ["--sx: -50%; --sy: -50%; scale: var(--sx) var(--sy);", [["scale", -0.5]]], + ["--sx: 12.5%; --sy: 12.5%; scale: var(--sx) var(--sy);", [["scale", 0.125]]], + ["--sx: 75%; scale: var(--sx);", [["scale", 0.75]]], + // Differing axes stay split across both keys. + ["--sx: 75%; --sy: 50%; scale: var(--sx) var(--sy);", [["scaleX", 0.75], ["scaleY", 0.5]]], + // Mixed: the number is untouched, the percentage becomes its fraction. + ["--sx: 2; --sy: 50%; scale: var(--sx) var(--sy);", [["scaleX", 2], ["scaleY", 0.5]]], + // A unitless number through the same resolver is unchanged. + ["--sx: 2; --sy: 2; scale: var(--sx) var(--sy);", [["scale", 2]]], + + // `transform` shorthand — a different runtime branch to the one above, + // because scaleX/scaleY are not resolver functions but transform keys. + ["--sx: 75%; transform: scaleX(var(--sx));", [["scaleX", 0.75]]], + ["--sx: 75%; transform: scaleY(var(--sx));", [["scaleY", 0.75]]], + ["--sx: 75%; transform: scale(var(--sx));", [["scale", 0.75]]], + ["--sx: 75%; --sy: 50%; transform: scaleX(var(--sx)) scaleY(var(--sy));", + [["scaleX", 0.75], ["scaleY", 0.5]]], + ["--sx: 2; transform: scaleX(var(--sx));", [["scaleX", 2]]], + ]; + + test("the census reaches both runtime branches", () => { + // A census that empties makes every `test.each` row below vanish while + // the suite stays green. + expect(census.length).toBeGreaterThan(0); + expect(census.some(([css]) => css.includes("scale: var("))).toBe(true); + expect(census.some(([css]) => css.includes("transform:"))).toBe(true); }); - }); - test("percentage — negative flips (-50% → -0.5)", () => { - expect(scaleStyle("scale: -50%;")).toStrictEqual({ - transform: [{ scaleX: -0.5 }, { scaleY: -0.5 }], + test.each(census)("resolves %s", (declarations, components) => { + expect(runtimeScaleComponents(declarations)).toStrictEqual(components); }); - }); - test("percentage — greater than 100% (150% → 1.5)", () => { - expect(scaleStyle("scale: 150%;")).toStrictEqual({ - transform: [{ scaleX: 1.5 }, { scaleY: 1.5 }], - }); - }); + test.each(census)( + "every scale component resolved from %s is a number", + (declarations, components) => { + const resolved = runtimeScaleComponents(declarations); - test("percentage — fractional (12.5% → 0.125)", () => { - expect(scaleStyle("scale: 12.5%;")).toStrictEqual({ - transform: [{ scaleX: 0.125 }, { scaleY: 0.125 }], - }); - }); + // Pin the count first: a resolver that stops emitting would make the + // type comparison below hold over two empty lists. + expect(resolved).toHaveLength(components.length); - test("percentage — different per-axis values (75% 50%)", () => { - expect(scaleStyle("scale: 75% 50%;")).toStrictEqual({ - transform: [{ scaleX: 0.75 }, { scaleY: 0.5 }], - }); - }); + expect( + resolved.map(([key, value]) => [key, typeof value]), + ).toStrictEqual(components.map(([key]) => [key, "number"])); + }, + ); - 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("a percentage translate is left alone — only scale is coerced", () => { + // The counterpart to every row above. React Native accepts a percentage + // for translate, so coercing one would be a regression rather than a fix; + // this is what keeps the runtime coercion scoped to the scale keys. + registerCSS( + `.competing-definition { --sx: 999%; } + .my-class { --sx: 75%; transform: translateX(var(--sx)); }`, + ); - 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 }] }); - }); + const style: unknown = render( + , + ).getByTestId(testID).props.style; - 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 }] }); - }); + expect(style).toStrictEqual({ transform: [{ translateX: "75%" }] }); + }); - test("unitless number is unchanged — no regression (2 → 2)", () => { - expect(scaleStyle("scale: 2;")).toStrictEqual({ - transform: [{ scaleX: 2 }, { scaleY: 2 }], + test("Tailwind v4 `scale-75` resolves to a number, beside a numeric decoy", () => { + // The exact shape measured crashing on an Android handset: + // Invariant Violation: Transform with key of "scale" must be a number: {"scale":"75%"} + // + // `.decoy` matches the same element and supplies a NUMBER, so a build + // that skips the percentage path entirely — dropping the declaration + // rather than coercing it — still renders a transform whose every value + // is numeric. Asserting the full component list is what separates + // "coerced" from "silently discarded"; a bare type check cannot. + const components = renderScaleComponents( + `.decoy { scale: 3; } + .scale-50 { --tw-scale-x: 50%; --tw-scale-y: 50%; scale: var(--tw-scale-x) var(--tw-scale-y); } + .scale-75 { --tw-scale-x: 75%; --tw-scale-y: 75%; scale: var(--tw-scale-x) var(--tw-scale-y); }`, + "decoy scale-75", + ); + + expect(components).toStrictEqual([ + ["scaleX", 3], + ["scaleY", 3], + ["scale", 0.75], + ]); + + // Stated separately because it is the invariant the device cares about, + // and it must hold for the decoy's components too. + expect(components.map(([, value]) => typeof value)).toStrictEqual([ + "number", + "number", + "number", + ]); }); }); }); diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 6281dda2..fa2cb27d 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -742,13 +742,13 @@ function parseTransform( return [[{}, "rotateZ", parseAngle(t.value, builder)]]; case "scale": return [ - [{}, "scaleX", parseLength(t.value[0], builder)], - [{}, "scaleY", parseLength(t.value[1], builder)], + [{}, "scaleX", parseScaleComponent(t.value[0], builder)], + [{}, "scaleY", parseScaleComponent(t.value[1], builder)], ]; case "scaleX": - return [[{}, "scaleX", parseLength(t.value, builder)]]; + return [[{}, "scaleX", parseScaleComponent(t.value, builder)]]; case "scaleY": - return [[{}, "scaleY", parseLength(t.value, builder)]]; + return [[{}, "scaleY", parseScaleComponent(t.value, builder)]]; case "skew": return [ [{}, "skewX", parseAngle(t.value[0], builder)], @@ -833,26 +833,42 @@ function parseScale( ]); } +/** + * The one parser for a scale component, shared by every emitter that produces + * one: the `scale` longhand, and `scale()` / `scaleX()` / `scaleY()` inside the + * `transform` shorthand. + * + * React Native's transform API is unitless, and enforces it by crashing the + * screen rather than ignoring the value: + * + * Invariant Violation: Transform with key of "scale" must be a number: {"scale":"75%"} + * + * lightningcss already holds a percentage as its fraction + * (`75%` → `{ type: "percentage", value: 0.75 }`), so the number needed here is + * the one it parsed. `parseLength` would serialise it back to the string `75%`, + * which is correct for a layout property and fatal for a transform. + */ +export function parseScaleComponent( + value: NumberOrPercentage, + builder: StylesheetBuilder, +): StyleDescriptor { + return value.type === "percentage" + ? round(value.value) + : parseLength(value, builder); +} + export function parseScaleValue( - translate: Scale, + scale: Scale, prop: keyof Extract, builder: StylesheetBuilder, ): StyleDescriptor { - if (translate === "none") { - return 0; + // `scale: none` means "do not scale", and the transform that does not scale + // is the identity one. Zero would collapse the element to nothing. + if (scale === "none") { + return 1; } - 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); + return parseScaleComponent(scale[prop], builder); } function parseLetterSpacing( diff --git a/src/native/styles/functions/transform-functions.ts b/src/native/styles/functions/transform-functions.ts index 8d3ad795..ab1fd438 100644 --- a/src/native/styles/functions/transform-functions.ts +++ b/src/native/styles/functions/transform-functions.ts @@ -1,31 +1,20 @@ import { isStyleDescriptorArray } from "react-native-css/utilities"; import type { StyleFunctionResolver } from "../resolve"; +import { normalizeScaleValue } from "../scale-value"; -// 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; -}; - +// A percentage is coerced before the type guards below, so an axis that +// resolved to "75%" is a valid numeric component rather than a string that +// reaches React Native's transform validator and crashes the screen. export const scale: StyleFunctionResolver = (resolveValue, descriptor) => { const args = descriptor[2]; if (!isStyleDescriptorArray(args)) { - return { scale: normalizeScaleArg(resolveValue(args)) }; + return { scale: normalizeScaleValue(resolveValue(args)) }; } - const x = normalizeScaleArg(resolveValue(args[0])); - const y = normalizeScaleArg(resolveValue(args[1])); + const x = normalizeScaleValue(resolveValue(args[0])); + const y = normalizeScaleValue(resolveValue(args[1])); const isXValid = typeof x === "string" || typeof x === "number"; const isYValid = typeof y === "string" || typeof y === "number"; diff --git a/src/native/styles/resolve.ts b/src/native/styles/resolve.ts index 8465e9b1..3cf4d3c7 100644 --- a/src/native/styles/resolve.ts +++ b/src/native/styles/resolve.ts @@ -12,6 +12,7 @@ import type { calculateProps } from "./calculate-props"; import { transformKeys } from "./defaults"; import * as functions from "./functions"; import { lineHeight } from "./line-height"; +import { normalizeScaleValue, scaleTransformKeys } from "./scale-value"; import * as shorthands from "./shorthands"; import { em, rem, vh, vw } from "./units"; import { varResolver } from "./variables"; @@ -122,7 +123,16 @@ export function resolveValue( ) as StyleDescriptor; } else if (transformKeys.has(name)) { // translate, rotate, scale, etc. - return { [name]: simpleResolve(value[2], castToArray) }; + // scaleX/scaleY arrive here rather than through a resolver function, so + // this is the second boundary a percentage can escape from — React + // Native rejects a non-numeric scale component by crashing the screen. + const resolved = simpleResolve(value[2], castToArray); + + return { + [name]: scaleTransformKeys.has(name) + ? normalizeScaleValue(resolved) + : resolved, + }; } else { let args = simpleResolve(value[2], castToArray); diff --git a/src/native/styles/scale-value.ts b/src/native/styles/scale-value.ts new file mode 100644 index 00000000..e3cee0a2 --- /dev/null +++ b/src/native/styles/scale-value.ts @@ -0,0 +1,37 @@ +/** + * React Native's transform API is unitless for scale, and enforces it by + * crashing the screen rather than ignoring the value: + * + * Invariant Violation: Transform with key of "scale" must be a number: {"scale":"75%"} + * + * The compiler collapses every percentage it can see (`parseScaleComponent` in + * `src/compiler/declarations.ts`), but it cannot see through a `var()` it is + * unable to inline. Tailwind v4 emits `--tw-scale-x` / `--tw-scale-y` from every + * `scale-*` utility, so a real stylesheet holds many competing definitions and + * none of them are inlinable — the percentage stays a string until the runtime + * resolves it, which is the boundary these two exports guard. + */ + +/** + * The transform components React Native requires to be unitless numbers. + * + * Deliberately narrower than `transformKeys`: React Native accepts a percentage + * string for `translateX` / `translateY` and a `deg` string for rotate and + * skew, so coercing those would be a regression rather than a fix. + */ +export const scaleTransformKeys = new Set(["scale", "scaleX", "scaleY"]); + +/** + * Turns a resolved `"N%"` into the unitless fraction React Native requires. + * Anything else — a number, a keyword, an unparseable string — is returned + * untouched, so this is safe to apply to any resolved transform value. + */ +export function normalizeScaleValue(value: unknown): unknown { + if (typeof value !== "string" || !value.endsWith("%")) { + return value; + } + + const percentage = Number.parseFloat(value); + + return Number.isNaN(percentage) ? value : percentage / 100; +} From eaec18d36d9f180e817dda91d30614077747af90 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 21:47:07 +0300 Subject: [PATCH 4/6] test(transforms): assert the rendered scale value, and fix the keyword only the compiler collapsed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every scale fix on this branch was pinned on one plane. The compiler tests read the emitted IR; nothing read back the object React Native is handed, which is the only place the crash lives: Invariant Violation: Transform with key of "scale" must be a number Measured by reverting each fix in turn against the transform suites: the three compile-time fixes reddened 40 compiler assertions and zero native ones, and the runtime fixes reddened zero compiler ones. Reverting all four together now reddens 121, of which 77 are native. - native/transform.test.tsx grows a census of every value the compiler can inline: the percentage family across the `scale` longhand and the `scale()` / `scaleX()` / `scaleY()` shorthand with one and two operands, the unitless spellings that were always correct and have to stay that way, and `scale: none`. Two rows assert the whole style rather than the collected components, because that object is literally what the transform validator receives. - The runtime census gains the `none` keyword, the two-operand `scale(x, y)` shorthand, and a negative control widened to the `translate` longhand. - scale-value.ts collapses `none` to the identity scale. The compiler half of that already landed; behind a `var()` it cannot inline, the runtime still produced `{ scale: "none" }` — the same crash as `{ scale: "75%" }`, on the same key. Four native assertions reproduce it and go green with the fix. - compiler/transform-scale.test.ts pins that a percentage behind a competing `var()` is deferred to the runtime unresolved, which is what makes the runtime guard load-bearing rather than redundant, and that translate, rotate and skew keep their units — the coercion is scoped to the three scale keys on purpose. - vendor/tailwind covers `scale-150` and `scale-none` through real Tailwind v4 output. --- .../compiler/transform-scale.test.ts | 123 ++++++++-- src/__tests__/native/transform.test.tsx | 229 ++++++++++++++---- .../vendor/tailwind/transform.test.ts | 21 ++ src/native/styles/scale-value.ts | 37 ++- 4 files changed, 335 insertions(+), 75 deletions(-) diff --git a/src/__tests__/compiler/transform-scale.test.ts b/src/__tests__/compiler/transform-scale.test.ts index a307ac89..83bc9428 100644 --- a/src/__tests__/compiler/transform-scale.test.ts +++ b/src/__tests__/compiler/transform-scale.test.ts @@ -13,29 +13,37 @@ import { compileWithAutoDebug } from "react-native-css/jest"; */ const scaleKeys = new Set(["scale", "scaleX", "scaleY"]); -type ScaleComponent = [key: string, value: unknown]; +type TransformComponent = [key: string, value: unknown]; /** - * Walks the emitted IR and collects every `[{}, , value]` descriptor + * Walks the emitted IR and collects every `[{}, , value]` descriptor * triple, wherever it is nested. Asserting on the collected components rather * than on the exact IR shape pins the property that keeps React Native alive — * no scale component is ever a string — instead of the nesting of the day. + * + * A descriptor triple leads with the modifier object, which is what separates it + * from the `[descriptor, propName, specificity]` entries the IR wraps it in; + * without that check a deferred `[…, "scale", 1]` entry reads as a component + * whose value is its specificity. */ -function collectScaleComponents( +function collectComponents( + wanted: ReadonlySet, node: unknown, - found: ScaleComponent[] = [], -): ScaleComponent[] { + found: TransformComponent[] = [], +): TransformComponent[] { if (typeof node !== "object" || node === null) { return found; } if (Array.isArray(node)) { - const [, key, value] = node; + const [modifier, key, value] = node; if ( node.length === 3 && + typeof modifier === "object" && + !Array.isArray(modifier) && typeof key === "string" && - scaleKeys.has(key) && + wanted.has(key) && !Array.isArray(value) ) { found.push([key, value]); @@ -43,24 +51,30 @@ function collectScaleComponents( } for (const child of Object.values(node)) { - collectScaleComponents(child, found); + collectComponents(wanted, child, found); } return found; } -function scaleComponentsFor(declarations: string): ScaleComponent[] { - const stylesheet = compileWithAutoDebug( - `.my-class { ${declarations} }`, - ).stylesheet(); - - const rule = stylesheet.s?.find(([name]) => name === "my-class")?.[1]; +/** The compiled declaration blocks of one class, in specificity order. */ +function ruleFor( + css: string, + className = "my-class", +): { v?: unknown; d?: unknown }[] { + const rule = compileWithAutoDebug(css) + .stylesheet() + .s?.find(([name]) => name === className)?.[1]; if (!rule) { - throw new Error(`No rule compiled for: ${declarations}`); + throw new Error(`No rule compiled for .${className} in: ${css}`); } - return collectScaleComponents(rule); + return rule; +} + +function scaleComponentsFor(declarations: string): TransformComponent[] { + return collectComponents(scaleKeys, ruleFor(`.my-class { ${declarations} }`)); } /** @@ -72,7 +86,7 @@ function scaleComponentsFor(declarations: string): ScaleComponent[] { * `dropsEveryScaleComponent` below pins that instead. */ // prettier-ignore -const census: [declarations: string, components: ScaleComponent[]][] = [ +const census: [declarations: string, components: TransformComponent[]][] = [ // `scale` longhand — a percentage is the fraction, never the "N%" string. ["scale: 75%;", [["scaleX", 0.75], ["scaleY", 0.75]]], ["scale: 0.75;", [["scaleX", 0.75], ["scaleY", 0.75]]], @@ -85,6 +99,8 @@ const census: [declarations: string, components: ScaleComponent[]][] = [ // Mixed: the number is untouched, the percentage becomes its fraction. ["scale: 2 50%;", [["scaleX", 2], ["scaleY", 0.5]]], ["scale: 2;", [["scaleX", 2], ["scaleY", 2]]], + // A third operand is the z axis, which React Native has no key for. + ["scale: 75% 50% 2;", [["scaleX", 0.75], ["scaleY", 0.5]]], // `scale: none` means "do not scale", which is identity — not zero. ["scale: none;", [["scaleX", 1], ["scaleY", 1]]], @@ -148,8 +164,75 @@ test.each([ "transform: scale3d(0.75, 0.5, 1);", "transform: scaleZ(75%);", ])("%s emits no scale component at all", (declarations) => { - // React Native has no 3d scale, so the compiler drops these. Pinned because - // "dropped" and "emitted as a string" are indistinguishable from a green - // suite that only ever asserts the rows it happens to list. + // React Native has no z axis, so no scale component is emitted for these. + // Pinned because "emitted nothing" and "emitted a string" are + // indistinguishable from a green suite that only asserts the rows it lists. expect(scaleComponentsFor(declarations)).toStrictEqual([]); }); + +/** + * The compiler is not the last boundary, and this is the proof. A `var()` with + * one visible definition is INLINED, which is why every variable row in the + * census above lands on a compile-time emitter — but a real Tailwind v4 + * stylesheet defines `--tw-scale-x` in every `scale-*` utility, so the compiler + * sees competing definitions and cannot resolve any of them. + * + * What it emits then is the percentage STRING plus a `var()` reference, and the + * number React Native receives is decided entirely by the runtime resolver. + * `src/__tests__/native/transform.test.tsx` is the plane that can observe that + * value; this test states why that plane has to exist. + */ +test("a percentage behind a competing var() is deferred to the runtime unresolved", () => { + const declarations = `--sx: 75%; --sy: 75%; scale: var(--sx) var(--sy);`; + + const deferred = ruleFor( + `.decoy { --sx: 999%; --sy: 999%; } + .my-class { ${declarations} }`, + ); + + // The variables survive as the raw percentage strings... + expect(deferred.map((block) => block.v)).toStrictEqual([ + [ + ["sx", "75%"], + ["sy", "75%"], + ], + ]); + + // ...and nothing in the rule is the fraction, so no compile-time emitter ran. + expect(collectComponents(scaleKeys, deferred)).toStrictEqual([]); + expect(JSON.stringify(deferred)).not.toContain("0.75"); + + // The same declarations WITHOUT a competing definition are inlined, which is + // what makes the assertions above a discrimination rather than a tautology: + // if this contrast ever collapses, one of these two halves fails. + expect( + collectComponents(scaleKeys, ruleFor(`.my-class { ${declarations} }`)), + ).toStrictEqual([ + ["scaleX", 0.75], + ["scaleY", 0.75], + ]); +}); + +/** + * The counterpart to the whole census: the coercion is scoped to the scale + * components and must stay there. React Native REQUIRES a unit on these — a + * percentage translate and a `deg` rotation are correct, and collapsing them to + * a bare number would be a regression dressed as consistency. + */ +// prettier-ignore +const unitsAreKept: [declarations: string, components: TransformComponent[]][] = [ + ["transform: translateX(75%);", [["translateX", "75%"]]], + ["transform: translateY(75%);", [["translateY", "75%"]]], + ["translate: 10%;", [["translateX", "10%"], ["translateY", 0]]], + ["transform: rotate(45deg);", [["rotate", "45deg"]]], + ["transform: skewX(45deg);", [["skewX", "45deg"]]], + ["transform: skewY(45deg);", [["skewY", "45deg"]]], +]; + +test.each(unitsAreKept)("%s keeps its unit", (declarations, components) => { + const keys = new Set(components.map(([key]) => key)); + + expect( + collectComponents(keys, ruleFor(`.my-class { ${declarations} }`)), + ).toStrictEqual(components); +}); diff --git a/src/__tests__/native/transform.test.tsx b/src/__tests__/native/transform.test.tsx index 9d8c509e..65aac0b9 100644 --- a/src/__tests__/native/transform.test.tsx +++ b/src/__tests__/native/transform.test.tsx @@ -84,45 +84,160 @@ describe("scale", () => { * * Invariant Violation: Transform with key of "scale" must be a number: {"scale":"75%"} * - * The compiler collapses every percentage it can see at build time, and - * `src/__tests__/compiler/transform-scale.test.ts` pins that plane. These - * tests pin the other one: what a percentage becomes after the RUNTIME has - * resolved it, which is the only plane that can observe the crash above. + * A percentage reaches a scale component down two independent paths, and each + * needs its own guard: the COMPILER collapses every one it can see at build + * time, and the RUNTIME collapses the ones hidden behind a `var()` it could + * not inline. `src/__tests__/compiler/transform-scale.test.ts` pins what the + * first emits into the stylesheet; the two censuses below pin what React + * Native is actually handed, which is the only plane the crash lives on. */ - describe("runtime resolver", () => { - const scaleKeys = new Set(["scale", "scaleX", "scaleY"]); + const scaleKeys = new Set(["scale", "scaleX", "scaleY"]); - type ScaleComponent = [key: string, value: unknown]; + type ScaleComponent = [key: string, value: unknown]; - /** - * Every scale component in the rendered `transform` array, in order. - * Collecting them rather than asserting the whole style lets one census - * cover both shapes the runtime produces — `{ scale }` when both axes - * agree, `{ scaleX } { scaleY }` when they do not. - */ - const renderScaleComponents = ( - css: string, - className: string, - ): ScaleComponent[] => { - registerCSS(css); + /** + * Every scale component in the rendered `transform` array, in order. + * Collecting them rather than asserting the whole style lets one census cover + * every shape the two planes produce — `{ scale }` when both axes agree, + * `{ scaleX } { scaleY }` when they do not, and the nested entry a runtime + * `scale(x, y)` still produces (a separate, pre-existing shape defect: it + * reproduces with plain numbers and is not this fix's to make flat). + */ + const collectScaleComponents = (entry: unknown): ScaleComponent[] => + Array.isArray(entry) + ? entry.flatMap((nested: unknown) => collectScaleComponents(nested)) + : Object.entries(entry as Record).filter(([key]) => + scaleKeys.has(key), + ); + + const renderStyle = (css: string, className: string): unknown => { + registerCSS(css); + + return render().getByTestId( + testID, + ).props.style; + }; + + const renderScaleComponents = ( + css: string, + className: string, + ): ScaleComponent[] => { + const { transform } = (renderStyle(css, className) ?? {}) as { + transform?: unknown; + }; - const style: unknown = render( - , - ).getByTestId(testID).props.style; + if (!Array.isArray(transform)) { + throw new Error(`No transform rendered for .${className}`); + } - const { transform } = (style ?? {}) as { transform?: unknown }; + return transform.flatMap((entry: unknown) => collectScaleComponents(entry)); + }; - if (!Array.isArray(transform)) { - throw new Error(`No transform rendered for .${className}`); - } + /** + * Every row here is a value the compiler CAN see, so the stylesheet already + * holds the number — but the assertion is read off the rendered component, + * which is the only place the crash lives. The compiler test file asserts + * the same census one plane earlier, against the IR. + * + * The two guards are LAYERED on this path, not alternatives: `resolve.ts` + * normalises a `scaleX` descriptor whether its value came from a `var()` or + * from a literal, so it repairs a compiler-emitted `"75%"` as well. That is + * why these rows pin the composite rather than the compiler half — with both + * guards reverted, `scale: 75%` renders `{ scaleX: "75%", scaleY: "75%" }` + * and every row below goes red. The one row the compiler half owns alone is + * `scale: none`: a wrong `0` is a number the runtime has no reason to touch. + */ + describe("inlined by the compiler", () => { + const inlinedScaleComponents = (declarations: string): ScaleComponent[] => + renderScaleComponents(`.my-class { ${declarations} }`, "my-class"); - return transform.flatMap((entry: unknown) => - Object.entries(entry as Record).filter(([key]) => - scaleKeys.has(key), - ), - ); - }; + // prettier-ignore + const census: [declarations: string, components: ScaleComponent[]][] = [ + // `scale` longhand. + ["scale: 75%;", [["scaleX", 0.75], ["scaleY", 0.75]]], + ["scale: 100%;", [["scaleX", 1], ["scaleY", 1]]], + ["scale: 0%;", [["scaleX", 0], ["scaleY", 0]]], + ["scale: -50%;", [["scaleX", -0.5], ["scaleY", -0.5]]], + ["scale: 150%;", [["scaleX", 1.5], ["scaleY", 1.5]]], + ["scale: 12.5%;", [["scaleX", 0.125], ["scaleY", 0.125]]], + ["scale: 75% 50%;", [["scaleX", 0.75], ["scaleY", 0.5]]], + ["scale: 2 50%;", [["scaleX", 2], ["scaleY", 0.5]]], + ["scale: 75% 50% 2;", [["scaleX", 0.75], ["scaleY", 0.5]]], + // The negative controls: a unitless scale was always correct, and has to + // stay that way — the fix must coerce percentages, not every value. + ["scale: 0.75;", [["scaleX", 0.75], ["scaleY", 0.75]]], + ["scale: 2;", [["scaleX", 2], ["scaleY", 2]]], + // `scale: none` is the identity transform. Zero would render nothing. + ["scale: none;", [["scaleX", 1], ["scaleY", 1]]], + + // `transform` shorthand — a separate compile-time emitter per function. + ["transform: scale(75%);", [["scaleX", 0.75], ["scaleY", 0.75]]], + ["transform: scale(75%, 50%);", [["scaleX", 0.75], ["scaleY", 0.5]]], + ["transform: scaleX(75%);", [["scaleX", 0.75]]], + ["transform: scaleY(75%);", [["scaleY", 0.75]]], + ["transform: scaleX(-50%);", [["scaleX", -0.5]]], + ["transform: scaleY(0%);", [["scaleY", 0]]], + ["transform: scaleX(100%);", [["scaleX", 1]]], + ["transform: scaleX(75%) scaleY(2);", [["scaleX", 0.75], ["scaleY", 2]]], + ["transform: scale(0.75);", [["scaleX", 0.75], ["scaleY", 0.75]]], + ["transform: scaleX(0.75);", [["scaleX", 0.75]]], + ["transform: scaleY(0.75);", [["scaleY", 0.75]]], + + // A `var()` with one visible definition is inlined, so these are compiled + // rather than resolved. The same spellings behind a competing definition + // are the runtime census below. + ["--s: 75%; scale: var(--s);", [["scaleX", 0.75], ["scaleY", 0.75]]], + ["--sx: 75%; --sy: 50%; scale: var(--sx) var(--sy);", [["scaleX", 0.75], ["scaleY", 0.5]]], + ["--s: 75%; transform: scale(var(--s));", [["scaleX", 0.75], ["scaleY", 0.75]]], + ["--s: 75%; transform: scaleX(var(--s));", [["scaleX", 0.75]]], + ]; + + test("the census covers both compile-time emitters", () => { + // A census that empties makes every row below vanish while staying green. + expect(census.length).toBeGreaterThan(0); + expect(census.some(([css]) => css.startsWith("scale:"))).toBe(true); + expect(census.some(([css]) => css.startsWith("transform:"))).toBe(true); + }); + + test.each(census)("renders %s", (declarations, components) => { + expect(inlinedScaleComponents(declarations)).toStrictEqual(components); + }); + + test.each(census)( + "every scale component rendered from %s is a number", + (declarations, components) => { + const rendered = inlinedScaleComponents(declarations); + + // Pin the count first: an emitter that stops emitting would make the + // type comparison below hold over two empty lists. + expect(rendered).toHaveLength(components.length); + + expect( + rendered.map(([key, value]) => [key, typeof value]), + ).toStrictEqual(components.map(([key]) => [key, "number"])); + }, + ); + test("`scale: 75%` hands React Native the whole style, unitless", () => { + // The census asserts components; this asserts the entire prop, because + // the object below is literally what React Native's transform validator + // is handed — the shape that crashed a handset with + // Invariant Violation: Transform with key of "scale" must be a number + expect( + renderStyle(`.my-class { scale: 75%; }`, "my-class"), + ).toStrictEqual({ transform: [{ scaleX: 0.75 }, { scaleY: 0.75 }] }); + }); + + test("`scale: none` is the identity transform, not a collapsed element", () => { + // Zero here is not a crash — it is worse to find, because the element + // renders at zero size and nothing reports an error. + expect( + renderStyle(`.my-class { scale: none; }`, "my-class"), + ).toStrictEqual({ transform: [{ scaleX: 1 }, { scaleY: 1 }] }); + }); + }); + + describe("runtime resolver", () => { /** * Reaches the runtime resolver, which is harder than it looks: the compiler * INLINES a `var()` it can resolve to a single value, so a fixture with one @@ -156,6 +271,11 @@ describe("scale", () => { ["--sx: 2; --sy: 50%; scale: var(--sx) var(--sy);", [["scaleX", 2], ["scaleY", 0.5]]], // A unitless number through the same resolver is unchanged. ["--sx: 2; --sy: 2; scale: var(--sx) var(--sy);", [["scale", 2]]], + // `none` is the other keyword that reaches a scale component, and the + // runtime has to agree with the compiler that it means identity — a + // `{ scale: "none" }` is the same crash as a `{ scale: "75%" }`. + ["--sx: none; scale: var(--sx);", [["scale", 1]]], + ["--sx: none; --sy: 2; scale: var(--sx) var(--sy);", [["scaleX", 1], ["scaleY", 2]]], // `transform` shorthand — a different runtime branch to the one above, // because scaleX/scaleY are not resolver functions but transform keys. @@ -165,6 +285,15 @@ describe("scale", () => { ["--sx: 75%; --sy: 50%; transform: scaleX(var(--sx)) scaleY(var(--sy));", [["scaleX", 0.75], ["scaleY", 0.5]]], ["--sx: 2; transform: scaleX(var(--sx));", [["scaleX", 2]]], + ["--sx: none; transform: scaleX(var(--sx));", [["scaleX", 1]]], + ["--sx: none; transform: scale(var(--sx));", [["scale", 1]]], + // The two-operand shorthand: one resolver call, two components. + ["--sx: 75%; --sy: 50%; transform: scale(var(--sx), var(--sy));", + [["scaleX", 0.75], ["scaleY", 0.5]]], + ["--sx: 75%; --sy: 75%; transform: scale(var(--sx), var(--sy));", + [["scale", 0.75]]], + ["--sx: 2; --sy: 3; transform: scale(var(--sx), var(--sy));", + [["scaleX", 2], ["scaleY", 3]]], ]; test("the census reaches both runtime branches", () => { @@ -194,20 +323,30 @@ describe("scale", () => { }, ); - test("a percentage translate is left alone — only scale is coerced", () => { - // The counterpart to every row above. React Native accepts a percentage - // for translate, so coercing one would be a regression rather than a fix; - // this is what keeps the runtime coercion scoped to the scale keys. - registerCSS( - `.competing-definition { --sx: 999%; } - .my-class { --sx: 75%; transform: translateX(var(--sx)); }`, - ); - - const style: unknown = render( - , - ).getByTestId(testID).props.style; - - expect(style).toStrictEqual({ transform: [{ translateX: "75%" }] }); + // The counterpart to every row above. React Native REQUIRES the unit on + // these, so coercing them would be a regression dressed as consistency; + // this is what keeps the runtime coercion scoped to the scale keys. + test.each([ + [ + "transform: translateX(var(--sx));", + { transform: [{ translateX: "75%" }] }, + ], + [ + "transform: translateY(var(--sx));", + { transform: [{ translateY: "75%" }] }, + ], + [ + "translate: var(--sx) var(--sy);", + { transform: [{ translateX: "75%" }, { translateY: "50%" }] }, + ], + ])("%s keeps its percentage", (declarations, expected) => { + expect( + renderStyle( + `.competing-definition { --sx: 999%; --sy: 999%; } + .my-class { --sx: 75%; --sy: 50%; ${declarations} }`, + "my-class", + ), + ).toStrictEqual(expected); }); test("Tailwind v4 `scale-75` resolves to a number, beside a numeric decoy", () => { diff --git a/src/__tests__/vendor/tailwind/transform.test.ts b/src/__tests__/vendor/tailwind/transform.test.ts index 2a7cf239..7e54db2b 100644 --- a/src/__tests__/vendor/tailwind/transform.test.ts +++ b/src/__tests__/vendor/tailwind/transform.test.ts @@ -58,6 +58,27 @@ describe("Transforms - Scale", () => { }, }); }); + test("scale-150", async () => { + expect(await renderCurrentTest()).toStrictEqual({ + props: { + style: { + transform: [{ scale: 1.5 }], + }, + }, + }); + }); + test("scale-none", async () => { + // The identity transform, through Tailwind's own output rather than a + // hand-written declaration — `none` is a keyword and reaches the same + // transform array a percentage does. + expect(await renderCurrentTest()).toStrictEqual({ + props: { + style: { + transform: [{ scaleX: 1 }, { scaleY: 1 }], + }, + }, + }); + }); }); describe("Transforms - Rotate", () => { diff --git a/src/native/styles/scale-value.ts b/src/native/styles/scale-value.ts index e3cee0a2..75f9fa02 100644 --- a/src/native/styles/scale-value.ts +++ b/src/native/styles/scale-value.ts @@ -4,12 +4,13 @@ * * Invariant Violation: Transform with key of "scale" must be a number: {"scale":"75%"} * - * The compiler collapses every percentage it can see (`parseScaleComponent` in - * `src/compiler/declarations.ts`), but it cannot see through a `var()` it is - * unable to inline. Tailwind v4 emits `--tw-scale-x` / `--tw-scale-y` from every - * `scale-*` utility, so a real stylesheet holds many competing definitions and - * none of them are inlinable — the percentage stays a string until the runtime - * resolves it, which is the boundary these two exports guard. + * The compiler collapses every scale value it can see (`parseScaleComponent` + * and `parseScaleValue` in `src/compiler/declarations.ts`), but it cannot see + * through a `var()` it is unable to inline. Tailwind v4 emits `--tw-scale-x` / + * `--tw-scale-y` from every `scale-*` utility, so a real stylesheet holds many + * competing definitions and none of them are inlinable — the value stays a + * string until the runtime resolves it, which is the boundary these two exports + * guard. */ /** @@ -22,12 +23,28 @@ export const scaleTransformKeys = new Set(["scale", "scaleX", "scaleY"]); /** - * Turns a resolved `"N%"` into the unitless fraction React Native requires. - * Anything else — a number, a keyword, an unparseable string — is returned - * untouched, so this is safe to apply to any resolved transform value. + * The scale that does not scale. `scale: none` is the CSS spelling of the + * identity transform, so the number it collapses to is 1 — the same value + * `parseScaleValue` emits for it when the compiler can see it. + */ +const IDENTITY_SCALE = 1; + +/** + * Turns a resolved scale component into the unitless number React Native + * requires: `"N%"` becomes its fraction and `"none"` becomes the identity. + * Anything else — a number, an unparseable string — is returned untouched, so + * this is safe to apply to any resolved scale value. */ export function normalizeScaleValue(value: unknown): unknown { - if (typeof value !== "string" || !value.endsWith("%")) { + if (typeof value !== "string") { + return value; + } + + if (value === "none") { + return IDENTITY_SCALE; + } + + if (!value.endsWith("%")) { return value; } From 613d3fc7eb792eb872430e87cb5bcfb08ea4cf91 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 23:20:19 +0300 Subject: [PATCH 5/6] test(transforms): close the three census gaps a reverted fix slips through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every scale fix on this branch is pinned, but three of those pins could not observe the thing they name. Each gap below is measured by reverting or widening the code under it and counting what goes red. - `scaleTransformKeys` had no guard against being WIDENED. Adding `rotate` to it reddens nothing, because the function resolver shadows that branch — but `skewX` / `skewY` sit in the same structural position as `scaleX` / `scaleY`, so they are the reachable ones. `{ skewX: "50%" }` would become `{ skewX: 0.5 }`, and React Native throws `Transform with key of "skewX" must be a string`: the same fatal pass, a different invariant. Two rows join the `keeps its percentage` census, and widening the set to the skew keys reddens exactly those two. - No census value could observe a lost `round()`. lightningcss stores a percentage as an f32, so the repair `round()` performs is visible only on a fraction that is not f32-exact — and every value in the three new censuses was exact, which left the one pre-existing `2%` fixture as the whole of that coverage. `110%` (the value issue #216 was reported with, and absent from the vendor suite as well) and `2%` join the compiler and native censuses, alongside Tailwind's own `scale-110`. Dropping `round()` goes from 1 red to 7. - Two census rows do not discriminate, and now say so. lightningcss pre-normalises a literal `scale(75%)` / `scale(75%, 50%)` between the compiler's two passes, so both emit byte-identical IR with `parseTransform`'s `case "scale"` reverted. The row that reaches the case is `--s: 75%; transform: scale(var(--s));`, and reverting the case reddens those two assertions and nothing else. `parseScaleComponent` drops its `export` — nothing outside `declarations.ts` imports it. `scaleTransformKeys`'s comment now states why `scale` sits in a set the `resolve.ts` caller can never reach it through: it mirrors React Native's own `scale`/`scaleX`/`scaleY` case group and is produced by the other caller, so it is defence in depth rather than a live key on that path. Also pins the one place the two planes disagree. `scale: 33.3333%` resolves to `0.3333` compiled — four decimal places, from `round()` repairing the f32 — and to `0.333333` at runtime, which divides the source string exactly. One declaration, two numbers, decided by whether the variable was inlinable. --- .../compiler/transform-scale.test.ts | 28 +++++++- src/__tests__/native/transform.test.tsx | 64 ++++++++++++++++++- .../vendor/tailwind/transform.test.ts | 12 ++++ src/compiler/declarations.ts | 2 +- src/native/styles/scale-value.ts | 21 +++++- 5 files changed, 119 insertions(+), 8 deletions(-) diff --git a/src/__tests__/compiler/transform-scale.test.ts b/src/__tests__/compiler/transform-scale.test.ts index 83bc9428..fbb9581e 100644 --- a/src/__tests__/compiler/transform-scale.test.ts +++ b/src/__tests__/compiler/transform-scale.test.ts @@ -83,7 +83,25 @@ function scaleComponentsFor(declarations: string): TransformComponent[] { * * `transform: scale3d(...)` is deliberately absent from this table — the * compiler drops 3d transforms entirely, so it emits no scale component at all. - * `dropsEveryScaleComponent` below pins that instead. + * The `emits no scale component` rows below pin that instead. + * + * TWO THINGS A ROW HERE CAN FAIL TO OBSERVE, both measured rather than assumed: + * + * 1. `round()`. lightningcss stores a percentage as an f32, so a value that is + * not representable in 32 bits arrives already wrong — `2%` reaches the + * compiler as `0.019999999552965164` — and `round()` is what repairs it. + * Most percentages here ARE f32-exact (`75%`, `50%`, `12.5%`, every power of + * two over a hundred), so dropping `round()` leaves them untouched and only + * the inexact rows go red. `2%` and `110%` are the two that can see it, and + * `110%` is the value issue #216 was reported with. + * + * 2. `case "scale"` in `parseTransform`. lightningcss pre-normalises a LITERAL + * `scale(75%)` / `scale(75%, 50%)` between the compiler's two passes, so + * those two rows emit byte-identical IR with the fix reverted and cannot + * discriminate on their own. `--s: 75%; transform: scale(var(--s));` is the + * row that reaches the case, because the variable defeats the pre-pass. The + * literal rows stay because they are the spellings a human writes, and + * because a change to the pre-pass should surface here rather than silently. */ // prettier-ignore const census: [declarations: string, components: TransformComponent[]][] = [ @@ -95,6 +113,12 @@ const census: [declarations: string, components: TransformComponent[]][] = [ ["scale: -50%;", [["scaleX", -0.5], ["scaleY", -0.5]]], ["scale: 150%;", [["scaleX", 1.5], ["scaleY", 1.5]]], ["scale: 12.5%;", [["scaleX", 0.125], ["scaleY", 0.125]]], + // The two f32-inexact rows — see note 1 above. Without `round()` these are + // `1.100000023841858` and `0.019999999552965164`; every other row is + // untouched by it. + ["scale: 110%;", [["scaleX", 1.1], ["scaleY", 1.1]]], + ["scale: 2%;", [["scaleX", 0.02], ["scaleY", 0.02]]], + ["transform: scaleX(110%);", [["scaleX", 1.1]]], ["scale: 75% 50%;", [["scaleX", 0.75], ["scaleY", 0.5]]], // Mixed: the number is untouched, the percentage becomes its fraction. ["scale: 2 50%;", [["scaleX", 2], ["scaleY", 0.5]]], @@ -105,6 +129,7 @@ const census: [declarations: string, components: TransformComponent[]][] = [ ["scale: none;", [["scaleX", 1], ["scaleY", 1]]], // `transform` shorthand — a separate emitter per function, same requirement. + // These two do NOT discriminate on their own — see note 2 above. ["transform: scale(75%);", [["scaleX", 0.75], ["scaleY", 0.75]]], ["transform: scale(75%, 50%);", [["scaleX", 0.75], ["scaleY", 0.5]]], ["transform: scale(0.75);", [["scaleX", 0.75], ["scaleY", 0.75]]], @@ -125,6 +150,7 @@ const census: [declarations: string, components: TransformComponent[]][] = [ // inlined. ["--s: 75%; scale: var(--s);", [["scaleX", 0.75], ["scaleY", 0.75]]], ["--sx: 75%; --sy: 50%; scale: var(--sx) var(--sy);", [["scaleX", 0.75], ["scaleY", 0.5]]], + // The row that actually exercises `case "scale"` — see note 2 above. ["--s: 75%; transform: scale(var(--s));", [["scaleX", 0.75], ["scaleY", 0.75]]], ["--s: 75%; transform: scaleX(var(--s));", [["scaleX", 0.75]]], ]; diff --git a/src/__tests__/native/transform.test.tsx b/src/__tests__/native/transform.test.tsx index 65aac0b9..dfff166f 100644 --- a/src/__tests__/native/transform.test.tsx +++ b/src/__tests__/native/transform.test.tsx @@ -160,6 +160,10 @@ describe("scale", () => { ["scale: -50%;", [["scaleX", -0.5], ["scaleY", -0.5]]], ["scale: 150%;", [["scaleX", 1.5], ["scaleY", 1.5]]], ["scale: 12.5%;", [["scaleX", 0.125], ["scaleY", 0.125]]], + // Issue #216's own value, and one of the two rows here that can observe + // a lost `round()` — see the compiler census for why most cannot. + ["scale: 110%;", [["scaleX", 1.1], ["scaleY", 1.1]]], + ["scale: 2%;", [["scaleX", 0.02], ["scaleY", 0.02]]], ["scale: 75% 50%;", [["scaleX", 0.75], ["scaleY", 0.5]]], ["scale: 2 50%;", [["scaleX", 2], ["scaleY", 0.5]]], ["scale: 75% 50% 2;", [["scaleX", 0.75], ["scaleY", 0.5]]], @@ -264,6 +268,7 @@ describe("scale", () => { ["--sx: 0%; --sy: 0%; scale: var(--sx) var(--sy);", [["scale", 0]]], ["--sx: -50%; --sy: -50%; scale: var(--sx) var(--sy);", [["scale", -0.5]]], ["--sx: 12.5%; --sy: 12.5%; scale: var(--sx) var(--sy);", [["scale", 0.125]]], + ["--sx: 110%; --sy: 110%; scale: var(--sx) var(--sy);", [["scale", 1.1]]], ["--sx: 75%; scale: var(--sx);", [["scale", 0.75]]], // Differing axes stay split across both keys. ["--sx: 75%; --sy: 50%; scale: var(--sx) var(--sy);", [["scaleX", 0.75], ["scaleY", 0.5]]], @@ -323,9 +328,22 @@ describe("scale", () => { }, ); - // The counterpart to every row above. React Native REQUIRES the unit on - // these, so coercing them would be a regression dressed as consistency; - // this is what keeps the runtime coercion scoped to the scale keys. + /** + * The counterpart to every row above, and the guard that decides how wide + * `scaleTransformKeys` may be. React Native validates each key against its + * OWN expectation, so a coercion applied to the wrong one does not tidy + * anything up — it swaps this crash for another: + * + * translateX / translateY number or a percentage string + * skewX / skewY must be a STRING, in deg or rad + * + * The skew rows are the sharp ones. `{ skewX: "75%" }` is already invalid, + * so a reader can talk themselves into "coercing it cannot make things + * worse" — but `{ skewX: 0.75 }` fails `must be a string`, a different + * invariant on the same fatal pass, and the percentage handling skew + * actually needs is a separate fix. Widening the set to reach them turns + * these two rows red, which is the point of listing them. + */ test.each([ [ "transform: translateX(var(--sx));", @@ -339,6 +357,8 @@ describe("scale", () => { "translate: var(--sx) var(--sy);", { transform: [{ translateX: "75%" }, { translateY: "50%" }] }, ], + ["transform: skewX(var(--sx));", { transform: [{ skewX: "75%" }] }], + ["transform: skewY(var(--sx));", { transform: [{ skewY: "75%" }] }], ])("%s keeps its percentage", (declarations, expected) => { expect( renderStyle( @@ -379,6 +399,44 @@ describe("scale", () => { "number", ]); }); + + /** + * The two planes do not agree to the last digit, and the disagreement is + * inherent rather than incidental — so it is pinned here rather than left + * for someone to meet as a diff between two builds of one stylesheet. + * + * lightningcss holds a percentage as an f32, which makes `2%` arrive at the + * compiler as `0.019999999552965164`; `round()` is what repairs that, and + * it repairs it to four decimal places. The runtime never sees an f32 — it + * has the source string — so it divides exactly and keeps every digit. + * + * The same declaration therefore lands on `0.3333` when the compiler can + * inline the variable and `0.333333` when it cannot. Four decimal places of + * scale is well under a device pixel, so neither is wrong; making them + * agree means either rounding the exact value or unrounding the repaired + * one, and `round()` is shared with every other compiled number. + */ + test("compile and runtime resolve one declaration to different precision", () => { + const declarations = `scale: var(--s);`; + + expect( + renderScaleComponents( + `.my-class { --s: 33.3333%; ${declarations} }`, + "my-class", + ), + ).toStrictEqual([ + ["scaleX", 0.3333], + ["scaleY", 0.3333], + ]); + + expect( + renderScaleComponents( + `.decoy { --s: 999%; } + .my-class { --s: 33.3333%; ${declarations} }`, + "my-class", + ), + ).toStrictEqual([["scale", 0.333333]]); + }); }); }); diff --git a/src/__tests__/vendor/tailwind/transform.test.ts b/src/__tests__/vendor/tailwind/transform.test.ts index 7e54db2b..ac2653bd 100644 --- a/src/__tests__/vendor/tailwind/transform.test.ts +++ b/src/__tests__/vendor/tailwind/transform.test.ts @@ -58,6 +58,18 @@ describe("Transforms - Scale", () => { }, }); }); + test("scale-110", async () => { + // The utility issue #216 was reported with, and the only one in this file + // whose fraction is not exactly representable in the f32 lightningcss + // stores a percentage as. + expect(await renderCurrentTest()).toStrictEqual({ + props: { + style: { + transform: [{ scale: 1.1 }], + }, + }, + }); + }); test("scale-150", async () => { expect(await renderCurrentTest()).toStrictEqual({ props: { diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index fa2cb27d..07e93d74 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -848,7 +848,7 @@ function parseScale( * the one it parsed. `parseLength` would serialise it back to the string `75%`, * which is correct for a layout property and fatal for a transform. */ -export function parseScaleComponent( +function parseScaleComponent( value: NumberOrPercentage, builder: StylesheetBuilder, ): StyleDescriptor { diff --git a/src/native/styles/scale-value.ts b/src/native/styles/scale-value.ts index 75f9fa02..9b4f039d 100644 --- a/src/native/styles/scale-value.ts +++ b/src/native/styles/scale-value.ts @@ -16,9 +16,24 @@ /** * The transform components React Native requires to be unitless numbers. * - * Deliberately narrower than `transformKeys`: React Native accepts a percentage - * string for `translateX` / `translateY` and a `deg` string for rotate and - * skew, so coercing those would be a regression rather than a fix. + * Deliberately narrower than `transformKeys`, and widening it is not a + * cosmetic call — React Native validates each key against its own expectation, + * so a coercion applied to the wrong one swaps this crash for another: + * + * scaleX / scaleY must be a number ← the keys this set exists for + * translateX / Y number or a percentage string + * rotate / skewX / skewY must be a STRING, in deg or rad + * + * `{ skewX: "50%" }` is already invalid, but `{ skewX: 0.5 }` is invalid too + * and on a different invariant (`must be a string`), so adding the skew keys + * here would move the crash rather than fix it. Their percentage handling is a + * separate defect with a separate answer. + * + * `scale` never reaches the caller in `resolve.ts` — the `scale` function + * resolver shadows the `transformKeys` branch for that name — and is listed + * anyway, because this set mirrors React Native's own `scale`/`scaleX`/`scaleY` + * case group and the other caller (`transform-functions.ts`) does produce it. + * Defence in depth, not a live key on that path. */ export const scaleTransformKeys = new Set(["scale", "scaleX", "scaleY"]); From e5ad3e82275af607a34da1961250214b32be508c Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 23:21:59 +0300 Subject: [PATCH 6/6] fix(transforms): flatten the transform shorthand, so every entry has one property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React Native counts the keys of every transform entry and crashes the screen when the count is not exactly one: You must specify exactly one property per transform object That is `_validateTransforms`, the same `__DEV__` pass that raises the scale invariant this branch is about — so these are full-screen render failures, not cosmetic shape defects. Measured by feeding the rendered array to React Native's own `processTransform`; each shape below threw, and none of them throws now. A resolver hands back either one component or a GROUP of them, and the shorthand resolver passed the group through as a single nested entry: transform: scale(var(--x), var(--y)) [[{ scaleX }, { scaleY }]] transform: translate(var(--x), var(--y)) [[{ translateX }, { translateY }]] transform: scale3d(…) / scaleZ(…) / matrix(…) [[]] transform: translateX(10px) scale3d(…) [{ translateX: 10 }, []] Two keys and zero keys fail the same invariant, so an unsupported transform is a crash rather than a no-op, and it takes its neighbours down with it. One `.flat()` before the existing filter closes all four: a group becomes a run of entries and an empty group disappears. Nothing else moves. A transform entry is an object whose value may itself be an array (`matrix`), and flattening one level does not reach inside an entry. Measured: 0 regressions across the suite. The native census could not have caught any of this, which is why it grows a guard rather than only rows. It collected scale components by recursing THROUGH a nested entry, so `[[{ scaleX: 0.75 }, { scaleY: 0.5 }]]` yielded two correct-looking numeric components and reported green on a style React Native refuses to render. `renderTransform` now refuses anything but a single-key entry before a census reads it, and the collector reads one level only. Reverting the `.flat()` reddens ten assertions, four of them census rows that were green before this commit. The percentage handling is orthogonal and unchanged — every shape above reproduces with plain numbers. This is a separable fix and can be dropped without touching the rest of the branch. --- src/__tests__/native/transform.test.tsx | 167 +++++++++++++++++----- src/native/styles/shorthands/transform.ts | 17 ++- 2 files changed, 149 insertions(+), 35 deletions(-) diff --git a/src/__tests__/native/transform.test.tsx b/src/__tests__/native/transform.test.tsx index dfff166f..c534f123 100644 --- a/src/__tests__/native/transform.test.tsx +++ b/src/__tests__/native/transform.test.tsx @@ -2,6 +2,46 @@ import { render } from "@testing-library/react-native"; import { View } from "react-native-css/components/View"; import { registerCSS, testID } from "react-native-css/jest"; +const renderStyle = (css: string, className: string): unknown => { + registerCSS(css); + + return render().getByTestId( + testID, + ).props.style; +}; + +/** + * The rendered `transform` array, checked against the shape React Native + * requires before anything is read out of it. + * + * `_validateTransforms` counts the keys of every entry and crashes the screen + * when the count is not exactly one: + * + * You must specify exactly one property per transform object + * + * The check lives here rather than in one test because a census that reads + * THROUGH a nested entry cannot see that crash: a group `[{ scaleX }, { scaleY }]` + * yields two correct-looking numeric components and reports green on a style + * React Native refuses to render. Both failing counts are covered — a group has + * two or more keys, and the empty entry an unsupported transform leaves behind + * has none. + */ +const renderTransform = (css: string, className: string): unknown[] => { + const { transform } = (renderStyle(css, className) ?? {}) as { + transform?: unknown; + }; + + if (!Array.isArray(transform)) { + throw new Error(`No transform rendered for .${className}`); + } + + expect( + transform.map((entry) => Object.keys(entry as object).length), + ).toStrictEqual(transform.map(() => 1)); + + return transform; +}; + describe("translate", () => { test("parsed", () => { registerCSS(`.my-class { translate: 10%; }`); @@ -98,40 +138,22 @@ describe("scale", () => { /** * Every scale component in the rendered `transform` array, in order. * Collecting them rather than asserting the whole style lets one census cover - * every shape the two planes produce — `{ scale }` when both axes agree, - * `{ scaleX } { scaleY }` when they do not, and the nested entry a runtime - * `scale(x, y)` still produces (a separate, pre-existing shape defect: it - * reproduces with plain numbers and is not this fix's to make flat). + * both shapes the two planes produce — `{ scale }` when the axes agree and + * `{ scaleX } { scaleY }` when they do not — without a row per shape. + * + * It reads one level only, on purpose. `renderTransform` has already refused + * anything but a single-key entry, so there is no nesting left to walk, and + * walking it would be the very thing that hid the crash. */ - const collectScaleComponents = (entry: unknown): ScaleComponent[] => - Array.isArray(entry) - ? entry.flatMap((nested: unknown) => collectScaleComponents(nested)) - : Object.entries(entry as Record).filter(([key]) => - scaleKeys.has(key), - ); - - const renderStyle = (css: string, className: string): unknown => { - registerCSS(css); - - return render().getByTestId( - testID, - ).props.style; - }; - const renderScaleComponents = ( css: string, className: string, - ): ScaleComponent[] => { - const { transform } = (renderStyle(css, className) ?? {}) as { - transform?: unknown; - }; - - if (!Array.isArray(transform)) { - throw new Error(`No transform rendered for .${className}`); - } - - return transform.flatMap((entry: unknown) => collectScaleComponents(entry)); - }; + ): ScaleComponent[] => + renderTransform(css, className).flatMap((entry: unknown) => + Object.entries(entry as Record).filter(([key]) => + scaleKeys.has(key), + ), + ); /** * Every row here is a value the compiler CAN see, so the stylesheet already @@ -339,7 +361,7 @@ describe("scale", () => { * * The skew rows are the sharp ones. `{ skewX: "75%" }` is already invalid, * so a reader can talk themselves into "coercing it cannot make things - * worse" — but `{ skewX: 0.75 }` fails `must be a string`, a different + * worse" — but `{ skewX: 0.375 }` fails `must be a string`, a different * invariant on the same fatal pass, and the percentage handling skew * actually needs is a separate fix. Widening the set to reach them turns * these two rows red, which is the point of listing them. @@ -403,7 +425,7 @@ describe("scale", () => { /** * The two planes do not agree to the last digit, and the disagreement is * inherent rather than incidental — so it is pinned here rather than left - * for someone to meet as a diff between two builds of one stylesheet. + * for someone to discover as a diff between two builds of one stylesheet. * * lightningcss holds a percentage as an f32, which makes `2%` arrive at the * compiler as `0.019999999552965164`; `round()` is what repairs that, and @@ -516,4 +538,85 @@ describe("transform", () => { transform: [{ translateX: "10%" }, { scaleX: 2 }], }); }); + + /** + * A resolver hands back either one component or a GROUP of them, and a group + * used to reach React Native as a single nested entry. `_validateTransforms` + * counts the keys of every entry and crashes the screen when the count is not + * one: + * + * You must specify exactly one property per transform object + * + * That is the same `__DEV__` pass that raises the scale invariant, so these + * are full-screen render failures rather than cosmetic shape defects — each + * shape below was measured throwing out of React Native's own + * `processTransform`. + * + * The fix is one `.flat()` in the `transform` shorthand resolver, which is + * why the rows span scale AND rotate: a group is a group whichever resolver + * built it. + */ + describe("one property per entry", () => { + test("a two-operand scale() with differing axes renders two entries", () => { + // `scale(var, var)` is the shape a two-operand authored shorthand takes + // when the axes disagree. It reproduces with plain numbers too — nothing + // about it is percentage-specific. + expect( + renderStyle( + `.decoy { --sx: 999%; --sy: 999%; } + .my-class { --sx: 75%; --sy: 50%; transform: scale(var(--sx), var(--sy)); }`, + "my-class", + ), + ).toStrictEqual({ transform: [{ scaleX: 0.75 }, { scaleY: 0.5 }] }); + }); + + test("a two-operand translate() renders two entries, beside a sibling", () => { + // A different resolver, so this is the row that says the fix is about + // groups rather than about scale. The `rotate(45deg)` sibling is here + // because a group and a plain component share the array — flattening has + // to leave the plain one exactly where it was. + // + // The LONGHANDS (`translate:`, `rotate:`, `scale:`) never nest: they do + // not route through the `transform` shorthand resolver at all. Measured, + // because a row that reads as coverage and cannot fail is worse than none. + expect( + renderStyle( + `.decoy { --t: 9px; } + .my-class { --t: 10px; transform: translate(var(--t), var(--t)) rotate(45deg); }`, + "my-class", + ), + ).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 renders no entry rather than an empty one", (declarations) => { + // React Native supports none of these, so the compiler emits an empty + // group for them. Zero keys fails the same invariant two keys does, which + // makes an unsupported transform a crash rather than a no-op. + expect( + renderTransform(`.my-class { ${declarations} }`, "my-class"), + ).toStrictEqual([]); + }); + + test("an empty group is dropped without taking its neighbour", () => { + // The discriminating half of the row above: dropping the whole + // declaration would also produce a valid style, so a supported transform + // has to survive beside the unsupported one. + expect( + renderTransform( + `.my-class { transform: translateX(10px) scale3d(1, 2, 3); }`, + "my-class", + ), + ).toStrictEqual([{ translateX: 10 }]); + }); + }); }); diff --git a/src/native/styles/shorthands/transform.ts b/src/native/styles/shorthands/transform.ts index ef603dd8..2e6a114d 100644 --- a/src/native/styles/shorthands/transform.ts +++ b/src/native/styles/shorthands/transform.ts @@ -11,9 +11,20 @@ export const transform: StyleFunctionResolver = ( const transforms = resolveValue(transformDescriptor[2]); if (Array.isArray(transforms)) { - return transforms.filter( - (transform) => transform !== undefined && transform !== "initial", - ) as unknown; + // A resolver returns either one component or a group of them, so the array + // arrives one level deep in places. React Native requires exactly one + // property per entry and enforces it by crashing the screen: + // + // You must specify exactly one property per transform object + // + // Flattening is what makes a group ({ scaleX }, { scaleY } from a + // two-operand `scale()`) a pair of entries rather than a single nested one, + // and what drops the empty group an unsupported transform leaves behind. + 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];