Skip to content

Ignore a transform holding a non-finite number, as the web does - #59229

Open
YevheniiKotyrlo wants to merge 12 commits into
react:mainfrom
YevheniiKotyrlo:fix/non-finite-transform-parser
Open

YevheniiKotyrlo wants to merge 12 commits into
react:mainfrom
YevheniiKotyrlo:fix/non-finite-transform-parser

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

Follow-up to #59133, which keeps a non-finite matrix away from Core Animation on Fabric. Other paths still draw one:

  • BaseViewProps::resolveTransform hands the matrix on unchecked, and on Android nativeProcessTransform gives it to BaseViewManager, which decomposes it and reads a NaN scale as 0: a NaN scale, a NaN matrix entry or a NaN translate with a transformOrigin collapses the view, an infinite translate or scale leaves it undrawn, and so does [{translateX: 10}, {scale: 1e39}], whose scale is infinite once it is a float. getBoundingClientRect and measureInWindow read the same matrix and return NaN on both platforms.
  • A legacy view rendered through the interop layer disappears on iOS: RCTConvert CATransform3D: converts the transform and UIView+React's updateTransform assigns it, with its origin composed in, unchecked.
  • Animated's native driver collapses even a lone NaN translate on Android while the animation runs (TransformHelper's Kotlin path), and from Android 10 a transform with a skew is built straight from the props (SkewMatrixHelper), so a NaN beside a skew hides the view.

On the web, NaN and Infinity are not CSS numbers (css-values-4 §5.3), so a transform holding one is an invalid declaration that is ignored (CSS 2.1 §4.2), and the element is drawn untransformed; the evidence comment has a snippet that shows it in a browser's console. This draws such a view untransformed on every path, where each path finishes its matrix:

  • BaseViewProps::resolveTransform, which iOS mounting, Android's prop path and the C++ readers share, returns the identity for a transform holding a non-finite operand or one whose matrix is not finite, and turns the transform about the center when its origin is not. The parser is unchanged, so the shadow tree keeps the transform as written and the view's flattening doesn't change while the value is NaN.
  • RCTConvert CATransform3D: returns the identity when the matrix it builds is not finite, updateTransform checks the matrix once the origin is composed in, and a non-finite origin converts as a missing one.
  • Android's TransformHelper.processTransform and the skew path in BaseViewManager return the identity when the matrix they build is not finite, and turn it about the center when its origin is not.
  • Each path also checks a perspective where it reads one, since -1 / ±Infinity is a finite m34. A perspective from 0 up to 1px renders as 1px, as css-transforms-2 defines, so perspective: 0 no longer produces -1 / 0.

React Native holds a transform's numbers as floats — an angle in degrees, a percentage once resolved — so a number past the largest float is infinite on Fabric. Every path now reads and checks them at that precision — iOS's CGFloat matrix, RCTConvert, Animated's native driver and the skew path — so such a number, or a composition that overflows a float, draws untransformed everywhere; before, the double paths kept it, and the native driver handed setTranslationX an infinity. The web converts an out-of-range number to the largest value it supports instead (css-values-4 §5); here that would draw a view scaled by 3.4 × 10³⁸, and on iOS Core Animation already turns the window black for a view scaled by 1e10, a valid transform this change leaves as main draws it.

Transform::Interpolate treats a transform holding a non-finite number as no transform, so a LayoutAnimation toward one ends on what the committed props draw; a valid transform whose matrix overflows still animates toward it, as Chromium and Firefox do, and each frame whose matrix overflows draws untransformed. It interpolates a perspective as its matrix (§14), so on iOS a LayoutAnimation toward or away from no perspective follows the browsers rather than reaching the strongest perspective on the way.

The checks add 95–125 instructions to a resolveTransform call (733 → 828 for one operation, 4,479 → 4,604 for four) and 61–197 to a Transform::Interpolate call under callgrind on Linux, 7–18 ns to a processTransform call and about 40 ns to a skewed transform's update on the JVM, while the iOS conversion costs what it did on main; the numbers are in the evidence comment.

Changelog:

[GENERAL] [FIXED] - Draw a view untransformed when its transform or transform origin holds NaN or Infinity, as the web does

Test Plan:

  • ResolveTransformTest (new cases) and TransformTest: a transform holding NaN, Infinity or -Infinity in any operation's operand, a translate entry or a matrix entry, a string that parses to infinity, a percentage that resolves past the largest float, or a composition that overflows a float resolves to the identity; a finite perspective is kept; a non-finite origin turns the transform about the center; a depth below 1px renders as 1px and a negative one keeps its depth; m34 interpolates toward and between perspectives as its matrix does, and a perspective interpolated away keeps the operations beside it; an animation toward or from a transform holding a non-finite number interpolates toward or from no transform, and one toward a composition that overflows draws each frame it can. Built into the Fantom tester's CMake tree on Linux, 12 of the 15 new cases fail on main — the other three pin what it already does — and all 84 tests in the three files pass with this change.
  • View-itest.js: getBoundingClientRect of a view whose transform holds a NaN translate, an infinite scale, a NaN matrix entry or a NaN after a finite operation, or a NaN translate with a transformOrigin, is its untransformed box, and a NaN transform origin turns the transform about the center. On main the 6 cases fail in all four flag combinations (Expected 0 but received NaN); with this change they pass, as do the 13 Fantom files that render a transform (621 tests).
  • View-nativeCSSParsing-itest.js: the transform string scale(3e1000), which the CSS parser reads as infinity, gives the untransformed box; on main it fails with Expected 0 but received NaN.
  • TransformHelperTest (new), MatrixMathHelperTest, SkewMatrixHelperTest and BaseViewManagerTest: the Kotlin path Animated's native driver takes and the skew path ignore a non-finite operand, one that parses to NaN or infinity ('NaNdeg'), a non-finite origin, and a number past the largest float — a translate, a perspective, an angle in degrees or radians, a skew angle, a percentage, an origin, a product, and numbers that would cancel out as doubles — and render a depth below 1px as 1px; one reader serves both paths' origins, so an origin percentage that is not a number leaves its axis at the center on both, as the skew path read it, where the native driver's path threw, and an ignored transform leaves nothing in the scratch matrix the next one reads. 19 of the 23 new cases fail on main, the other four pinning what it already does; with this change they pass, and so does the whole ReactAndroid JVM suite (787 tests).
  • RCTConvert_CATransform3DTests (new, RNTesterUnitTests) and two new RCTViewComponentViewTests cases: RCTConvert and updateTransform ignore a non-finite operand or origin, a number past the largest float and a matrix that overflows a float, and render a depth below 1px as 1px; a Fabric view ignores a CGFloat matrix whose entries overflow a float, and so does the conversion it mounts through. On an iPad (iOS 27.0.1), 8 of the 11 RCTConvert_CATransform3DTests and both new RCTViewComponentViewTests cases fail on main and pass with this change. RNTesterUnitTests runs on a device only inside a host app, which I gave it locally as RNTester, as RNTesterIntegrationTests already has; the rest of it passes there too, except five tests that fail on main as well: RCTConvert_NSColorTests testGenerateFallbacks, whose expected fallbacks differ from iOS 27's system colours, and four RCTBundleURLProviderTests that read the device's packager setup. React-RCTFabric's test spec, which no Podfile installs, I ran with a local app host: of its 65 tests only RCTTextInputUtilsTest testCopyBackedTextInputPreservesTintColor fails, on main as well.
  • RNTester release builds of main and of this change, with a root component rendering each case between two markers, on an Android phone and on the iPad: on main the phone draws only three of the twelve cases, and on the iPad scale 1e20, scale 1e20 blacks out every view drawn before it; with this change every case draws untransformed and the NaN origins turn about the center. Screenshots and per-row pixel counts are in the evidence comment.
  • prettier, ESLint, clang-format, ktfmt, google-java-format and Flow pass, the C++ API snapshots validate unchanged, and React-graphics and React-Fabric build as dynamic frameworks (USE_FRAMEWORKS=dynamic) in Debug and Release.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 20, 2026
@YevheniiKotyrlo

YevheniiKotyrlo commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

RNTester release builds of main at a613baf9d1 and of this PR, with the scene below as the root component and a label naming the build. Each row renders its case between two orange markers.

Android — Galaxy S23, Android 14

main with this PR

iOS — iPad Pro 12.9-inch (4th generation), iOS 27.0.1

main with this PR

On the iPad, main resolves scale 1e20, scale 1e20 to a CGFloat matrix holding 1e40, which #59133's check passes as finite, and Core Animation draws the frame black from that view on: the label and every row before it are gone, and only the rows after it draw over the black. Core Animation does the same for a valid transform: with this PR, as on main, a view scaled by 1e10 turns the window black but for the views drawn after it, where one scaled by 1e5 covers the screen; this PR leaves such a transform as it is.

Steelblue pixels per row in the full frames. A square is 11,664 px on the phone and 5,184 on the iPad; a control row loses the corner its marker covers, and — marks a row under the black.

Row Android main Android with this PR iPad main iPad with this PR
translateX: 40 (control) 9,360 9,360 — 4,160
translateX: NaN 11,664 11,664 — 5,184
scale: Infinity 0 11,664 — 5,184
matrix with NaN 0 11,664 — 5,184
scaleX: 2, transformOrigin: [NaN, 0, 0] 0 22,464 — 9,984
perspective: 0 11,664 11,664 — 5,184
skewX: '10deg' (control) 11,568 11,568 — 5,108
skewX: '10deg', translateX: NaN 0 11,664 — 5,184
skewX: '10deg', transformOrigin: [NaN, 0, 0] 0 11,568 — 5,108
translateX: 10, scale: 1e39 0 11,664 — 5,184
scale: 1e20, scale: 1e20 0 11,664 0 5,184
legacy view, translateX: 40 (control) 9,360 9,360 4,160 4,160
legacy view, translateX: NaN 11,664 11,664 0 5,184
legacy view, scale: 3e38, transformOrigin: 'top left' 0 11,664 0 5,184
native driver, translateX → NaN, in flight 0 11,664 5,184 5,184
Scene

BUILD is replaced with each build's label before it is bundled, and MyLegacyViewNativeComponent is RNTester's NativeComponentExample legacy view.

import * as React from 'react';
import {useEffect, useRef, useState} from 'react';
import {Animated, ScrollView, StyleSheet, Text, View} from 'react-native';
import MyLegacyViewNativeComponent from '../NativeComponentExample/js/MyLegacyViewNativeComponent';

const BUILD = 'unset';

const SUBJECT_COLOR = '#4682B4';

const ROWS = [
  {label: 'translateX 40 (control)', style: {transform: [{translateX: 40}]}},
  {label: 'translateX NaN', style: {transform: [{translateX: NaN}]}},
  {label: 'scale Infinity', style: {transform: [{scale: Infinity}]}},
  {
    label: 'matrix with NaN',
    style: {transform: [{matrix: [NaN, 0, 0, 0, 1, 0, 0, 0, 1]}]},
  },
  {
    label: 'scaleX 2, origin [NaN, 0, 0]',
    style: {transform: [{scaleX: 2}], transformOrigin: [NaN, 0, 0]},
  },
  {label: 'perspective 0', style: {transform: [{perspective: 0}]}},
  {label: 'skewX 10deg (control)', style: {transform: [{skewX: '10deg'}]}},
  {
    label: 'skewX 10deg, translateX NaN',
    style: {transform: [{skewX: '10deg'}, {translateX: NaN}]},
  },
  {
    label: 'skewX 10deg, origin [NaN, 0, 0]',
    style: {transform: [{skewX: '10deg'}], transformOrigin: [NaN, 0, 0]},
  },
  {
    label: 'translateX 10, scale 1e39',
    style: {transform: [{translateX: 10}, {scale: 1e39}]},
  },
  {
    label: 'scale 1e20, scale 1e20',
    style: {transform: [{scale: 1e20}, {scale: 1e20}]},
  },
];

function Row({label, children}) {
  return (
    <View style={styles.row}>
      <Text style={styles.label}>{label}</Text>
      <View style={styles.marker} />
      {children}
      <View style={styles.marker} />
    </View>
  );
}

function NativeDriverRow() {
  const driven = useRef(new Animated.Value(0)).current;
  useEffect(() => {
    const loop = Animated.loop(
      Animated.timing(driven, {
        duration: 120,
        toValue: NaN,
        useNativeDriver: true,
      }),
    );
    loop.start();
    return () => loop.stop();
  }, [driven]);
  return (
    <Row label="native driver translateX NaN, in flight">
      <Animated.View
        style={[styles.subject, {transform: [{translateX: driven}]}]}
      />
    </Row>
  );
}

export default function NonFiniteTransformProbe() {
  const [applied, setApplied] = useState(false);

  useEffect(() => {
    setApplied(true);
  }, []);

  return (
    <View style={styles.container}>
      <ScrollView contentContainerStyle={styles.content}>
        <Text style={styles.build}>{BUILD}</Text>
        {ROWS.map(row => (
          <Row key={row.label} label={row.label}>
            <View style={[styles.subject, applied ? row.style : null]} />
          </Row>
        ))}
        <Row label="legacy view translateX 40 (control)">
          <MyLegacyViewNativeComponent
            color={SUBJECT_COLOR}
            style={[styles.legacySubject, {transform: [{translateX: 40}]}]}
          />
        </Row>
        <Row label="legacy view translateX NaN">
          <MyLegacyViewNativeComponent
            color={SUBJECT_COLOR}
            style={[
              styles.legacySubject,
              applied ? {transform: [{translateX: NaN}]} : null,
            ]}
          />
        </Row>
        <Row label="legacy view scale 3e38, origin top left">
          <MyLegacyViewNativeComponent
            color={SUBJECT_COLOR}
            style={[
              styles.legacySubject,
              applied
                ? {transform: [{scale: 3e38}], transformOrigin: 'top left'}
                : null,
            ]}
          />
        </Row>
        <NativeDriverRow />
      </ScrollView>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {flex: 1, backgroundColor: 'white'},
  content: {padding: 16, paddingTop: 24, gap: 6},
  build: {fontSize: 14, color: 'black'},
  row: {flexDirection: 'row', alignItems: 'center', gap: 12, height: 40},
  label: {flex: 1, fontSize: 12, color: 'black'},
  marker: {width: 16, height: 16, backgroundColor: 'rgb(255, 140, 0)'},
  subject: {width: 36, height: 36, backgroundColor: SUBJECT_COLOR},
  legacySubject: {width: 36, height: 36},
});

On the web

The cases of View-itest.js as CSS, to paste into the devtools console of about:blank:

{
  const cases = [
    ['translateX(10px)', ''],
    [`translateX(${NaN}px)`, ''],
    [`scale(${Infinity})`, ''],
    [`matrix(${NaN}, 0, 0, 1, 0, 0)`, ''],
    [`translateX(10px) scaleY(${NaN})`, ''],
    [`translateX(${NaN}px)`, 'top left'],
    ['scaleX(2)', `${NaN}px 0px 0px`],
  ];
  console.table(
    cases.map(([transform, transformOrigin]) => {
      const view = document.createElement('div');
      view.style.cssText = 'position: fixed; left: 0; top: 0; width: 10px; height: 10px';
      view.style.transform = transform;
      view.style.transformOrigin = transformOrigin;
      document.body.append(view);
      const {x, y, width, height} = view.getBoundingClientRect();
      const computed = getComputedStyle(view).transform;
      view.remove();
      return {transform, transformOrigin, computed, x, y, width, height};
    }),
  );
}

Chromium 153, Firefox 155 and WebKit 26.6 print the same rows: a transform holding NaN or Infinity is ignored, so it computes to none and the view keeps its 10 × 10 box at 0, 0, and so is the NaN origin, so scaleX(2) turns about the center — the boxes View-itest.js expects with this PR.

transform transformOrigin computed x y width height
translateX(10px) matrix(1, 0, 0, 1, 10, 0) 10 0 10 10
translateX(NaNpx) none 0 0 10 10
scale(Infinity) none 0 0 10 10
matrix(NaN, 0, 0, 1, 0, 0) none 0 0 10 10
translateX(10px) scaleY(NaN) none 0 0 10 10
translateX(NaNpx) top left none 0 0 10 10
scaleX(2) NaNpx 0px 0px matrix(2, 0, 0, 1, 0, 0) -5 0 20 10

Cost

BaseViewProps::resolveTransform for a 100 × 100 frame, and Transform::Interpolate halfway between two transforms, the call a LayoutAnimation makes each frame, from a Release build of the Fantom tester's sources, clang 14 on Linux.

Transform Instructions per call, main with this PR
[{translateX: 10.5}] 733 828 (+95)
[{translateX: 10.5}], transformOrigin: [10, '50%', 0] 2,722 2,831 (+109)
[{matrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 10.5, -4, 0, 1]}] 329 429 (+100)
[{translateX: 10.5}, {translateY: -4}, {scale: 1.25}, {rotate: '45deg'}] 4,479 4,604 (+125)
Interpolation Instructions per call, main with this PR
[{translateX: 0}] → [{translateX: 10.5}] 865 930 (+65)
the four operations above, from translateX: 0, translateY: 0, scale: 1 and rotate: '0deg' 4,959 5,156 (+197)
[{perspective: 100}, {rotateY: '30deg'}] → [{perspective: 200}, {rotateY: '60deg'}] 2,364 2,425 (+61)

Counted with callgrind inside the measured loop alone, as the difference between 110,000 and 10,000 calls; each binary is checked first to resolve or interpolate translateX: NaN the way its source says.

TransformHelper.processTransform, the path Animated's native driver takes on Android, for a 100 × 100 view on the JVM in the Android CI image: three rounds each side, each the median of 15 samples of 200,000 calls; the table gives the middle round and the range of the three.

Transform ns per call, main with this PR
[{translateX: 10.5}] 28.8 (28.1–29.1) 35.7 (35.6–36.3)
[{translateX: 10.5}, {translateY: -4}, {scale: 1.25}, {rotate: '45deg'}] 147.5 (144.6–149.1) 163.4 (159.7–165.2)
the same, transformOrigin: [10, 20, 0] 171.9 (170.5–173.4) 186.0 (183.0–186.4)
the same, transformOrigin: ['50%', '25%', 0] 208.7 (206.8–213.3) 226.5 (223.4–349.6)

One round with this PR scattered on the percentage origin (227.8 to 399.7 ns across its samples); the other two sit at 226.5 and 223.4.

From Android 10 a transform with a skew is built by SkewMatrixHelper instead. On the same JVM, under Robolectric's native graphics, its builder costs what it did on main within its spread — a median of 569–596 ns a call for skewX across three rounds, against 578–644 — and the check this PR adds after it, SkewMatrixHelper.isFinite, costs 40.0–41.8 ns.

RCTCATransform3DFromTransformMatrix, on an Apple M1 at -Os: 5.16 ns per call on main and 5.10 with this PR, nine interleaved rounds of 50,000,000 calls each, against a 0.01 ns spread between two copies of main's. A legacy view's RCTConvert CATransform3D: and updateTransform each add one RCTIsFiniteTransform call, about 6 ns there: the conversion through it measured 9.59 ns against 3.53 with no check.

@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 20, 2026 14:29
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Oct 20, 2026
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as draft October 20, 2026 16:48
…not finite

From Android 10, BaseViewManager hands a transform with a skew to SkewMatrixHelper straight from the props, past the parser's check, so a NaN beside the skew set a NaN animation matrix and hid the view; a non-finite origin turns it about the centre, as elsewhere.
…ne past the largest float is kept

Narrowing first turned a finite number past the largest float into infinity, and the check then ignored a transform main draws.
… the matrix is not finite

The double check beside a float build let a finite number past the largest float reach the layer as infinity, and no operand check sees a composition that overflows; a perspective of infinity is the one non-finite operand whose matrix comes out finite, so it is checked where it is read.
The default perspective operation stood for none with a depth of 0, so interpolating toward none reached the strongest perspective, and an overshoot drove the depth negative; it now interpolates -1 / depth, with none as an infinite depth: https://drafts.csswg.org/css-transforms-2/#interpolation-of-transform-functions
…s to literal matrices

Every non-finite operand but a perspective's makes the matrix non-finite, so the walk over the operands before the parse went over each transform a second time on the native driver's frame path.
toValueUnitIfFinite replaces toValueUnit followed by a separate check at each
translate and origin entry, so no call site can read the value before the
check, and toFloatIfFinite no longer guards a type its callers already checked.
Single-axis scales, an operand the parser cannot read, a string that parses
to infinity, a negative perspective, a translate interpolated beside the
perspective rule, the skew path's origin and depth checks, a transform origin
RCTConvert keeps, a normal perspective, an angle in radians and a translate
with a depth.
…arser as main has it

BaseViewProps::resolveTransform, which iOS mounting, Android's prop path
through nativeProcessTransform and the C++ readers share, returns the
identity when a perspective it reads or the matrix it finishes is not
finite, and leaves a non-finite origin out, so the transform turns about
the centre. The parser's own checks left a plain view whose transform
turned NaN with no transform, so the view stopped forming a native view
until the value was finite again, and no operand check saw a composition
of finite numbers that overflows.

UIView+React's updateTransform checks the matrix it assigns once the
origin is composed in, RCTConvert converts a non-finite origin as a
missing one, and interpolating a perspective away leaves no perspective
operation behind.
…n invalid one as none

React Native holds a transform's numbers as floats, so a finite number past
the largest float is infinite on Fabric. Animated's native driver and
Android's skew path read them as doubles, and iOS Fabric and RCTConvert
finish their matrices as doubles, so a value past the largest float, or a
composition that overflows a float, was drawn on one path and ignored on
another; on the native driver it reached setScaleX and setTranslationX as
Float.MAX_VALUE and infinity. Each path now checks at float precision:
resolveTransform's matrix, RCTIsFiniteTransform, and the Kotlin result,
perspective and origin checks. convertToRadians and RCTConvert read an
operand as a float, as the C++ parser holds it.

Transform::hasFiniteOperands decides once what a transform holding a
non-finite number is: resolveTransform draws it untransformed and
Interpolate interpolates it as no transform, so a LayoutAnimation toward
perspective: Infinity ends on what the committed props draw instead of
snapping to it.

resolveTransform returns its matrix instead of a conditional expression's
copy of it.
…ths, and keep the operand check private

The C++ parser holds an angle with a unit in float degrees and resolves a
percentage in floats, and Animated's native driver read a radian angle, a
percentage and every other number as a double, so '1e37rad', '1e37%' or
numbers past the largest float that cancel out as doubles were drawn there
and ignored on Fabric. convertToRadians holds a unit angle in float degrees,
resolvePercentage resolves a translate's and an origin's percentage in
floats, every number processTransform reads is held as a float, and an
origin is checked on the numbers it declares, as resolveTransform checks it.
resolveTransformOrigin is the one reader of an origin for processTransform
and the skew path, checking each number as it reads it, so a frame parses
an origin once, and a percentage that is not a number leaves its axis at
the center on both paths, as the skew path read it, where processTransform
threw.

A legacy view's RCTConvert holds a radian angle in float degrees too, and a
matrix's entries as floats, so an entry past the largest float stays
infinite through the operations after it.

hasFiniteOperands moves to the private TransformUtils.h, so the C++ API
snapshots stay as they are, and Interpolate checks each endpoint once. A
transform holding a non-finite number is an invalid declaration and
interpolates as no transform; a valid one whose matrix overflows still
animates toward it, as Chromium and Firefox do, and each frame whose matrix
overflows is drawn untransformed. Interpolate's doc says what it does:
operation by operation, a perspective as its matrix, stopping at an
arbitrary matrix.

RCTCATransform3DFromTransformMatrix checks each entry as a float, the
predicate resolveTransform uses, in the loop it already has, so the
conversion costs what it did.

The skew angle past the largest float is tested through BaseViewManager, as
its siblings are, so the case compiles against main. A transform string the
CSS parser reads is tested through getBoundingClientRect, and the Kotlin
path is tested for leaving nothing in its scratch matrix after an ignored
transform.
…ess checks as loops

Transform.cpp included its own header as "Transform.h" and the operand check's header as
<react/renderer/graphics/TransformUtils.h>. With USE_FRAMEWORKS=dynamic the second resolves into
React_graphics.framework's copy of the headers, so Transform.h arrived twice, as two files, and
test_ios_rntester_dynamic_frameworks failed on redefinitions. Transform.cpp now includes its header as
<react/renderer/graphics/Transform.h>, as fromRawValueShared.cpp includes its own.

hasFiniteOperands and isFiniteMatrix are range-for loops, as the check in RCTConversions.h is: std::all_of
compiled to an out-of-line std::__find_if_not, 46 of the 84 instructions the checks added to a
Transform::Interpolate call with one operation.
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 21, 2026 13:18
meta-codesync Bot pushed a commit that referenced this pull request Oct 22, 2026
…d later (#59314)

Summary:
A view whose `elevation` is `NaN` or infinite ends the app on Android 9 and later, and so do a `DrawerLayoutAndroid`'s `elevation` and the deprecated `scaleX` and `scaleY` props. From API 28, `View.setElevation`, `setScaleX` and `setScaleY` pass their value through `sanitizeFloatPropertyValue` ([`View.java`, line 20100](https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-16.0.0_r1/core/java/android/view/View.java#20100)), which throws `IllegalArgumentException: Cannot set 'elevation' to Float.NaN` for an app targeting API 28 or later ([line 5903](https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-16.0.0_r1/core/java/android/view/View.java#5903)). `BaseViewManager.setTransformProperty` already reads every value it hands those setters through its own `sanitizeFloatPropertyValue`, but `BaseViewManager.setElevation`, the deprecated scale setters and `ReactDrawerLayoutManager.setElevation`, which hands `DrawerLayout.setDrawerElevation` its value without `super`, do not.

This moves the helper to `com.facebook.react.uimanager.internal.FloatPropertySanitizer`, which the drawer can reach and the public API leaves out, and reads those values through it — the elevations after the dp-to-pixel conversion, so an infinite elevation isn't multiplied back to infinity on a density above 1, and a finite one that overflows in pixels is caught too. A `NaN` takes 0 and ±∞ ±`Float.MAX_VALUE`: the values the framework itself gives an app that does not opt into the throw. A finite value costs the helper's `NaN` test and its two bounds, in setters that run when the prop changes.

#59229 makes a transform holding a non-finite value ignored, as the web does.

## Changelog:

[ANDROID] [FIXED] - A `NaN` or infinite `elevation`, or deprecated `scaleX` or `scaleY`, no longer crashes the app on Android 9 and later

Pull Request resolved: #59314

Test Plan:
- `BaseViewManagerTest`: an elevation of `NaN`, `Infinity`, `-Infinity` and `1e39` through the props map, a `NaN` after a finite one, a finite elevation that overflows only in pixels at density 3, and `NaN` and infinite `scaleX` and `scaleY`. `ReactDrawerLayoutManagerTest` (new): a drawer at a `NaN` and an infinite elevation, and at a `Float.MAX_VALUE` elevation at density 3. On `main` the 11 new cases fail on `View`'s own refusal (`Cannot set 'elevation' to Float.NaN`, `Cannot set 'scaleX' to Float.NaN`, …); with this change they pass, and so does the whole ReactAndroid JVM suite (776 tests).
- google-java-format and ktfmt pass.
- To reproduce, on Android 9 or later:

  ```jsx
  <View style={{width: 48, height: 48, backgroundColor: 'white', elevation: NaN}} />
  ```

  On `main` the app ends with `Cannot set 'elevation' to Float.NaN`, as it does for a `DrawerLayoutAndroid` at `elevation: NaN`, and with `Cannot set 'scaleX' to Float.NaN` for a `View` given `scaleX={NaN}`; with this change each draws. I ran RNTester release builds of `main` and of this change on an Android 14 phone with a root component rendering every case; a screenshot and the scene are in the [evidence comment](#59314 (comment)).

Reviewed By: Abbondanzo

Differential Revision: D126883976

Pulled By: javache

fbshipit-source-id: e87a85ab56a50c5bbb94891888ced851d9a453b9

This branch has not been deployed

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

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant