Skip to content

fix(transforms): treat scale percentage values as unitless (#216) - #389

Open
YevheniiKotyrlo wants to merge 6 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/scale-percentage
Open

fix(transforms): treat scale percentage values as unitless (#216)#389
YevheniiKotyrlo wants to merge 6 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/scale-percentage

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Tailwind v4 scale-* utilities crash React Native's transform validator on native:

Invariant Violation: Transform with key of "scale" must be a number: {"scale":"75%"}

CSS scale accepts a percentage (75% is a 0.75 factor); React Native's transform API is unitless and enforces it by crashing the screen rather than ignoring the value. On a dev build that crash is a full-screen modal that survives navigation, so one scale-75 takes the whole screen down.

The percentage escapes at four boundaries, on two planes, and each one crashes on its own:

Plane Site Shape that reaches it
compile parseScaleValue scale: 75% — the scale longhand
compile parseTransform's scale / scaleX / scaleY cases transform: scaleX(75%)
runtime the scale() function resolver scale: var(--tw-scale-x) var(--tw-scale-y) — every Tailwind scale-*
runtime resolveValue's transformKeys passthrough transform: scaleX(var(--sx)) where the variable holds 75%

else if (name in functionResolvers) shadows else if (transformKeys.has(name)) for any name in both, so the scale longhand always routes through the function resolver while scaleX / scaleY — function resolvers for neither — always take the transformKeys branch. Both are live, and a fix in one is invisible to the other.

Rather than a coercion per call site, each plane gets one parser:

  • Compile — parseScaleComponent (src/compiler/declarations.ts). lightningcss already holds 75% as { type: "percentage", value: 0.75 }, so the number wanted is the one it parsed; parseLength serialises it back to "75%", which is right for a layout property and fatal for a transform.
  • Runtime — normalizeScaleValue (src/native/styles/scale-value.ts, new), imported by transform-functions.ts and resolve.ts. This is the plane Tailwind actually hits: scale-75 emits --tw-scale-x: 75% and a var() the compiler cannot inline, so the percentage survives as a string until resolve time.

scaleTransformKeys is deliberately narrower than transformKeysscale, scaleX, scaleY and nothing else. 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 a different one. The skew keys are the trap worth naming, because they sit in the same structural position as scaleX / scaleY and are therefore reachable: { skewX: "50%" } is already invalid, but coercing it to { skewX: 0.5 } fails Transform with key of "skewX" must be a string on the same fatal pass. Two rows in the test census go red if the set is widened to reach them.

Fixes #216.

scale: none is the identity, not zero

parseScaleValue returned 0 for the none keyword, which collapses the element to nothing; src/native/styles/defaults.ts already carries scale: 1, scaleX: 1, scaleY: 1. Both planes now return 1 — the runtime one matters because behind an un-inlinable var() the keyword survives as the string "none" and produces { scale: "none" }, the same crash as { scale: "75%" } on the same key.

This is a behaviour change outside #216's scope riding in the same PR. It is separable if you would rather it landed on its own.

Reproduction (before this PR)

<View className="scale-75 h-20 w-20 bg-red-500" />

Crashes on first render with Transform with key of "scale" must be a number: {"scale":"75%"}.

A second fix, in its own commit: one property per transform entry

_validateTransforms — the same __DEV__ pass that raises the scale invariant — also counts the keys of every entry and crashes when the count is not exactly one:

You must specify exactly one property per transform object

A resolver hands back either a single component or a group of them, and the transform shorthand resolver passed a group through as a single nested entry. Measured by feeding the rendered array to React Native's own processTransform under __DEV__ — every shape below threw:

Authored CSS Rendered transform
transform: scale(var(--x), var(--y)), differing axes [[{ 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, an empty group disappears. A transform entry is an object whose value may be an array (matrix), and flattening one level does not reach inside an entry, so nothing else moves — measured at 0 regressions across the suite.

This is a scope widening and I want to be explicit about it. It is not percentage-related and every shape above reproduces with plain numbers. I included it because leaving a measured crash of the same invariant unfixed, in a PR whose whole subject is that invariant, is hard to defend — but it is the last commit on the branch (fix(transforms): flatten the transform shorthand…) and touches only src/native/styles/shorthands/transform.ts plus its tests, so say the word and I will split it into its own PR.

The Tailwind LONGHAND path was never affected: scale: var(--x) var(--y) renders flat and React Native accepts it. Only the authored transform: scale(x, y) shorthand nests.

Test plan

Every fix is pinned on both planes — the compiler tests read the emitted IR, the native tests read back the object React Native is actually handed, which is the only place the crash lives.

  • src/__tests__/compiler/transform-scale.test.ts — the compile-plane census across both emitters: percentage, identity, zero, negative, > 100%, fractional, per-axis, mixed number/percentage, a third operand, none, and the unitless spellings that were always correct. Plus two class-level guards: that a percentage behind a competing var() is deferred to the runtime unresolved (which is what makes the runtime parser load-bearing rather than redundant), and that translateX / translateY / rotate / skew keep their units.
  • src/__tests__/native/transform.test.tsx — the same census rendered, plus the runtime census behind competing --sx / --sy definitions so the compiler cannot inline the var() away. Two tests assert the whole style prop rather than the collected components, because that object is literally what the transform validator receives. renderTransform refuses any entry that does not carry exactly one key before a census reads it.
  • src/__tests__/vendor/tailwind/transform.test.tsscale-0 / scale-x-50 / scale-y-50 / scale-50 / scale-110 / scale-150 / scale-none through real Tailwind v4 output. The first four had encoded the buggy "N%" strings and passed while the behaviour was broken.

Each census carries a guard that it is non-empty, so emptying one turns the suite red instead of silently deleting its rows.

Every assertion is mutation-proven — reverting each fix in turn reddens the tests that guard it, and only those. Three results are worth surfacing for review, because in each case a test that reads as coverage was not carrying its weight:

  • 110% is in the census because f32 hides a lost round(). lightningcss stores a percentage as an f32, so 2% arrives as 0.019999999552965164 and round() is what repairs it. Every other value in the three censuses is f32-exact, so dropping round() used to redden exactly one assertion. 110% is issue Animation error caused by incorrect value type #216's own reported value and was absent from all three censuses and the vendor suite; with it and 2% added, dropping round() reddens seven.
  • transform: scale(75%) and scale(75%, 50%) do not discriminate. lightningcss pre-normalises the literal two-argument form between the compiler's two passes, so both emit byte-identical IR with parseTransform's case "scale" reverted. The row that actually reaches the case is --s: 75%; transform: scale(var(--s));. All three rows stay, and the test file now says which is which.
  • The native census could not see the nesting crash it renders. 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. With the shape guard in place, reverting the .flat() reddens ten assertions, four of them census rows that were previously green.

On the compiler-inlined path the two parsers are layered, not alternatives — resolve.ts normalises a scaleX descriptor whether its value came from a var() or a literal, so it repairs a compiler-emitted "75%" too. The native census therefore pins the composite; scale: none is the one case where the compiler half is independently observable, because a wrong 0 is a number the runtime has no reason to touch.

yarn test, yarn typecheck and yarn lint pass: 1265 total, 3 failed. Those three are babel/ path-rewrite cases that fail identically on clean main in my Windows environment and are unrelated to this change.

Out of scope, but measured while testing

Everything here reproduces on main, none of it is caused by this PR, and none of it is fixed by it. Happy to open issues or follow-up PRs for any of them.

The none keyword crashes past scale, on two more properties. The insight behind the scale: none fix generalises, and neither of these is one line, so I left them:

Authored CSS Rendered React Native says
rotate: none behind an un-inlinable var() { rotate: "none" } Rotate transform must be expressed in degrees (deg) or radians (rad)
translate: none behind an un-inlinable var() { translate: true } Transform with key of translate must have an array as the value

The runtime passthrough still hands React Native an arbitrary string. --s: banana; scale: var(--s) renders { scale: "banana" } and throws the exact invariant this PR is named for. normalizeScaleValue deliberately passes an unparseable string through untouched, because bounding it properly means dropping the component, and resolveValue's transformKeys branch has no protocol for that — it wraps whatever it is given in { [name]: value }, so returning undefined produces a worse crash rather than no component. Inventing that protocol is a bigger change than this PR's subject, and which way it should go (drop, or fall back to the identity) is your call rather than mine.

Compile and runtime resolve one declaration to different precision. scale: 33.3333% lands on 0.3333 when the compiler can inline the variable (four decimal places, from round() repairing the f32) and on 0.333333 when it cannot (the runtime divides the source string exactly). Four decimal places of scale is well under a device pixel so neither is wrong, and making them agree means either rounding the exact value or unrounding the repaired one — round() is shared with every other compiled number, so I only pinned the divergence rather than picking a side.


Overlap with #399. @kdwkr's #399 fixes the same crash from the runtime side, touching src/native/styles/functions/transform-functions.ts — one of the files this PR also changes. Neither of us referenced the other; flagging it so whoever reviews sees both.

The difference is scope rather than approach. #399 coerces a percentage at resolve time in that one function. This PR routes all four sites through one parser per plane, so scale, scaleX, scaleY and the scale shorthand agree, and adds the none keyword and the both-plane test coverage.

Both land on the same file, so whichever merges second needs a rebase. If #399 goes first this PR still applies — the compiler half is untouched by it, and the runtime change collapses to whatever you prefer to keep.

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 nativewind#216
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review July 23, 2026 14:55
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.
…d only the compiler collapsed

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.
…rough

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 nativewind#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.
…one property

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.
@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — Nothing renders. The app body is replaced by RN's error surface: Transform with key of "scale" must be a number: {"scale":"75%"}.

FIXED — The subject box is three quarters the width of the control beside it — 75% read as the number 0.75.

before — stock 3.0.7 after — with this PR

Both frames come from the same device in the same run (Android 36 emulator, 1140×2400 @ 480dpi), differing only in whether this PR is applied.

Each frame carries a build-probe width=<dp> line — a rem-derived box that resolves differently on a patched build. The capture harness reads it off the device and refuses to save a frame whose probe disagrees with the variant it claims, so a before image cannot silently be a second after.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Animation error caused by incorrect value type

1 participant