refactor: deliver animated hinges through a native Fabric view - #9
Merged
Merged
Conversation
The Reanimated entry point registered the handler returned by useEvent against the React root tag, which has no component and no event emitter. Reaching that tag needed private surface area on both platforms: iOS looked up an already loaded ReanimatedModule and added it as an RCTEventDispatcher observer, then emitted through notifyObserversOfEvent; Android needed a Java event class to reference the internal FabricEventEmitter type and to override dispatchModern, otherwise every update accumulated in the pending event queue of SurfaceMountingManager. The library now ships a HingesObserverView Fabric component. The new AnimatedHingesProvider mounts one, sized 0x0 and not interactive, holds the shared value and attaches the useEvent handler to the onHingesChange direct event of that view. That is the ordinary path for a custom Fabric view event, so both hacks are deleted along with HingesChangeEvent.java. On iOS the view owns a UIHingeInteraction under the same SDK and availability guard the module uses. On Android the observation moved out of the module into a shared HingeSource used by both the module and the view. Reanimated registers worklet event handlers on the UI runtime asynchronously, so after mount the provider hops through scheduleOnUI and back with scheduleOnRN before sending the refresh command to the view, which re-emits the current snapshot for the now registered handler. useAnimatedHinges keeps its signature but now reads the shared value from the provider and throws when rendered without one. The core entry point is unchanged.
janicduplessis
force-pushed
the
@janic/animated-hinges-provider
branch
from
September 21, 2026 11:00
96e56fd to
83017e8
Compare
janicduplessis
marked this pull request as ready for review
September 21, 2026 11:01
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
The Reanimated entry point used to receive hinge updates through private React
Native surface area.
useAnimatedHinges()took the handler that Reanimated'suseEventreturns, reached into itsworkletEventHandler, and registered itagainst the React root tag, which has no component and therefore no event
emitter. Making an event reach that tag needed a hack on each platform:
HingesModuleemitted throughRCTEventDispatcher'snotifyObserversOfEvent, and had to look up an already-loadedReanimatedModulefrom the module registry andaddDispatchObserverit,because Reanimated 4.7 registers itself in
setBridge, which can run beforeReact Native injects its module registry.
HingesChangeEventhad to be written in Java rather than Kotlinpurely to reference
FabricEventEmitter, which React Native declaresinternal. It overrodedispatchModernto skip that emitter, becauseotherwise every hinge update accumulated without bound in
the
pendingEventQueueofSurfaceMountingManager, a queue only drained byupdateEventEmitter, which never runs for a root tag.Both depend on implementation details that can change in any React Native or
Reanimated release, and the second was an unbounded memory growth hazard that
only an override kept in check.
Solution
The library now ships a real Fabric component,
HingesObserverView, and theReanimated entry point mounts one.
AnimatedHingesProviderrenders it 0x0,absolutely positioned and non-interactive, holds the shared value, and attaches
Reanimated's
useEventhandler to the view's ownonHingesChangeDirectEventHandler. That is the ordinary public path for a custom Fabric viewevent, so no private type is involved on either platform and the view has a real
event emitter.
The native observation itself is unchanged in behavior. On iOS the view owns a
UIHingeInteractionunder the same SDK and@availableguard the module uses.On Android the observation logic that lived inside
HingesModule.Observationmoved into a shared
HingeSource, which both the module and the view now use,so the WindowManager and sensor mapping is defined once.
The remaining ordering step. Reanimated registers a worklet event handler on
the UI runtime asynchronously, so a snapshot emitted at mount can arrive before
anything is listening. After mount the provider hops
scheduleOnUI→scheduleOnRNand then sends the view'srefreshcommand, which makes the viewre-emit its current snapshot for the now-registered handler. The callback given
to
scheduleOnRNmust be defined on the React Native runtime; a function createdinside the worklet throws at runtime, which is why it is hoisted out of the
scheduleOnUIbody.API change
The Reanimated entry point now requires a provider:
useAnimatedHinges(): SharedValue<readonly Hinge[]>keeps its signature. It nolonger reads
RootTagContextor calls the TurboModule; it reads the provider'sshared value and throws a named error when there is no provider above it. Every
consumer under one provider now shares one value and one native observation,
where each hook previously owned its own. The core entry point
(
useHinges,createHingeObserver, the TurboModule) is untouched.Test plan
iOS, own iPhone Duo simulator on iOS 27.1, Xcode 27.1 beta, Release build
with no Metro. Cold launch with the device closed: Sensor Lab shows
NATIVE ANGLE 0.0°,closed,1 readings receivedand theNATIVE HINGEpill. Those come from
useHingeTelemetry(useAnimatedHinges()), so they aredelivery through the new view. Field Notes shows
NATIVE HINGE/NATIVE 0.0°fromuseHinges(), confirming the unchanged core path.Compile guard: the
Hingespod target builds for arm64 and x86_64 againstiPhoneSimulator27.0.sdkwith the default Xcode 27.0 toolchain, compilingHingesObserverView.mmwith the interaction guarded out.Android,
pixel_9_pro_foldemulator on API 36, Release variant with theembedded bundle. Driving
hinge-angle0through the emulator console:45 degreesgives45.0°/partiallyOpenand180 degreesgives180.0°/fullyOpenin the Sensor Lab, and a relaunch lands on Field Notes showingNATIVE 180.0°. The Sensor Lab values come fromuseHingeTelemetry(useAnimatedHinges()).pnpm e2e:androiditself could not run: another agent-device session has heldthat emulator's lease since early this morning, so its cold launch fails with
DEVICE_IN_USE. The readings above were taken with plainadbanduiautomator dump, which covers the same states the script asserts. Thecommitted check is still unexercised against this branch.
The unit tests do not model the Worklets restriction on
scheduleOnRN, so theypassed against a build that could not launch. The crash was only caught on
device.
docs/verification/animated-provider-view-2026-09-21.mdrecords what wasand was not established.
Risk
Anyone importing
react-native-hinges/reanimatedmust add the provider oruseAnimatedHinges()throws. The change adds a mounted native view where the oldmechanism added none, which is visible in the view hierarchy but has no layout
effect. Changing iOS hinge angles and any physical hardware remain unverified on
both the old and new mechanism.