diff --git a/src/__tests__/compiler/transform-scale.test.ts b/src/__tests__/compiler/transform-scale.test.ts new file mode 100644 index 00000000..fbb9581e --- /dev/null +++ b/src/__tests__/compiler/transform-scale.test.ts @@ -0,0 +1,264 @@ +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 TransformComponent = [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. + * + * 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 collectComponents( + wanted: ReadonlySet, + node: unknown, + found: TransformComponent[] = [], +): TransformComponent[] { + if (typeof node !== "object" || node === null) { + return found; + } + + if (Array.isArray(node)) { + const [modifier, key, value] = node; + + if ( + node.length === 3 && + typeof modifier === "object" && + !Array.isArray(modifier) && + typeof key === "string" && + wanted.has(key) && + !Array.isArray(value) + ) { + found.push([key, value]); + } + } + + for (const child of Object.values(node)) { + collectComponents(wanted, child, found); + } + + return found; +} + +/** 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 .${className} in: ${css}`); + } + + return rule; +} + +function scaleComponentsFor(declarations: string): TransformComponent[] { + return collectComponents(scaleKeys, ruleFor(`.my-class { ${declarations} }`)); +} + +/** + * 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. + * 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[]][] = [ + // `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]]], + // 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]]], + ["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]]], + + // `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]]], + ["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]]], + // 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]]], +]; + +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 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 b7a08fca..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%; }`); @@ -55,8 +95,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 +117,349 @@ describe("scale", () => { transform: [{ scaleX: 2 }, { scaleY: 3 }], }); }); + + /** + * 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%"} + * + * 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. + */ + 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 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 renderScaleComponents = ( + css: string, + className: string, + ): 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 + * 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"); + + // 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]]], + // 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]]], + // 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 + * 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: 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]]], + // 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]]], + // `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. + ["--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]]], + ["--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", () => { + // 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.each(census)("resolves %s", (declarations, components) => { + expect(runtimeScaleComponents(declarations)).toStrictEqual(components); + }); + + test.each(census)( + "every scale component resolved from %s is a number", + (declarations, components) => { + const resolved = runtimeScaleComponents(declarations); + + // 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); + + expect( + resolved.map(([key, value]) => [key, typeof value]), + ).toStrictEqual(components.map(([key]) => [key, "number"])); + }, + ); + + /** + * 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.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. + */ + 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%" }] }, + ], + ["transform: skewX(var(--sx));", { transform: [{ skewX: "75%" }] }], + ["transform: skewY(var(--sx));", { transform: [{ skewY: "75%" }] }], + ])("%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", () => { + // 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", + ]); + }); + + /** + * 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 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 + * 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]]); + }); + }); }); describe("transform", () => { @@ -153,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/__tests__/vendor/tailwind/transform.test.ts b/src/__tests__/vendor/tailwind/transform.test.ts index a056663f..ac2653bd 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,40 @@ describe("Transforms - Scale", () => { expect(await renderCurrentTest()).toStrictEqual({ props: { style: { - transform: [{ scale: "50%" }], + transform: [{ scale: 0.5 }], + }, + }, + }); + }); + 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: { + 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 }], }, }, }); diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 13013642..07e93d74 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,16 +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. + */ +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; } - return parseLength(translate[prop], 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 c9826db6..ab1fd438 100644 --- a/src/native/styles/functions/transform-functions.ts +++ b/src/native/styles/functions/transform-functions.ts @@ -1,16 +1,20 @@ import { isStyleDescriptorArray } from "react-native-css/utilities"; import type { StyleFunctionResolver } from "../resolve"; +import { normalizeScaleValue } from "../scale-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: resolveValue(args) }; + return { scale: normalizeScaleValue(resolveValue(args)) }; } - const x = resolveValue(args[0]); - const y = 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..9b4f039d --- /dev/null +++ b/src/native/styles/scale-value.ts @@ -0,0 +1,69 @@ +/** + * 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 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. + */ + +/** + * The transform components React Native requires to be unitless numbers. + * + * 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"]); + +/** + * 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") { + return value; + } + + if (value === "none") { + return IDENTITY_SCALE; + } + + if (!value.endsWith("%")) { + return value; + } + + const percentage = Number.parseFloat(value); + + return Number.isNaN(percentage) ? value : percentage / 100; +} 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];