): WritableMap = Arguments.createMap().apply {
+ putArray("hinges", Arguments.createArray().apply {
+ hinges.forEach { hinge ->
+ pushMap(Arguments.createMap().apply {
+ putString("status", hinge.status)
+ putDouble("angle", hinge.angle ?: 0.0)
+ putBoolean("hasAngle", hinge.angle != null)
+ })
+ }
+ })
+}
diff --git a/android/src/main/java/com/appandflow/hinges/HingesChangeEvent.java b/android/src/main/java/com/appandflow/hinges/HingesChangeEvent.java
deleted file mode 100644
index 6f73fed..0000000
--- a/android/src/main/java/com/appandflow/hinges/HingesChangeEvent.java
+++ /dev/null
@@ -1,45 +0,0 @@
-package com.appandflow.hinges;
-
-import com.facebook.react.bridge.WritableMap;
-import com.facebook.react.fabric.events.FabricEventEmitter;
-import com.facebook.react.uimanager.events.Event;
-import com.facebook.react.uimanager.events.RCTModernEventEmitter;
-
-/**
- * This file is Java, not Kotlin, because {@code FabricEventEmitter} is declared {@code internal} in
- * react-native's Kotlin sources: Java can reference it, Kotlin cannot.
- *
- * Without the {@link #dispatchModern} override, react-native routes this root-tag event through
- * {@code FabricUIManager.receiveEvent} into
- * {@code fabric/mounting/SurfaceMountingManager.kt#dispatchEvent}, which appends events for a
- * {@code ViewState} with no {@code eventEmitter} to {@code pendingEventQueue}. That queue is only
- * drained by {@code updateEventEmitter}, which never runs for the root tag, so every hinge update
- * would accumulate there without bound.
- */
-final class HingesChangeEvent extends Event {
- private final WritableMap payload;
-
- HingesChangeEvent(int rootTag, WritableMap payload) {
- super(rootTag, rootTag);
- this.payload = payload;
- }
-
- @Override
- public String getEventName() {
- return "topHingesChange";
- }
-
- @Override
- protected WritableMap getEventData() {
- return payload;
- }
-
- @Override
- public void dispatchModern(RCTModernEventEmitter emitter) {
- // RN 0.88 notifies native event observers before FabricEventEmitter. The root
- // has no hinges prop/event emitter; only observers such as Reanimated consume this event.
- if (!(emitter instanceof FabricEventEmitter)) {
- super.dispatchModern(emitter);
- }
- }
-}
diff --git a/android/src/main/java/com/appandflow/hinges/HingesModule.kt b/android/src/main/java/com/appandflow/hinges/HingesModule.kt
index 6c0b42a..fd55ef3 100644
--- a/android/src/main/java/com/appandflow/hinges/HingesModule.kt
+++ b/android/src/main/java/com/appandflow/hinges/HingesModule.kt
@@ -1,22 +1,6 @@
package com.appandflow.hinges
-import android.app.Activity
-import android.content.Context
-import android.content.ContextWrapper
-import android.hardware.Sensor
-import android.hardware.SensorEvent
-import android.hardware.SensorEventListener
-import android.hardware.SensorManager
-import android.os.Build
import android.view.View
-import androidx.core.content.ContextCompat
-import androidx.core.util.Consumer
-import androidx.window.WindowSdkExtensions
-import androidx.window.java.layout.WindowInfoTrackerCallbackAdapter
-import androidx.window.layout.FoldingFeature
-import androidx.window.layout.WindowInfoTracker
-import androidx.window.layout.WindowLayoutInfo
-import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.bridge.WritableMap
@@ -24,8 +8,6 @@ import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.uimanager.UIManagerHelper
import java.util.concurrent.ConcurrentHashMap
-private data class HingeState(val status: String, val angle: Double?)
-
@ReactModule(name = HingesModule.NAME)
class HingesModule(context: ReactApplicationContext) : NativeHingesSpec(context) {
private val observations = mutableMapOf()
@@ -89,110 +71,31 @@ class HingesModule(context: ReactApplicationContext) : NativeHingesSpec(context)
private fun emit(rootTag: Int, hinges: List) {
if (invalidated) return
- UIManagerHelper.getEventDispatcher(reactApplicationContext)
- ?.dispatchEvent(HingesChangeEvent(rootTag, payload(hinges)))
emitOnHingesChange(payload(hinges).apply { putDouble("rootTag", rootTag.toDouble()) })
}
- private inner class Observation(val rootTag: Int, val root: View) :
- SensorEventListener, View.OnAttachStateChangeListener {
+ private inner class Observation(val rootTag: Int, val root: View) : View.OnAttachStateChangeListener {
var retainCount = 1
- private val sensorManager = root.context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
- private var hingeSensor: Sensor? = null
- private var hingeAngle: Double? = null
- private var tracker: WindowInfoTrackerCallbackAdapter? = null
- private var foldingFeatures: List = emptyList()
- private var active = false
- private val layoutInfoConsumer = Consumer { info ->
- if (active) {
- foldingFeatures = info.displayFeatures.filterIsInstance()
- update()
- }
+ private val source = HingeSource(root.context) { hinges ->
+ if (observations[rootTag] !== this) return@HingeSource
+ snapshots[rootTag] = hinges
+ emit(rootTag, hinges)
}
- fun start() {
- if (active) return
- val activity = root.context.findActivity() ?: return
- active = true
- if (Build.VERSION.SDK_INT >= 30) {
- hingeSensor = sensorManager.getDefaultSensor(Sensor.TYPE_HINGE_ANGLE)
- hingeSensor?.let { sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_NORMAL) }
- }
- val windowTracker = WindowInfoTracker.getOrCreate(activity)
- if (WindowSdkExtensions.getInstance().extensionVersion >= 9) {
- foldingFeatures = windowTracker.getCurrentWindowLayoutInfo(activity)
- .displayFeatures.filterIsInstance()
- }
- tracker = WindowInfoTrackerCallbackAdapter(windowTracker).also {
- it.addWindowLayoutInfoListener(activity, ContextCompat.getMainExecutor(activity), layoutInfoConsumer)
- }
- update()
- }
+ fun start() = source.start()
- fun stop() {
- active = false
- tracker?.removeWindowLayoutInfoListener(layoutInfoConsumer)
- tracker = null
- sensorManager.unregisterListener(this)
- hingeSensor = null
- hingeAngle = null
- foldingFeatures = emptyList()
- }
+ fun stop() = source.stop()
- override fun onViewAttachedToWindow(view: View) = start()
+ override fun onViewAttachedToWindow(view: View) = source.start()
override fun onViewDetachedFromWindow(view: View) {
- stop()
+ source.stop()
snapshots[rootTag] = emptyList()
emit(rootTag, emptyList())
}
-
- override fun onSensorChanged(event: SensorEvent) {
- if (!active) return
- hingeAngle = Math.toRadians(event.values[0].toDouble())
- update()
- }
-
- override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
-
- private fun update() {
- // Android sensors have no WindowManager feature ID; do not associate multiple hinges.
- val hinges = foldingFeatures.map { feature ->
- val status = when (feature.state) {
- FoldingFeature.State.FLAT -> "fullyOpen"
- FoldingFeature.State.HALF_OPENED -> "partiallyOpen"
- else -> "unknown"
- }
- HingeState(status, if (foldingFeatures.size == 1) hingeAngle else null)
- }.ifEmpty {
- if (hingeSensor != null) listOf(HingeState("unknown", hingeAngle)) else emptyList()
- }
- if (snapshots[rootTag] != hinges) {
- snapshots[rootTag] = hinges
- emit(rootTag, hinges)
- }
- }
}
companion object {
const val NAME = "NativeHinges"
}
}
-
-private fun Context.findActivity(): Activity? = when (this) {
- is Activity -> this
- is ContextWrapper -> baseContext.findActivity()
- else -> null
-}
-
-private fun payload(hinges: List): WritableMap = Arguments.createMap().apply {
- putArray("hinges", Arguments.createArray().apply {
- hinges.forEach { hinge ->
- pushMap(Arguments.createMap().apply {
- putString("status", hinge.status)
- putDouble("angle", hinge.angle ?: 0.0)
- putBoolean("hasAngle", hinge.angle != null)
- })
- }
- })
-}
diff --git a/android/src/main/java/com/appandflow/hinges/HingesObserverView.kt b/android/src/main/java/com/appandflow/hinges/HingesObserverView.kt
new file mode 100644
index 0000000..8681360
--- /dev/null
+++ b/android/src/main/java/com/appandflow/hinges/HingesObserverView.kt
@@ -0,0 +1,41 @@
+package com.appandflow.hinges
+
+import android.content.Context
+import com.facebook.react.bridge.ReactContext
+import com.facebook.react.bridge.WritableMap
+import com.facebook.react.uimanager.UIManagerHelper
+import com.facebook.react.uimanager.events.Event
+import com.facebook.react.views.view.ReactViewGroup
+
+internal class HingesChangeEvent(surfaceId: Int, viewTag: Int, private val data: WritableMap) :
+ Event(surfaceId, viewTag) {
+ override fun getEventName() = EVENT_NAME
+
+ override fun getEventData() = data
+
+ companion object {
+ const val EVENT_NAME = "topHingesChange"
+ }
+}
+
+class HingesObserverView(context: Context) : ReactViewGroup(context) {
+ private val source = HingeSource(context) { emitChange(it) }
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ source.start()
+ }
+
+ override fun onDetachedFromWindow() {
+ source.stop()
+ super.onDetachedFromWindow()
+ }
+
+ fun refresh() = emitChange(source.hinges)
+
+ private fun emitChange(hinges: List) {
+ val reactContext = context as? ReactContext ?: return
+ val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id) ?: return
+ dispatcher.dispatchEvent(HingesChangeEvent(UIManagerHelper.getSurfaceId(this), id, payload(hinges)))
+ }
+}
diff --git a/android/src/main/java/com/appandflow/hinges/HingesObserverViewManager.kt b/android/src/main/java/com/appandflow/hinges/HingesObserverViewManager.kt
new file mode 100644
index 0000000..08697e6
--- /dev/null
+++ b/android/src/main/java/com/appandflow/hinges/HingesObserverViewManager.kt
@@ -0,0 +1,29 @@
+package com.appandflow.hinges
+
+import com.facebook.react.module.annotations.ReactModule
+import com.facebook.react.uimanager.SimpleViewManager
+import com.facebook.react.uimanager.ThemedReactContext
+import com.facebook.react.uimanager.ViewManagerDelegate
+import com.facebook.react.viewmanagers.HingesObserverViewManagerDelegate
+import com.facebook.react.viewmanagers.HingesObserverViewManagerInterface
+
+@ReactModule(name = HingesObserverViewManager.NAME)
+class HingesObserverViewManager :
+ SimpleViewManager(), HingesObserverViewManagerInterface {
+ private val delegate = HingesObserverViewManagerDelegate(this)
+
+ override fun getDelegate(): ViewManagerDelegate = delegate
+
+ override fun getName() = NAME
+
+ override fun createViewInstance(context: ThemedReactContext) = HingesObserverView(context)
+
+ override fun refresh(view: HingesObserverView) = view.refresh()
+
+ override fun getExportedCustomDirectEventTypeConstants(): MutableMap =
+ mutableMapOf(HingesChangeEvent.EVENT_NAME to mutableMapOf("registrationName" to "onHingesChange"))
+
+ companion object {
+ const val NAME = "HingesObserverView"
+ }
+}
diff --git a/android/src/main/java/com/appandflow/hinges/HingesPackage.kt b/android/src/main/java/com/appandflow/hinges/HingesPackage.kt
index 9b59283..4780513 100644
--- a/android/src/main/java/com/appandflow/hinges/HingesPackage.kt
+++ b/android/src/main/java/com/appandflow/hinges/HingesPackage.kt
@@ -8,7 +8,8 @@ import com.facebook.react.module.model.ReactModuleInfoProvider
import com.facebook.react.uimanager.ViewManager
class HingesViewPackage : BaseReactPackage() {
- override fun createViewManagers(reactContext: ReactApplicationContext): List> = emptyList()
+ override fun createViewManagers(reactContext: ReactApplicationContext): List> =
+ listOf(HingesObserverViewManager())
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
if (name == HingesModule.NAME) HingesModule(reactContext) else null
diff --git a/docs/verification/animated-provider-view-2026-09-21.md b/docs/verification/animated-provider-view-2026-09-21.md
new file mode 100644
index 0000000..222b71d
--- /dev/null
+++ b/docs/verification/animated-provider-view-2026-09-21.md
@@ -0,0 +1,142 @@
+# Animated hinges provider view: September 21, 2026
+
+Verification of the replacement of the Reanimated delivery mechanism. The
+optional entry point now exposes `AnimatedHingesProvider`, which renders a
+hidden `HingesObserverView` Fabric component and feeds its `onHingesChange`
+direct event to a Reanimated `useEvent` handler. The previous mechanism
+registered a root-tag event handler and depended on `notifyObserversOfEvent`
+plus an explicit Reanimated module registration on iOS, and on React Native's
+`internal` `FabricEventEmitter` type on Android. Both are removed.
+
+Stack: React Native `0.88.0-rc.1`, Reanimated `4.7.0`, Worklets `0.13.0`,
+example app `hinges.example`.
+
+## Repository checks
+
+`pnpm run format`, `format:check`, `lint`, `typecheck`, `test`, `build` and
+`docs:build` all pass on the working tree. The rewritten
+`src/__tests__/reanimated.test.tsx` covers the seeded `[]` value, worklet updates
+without a React render, the `refresh` request after the scheduling hop, no
+request after unmount, and the error thrown outside the provider.
+`src/__tests__/index.test.tsx` still asserts that the core entry point loads
+neither Reanimated nor Worklets.
+
+## iOS
+
+Own iPhone Duo simulator `A7052FCA-BF42-4AC9-96E4-3B4D5EA4424B`
+(`hinges-provider-duo`, iOS 27.1), built with Xcode 27.1 beta, Release
+configuration, installed and cold launched with `simctl`. No Metro server: the
+Release variant embeds the bundle.
+
+The first Release build of the new provider crashed on launch:
+
+```
+[Worklets] Locally defined function passed to scheduleOnRN. Only functions
+defined on the RN Runtime or host functions can be scheduled on the RN Runtime.
+```
+
+The callback passed to `scheduleOnRN` was created inside the `scheduleOnUI`
+worklet. Defining it on the React Native runtime and referencing it from the
+worklet fixed it. The Jest mocks do not model this Worklets restriction, so the
+unit tests passed while the app could not start. That gap remains.
+
+After the fix, a cold launch with the simulator held closed produced:
+
+| Consumer | Observed value |
+| ------------------------------ | ------------------------------------ |
+| Sensor Lab `NATIVE ANGLE` | `0.0°`, `closed`, 1 reading received |
+| Sensor Lab mode pill | `NATIVE HINGE` |
+| Sensor Lab React `useHinges()` | `0.000 rad` |
+| Sensor Lab standalone observer | `0.000 rad` |
+| Field Notes native readout | `NATIVE HINGE`, `NATIVE 0.0°` |
+
+The Sensor Lab angle, status and reading count come from
+`useHingeTelemetry(useAnimatedHinges())`, so a non-empty reading there is
+delivery through the new view to the shared value on the UI runtime. The Field
+Notes readout comes from `useHinges()` and confirms the unchanged core path.
+Screenshots: `/tmp/hinges-provider-ios/01-field-notes.png` and
+`/tmp/hinges-provider-ios/02-sensor-lab-native-angle.png`.
+
+Capture used the active Duo display explicitly. `xcrun simctl io
+enumerate` lists two `Display class: 0` framebuffers; the 2007x2853 inner
+display is black while the device is closed, and agent-device's own screenshot
+also defaulted to it.
+
+The simulator only reports a static closed hinge. Changing iOS angles, physical
+hardware, several simultaneously mounted providers, and first-visible-frame
+availability were not established by this run.
+
+### Compile guard
+
+The library also builds against the older SDK. With the default Xcode 27.0
+toolchain, a `generic/platform=iOS Simulator` destination and
+`CODE_SIGNING_ALLOWED=NO`, the `Hinges` pod target succeeded for both arm64 and
+x86_64 against `iPhoneSimulator27.0.sdk`, compiling `HingesObserverView.mm`.
+The full application scheme was not built with that toolchain because the host
+disk filled during linking; the target that contains the new code was.
+
+`HingesObserverView.mm` keeps the same
+`__IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_27_1` guard and
+`@available(iOS 27.1, *)` check the module used, so an older SDK compiles the
+interaction out and the view emits nothing.
+
+## Android
+
+Emulator `emulator-5580`, AVD `reserved-regions-fold-demo`, a `pixel_9_pro_fold`
+on API 36 with a hinge sensor. Release variant with the embedded bundle, no
+Metro.
+
+The same Worklets crash appeared here, and for the same reason: the gradle
+bundle task did not re-run when only the library source outside `example/`
+changed, so the first rebuild after the fix still packaged the old bundle.
+Deleting `app/build/generated/assets/react/release/index.android.bundle` before
+`:app:assembleRelease` produced a bundle that launches.
+
+With that build installed, driving `hinge-angle0` through the emulator console
+produced:
+
+| Input | Sensor Lab reading | Field Notes readout |
+| ------------- | ------------------------ | ------------------- |
+| 180 degrees | `NATIVE HINGE` pill | |
+| 45 degrees | `45.0°`, `partiallyOpen` | |
+| 180 degrees | `180.0°`, `fullyOpen` | |
+| 180, relaunch | | `NATIVE 180.0°` |
+
+The Sensor Lab angle and status come from
+`useHingeTelemetry(useAnimatedHinges())`, so they are delivery through the new
+view. Screenshots are in `/tmp/hinges-android-evidence/`.
+
+`pnpm e2e:android` itself did not complete. Its gradle build, install and sensor
+injection succeed, then the cold launch fails:
+
+```
+FAIL open field notes: cold launch
+Error (DEVICE_IN_USE): Device is already in use by session "cwd:738c1e202b917171:default".
+```
+
+Another agent-device session has held that emulator's lease since 01:31 local
+time and last issued a command at 05:48. Twelve retries over roughly thirty
+minutes hit the same error, and the lease was not reclaimed. The readings above
+were therefore taken with plain `adb`: `am start`, `uiautomator dump` for the
+text assertions, `input tap` to open the Sensor Lab, and
+`adb emu sensor set hinge-angle0` for the input. That covers the same states the
+script asserts, but it is not a run of the committed check, and the committed
+check remains unexercised against this branch.
+
+Two further notes from this run. `screencap` needs an explicit display id on this
+AVD, which `cmd display get-displays` reports as `local:4619827259835644672`.
+And `ViewManagerPropertyUpdater` logs `Could not find generated setter for class
+com.appandflow.hinges.HingesObserverViewManager` at startup, which is expected
+for a view manager with no props; the `refresh` command still arrives through
+the generated delegate, as the readings show.
+
+## Not established
+
+- Changing iOS hinge angles, and any physical device on either platform.
+- More than one mounted `AnimatedHingesProvider`, and provider remount timing.
+- First-visible-frame availability. The provider seeds `[]` and the native view
+ emits afterwards.
+- Reanimated and React Native versions other than the ones listed above. The
+ `useEvent` handler, the `scheduleOnUI` to `scheduleOnRN` hop and the
+ `DirectEventHandler` payload are public integration points, but they have only
+ been exercised against this pair.
diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock
index 70fb30b..dfc1997 100644
--- a/example/ios/Podfile.lock
+++ b/example/ios/Podfile.lock
@@ -14,7 +14,7 @@ PODS:
- hermes-engine (260318099.0.3):
- hermes-engine/Pre-built (= 260318099.0.3)
- hermes-engine/Pre-built (260318099.0.3)
- - Hinges (0.1.0-alpha.1):
+ - Hinges (0.1.0-alpha.2):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -1836,7 +1836,7 @@ PODS:
- React-utils (= 0.88.0-rc.1)
- ReactNativeDependencies
- ReactNativeDependencies (0.88.0-rc.1)
- - ReservedRegions (0.1.0-alpha.1):
+ - ReservedRegions (0.1.0-alpha.2):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -2328,7 +2328,7 @@ SPEC CHECKSUMS:
fmt: ba0886e864ebb21d14c98b2e12e164b8002f5b93
glog: 3cf7cb46f743c4bc758ae3e6d9c15bd1b4cfd6d4
hermes-engine: 7e5f90072b91f62ead9584eebdced74fa9c742a0
- Hinges: 8564ab447c0d7fefa06843ddb26a25e5469f7677
+ Hinges: 0aa917de16d8777f334ab5124fdc336180606e46
RCT-Folly: 82921e585c0400eda46fac1068297aa9dfb035e4
RCTDeprecation: 791606bb67573791862d7af9a787878fefcf1aff
RCTRequired: 57e3c3f5e7102529bf9a42348ff77153b01cfa0b
@@ -2407,7 +2407,7 @@ SPEC CHECKSUMS:
ReactCodegen: 30f23f4b49be969efe5b85109e751434b714c533
ReactCommon: cb43d161764e3bac17dac2a80dce2b4edf5b3a2f
ReactNativeDependencies: dabb9681fdf66316a899de1ce3ef247606b513a8
- ReservedRegions: 84a8a06479c5a6c74934587a4c78111bb7a89a94
+ ReservedRegions: fcfabb0bff4b0916df575a1aeaf5088e5aec79c7
RNReanimated: fc7ee08e781ec9e8bbf45359c34ef5eaef56f560
RNWorklets: c3a33449674e0847c3d05bb730f6443290f19e6e
SocketRocket: 37aec555668fb852ec12e3c0de59a86ca58f0871
diff --git a/example/src/App.tsx b/example/src/App.tsx
index 31d5715..a07b086 100644
--- a/example/src/App.tsx
+++ b/example/src/App.tsx
@@ -23,7 +23,7 @@ import Animated, {
withTiming,
} from 'react-native-reanimated';
import { createHingeObserver, useHinges } from 'react-native-hinges';
-import { useAnimatedHinges } from 'react-native-hinges/reanimated';
+import { AnimatedHingesProvider, useAnimatedHinges } from 'react-native-hinges/reanimated';
import { useHingeTelemetry } from './useHingeTelemetry';
import { FieldNotes } from './FieldNotes';
@@ -215,21 +215,23 @@ function Choice({ label, selected, onPress }: { label: string; selected: boolean
export default function App() {
const [lab, setLab] = useState(false);
return (
-
-
-
- {lab ? (
- <>
-
- setLab(false)}>
- Back to Field Notes
-
- >
- ) : (
- setLab(true)} />
- )}
-
-
+
+
+
+
+ {lab ? (
+ <>
+
+ setLab(false)}>
+ Back to Field Notes
+
+ >
+ ) : (
+ setLab(true)} />
+ )}
+
+
+
);
}
diff --git a/ios/HingesModule.mm b/ios/HingesModule.mm
index 42a0999..3a6edbe 100644
--- a/ios/HingesModule.mm
+++ b/ios/HingesModule.mm
@@ -1,7 +1,5 @@
#import "HingesModule.h"
-#import
-#import
#import
#import
#import
@@ -23,7 +21,6 @@ @implementation HingesModule {
NSMutableDictionary *_observations;
NSMutableDictionary *> *_snapshots;
std::atomic _invalidated;
- __weak id _reanimatedObserver;
__weak RCTSurfacePresenter *_surfacePresenter;
}
@@ -32,8 +29,6 @@ + (NSString *)moduleName
return @"NativeHinges";
}
-@synthesize moduleRegistry = _moduleRegistry;
-
+ (BOOL)requiresMainQueueSetup
{
return NO;
@@ -64,12 +59,7 @@ - (NSDictionary *)getSnapshot:(double)rootTag
- (void)emitSnapshotForRoot:(NSNumber *)rootTag hinges:(NSArray *)hinges
{
if (_invalidated) return;
- NSDictionary *body = @{@"rootTag": rootTag, @"hinges": hinges};
- id dispatcher = [_moduleRegistry moduleForName:"EventDispatcher"];
- [dispatcher notifyObserversOfEvent:[[RCTComponentEvent alloc] initWithName:@"onHingesChange"
- viewTag:rootTag
- body:body]];
- [self emitOnHingesChange:body];
+ [self emitOnHingesChange:@{@"rootTag": rootTag, @"hinges": hinges}];
}
- (void)updateRoot:(NSNumber *)rootTag
@@ -88,13 +78,6 @@ - (void)startObserving:(double)rootTag
{
RCTExecuteOnMainQueue(^{
if (self->_invalidated) return;
- id reanimated = [self->_moduleRegistry moduleForName:"ReanimatedModule" lazilyLoadIfNecessary:NO];
- if (reanimated != self->_reanimatedObserver && [reanimated conformsToProtocol:@protocol(RCTEventDispatcherObserver)]) {
- // Reanimated 4.7 registers in setBridge before RN 0.88 injects its module registry.
- id dispatcher = [self->_moduleRegistry moduleForName:"EventDispatcher"];
- [dispatcher addDispatchObserver:reanimated];
- self->_reanimatedObserver = reanimated;
- }
NSNumber *tag = @(rootTag);
HingeRootObservation *observation = self->_observations[tag];
if (observation == nil) {
diff --git a/ios/HingesObserverView.h b/ios/HingesObserverView.h
new file mode 100644
index 0000000..f600630
--- /dev/null
+++ b/ios/HingesObserverView.h
@@ -0,0 +1,8 @@
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface HingesObserverView : RCTViewComponentView
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/HingesObserverView.mm b/ios/HingesObserverView.mm
new file mode 100644
index 0000000..c8386a0
--- /dev/null
+++ b/ios/HingesObserverView.mm
@@ -0,0 +1,86 @@
+#import "HingesObserverView.h"
+
+#import
+#import
+#import
+#import
+#import
+
+using namespace facebook::react;
+
+@interface HingesObserverView ()
+- (void)emitSnapshot;
+@end
+
+@implementation HingesObserverView {
+ HingesObserverViewEventEmitter::OnHingesChange _snapshot;
+}
+
++ (ComponentDescriptorProvider)componentDescriptorProvider
+{
+ return concreteComponentDescriptorProvider();
+}
+
+- (instancetype)initWithFrame:(CGRect)frame
+{
+ if (self = [super initWithFrame:frame]) {
+ static const auto defaultProps = std::make_shared();
+ _props = defaultProps;
+#if defined(__IPHONE_27_1) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_27_1
+ if (@available(iOS 27.1, *)) {
+ __weak HingesObserverView *weakSelf = self;
+ UIHingeInteraction *interaction = [[UIHingeInteraction alloc]
+ initWithUpdateHandler:^(UIHingeInteraction *interaction, UIHingeInteractionUpdate *update) {
+ HingesObserverView *strongSelf = weakSelf;
+ if (strongSelf == nil) return;
+ HingesObserverViewEventEmitter::OnHingesChange snapshot;
+ UIHinge *hinge = update.hinge;
+ if (hinge != nil) {
+ std::string status = "unknown";
+ switch (hinge.status) {
+ case UIHingeStatusClosed: status = "closed"; break;
+ case UIHingeStatusPartiallyOpen: status = "partiallyOpen"; break;
+ case UIHingeStatusFullyOpen: status = "fullyOpen"; break;
+ case UIHingeStatusUnknown: break;
+ }
+ snapshot.hinges.push_back({status, hinge.angle, true});
+ }
+ strongSelf->_snapshot = std::move(snapshot);
+ [strongSelf emitSnapshot];
+ }];
+ [self addInteraction:interaction];
+ }
+#endif
+ }
+ return self;
+}
+
+- (void)emitSnapshot
+{
+ if (_eventEmitter == nullptr) return;
+ std::static_pointer_cast(_eventEmitter)->onHingesChange(_snapshot);
+}
+
+- (void)updateEventEmitter:(const EventEmitter::Shared &)eventEmitter
+{
+ [super updateEventEmitter:eventEmitter];
+ [self emitSnapshot];
+}
+
+- (void)refresh
+{
+ [self emitSnapshot];
+}
+
+- (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args
+{
+ RCTHingesObserverViewHandleCommand(self, commandName, args);
+}
+
+- (void)prepareForRecycle
+{
+ _snapshot = {};
+ [super prepareForRecycle];
+}
+
+@end
diff --git a/package.json b/package.json
index 4e60332..6ecb6cc 100644
--- a/package.json
+++ b/package.json
@@ -123,7 +123,7 @@
"packageManager": "pnpm@12.5.1",
"codegenConfig": {
"name": "HingesSpec",
- "type": "modules",
+ "type": "all",
"jsSrcsDir": "src",
"android": {
"javaPackageName": "com.appandflow.hinges"
@@ -133,6 +133,11 @@
"NativeHinges": {
"className": "HingesModule"
}
+ },
+ "components": {
+ "HingesObserverView": {
+ "className": "HingesObserverView"
+ }
}
}
},
diff --git a/src/HingesObserverViewNativeComponent.ts b/src/HingesObserverViewNativeComponent.ts
new file mode 100644
index 0000000..543e4c0
--- /dev/null
+++ b/src/HingesObserverViewNativeComponent.ts
@@ -0,0 +1,27 @@
+import type * as React from 'react';
+import {
+ codegenNativeCommands,
+ codegenNativeComponent,
+ type CodegenTypes,
+ type HostComponent,
+ type ViewProps,
+} from 'react-native';
+
+export type HingesObserverChangeEvent = Readonly<{
+ hinges: { status: string; angle: CodegenTypes.Double; hasAngle: boolean }[];
+}>;
+
+export interface NativeProps extends ViewProps {
+ onHingesChange?: CodegenTypes.DirectEventHandler;
+}
+
+export interface NativeCommands {
+ /** Re-emits the view's current hinge snapshot so a late listener observes it. */
+ refresh: (viewRef: React.ComponentRef>) => void;
+}
+
+export const Commands: NativeCommands = codegenNativeCommands({
+ supportedCommands: ['refresh'],
+});
+
+export default codegenNativeComponent('HingesObserverView');
diff --git a/src/__tests__/reanimated.test.tsx b/src/__tests__/reanimated.test.tsx
index a8df527..c5af706 100644
--- a/src/__tests__/reanimated.test.tsx
+++ b/src/__tests__/reanimated.test.tsx
@@ -1,12 +1,10 @@
import * as React from 'react';
import { beforeEach, expect, it, jest } from '@jest/globals';
import { act, create } from 'react-test-renderer';
-import { RootTagContext, type RootTag } from 'react-native';
import type { SharedValue } from 'react-native-reanimated';
-import { useAnimatedHinges } from '../reanimated';
+import { AnimatedHingesProvider, useAnimatedHinges } from '../reanimated';
import type { Hinge } from '../index';
-import NativeHinges from '../HingesModule';
-import type { HingesChangeEvent } from '../NativeHinges';
+import { Commands, type HingesObserverChangeEvent } from '../HingesObserverViewNativeComponent';
const mockUIQueue: (() => void)[] = [];
const mockRNQueue: (() => void)[] = [];
@@ -19,10 +17,12 @@ function flushSetup() {
while (mockRNQueue.length) mockRNQueue.shift()?.();
}
-const mockWorklets = new Map void>>();
+const mockWorkletHandlers = new Set<(event: HingesObserverChangeEvent) => void>();
jest.mock('react-native-reanimated', () => {
const react = jest.requireActual('react');
return {
+ __esModule: true,
+ createAnimatedComponent: (component: unknown) => component,
useSharedValue: (initial: T) =>
react.useMemo(() => {
let current = initial;
@@ -33,125 +33,99 @@ jest.mock('react-native-reanimated', () => {
},
};
}, []),
- useEvent: (handler: (event: HingesChangeEvent) => void) =>
- react.useMemo(
- () => ({
- workletEventHandler: {
- registerForEvents: (root: number) =>
- mockUIQueue.push(() => {
- const handlers = mockWorklets.get(root) ?? new Set();
- handlers.add(handler);
- mockWorklets.set(root, handlers);
- }),
- unregisterFromEvents: (root: number) =>
- mockUIQueue.push(() => {
- mockWorklets.get(root)?.delete(handler);
- }),
- },
- }),
- [],
- ),
+ useEvent: (handler: (event: HingesObserverChangeEvent) => void) =>
+ react.useMemo(() => {
+ mockWorkletHandlers.add(handler);
+ return handler;
+ }, []),
};
});
-jest.mock('../HingesModule', () => ({
+jest.mock('../HingesModule', () => ({ __esModule: true, default: {} }));
+jest.mock('../HingesObserverViewNativeComponent', () => ({
__esModule: true,
- default: {
- getSnapshot: () => ({ hinges: [] }),
- startObserving: jest.fn((rootTag: number) => {
- for (const handler of mockWorklets.get(rootTag) ?? [])
- handler({ rootTag, hinges: [{ status: 'closed', angle: 0, hasAngle: true }] });
- }),
- stopObserving: jest.fn(),
- },
+ default: 'HingesObserverView',
+ Commands: { refresh: jest.fn() },
}));
+
+const mockNativeView = { id: 'hinges-observer-view' };
+function render(element: React.ReactElement) {
+ return create(element, { createNodeMock: () => mockNativeView });
+}
+function emit(hinges: HingesObserverChangeEvent['hinges']) {
+ for (const handler of mockWorkletHandlers) handler({ hinges });
+}
+
beforeEach(() => {
- mockWorklets.clear();
+ mockWorkletHandlers.clear();
mockUIQueue.length = 0;
mockRNQueue.length = 0;
jest.clearAllMocks();
});
-it('registers before acquiring native observation, isolates roots, and retains updates without React renders', async () => {
- const values = new Map>();
+it('seeds an empty shared value, then updates it from native events without a React render', async () => {
+ let value: SharedValue | undefined;
let renders = 0;
- function Consumer({ name }: { name: string }) {
+ function Consumer() {
const hinges = useAnimatedHinges();
React.useEffect(() => {
- values.set(name, hinges);
+ value = hinges;
renders++;
});
return null;
}
- const first = ;
- const tree = (two: boolean) => (
- <>
-
- {first}
- {two && }
-
-
-
-
- >
- );
- let renderer: ReturnType;
await act(() => {
- renderer = create(tree(true));
+ render(
+
+
+ ,
+ );
});
- expect(NativeHinges.startObserving).not.toHaveBeenCalled();
- await act(flushSetup);
- expect(values.get('first')?.get()).toEqual([{ status: 'closed', angle: 0 }]);
+ expect(value?.get()).toEqual([]);
+
const previousRenders = renders;
- for (const handler of mockWorklets.get(1) ?? [])
- handler({ rootTag: 1, hinges: [{ status: 'future', angle: 123, hasAngle: false }] });
- expect(values.get('first')?.get()).toEqual([{ status: 'unknown', angle: null }]);
- expect(values.get('second')?.get()).toEqual([{ status: 'unknown', angle: null }]);
- expect(values.get('other')?.get()).toEqual([{ status: 'closed', angle: 0 }]);
+ emit([{ status: 'partiallyOpen', angle: Math.PI / 2, hasAngle: true }]);
+ expect(value?.get()).toEqual([{ status: 'partiallyOpen', angle: Math.PI / 2 }]);
+ emit([{ status: 'future', angle: 123, hasAngle: false }]);
+ expect(value?.get()).toEqual([{ status: 'unknown', angle: null }]);
expect(renders).toBe(previousRenders);
- await act(() => renderer.update(tree(false)));
+});
+
+it('requests the current snapshot only after the worklet registration hop', async () => {
+ await act(() => {
+ render();
+ });
+ expect(Commands.refresh).not.toHaveBeenCalled();
await act(flushSetup);
- expect(mockWorklets.get(1)?.size).toBe(1);
- expect(NativeHinges.stopObserving).toHaveBeenCalledWith(1);
+ expect(Commands.refresh).toHaveBeenCalledWith(mockNativeView);
+});
+
+it('does not request a snapshot when unmounted before the hop completes', async () => {
+ let renderer: ReturnType;
+ await act(() => {
+ renderer = render();
+ });
await act(() => renderer.unmount());
await act(flushSetup);
- expect(mockWorklets.get(1)?.size).toBe(0);
- expect(mockWorklets.get(2)?.size).toBe(0);
- expect(values.get('first')?.get()).toEqual([{ status: 'unknown', angle: null }]);
+ expect(Commands.refresh).not.toHaveBeenCalled();
});
-function SetupConsumer() {
+function OrphanConsumer() {
useAnimatedHinges();
return null;
}
-it('throws a clear error when useAnimatedHinges renders without a RootTagContext provider', () => {
+it('throws a clear error when useAnimatedHinges renders outside the provider', () => {
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
try {
expect(() =>
act(() => {
- create();
+ render();
}),
).toThrow(
- 'useAnimatedHinges must render inside a React Native root: RootTagContext is 0 (or invalid). ' +
- 'In tests, wrap the tree in .',
+ 'useAnimatedHinges must render inside an AnimatedHingesProvider from react-native-hinges/reanimated. ' +
+ 'Wrap the tree in at or above this component.',
);
} finally {
error.mockRestore();
}
});
-
-it('does not acquire observation if unmounted before the UI registration acknowledgment', async () => {
- let renderer: ReturnType;
- await act(() => {
- renderer = create(
-
-
- ,
- );
- });
- await act(() => renderer.unmount());
- await act(flushSetup);
- expect(NativeHinges.startObserving).not.toHaveBeenCalled();
- expect(NativeHinges.stopObserving).not.toHaveBeenCalled();
- expect(mockWorklets.get(1)?.size).toBe(0);
-});
diff --git a/src/reanimated.tsx b/src/reanimated.tsx
index e6334d6..cc93fd2 100644
--- a/src/reanimated.tsx
+++ b/src/reanimated.tsx
@@ -1,55 +1,79 @@
-import { useLayoutEffect, useMemo } from 'react';
+import { createContext, useContext, useEffect, useRef, type ComponentRef, type ReactNode } from 'react';
+import { StyleSheet } from 'react-native';
import { scheduleOnRN, scheduleOnUI } from 'react-native-worklets';
-import { useEvent, useSharedValue, type SharedValue } from 'react-native-reanimated';
-import { createHingeObserver, mapHinges, type Hinge } from './HingeObserver';
-import { useRootTag } from './useRootTag';
-import NativeHinges from './HingesModule';
-import type { HingesChangeEvent } from './NativeHinges';
+import { createAnimatedComponent, useEvent, useSharedValue, type SharedValue } from 'react-native-reanimated';
+import { mapHinges, type Hinge } from './HingeObserver';
+import HingesObserverView, {
+ Commands,
+ type HingesObserverChangeEvent,
+ type NativeProps,
+} from './HingesObserverViewNativeComponent';
+
+const HingesSharedValueContext = createContext | null>(null);
+const AnimatedHingesObserverView = createAnimatedComponent(HingesObserverView);
+const styles = StyleSheet.create({
+ observer: { position: 'absolute', width: 0, height: 0 },
+});
/**
- * Returns root-scoped native hinge snapshots on the UI runtime, without a provider.
- * Read with get() inside worklets; do not write to the value. Angles are raw radians,
- * with null for unavailable readings. No interpolation or sampling rate is imposed.
- * The shared value starts from the native cache and retains its last value after unmount.
+ * Observes hinges for its subtree and publishes them to a Reanimated shared value.
+ * Renders one hidden native view that delivers native updates straight to the UI runtime.
+ * Read the value with useAnimatedHinges.
*/
-export function useAnimatedHinges(): SharedValue {
- const rootTag = useRootTag('useAnimatedHinges');
- const observer = useMemo(() => createHingeObserver(rootTag), [rootTag]);
- const hinges = useSharedValue(observer.get());
- const event = useEvent(
- (update) => {
+export function AnimatedHingesProvider({ children }: { children?: ReactNode }) {
+ const hinges = useSharedValue([]);
+ const view = useRef | null>(null);
+ const onHingesChange = useEvent(
+ (event) => {
'worklet';
- hinges.set(mapHinges(update.hinges));
+ hinges.set(mapHinges(event.hinges));
},
- ['onHingesChange', 'topHingesChange'],
+ ['onHingesChange'],
);
- // Reanimated 4.7 useEvent disguises its WorkletEventHandler as a callback; root events have no component prop to attach it.
- const { workletEventHandler } = event as unknown as {
- workletEventHandler: {
- registerForEvents(tag: number): void;
- unregisterFromEvents(tag: number): void;
- };
- };
- useLayoutEffect(() => {
- hinges.set(observer.get());
- workletEventHandler.registerForEvents(rootTag);
+ useEffect(() => {
let cancelled = false;
- let started = false;
- const start = () => {
- if (cancelled) return;
- started = true;
- NativeHinges.startObserving(rootTag);
+ // Worklets only accepts a function defined on the React Native runtime in scheduleOnRN.
+ const requestSnapshot = () => {
+ if (cancelled || view.current === null) return;
+ Commands.refresh(view.current);
};
- // Reanimated registers events asynchronously on the UI scheduler. Acquire after it runs so initial replay is observed.
+ // Reanimated registers worklet event handlers on the UI runtime asynchronously; ask for the
+ // current snapshot only after a hop through it, so the handler exists when the view re-emits.
scheduleOnUI(() => {
- scheduleOnRN(start);
+ scheduleOnRN(requestSnapshot);
});
return () => {
cancelled = true;
- workletEventHandler.unregisterFromEvents(rootTag);
- if (started) NativeHinges.stopObserving(rootTag);
};
- }, [rootTag, observer, hinges, workletEventHandler]);
+ }, []);
+
+ return (
+
+ {children}
+
+
+ );
+}
+
+/**
+ * Returns this provider's native hinge snapshots as a shared value on the UI runtime.
+ * Read with get() inside worklets; do not write to the value. Angles are raw radians,
+ * with null for unavailable readings. No interpolation or sampling rate is imposed.
+ */
+export function useAnimatedHinges(): SharedValue {
+ const hinges = useContext(HingesSharedValueContext);
+ if (hinges === null) {
+ throw new Error(
+ 'useAnimatedHinges must render inside an AnimatedHingesProvider from react-native-hinges/reanimated. ' +
+ 'Wrap the tree in at or above this component.',
+ );
+ }
return hinges;
}
diff --git a/website/docs/api.md b/website/docs/api.md
index 14c7892..b27705f 100644
--- a/website/docs/api.md
+++ b/website/docs/api.md
@@ -60,12 +60,22 @@ function createHingeObserver(rootTag: number | RootTag): HingeObserver;
Creates an observer for an existing React root. Obtain its tag from React Native's `RootTagContext` or a native host integration. Creation reads the native cache once; `get()` returns the latest observed snapshot and subscriptions keep it current. The last native subscriber releases observation and the cache. See [observer usage](./observers.md).
+## AnimatedHingesProvider
+
+```tsx
+function AnimatedHingesProvider(props: { children?: ReactNode }): ReactNode;
+```
+
+Provides one shared value to its subtree and renders one hidden native `HingesObserverView` that observes hinges and emits changes to Reanimated's UI runtime. Requires the optional Reanimated and Worklets peers. Place it above every `useAnimatedHinges()` consumer; one provider per tree is enough.
+
+The view is `0x0`, absolutely positioned and not interactive, so it does not affect layout. Unmounting the provider removes it and ends that observation.
+
## useAnimatedHinges
```ts
function useAnimatedHinges(): SharedValue;
```
-Returns a shared value initialized from this React root's native cache or `[]`. Requires the optional Reanimated and Worklets peers. Native events update it directly on the UI runtime. No provider is required. Read with `get()` inside worklets and do not mutate it.
+Returns the enclosing provider's shared value, seeded with `[]` and updated on the UI runtime when the native view reports a change. Read with `get()` inside worklets and do not mutate it. Throws when rendered outside `AnimatedHingesProvider`.
-Each hook owns its shared value and native subscription. A retained shared value keeps its last snapshot after unmount. See [Reanimated integration](./reanimated.md).
+Every consumer under one provider reads the same shared value. An externally retained shared value keeps its last snapshot after unmount. See [Reanimated integration](./reanimated.md).
diff --git a/website/docs/example.md b/website/docs/example.md
index c591d42..876f9e4 100644
--- a/website/docs/example.md
+++ b/website/docs/example.md
@@ -76,7 +76,7 @@ and the animation separately:
A sample's changes-per-second value is not animation FPS or a hardware sampling-rate guarantee. Use changing native input during a stall test. A preview animation continuing to move only verifies that generated animation.
-The example includes Reanimated 4.7.0 and Worklets 0.13.0. See [the optional integration](./reanimated.md) for how its animated and ordinary consumers share native root observation.
+The example includes Reanimated 4.7.0 and Worklets 0.13.0. Its root is wrapped in `AnimatedHingesProvider`, so both screens read one shared value. See [the optional integration](./reanimated.md) for how that value is fed.
## Native testing
diff --git a/website/docs/platforms.md b/website/docs/platforms.md
index 4a01346..3050cb1 100644
--- a/website/docs/platforms.md
+++ b/website/docs/platforms.md
@@ -5,7 +5,7 @@ description: How UIKit, WindowManager, and hinge sensors map to the API.
## iOS
-Hinge observation uses `UIHingeInteraction` on the React root's view hierarchy. A native update supplies zero or one `UIHinge`. UIKit's status maps to the public strings, and its angle is exposed in radians.
+Hinge observation uses `UIHingeInteraction`. The core hook attaches it to the React root's view hierarchy; the [Reanimated provider](./reanimated.md) attaches its own to the hidden `HingesObserverView` it renders. A native update supplies zero or one `UIHinge`. UIKit's status maps to the public strings, and its angle is exposed in radians.
| UIKit status | Public status |
| -------------- | --------------- |
@@ -34,7 +34,7 @@ The host app must adopt the UIScene lifecycle; the example uses `UISceneDelegate
## Android
-The native module observes Jetpack WindowManager folding features for its Activity window and, on API 30 or later, the optional device-level `TYPE_HINGE_ANGLE` sensor for angle. The combined snapshot is associated with that root's hierarchy; the two native sources do not have identical scope.
+The library observes Jetpack WindowManager folding features for its Activity window and, on API 30 or later, the optional device-level `TYPE_HINGE_ANGLE` sensor for angle. The native module runs that observation for a React root; the [Reanimated provider](./reanimated.md) runs the same observation from its hidden `HingesObserverView` while that view is attached. The combined snapshot is associated with the observing hierarchy; the two native sources do not have identical scope.
| WindowManager state | Public status |
| --------------------- | --------------- |
diff --git a/website/docs/reanimated.md b/website/docs/reanimated.md
index 0570cc3..32deed8 100644
--- a/website/docs/reanimated.md
+++ b/website/docs/reanimated.md
@@ -23,7 +23,7 @@ This integration is included in `0.1.0-alpha.2`. See [installation](./installati
```tsx
import Animated, { useAnimatedStyle } from 'react-native-reanimated';
-import { useAnimatedHinges } from 'react-native-hinges/reanimated';
+import { AnimatedHingesProvider, useAnimatedHinges } from 'react-native-hinges/reanimated';
function HingeCard() {
const hinges = useAnimatedHinges();
@@ -39,15 +39,19 @@ function HingeCard() {
}
export default function App() {
- return ;
+ return (
+
+
+
+ );
}
```
The example reads the first hinge and hides the card when its angle is unavailable. An app supporting several hinges must choose the appropriate observation; array order is not a stable hardware identity.
-No provider is needed. The regular hook, explicit observers, and animated hooks share native observation for the same React root. Each animated hook owns a shared value.
+`AnimatedHingesProvider` is required. It holds the shared value and renders the native view that feeds it, so `useAnimatedHinges()` throws when there is no provider above it. Every consumer under one provider reads the same shared value. The regular hook and explicit observers keep their own root-scoped native observation and do not need the provider.
-`useAnimatedHinges()` throws if rendered outside a React Native root, where `RootTagContext` is still its default of `0`. In tests without an `AppContainer`, wrap the tree in ``.
+The provider is independent of `RootTagContext`, so a test can render it without an `AppContainer`.
## Shared-value contract
@@ -55,7 +59,7 @@ No provider is needed. The regular hook, explicit observers, and animated hooks
function useAnimatedHinges(): SharedValue;
```
-The shared value starts from the native cache or `[]`. Hinge status and raw radians have the same meaning as the ordinary API; unavailable angles remain `null`.
+The shared value starts at `[]` and receives the provider view's first snapshot once it is mounted. Hinge status and raw radians have the same meaning as the ordinary API; unavailable angles remain `null`.
Read it with `get()` inside a worklet such as `useAnimatedStyle`. Treat the value as read-only even though the underlying Reanimated type exposes setters. Reading a shared value during React rendering is not supported; use the regular `useHinges()` hook for rendered text. See [Reanimated's shared-value guidance](https://docs.swmansion.com/react-native-reanimated/docs/core/useSharedValue/).
@@ -79,10 +83,14 @@ Keeping the animated subtree mounted avoided this failure in the tested case;
apps that gate its mount need to apply the patch, rebuild the native app, and
validate it themselves.
-## Lifetime and validation limits
+## How updates are delivered
+
+`AnimatedHingesProvider` renders one `HingesObserverView`, a Fabric component the library ships. The view is `0x0`, absolutely positioned and not interactive. On iOS it holds a `UIHingeInteraction`; on Android it observes the Activity window's folding features and the default hinge-angle sensor while attached. Each change is emitted as the component's own `onHingesChange` direct event, which Reanimated's `useEvent` handler receives on the UI runtime. This is the ordinary public path for a custom Fabric view event, so no private React Native or Reanimated type is involved.
-Unmounting unregisters the root-tag event handler and releases the native subscription. An externally retained shared value keeps its last snapshot. The native cache is cleared when the root has no subscribers.
+Reanimated registers a worklet event handler on the UI runtime asynchronously. After mount the provider therefore hops through `scheduleOnUI` and back with `scheduleOnRN` before sending the view's `refresh` command, which makes the view re-emit its current snapshot for the now-registered handler. Without that hop the first snapshot can be emitted before anything is listening.
+
+## Lifetime and validation limits
-This prototype registers the handler returned by Reanimated 4.7's `useEvent` directly against the React root tag. Android uses RN 0.88's internal Fabric event emitter type to route the event only to native observers; iOS uses `notifyObserversOfEvent` and explicitly registers an already-loaded Reanimated module with that dispatcher. These integration points need revalidation when upgrading React Native or Reanimated. They avoid delivering angle updates through the JavaScript thread first.
+Unmounting the provider removes the native view, which ends its observation. An externally retained shared value keeps its last snapshot.
-The root-scoped Android implementation has been tested with fixed-angle cold launches and native angle updates during a one-second JS stall. These emulator checks do not establish physical-device rates or first-frame availability. A simulated-preview animation does not verify sensor delivery.
+The Android path has been tested on a `pixel_9_pro_fold` emulator, driving `hinge-angle0` from 45 to 180 degrees and reading the angle and status back from `useAnimatedHinges()`. The iOS path has been tested on an iPhone Duo simulator, which reports only a static closed hinge. Changing iOS angles, physical hardware, and first-frame availability are not established. A simulated-preview animation does not verify sensor delivery.