Skip to content

refactor: deliver animated hinges through a native Fabric view - #9

Merged
janicduplessis merged 1 commit into
mainfrom
@janic/animated-hinges-provider
Sep 21, 2026
Merged

janicduplessis merged 1 commit into
mainfrom
@janic/animated-hinges-provider

Conversation

@janicduplessis

@janicduplessis janicduplessis commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Description

The Reanimated entry point used to receive hinge updates through private React
Native surface area. useAnimatedHinges() took the handler that Reanimated's
useEvent returns, reached into its workletEventHandler, and registered it
against 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:

  • iOS: HingesModule emitted through RCTEventDispatcher's
    notifyObserversOfEvent, and had to look up an already-loaded
    ReanimatedModule from the module registry and addDispatchObserver it,
    because Reanimated 4.7 registers itself in setBridge, which can run before
    React Native injects its module registry.
  • Android: HingesChangeEvent had to be written in Java rather than Kotlin
    purely to reference FabricEventEmitter, which React Native declares
    internal. It overrode dispatchModern to skip that emitter, because
    otherwise every hinge update accumulated without bound in
    the pendingEventQueue of SurfaceMountingManager, a queue only drained by
    updateEventEmitter, 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 the
Reanimated entry point mounts one. AnimatedHingesProvider renders it 0x0,
absolutely positioned and non-interactive, holds the shared value, and attaches
Reanimated's useEvent handler to the view's own onHingesChange
DirectEventHandler. That is the ordinary public path for a custom Fabric view
event, 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
UIHingeInteraction under the same SDK and @available guard the module uses.
On Android the observation logic that lived inside HingesModule.Observation
moved 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 →
scheduleOnRN and then sends the view's refresh command, which makes the view
re-emit its current snapshot for the now-registered handler. The callback given
to scheduleOnRN must be defined on the React Native runtime; a function created
inside the worklet throws at runtime, which is why it is hoisted out of the
scheduleOnUI body.

API change

The Reanimated entry point now requires a provider:

import { AnimatedHingesProvider, useAnimatedHinges } from 'react-native-hinges/reanimated';

export default function App() {
  return (
    <AnimatedHingesProvider>
      <Screen />
    </AnimatedHingesProvider>
  );
}

useAnimatedHinges(): SharedValue<readonly Hinge[]> keeps its signature. It no
longer reads RootTagContext or calls the TurboModule; it reads the provider's
shared 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 received and the NATIVE HINGE
    pill. Those come from useHingeTelemetry(useAnimatedHinges()), so they are
    delivery through the new view. Field Notes shows NATIVE HINGE /
    NATIVE 0.0° from useHinges(), confirming the unchanged core path.

  • Compile guard: the Hinges pod target builds for arm64 and x86_64 against
    iPhoneSimulator27.0.sdk with the default Xcode 27.0 toolchain, compiling
    HingesObserverView.mm with the interaction guarded out.

  • Android, pixel_9_pro_fold emulator on API 36, Release variant with the
    embedded bundle. Driving hinge-angle0 through the emulator console:
    45 degrees gives 45.0° / partiallyOpen and 180 degrees gives 180.0° /
    fullyOpen in the Sensor Lab, and a relaunch lands on Field Notes showing
    NATIVE 180.0°. The Sensor Lab values come from
    useHingeTelemetry(useAnimatedHinges()).

    pnpm e2e:android itself could not run: another agent-device session has held
    that emulator's lease since early this morning, so its cold launch fails with
    DEVICE_IN_USE. The readings above were taken with plain adb and
    uiautomator dump, which covers the same states the script asserts. The
    committed check is still unexercised against this branch.

The unit tests do not model the Worklets restriction on scheduleOnRN, so they
passed against a build that could not launch. The crash was only caught on
device. docs/verification/animated-provider-view-2026-09-21.md records what was
and was not established.

Risk

Anyone importing react-native-hinges/reanimated must add the provider or
useAnimatedHinges() throws. The change adds a mounted native view where the old
mechanism 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.

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
janicduplessis force-pushed the @janic/animated-hinges-provider branch from 96e56fd to 83017e8 Compare September 21, 2026 11:00
@janicduplessis
janicduplessis marked this pull request as ready for review September 21, 2026 11:01
@janicduplessis
janicduplessis merged commit 638db8f into main Sep 21, 2026
4 checks passed
@janicduplessis
janicduplessis deleted the @janic/animated-hinges-provider branch September 21, 2026 11:15
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