Skip to content

feat!: run on react-native-worklets instead of react-native-worklets-core - #349

Open
eduardoborges wants to merge 23 commits into
margelo:mainfrom
eduardoborges:v2/react-native-worklets
Open

eduardoborges wants to merge 23 commits into
margelo:mainfrom
eduardoborges:v2/react-native-worklets

Conversation

@eduardoborges

@eduardoborges eduardoborges commented Sep 3, 2026 •

Copy link
Copy Markdown

Continues #327 by @hannojg: drops react-native-worklets-core and runs on react-native-worklets, the worklet runtime that ships with Reanimated 4. Opened as a draft because everything below was verified on simulators and emulators only.

What changes for users

  • Peer dependencies are react-native-worklets >= 0.12 and react-native-reanimated >= 4. The react-native-worklets-core patch and its Babel plugin go away; apps use the react-native-worklets/plugin they already have for Reanimated.
  • Reanimated shared values are accepted directly by useApplyTransformations, lights, Camera and the rest. useSyncSharedValue and useDerivedValue are removed, since Reanimated's own useSharedValue and useDerivedValue work as they are.
  • useWorkletCallback returns a JS function that schedules the worklet on the Filament runtime and resolves with the result. It cannot be captured inside another worklet or handed to native callbacks; pass a plain 'worklet' function there. The PhysicsCoin example was doing exactly that with its collision callback.
  • RenderableManager.destroyEntity() is new. Entities from createPlane, createImageBackgroundShape and createDebugCubeWireframe were never destroyed, and releasing their material hit a Filament precondition abort. BackgroundImage and DebugBox clean up after themselves now, and the material deleters detach any renderable that still uses an instance.
  • Android compiles with the app's ndkVersion (AGP 9 defaults to NDK 28, whose libc++ fails to dlopen next to React Native's NDK 27 build) and as C++20.
  • The getting started, Reanimated and transformation docs are rewritten for the new setup.

How it works

The Filament runtime is a regular react-native-worklets runtime created from JS with createWorkletRuntime, backed by an AsyncQueueUI whose scheduler forwards jobs to the render thread. Its __globalDispatcher runs jobs under the runtime lock. Every native entry into that runtime (frame callbacks, promise resolution, shared value listeners) takes the react-native-worklets runtime mutex first. Hot reload, serializables and shared value reads touch the runtime from other threads, and Hermes' reentrancy check traps otherwise.

Reanimated only allows shared value listeners on the UI runtime, so useSharedValueListener registers there and forwards changes to the Filament runtime by listener id. State shared across runtimes (surface liveness, animator crossfade, last applied transform) lives in createSynchronizable values.

Two Android details worth knowing while reviewing:

  • libc++ on Android compares type_info by pointer, and typeinfo for worklets::AsyncQueue is a weak symbol with one copy per .so. A dynamic_pointer_cast across libraries then fails depending on dlopen order, so instead of subclassing AsyncQueue we hand react-native-worklets an AsyncQueueUI with a custom UIScheduler.
  • Hermes finalizes HybridObjects on its GC thread. A wrapper holding the last reference to a Filament resource then calls JDispatcher from a thread that is not attached to the JVM. JDispatcher now wraps its JNI calls in jni::ThreadScope::WithClassLoader, like the other native finalizers already did.

withCleanupScope used to wait on InteractionManager, which React Native 0.87 removed from core. It now defers by one tick. The shorter delay exposed three teardown races that were already in the code base: a material destroyed while a renderable still used it, the recorder surface freed before Filament ran createSwapChain, and the GC thread JNI call above. All three are fixed here.

Testing

  • Example app on React Native 0.83.1, iPhone 17 Pro simulator and an arm64 Android emulator with host GPU. A throwaway React Native 0.87.1 app with the New Architecture, Reanimated 4.6.0 and react-native-worklets 0.12.1 on both platforms, including both library load orders on Android.
  • The example has a new Stress screen that pushes and pops all 17 example scenes. Both platforms pass 3 rounds at 1.5 s per screen and 5 rounds at 250 ms per screen (scenes unmount while their assets are still loading) without a crash. On iOS the fast run created and destroyed 160 engines.
  • Checked by hand: GLB with IBL, shared value rotation, animator crossfade, and in the 0.87 app drag with inertia plus device tilt through useAnimatedSensor.

Not done: no physical device yet. Under the 250 ms loop the Android emulator logs Java OutOfMemoryError while loading assets, without crashing; model buffers are only freed once Hermes collects the wrapper, so having useModel release its buffer after loadResources would help. The offscreen recording example double starts under StrictMode and logs a rejected promise, which predates this branch.

Since rc.5

A night of looping every example screen (3 rounds at 1.5 s, 5 rounds at 250 ms, a 20 round soak with memory sampling), JS reloads, Fast Refresh, backgrounding and rotation on both platforms, plus a React Native 0.87.1 app, turned up a few more things, all in this branch:

  • Buffers that finish loading after their effect cleaned up are released even with releaseOnUnmount: false; they piled up on Android until asset loads hit OutOfMemoryError. Skybox releases its texture buffer on unmount.
  • useLightEntity destroys its light on unmount.
  • FilamentView no longer rejects when the native view is gone before findFilamentView resolves.
  • The recording example stops the recorder only when one runs, the test hybrid object initialises its enum, enableTransparentRendering={false} documents that a Skybox is required, and there is a TwoScenes example with two engines on one screen.

Memory over the soak stayed flat (Android PSS 688 MB from the first to the last sample, iOS RSS falling from 180 MB to 108 MB), thread counts stable, and Filament engines are created and destroyed in equal numbers once Hermes collects the wrappers.

CI

The workflows were already red before this branch touched them. Two commits at the tip fix that and can be cherry-picked on their own:

  • macos-latest now means macos-26 with Xcode 26, whose clang refuses the two virtual methods declared inside the final PlatformMetal class while the Metal backend compiles with -Werror. filament_clang21.patch applies the two-line change from Fix macOS build with clang 21 google/filament#9928 until the submodule is bumped past it.
  • android-actions/setup-android@v3 runs sdkmanager tools, a package that no longer exists, so both Android jobs died before compiling anything. v4 installs only the packages it is asked for.
  • The docs workflows ran yarn classic inside package/, which finds the root bun workspace and refuses it because the root is not private. They now install the workspace with bun the way validate-js does, and the root is marked private.

Prereleases with the Filament and Bullet binaries are on the fork: https://github.com/eduardoborges/react-native-filament/releases (latest v2.0.0-rc.6).

…core

Replaces react-native-worklets-core with react-native-worklets, the worklet
runtime of Reanimated 4. Continues margelo#327.

Native
- The Filament worklet runtime is a react-native-worklets runtime created from
  JS with a custom AsyncQueue that forwards jobs to the render thread
  (WorkletAsyncQueue). Its global dispatcher runs jobs under the runtime lock
  (WorkletRuntimeDispatcher).
- Every native call into a worklet runtime (frame callbacks, promise
  resolution, listeners) holds the react-native-worklets runtime mutex
  (WorkletRuntimeLock). The runtime is touched from other threads by hot
  reload, custom serializables and shared value reads, so entering it from the
  render thread without the lock traps in Hermes' reentrancy check.
- The runtime collector global is renamed so it no longer clobbers the one
  react-native-worklets installs.
- iOS gets the runtime and CallInvoker from RCTBridgeProxy and
  RCTCallInvokerModule; RCTCxxBridge is gone in React Native 0.87.
- Android links the react-native-worklets prefab, compiles as C++20 and uses
  the folly flags its headers need. The CallInvoker comes from ReactContext.

JS
- Hooks run worklets with runOnRuntimeAsync / scheduleOnRuntime.
- Reanimated shared values are accepted directly. Listeners live on the UI
  runtime (Reanimated only allows them there) and forward changes to the
  Filament runtime by listener id (useSharedValueListener).
- Cross-runtime state (surface liveness, animator state, aspect ratio,
  last applied transform) uses createSynchronizable.
- Render callbacks are kept in React state; the FilamentView re-installs its
  frame listener when the list changes.
- useSyncSharedValue and useDerivedValue are removed: Reanimated's own
  shared values and useDerivedValue work directly.

Peers: react-native-worklets >= 0.12, react-native-reanimated >= 4.
…own their asset

Render callbacks are registered in a registry on the Filament runtime. Adding
and removing them are jobs on that runtime, so they stay ordered with the
release of the resources a callback uses. Keeping the list in React state made
the FilamentView re-install its frame listener only after a re-render, and a
frame could still run the old callback after its asset was destroyed.

AnimatorWrapper and FilamentInstanceWrapper now hold a shared_ptr to the
FilamentAsset that owns the gltfio Animator and FilamentInstance they wrap.
A frame callback that still holds an animator after the asset was released
no longer reads freed memory (SIGSEGV in Animator::getAnimationCount on
unmount).
withCleanupScope deferred the release with InteractionManager.runAfterInteractions,
which throws on React Native 0.87. A setTimeout does the same job: it runs after
the other cleanup functions of the commit.
Without ndkVersion the Android Gradle Plugin of React Native 0.87 picks NDK 28
while React Native and the other modules use NDK 27. The libc++ ABIs differ and
dlopen fails with a missing __cxa_init_primary_exception.
… subclass of AsyncQueue

react-native-worklets finds the queue of a custom runtime with a dynamic_pointer_cast on
the object it gets from JS. Android's libc++ compares type_info by pointer, and the
type_info of worklets::AsyncQueue is a weak symbol every library gets its own copy of,
bound in dlopen order. A subclass defined here fails the cast (silently: null queue,
then an assert on the first schedule) whenever libworklets.so loads before this library,
which Reanimated makes the common case.

AsyncQueueUI is defined inside libworklets.so, so its type chain always matches. The
Filament queue is now an AsyncQueueUI with a UIScheduler that forwards every job to
the render thread.
Hermes finalizes HybridObjects on its GC thread (hades). When such a wrapper
holds the last reference to a Filament resource, the resource deleter calls
JDispatcher::runAsync from that thread, which is not attached to the JVM, and
fbjni throws inside a destructor chain: std::terminate, "Unable to retrieve
jni environment". Wrap scheduleTrigger() and the destructor in
jni::ThreadScope::WithClassLoader like the other native finalizers do.
… with them

Cycling every example screen exposed two teardown races that the old
InteractionManager delay used to hide:

- The recorder (and view) native window could be released while Filament
  still had createSwapChain queued on its backend thread, crashing inside
  eglCreateWindowSurface. The swapchain deleter now owns the surface or
  recorder until the swapchain is destroyed and flushed.
- Releasing a material while a renderable still referenced one of its
  instances hit a Filament precondition abort. Material deleters now detach
  such renderables first, and RenderableManager.destroyEntity() lets
  components free shapes created with createPlane, createImageBackgroundShape
  or createDebugCubeWireframe. BackgroundImage and DebugBox use it.
…d up

StrictMode runs the useBuffer effect twice. The first load could resolve after
its cleanup scheduled the release, so the released buffer landed in state and
the next createMaterial failed with "already been manually released".

Also document that useWorkletCallback returns a JS trampoline that must not be
captured inside another worklet or handed to native callbacks.
…allback

The Stress screen pushes and pops every example screen for a number of rounds
and logs progress as [stress:<platform>]. The 250 ms variant unmounts scenes
while their assets are still loading.

PhysicsCoin passed a useWorkletCallback trampoline to Bullet, which cannot
call it from the worklet runtime. CastShadow destroys its shadow plane before
the material is released.
tsc in examples/Shared failed on navigation.navigate('Test') because the
untyped useNavigation() resolves the route name to never.
…ting after unmount

A buffer that finishes loading after its effect cleaned up has no consumer,
so useBuffer releases it even when releaseOnUnmount is false. Under a fast
mount and unmount loop those buffers piled up until Hermes collected the
wrappers and the Android emulator ran out of Java heap while loading assets.
Skybox now also releases its texture buffer on unmount; the KTX path copies
the bytes into a bundle, so nothing else needs it.

findFilamentView rejects when the native view is already gone. FilamentView
swallows that only when the component unmounted in the meantime.
… it runs

createNewHybridObject() returned an object whose enum member was never set,
so logging it threw "Invalid Enum passed". The recording example stopped the
recorder from a StrictMode cleanup before anything was recorded, which throws
in native on both platforms.
…ds a skybox

useLightEntity created a light per mount and never destroyed it. The example
gets a 20 round soak entry on the stress screen.
Two FilamentScene instances side by side, each with its own engine and a
Reanimated driven rotation. Part of the stress list.
Reanimated warns that the argument is only used by its web implementation.
@eduardoborges
eduardoborges force-pushed the v2/react-native-worklets branch from 591443c to 978d132 Compare September 3, 2026 13:31
@eduardoborges
eduardoborges marked this pull request as ready for review September 4, 2026 17:43
The macos-26 runners moved to Xcode 26, whose clang refuses the two virtual
methods declared inside the final PlatformMetal class, and the Metal backend
compiles with -Werror. Xcode 27 beta (Apple clang 21) does the same locally.
Upstream dropped the virtual keyword in google/filament#9928.
filament_clang21.patch carries that change until the submodule moves past it.
setup-android v3 still runs "sdkmanager tools", a package that no longer
exists, so every Android build died before compiling anything. v4 installs
only the packages it is given.

The docs workflows ran yarn classic inside package/, which walks up to the
root workspace and refuses it because the root is not private. The repo is a
bun workspace, so install it with bun the way validate-js does, and mark the
root private for anyone who still runs yarn there.

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant