Skip to content

fix(react-native): bugs reported from ng-native - #77

Open
NathanWalker wants to merge 15 commits into
android-react-nativefrom
fix/react-native-reported-issues
Open

NathanWalker wants to merge 15 commits into
android-react-nativefrom
fix/react-native-reported-issues

Conversation

@NathanWalker

@NathanWalker NathanWalker commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Fixes the @nativescript/react-native bugs reported in the "NativeScript bugs" section of ng-native/ng-native#931. The numbers below match that list.

Fixes

1. iOS: a recycled NativeScriptUIView stayed empty when remounted with its last handle. updateProps diffed against _props, which Fabric keeps across prepareForRecycle while the container is cleared. It now diffs against the oldProps argument. RCTMountingManager passes nullptr on a fresh mount, which maps to the default props. _props is deliberately left alone, because RN's own prepareForRecycle diffs layer state against it.

2. iOS: a native object returned from a worklet crashed the next JS reload. react-native-worklets shares host objects between runtimes, so the JS runtime finalizes the UI bridge's host object after the UI runtime is gone, and the bridge then released jsi::Values into a dead runtime.

  • Each bridge's API host object now marks the bridge torn down as its runtime is finalized. From then on, Values the bridge drops are leaked rather than released.
  • Fixing Vectors support #2 exposed a second problem: the per-thread lookup caches in ObjCBridge.mm identified a bridge by its memory address. A bridge allocated where a destroyed one used to live matched that bridge's cached entries, and the property-getter cache holds raw pointers into it. Each bridge now has a process-unique ID instead.

3. Android: after a JS reload, NativeScript said it was installed but the new runtime had none of its globals.

  • install() and isInstalled() now work per host jsi::Runtime instead of using a process-wide flag.
  • The module's invalidate() detaches the old runtime. RN 0.86 calls it after the JS thread has stopped and before the runtime is destroyed.
  • Detaching releases every handle while the runtime is still alive and unregisters it. It also resets the main-runtime state so the next attach binds to the new JS thread.
  • The metadata tree is built once per process.

4. Android: SIGSEGV in NativeScript's GC after a reload.

  • GC notifications for a detached runtime do nothing, in Java, native code and JNI.
  • notifyGcFast no longer throws on the GC listener's thread.
  • A Java→JS callback for an object whose runtime was reloaded away (for example a postDelayed Runnable) now logs a warning and returns the default for its return type, instead of calling into the destroyed runtime.
  • A call on an object that never belonged to a runtime still throws as before.

5. The Worklets Babel plugin was not configured with strictGlobal.

  • The config plugin and nativescript-rn configure now write ['react-native-worklets/plugin', { strictGlobal: true }] and ['babel-preset-expo', { worklets: false }].
  • They upgrade bare entries in an existing config, and warn when an entry that has options lacks strictGlobal.
  • The Babel setup now also runs on Android-only prebuilds.
  • Documented in the README.

6. init("", { globals: true }) made every enum member a bare global (Zone, Center). Only full C constant names become globals now, for example NSTextAlignmentCenter, NSOrderedAscending and kCLAuthorizationStatusRestricted. Short names stay on their enum. The same rule applies to the JS installGlobals() and to the shared global installer that the Worklets UI runtime uses. It is consistent with the runtime's EnumConstantsAsGlobalVariables test.

7. Android: init() threw a misleading "could not install into the Worklets UI runtime". init() no longer tries the Worklets install on Android, so it returns true. runOnUI() and installWorklets() on Android now say they are iOS only.

8. Importing the package without its native module raised an error no try/catch could catch. The module is looked up with TurboModuleRegistry.get.

  • The new NativeScript.isAvailable() reports whether the module is present.
  • isInstalled() returns false when it isn't.
  • init() and the other functions that need the module throw an error saying to rebuild.

9. Generated typings.

  • Nullable callbacks are now emitted as (() => void) | null.
  • NativeObject.extend is declared, with objc.ExtendOptions.
  • The README no longer says Android declarations are off by default: nativescript.gradle generates them from the app's classpath by default, so @nativescript/types-android is not needed.

Also in this PR

README rewritten for npm readers.

  • Requirements and installation come first. The monorepo build scripts moved to a closing "Building from source" section, and the stale 0.0.1 tarball steps are gone.
  • It now says that on iOS, class globals are installed in the Worklets UI runtime, and on the JS thread only with init("", { globals: true }). The examples that touched UIKit or undefined names off the UI runtime are fixed.
  • It documents Android release metadata filtering, metadata.android.include, and the Android steps of configure and the Expo plugin.

metadata.android.include was deleted by every prebuild and configure run. ensureMetadataConfig replaced the whole reactNative.metadata block in nativescript.react-native.json. That list is the only way to keep a class in filtered Android release metadata when the bundle analysis cannot see it. It is also the only way to keep autolinked module packages, because the release blacklist beats whitelist.mdg.

  • The metadata is now merged into the existing block.
  • The config plugin accepts metadata.android.include in its options.
  • The plugin writes the file on Android-only prebuilds too.

nativescript-rn configure said Android typings were generated on demand. nativescript.gradle generates them by default, so the hint no longer passes -PnsGenerateTypings=true.

Not in this PR

10. Android has no Worklets runtime, so Android views can't be touched. This needs real work, not a patch:

  • The shared metadata/JNI caches need locking.
  • The process-wide "main runtime" state needs reworking.
  • The attach needs a C++ path to WorkletRuntimeHolder, scheduled onto the main thread.

The README now states the limitation. A follow-up could add a smaller "run on main and wait" call.

Also left for follow-ups:

  • A native object returned from runOnUI and then used on the JS thread still runs through the UI bridge. This PR only makes teardown safe; re-wrapping host objects as they cross runtimes would be the full fix.
  • Two React instances in one Android process: detaching the main one resets the main-runtime state that the other relies on.

Testing

  • iOS 26.5 simulator, Debug build (RN 0.86.3 / Expo app, each case run against the code before this branch and with it):
  • Android emulator (RN 0.86.3 / Expo, bridgeless, Hermes), five reloads in a row:
    • Each runtime starts with isInstalled() === false, install() works, and typeof java === 'object'.
    • About 2,750 Java objects were collected under forced GC with no crash.
    • A Runnable and a Handler.Callback posted before a reload are dropped with a warning after it, and a callback whose runtime is still alive still runs.
  • Typings: tsc --strict against freshly generated iOS 27.1 declarations covering presentViewControllerAnimatedCompletion(vc, true, null) and extend(). They fail before this branch and pass with it.
  • Node tests: new config-plugin and enum-member-globals cases. The config-plugin cases include a hand-written metadata.android block surviving a run and plugin options setting it; both fail before this branch.
  • Not verified: the old-bridge architecture (RN 0.79–0.81 with the New Architecture off) on Android. The invalidate() path for it posts the detach to the JS thread.

…when it matches its last one

Fabric keeps _props across prepareForRecycle while the container is
cleared, so diffing against _props skipped setNativeViewHandle when a
recycled host was mounted with the handle it last showed, leaving it
empty. Diff against the oldProps argument instead, which is null on a
fresh mount.
react-native-worklets shares host objects across runtimes, so a native
object returned from runOnUI is finalized by the JS runtime after the UI
runtime is gone. Its destructor released the UI bridge's expandos and
caches into the dead runtime, crashing every JS reload (EXC_BAD_ACCESS
in NativeApiBridge::forgetObjectExpandos during HadesGC::finalizeAll).

The bridge's API host object now marks the bridge torn down as its
runtime is finalized; from then on Values the bridge drops are leaked.
install() returned early on a process-wide flag, so after a reload the
new runtime had none of NativeScript's globals while isInstalled()
reported true. The old com.tns.Runtime also stayed subscribed to GC
notifications and released handles into the destroyed jsi::Runtime.

- isInstalled()/install() are per host jsi::Runtime.
- invalidate() detaches: releases every handle while the runtime is
  still alive, unregisters it, and resets main-runtime state so the next
  attach binds to the new JS thread. Metadata is built once per process.
- GC notifications for a detached runtime are no-ops at every layer.
- A Java callback into JavaScript that was reloaded away logs a warning
  and returns a default instead of throwing on the calling thread.
Every key of an enum object became a global, including the short member
names (Center, Fill, CloudKit's Zone). Libraries that feature-detect a
global by name were misled: Angular's zoneless scheduler checks
typeof Zone and crashed at bootstrap after init({ globals: true }).

Only full C constant names (NSTextAlignmentCenter, NSOrderedAscending,
kCLAuthorizationStatusRestricted) are defined as globals now, in both
the JS installGlobals() and the shared global installer used by the
Worklets UI runtime. Short names stay on their enum.
- The TurboModule is looked up with TurboModuleRegistry.get, so importing
  the package in Expo Go or a build made before it was added no longer
  raises an uncatchable invariant. isAvailable() reports whether the
  module is present; init() and the other functions that need it throw
  an error saying to rebuild.
- init() no longer installs into a Worklets runtime on Android, where
  there is none, so it returns true instead of throwing a message about
  missing react-native-worklets setup. runOnUI()/installWorklets() on
  Android say they are iOS only.
- README: the Android example uses NativeScript.init(), and the threads
  section states that Android views cannot be touched from JavaScript.
Without strictGlobal the Worklets plugin captures every name a worklet
does not declare into its closure, read on the JS thread when the module
is evaluated, where NativeScript's classes do not exist: a module-level
worklet naming UILabel failed at startup with
"ReferenceError: Property 'UILabel' doesn't exist".

The config plugin and configure CLI now write
['react-native-worklets/plugin', { strictGlobal: true }] and
['babel-preset-expo', { worklets: false }] (so the preset adds no second
copy), upgrade bare entries in an existing config, and warn when an
entry with options lacks strictGlobal. The Babel setup also runs on
Android-only prebuilds. Documented in the README.
….extend

- The emitter wrote nullable callbacks as `() => void | null`, which
  TypeScript reads as a function returning void | null, so passing null
  (e.g. presentViewControllerAnimatedCompletion's completion) was a type
  error. They are parenthesized now.
- extend() works on every native class at runtime but was undeclared.
  NativeObject.extend types overridden methods from the class, accepts
  protocol/exposed methods, and takes objc.ExtendOptions.
- README: Android declarations are generated by nativescript.gradle by
  default from the app's classpath; @nativescript/types-android is not
  needed.
The property-getter, round-trip and expando lookup caches identified a
bridge by its address. After a JS reload a new bridge can be allocated
where a destroyed one lived and match its entries: the getter cache then
returned a raw pointer into the freed bridge (EXC_BAD_ACCESS in
preparedObjCInvocationIsInit on the next launch), and cached misses
could hide values that exist.
@coderabbitai

coderabbitai Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: a7446a76-c263-47ef-b2b5-fc8d15b341ad

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Put requirements and installation first and move the monorepo build
scripts to a closing section. Document that iOS JS-thread globals are
opt-in via init("", { globals: true }), Android release metadata
filtering, and the Android steps of configure and the Expo plugin.
Fix examples that touched UIKit or undefined names off the UI runtime.
…act-native.json

The config plugin and nativescript-rn configure replaced the whole
reactNative.metadata block, so every prebuild or configure run deleted
a hand-written metadata.android.include. That list is what keeps
classes, including autolinked module packages, in filtered Android
release metadata.

Merge into the existing metadata instead, accept metadata.android.include
in the plugin options, and write the file on Android-only prebuilds too.
Document the include list in the README.
…nfigure

nativescript.gradle generates them unless -PnsGenerateTypings=false, so
the next-steps hint no longer calls them on demand or passes =true.
A guest's JS thread is not the main thread, so no JavaScript could touch
an Android view. The main thread now borrows the jsi::Runtime while the
JS thread waits, as React Native's executeSynchronouslyOnSameThread does:

- Java->JS callbacks made on the main thread (a view's listeners) run
  there instead of being posted to the JS thread.
- __nativeScriptRunOnMainThread(fn) runs fn on the main thread from the
  JS thread, which lends the runtime until it returns.
- A JS thread parked inside a Java->JS call releases the EngineHost lock
  for the borrower and takes it back after.
- Callbacks delivered as plain looper messages drain microtasks, which no
  React Native task would otherwise do.

Also installs __nativeScriptAndroidViewHandle, __nativeScriptAndroidContext
and __nativeScriptIsMainThread for the JS API.
…View

The Fabric component was registered on Android with no view manager. It
now hosts the view its nativeViewHandle names, sized to the host, moving
it from any previous host.
…ndroid

runOnUI runs its callback on the Android main thread instead of throwing.
nativeViewHandle(view) names a view for NativeScriptUIView on both
platforms, androidContext() is the Context to build a view with, and
isMainThread() answers on Android. The README's Android section describes
the threading model and how to show a view.

This branch was successfully deployed

1 active deployment
npm-publish — 1050be2a Deployed Oct 22, 2026 by NathanWalker via Publish react-native #28
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.

1 participant