Skip to content

fix(views): fill Props::rawProps on Android when overriding cloneProps - #1655

Open
ronickg wants to merge 2 commits into
margelo:mainfrom
ronickg:repro/android-view-props
Open

ronickg wants to merge 2 commits into
margelo:mainfrom
ronickg:repro/android-view-props

Conversation

@ronickg

@ronickg ronickg commented Sep 21, 2026

Copy link
Copy Markdown

Fixes #1656.

The problem

ViewComponentDescriptor::cloneProps overrides React Native's implementation and
does not call initializeDynamicProps. Up to RN 0.85 that was harmless, because
Props::Props called it for you via Props::initialize. RN 0.86 removed that, so
nothing fills Props::rawProps for a Hybrid View any more — and on Android that
map is exactly what gets serialized to Java and applied by
ViewManager.updateProperties.

The effect: a Hybrid View silently receives no backgroundColor, border*,
transform, opacity, testID or accessibility props on Android. iOS is
unaffected, because RCTViewComponentView reads the typed C++ fields directly.

Nothing warns, because React Native never sees a prop to report as unsupported.

Left is a plain RN View, right is TestView, same style object, on RN 0.87.1
with no feature flags:

before and after

Props::rawProps has exactly one writer per RN version:

writers of Props::rawProps
RN 0.85.3 Props::initialize (from the constructor) + ConcreteComponentDescriptor::cloneProps
RN 0.87.1 ConcreteComponentDescriptor::cloneProps only

(ShadowNode::propsForClonedShadowNode also touches it, but it is gated on
!rawProps.empty() — it merges, it cannot create.)

A corroborating detail: nitrogen generates a per-view filterObjectKeys and threads
it through ViewProps → BaseViewProps → YogaStylableProps → Props, where React
Native's only consumer of it is rawProps.toDynamic(filterObjectKeys) inside
initializeDynamicProps. Without that call it is dead code.

The fix

auto newProps = TShadowNode::Props(context, /* & */ rawProps, props);
#ifdef RN_SERIALIZABLE_STATE
  TShadowNode::initializeDynamicProps(newProps, rawProps, props);
#endif
return newProps;

This does add a rawProps.toDynamic() per props clone, which may be part of what
the override was avoiding. If you'd prefer something narrower — serializing only
the base ViewProps keys — I'm happy to rework it that way.

Why the harness didn't catch it

It couldn't. On the pinned RN 0.85.3 the constructor still fills the map, so the
bug has no observable effect. Turning on enableExclusivePropsUpdateAndroid makes
0.85 take the same path 0.86+ takes unconditionally, and then it does — and not
subtly. queryByTestId resolves against the native view tree, and testID is
itself a base view prop, so most of the file can't even find the view it rendered:

full nitro.views.harness, Pixel 9 API 36 emulator, flag on

  without this fix ....  8 failed,  2 passed
  with it .............  10 passed

Six of those eight are existing tests, not ones added here:

● TestView › renders with native props, layout, pixels, methods, and callbacks
● TestView › updates every prop, changes pixels, and resizes the same native view
● TestView › unmounts and creates a fresh native view when remounted
● multiple RecyclableTestViews › keeps instances isolated while one is updated and recycled
● RecyclableTestView › renders, changes pixels, and resizes the same native view
● RecyclableTestView › reuses and resets the native view across recycling cycles

What's in this PR

file why
cpp/views/ViewComponentDescriptor.hpp the fix
apps/example/android/.../MainApplication.kt turns on enableExclusivePropsUpdateAndroid so the harness exercises the 0.86+ path. Droppable once the floor is >= 0.86, where it's unconditional.
apps/example/__tests__/nitro.views.harness.tsx a dedicated pair of tests naming the cause (applies opacity to the native view, plus a control)

The opacity test asserts the blue surface washes out at 10% alpha rather than
asserting on PNG alpha — a screen capture is always alpha 255, so that would
have passed either way.

Verification

  • bun specs — no codegen drift
  • bun typecheck — clean
  • bun example lint-ci — clean
  • clang-format -style=file:./config/.clang-format on the changed header — clean
  • bun lint-kotlin — ktlint not installed locally; its scope is packages/*/android, and the Kotlin change here is under apps/example
  • nitro.harness (the other file) shows 11 pre-existing ArrayBuffer/HardwareBuffer
    failures on this emulator — identical with and without the flag, verified with a
    control build

A clean, fix-free reproduction on RN 0.87.1 is on
bump/rn-0.87: build it and
open the example's View tab — that's the screenshot above.

Tested on a Pixel 9 API 36 emulator (Android 16, arm64). I have not run this on
physical hardware or on iOS; the iOS claim above is from reading
RCTViewComponentView, not from a measurement.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Sep 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
nitro-docs Skipped Skipped Sep 21, 2026 8:53pm UTC

Request Review

Ronald Goedeke and others added 2 commits September 21, 2026 17:52
React Native 0.87 removed the initializeDynamicProps call from the Props
constructor (Props::Props -> Props::initialize), leaving
ConcreteComponentDescriptor::cloneProps as its only caller.

ViewComponentDescriptor overrides cloneProps to use Nitro's cached copy
constructor and did not make that call. Up to RN 0.85 that was harmless
because the constructor did it; from RN 0.86 onwards Props::rawProps stays
empty, and on Android that map is what gets serialized to Java and handed to
ViewManager.updateProperties. The result is that a Hybrid View receives no
base ViewProps at all on Android - no backgroundColor, no transform, no
opacity, no testID - while iOS is unaffected because RCTViewComponentView
reads the typed C++ fields directly.

Nothing warns: React Native never sees the props, so it never reports an
unsupported one.

Measured on RN 0.87.1: Props::rawProps held 0 entries before this call and
102 after. On RN 0.85.3 it already held the view's props, which is why the
example app - pinned to 0.85.3 - does not show the symptom.

Adds a harness test asserting opacity reaches the native view. TestView
paints its own surface so backgroundColor is masked, but nothing native can
override a view's alpha.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The base-view-prop tests could not fail on the pinned RN 0.85.3: `Props::Props`
still filled `Props::rawProps` there, so the missing `initializeDynamicProps`
call in `ViewComponentDescriptor::cloneProps` had no visible effect.

Turning on `enableExclusivePropsUpdateAndroid` makes 0.85 take the same path
0.86+ takes unconditionally, which is what the tests are meant to cover. It can
be dropped once the floor is >= 0.86.

Also fixes the opacity assertion: it asserted on PNG alpha, which is always 255
in a screen capture. It now asserts the blue surface washes out at 10% alpha.

Measured on an API 36 emulator, full `nitro.views.harness`:

  without the cloneProps fix:  8 failed, 2 passed
  with it:                    10 passed

The 11 ArrayBuffer/HardwareBuffer failures in `nitro.harness` are pre-existing
on this emulator and identical with and without the flag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ronickg
ronickg force-pushed the repro/android-view-props branch from 682b537 to 75f9cad Compare September 21, 2026 20:53
ronickg added a commit to ronickg/react-native-nitro-rolling-number that referenced this pull request Sep 21, 2026
…ver both packages (#5)

* Android: Hybrid Views were getting none of their base view props

From React Native 0.86 a Nitro Hybrid View on Android received no
backgroundColor, no border*, no transform, no opacity, no testID and no
accessibility props, while iOS was fine. There is no error to go with it:
React Native never sees the prop, so it never warns.

On Android those props only reach the view through Props::rawProps, a
folly::dynamic that is serialised to Java and handed to
ViewManager.updateProperties. The one thing that fills that map is
initializeDynamicProps, which React Native calls at the end of
ConcreteComponentDescriptor::cloneProps. Nitro overrides cloneProps to
use its cached copy constructor and, as shipped in 0.37.1, never makes
that call. The map stays empty and every style prop disappears.

The patch adds the call back. Filed upstream as margelo/nitro#1656 with
the fix in margelo/nitro#1655; drop the patch, the patchedDependencies
entry and the tripwire test once a release contains it.

The tripwire (NitroViewProps.test.tsx) reads the installed Nitro source
and fails if the patch stops being applied - a version bump, a fresh
install without patches - and separately if React Native moves the call,
in which case the patch needs rewriting rather than reapplying. The
example gets a two-box repro screen so the symptom can be seen on a
device rather than inferred.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* The onboarding flow reads as an onboarding, not as a test harness

The four keyboard screens exist to compare NitroInput with TextInput
through a realistic flow, and they looked like the harness they are:
"Step 2 / 4" in grey, every field inside a captioned card, the keyboard
timeline taking half the screen, a navigation title competing with the
question.

Now each step is a progress rail, the question as a headline with one
line under it, the field, and one button. The timeline is still there -
it is the reason the screens exist - but folded away behind "Show
timeline", so the flow reads as the thing it imitates. The three pushed
steps share their chrome through one options object: no title, a
minimal back chevron, no header hairline. The search sheet hides its
header, since the sheet's grabber is the chrome, and its Done button
names what was picked. The details form focuses its first field on
mount, like the email step already did. Every testID is unchanged, so
the recorded flows still run.

The native reference apps under native/ (UIKit, SwiftUI, Android views
and Compose, the same four screens for comparing keyboard behaviour) and
the screen recordings under example/.recordings/ are ignored: reference
material, like the *-INTERNALS.md notes, not part of the library.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* The root scripts only knew about one of the two packages

Every root script routed to packages/react-native-nitro-rolling-number,
so `bun run test` at the root never ran the input package's suite, and
plain `bun test` at the root ran bun's own runner against jest files and
reported failures that were not there. Both packages are live - the
example imports both - so the scripts now fan out over packages/* with
bun's --filter, and test:cpp joins them since both packages have one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Ronald Goedeke <ronald@margelo.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
@mrousavy
mrousavy requested a review from hannojg September 22, 2026 09:49

This branch was previously deployed

1 inactive deployment
Preview 75f9cad5 Deployed Sep 21, 2026 by vercel[bot]
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.

Android: Hybrid Views receive no base ViewProps on React Native >= 0.86

1 participant