From 83017e83566c7bbbc21907fc52fa0b0fddda7b76 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 21 Sep 2026 06:48:26 -0400 Subject: [PATCH] refactor: deliver animated hinges through a native Fabric view 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. --- README.md | 25 ++- .../java/com/appandflow/hinges/HingeSource.kt | 117 ++++++++++++++ .../appandflow/hinges/HingesChangeEvent.java | 45 ------ .../com/appandflow/hinges/HingesModule.kt | 115 ++------------ .../appandflow/hinges/HingesObserverView.kt | 41 +++++ .../hinges/HingesObserverViewManager.kt | 29 ++++ .../com/appandflow/hinges/HingesPackage.kt | 3 +- .../animated-provider-view-2026-09-21.md | 142 +++++++++++++++++ example/ios/Podfile.lock | 8 +- example/src/App.tsx | 34 ++-- ios/HingesModule.mm | 19 +-- ios/HingesObserverView.h | 8 + ios/HingesObserverView.mm | 86 +++++++++++ package.json | 7 +- src/HingesObserverViewNativeComponent.ts | 27 ++++ src/__tests__/reanimated.test.tsx | 146 +++++++----------- src/reanimated.tsx | 100 +++++++----- website/docs/api.md | 14 +- website/docs/example.md | 2 +- website/docs/platforms.md | 4 +- website/docs/reanimated.md | 26 ++-- 21 files changed, 662 insertions(+), 336 deletions(-) create mode 100644 android/src/main/java/com/appandflow/hinges/HingeSource.kt delete mode 100644 android/src/main/java/com/appandflow/hinges/HingesChangeEvent.java create mode 100644 android/src/main/java/com/appandflow/hinges/HingesObserverView.kt create mode 100644 android/src/main/java/com/appandflow/hinges/HingesObserverViewManager.kt create mode 100644 docs/verification/animated-provider-view-2026-09-21.md create mode 100644 ios/HingesObserverView.h create mode 100644 ios/HingesObserverView.mm create mode 100644 src/HingesObserverViewNativeComponent.ts diff --git a/README.md b/README.md index 6a06133..ed7b3e4 100644 --- a/README.md +++ b/README.md @@ -91,8 +91,11 @@ The optional `react-native-hinges/reanimated` entry point requires Reanimated the Worklets Babel plugin and rebuild native dependencies using [Reanimated's setup guide](https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/getting-started/). +Wrap the consuming tree in `AnimatedHingesProvider`. It renders one hidden native +view that observes hinges and delivers each update to Reanimated's UI runtime. + ```tsx -import { useAnimatedHinges } from 'react-native-hinges/reanimated'; +import { AnimatedHingesProvider, useAnimatedHinges } from 'react-native-hinges/reanimated'; import { useAnimatedStyle } from 'react-native-reanimated'; function useHingeCardStyle() { @@ -105,15 +108,23 @@ function useHingeCardStyle() { }; }); } + +export default function App() { + return ( + + + + ); +} ``` -No animated provider is needed. The hook returns a read-only-by-contract -`SharedValue`, initialized from the native cache or `[]`. -Read it inside worklets and do not write to it. Native events update the value -on the UI runtime, preserving raw radians and nullable angles without JS delivery -as an intermediate step. The library adds no smoothing or sampling-frequency guarantee. +The hook returns the provider's read-only-by-contract `SharedValue`, +seeded with `[]`. Read it inside worklets and do not write to it. It throws when +rendered without the provider. Native events update the value on the UI runtime, +preserving raw radians and nullable angles without JS delivery as an intermediate +step. The library adds no smoothing or sampling-frequency guarantee. -Unmounting unregisters the worklet and releases its native subscription. An +Unmounting the provider removes its native view and ends that observation. An externally retained shared value keeps its last snapshot. See the [integration guide](website/docs/reanimated.md) for setup and compatibility limits. diff --git a/android/src/main/java/com/appandflow/hinges/HingeSource.kt b/android/src/main/java/com/appandflow/hinges/HingeSource.kt new file mode 100644 index 0000000..b990713 --- /dev/null +++ b/android/src/main/java/com/appandflow/hinges/HingeSource.kt @@ -0,0 +1,117 @@ +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 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.WritableMap + +internal data class HingeState(val status: String, val angle: Double?) + +/** + * Observes the Activity window's folding features and the default hinge-angle sensor + * while started, reporting a deduplicated snapshot to [onChange]. + */ +internal class HingeSource(private val context: Context, private val onChange: (List) -> Unit) : + SensorEventListener { + var hinges: List = emptyList() + private set + + private val sensorManager = 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() + } + } + + fun start() { + if (active) return + val activity = 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 stop() { + active = false + tracker?.removeWindowLayoutInfoListener(layoutInfoConsumer) + tracker = null + sensorManager.unregisterListener(this) + hingeSensor = null + hingeAngle = null + foldingFeatures = emptyList() + hinges = 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 next = 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 (hinges == next) return + hinges = next + onChange(next) + } +} + +internal fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} + +internal 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/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.