From a359ab72d32006c66d3be17b335962a36275df45 Mon Sep 17 00:00:00 2001 From: Paul-Andrei Petan Date: Mon, 14 Sep 2026 16:44:03 +0300 Subject: [PATCH] fix: Clear native output callbacks in output hooks on unmount useFrameOutput, useDepthOutput and useObjectOutput hand JS callbacks to native outputs and never clear them. The native side pins those callbacks as GC roots, so the hook's frame output, its worklet runtime and everything the callbacks close over stay alive after the component unmounts. Return effect cleanups that set the callbacks back to undefined, and add a Harness test that mounts useFrameOutput on a session which outlives the component and asserts no further Frames reach the unmounted callback. --- apps/simple-camera/__tests__/README.md | 2 +- .../__tests__/visioncamera.hooks.harness.tsx | 110 ++++++++++++++++++ .../src/hooks/useDepthOutput.ts | 6 + .../src/hooks/useFrameOutput.ts | 8 ++ .../src/hooks/useObjectOutput.ts | 3 + 5 files changed, 128 insertions(+), 1 deletion(-) diff --git a/apps/simple-camera/__tests__/README.md b/apps/simple-camera/__tests__/README.md index ddcc20cb5e..23e37ad7a2 100644 --- a/apps/simple-camera/__tests__/README.md +++ b/apps/simple-camera/__tests__/README.md @@ -27,7 +27,7 @@ Tests are split by domain. Each file tests one slice of the imperative `VisionCa | [visioncamera.multi-output.harness.ts](visioncamera.multi-output.harness.ts) | Multi-output sessions that combine photo, video, and frame outputs, output replacement while other outputs stay attached, persistent recording across session restarts | | [visioncamera.constraints.harness.ts](visioncamera.constraints.harness.ts) | `VisionCamera.resolveConstraints` + `onSessionConfigSelected`, FPS / HDR / stabilization / binned / pixelFormat / resolutionBias constraints | | [visioncamera.controller.harness.ts](visioncamera.controller.harness.ts) | `CameraController` — zoom, torch, exposure bias, focus metering, low-light boost, subject area listener | -| [visioncamera.hooks.harness.tsx](visioncamera.hooks.harness.tsx) | React hook reactivity for `useCameraDevice(...)` position and physical-device filter changes, and `useCamera(...).onUIRotationChanged` | +| [visioncamera.hooks.harness.tsx](visioncamera.hooks.harness.tsx) | React hook reactivity for `useCameraDevice(...)` position and physical-device filter changes, `useCamera(...).onUIRotationChanged`, and `useFrameOutput(...)` unmount cleanup | | [visioncamera.utils.harness.ts](visioncamera.utils.harness.ts) | Pure public utilities such as `getUIRotation(...)` across every output/interface orientation pair | | [visioncamera.coordinates.harness.ts](visioncamera.coordinates.harness.ts) | `Frame.convertFramePointToCameraPoint` / `convertCameraPointToFramePoint`, `PreviewView.convertViewPointToCameraPoint` / `convertCameraPointToViewPoint`, `PreviewView.createMeteringPoint`, `convertScannedObjectCoordinatesToViewCoordinates`, end-to-end Frame → Camera → View round-trip | | [visioncamera.nativepreviewview.harness.tsx](visioncamera.nativepreviewview.harness.tsx) | Bare `NativePreviewView` lifecycle, layout-sensitive preview regression coverage, `resizeMode`, Android `implementationMode`, gesture controllers, multi-preview mounting, `PreviewView` ref methods, Android `takeSnapshot()` dimensions | diff --git a/apps/simple-camera/__tests__/visioncamera.hooks.harness.tsx b/apps/simple-camera/__tests__/visioncamera.hooks.harness.tsx index 8cb22d5522..cc18768d8c 100644 --- a/apps/simple-camera/__tests__/visioncamera.hooks.harness.tsx +++ b/apps/simple-camera/__tests__/visioncamera.hooks.harness.tsx @@ -1,6 +1,7 @@ import { useEffect } from 'react' import { StyleSheet } from 'react-native' import { + assert, beforeAll, describe, expect, @@ -9,6 +10,7 @@ import { type Mock, render, waitFor, + waitUntil, } from 'react-native-harness' import { Screen, @@ -18,6 +20,7 @@ import { import type { CameraDevice, CameraDeviceFactory, + CameraFrameOutput, CameraOrientation, CameraPhotoOutput, CameraPosition, @@ -30,12 +33,14 @@ import { getUIRotation, useCamera, useCameraDevice, + useFrameOutput, useOrientation, usePhotoOutput, usePreviewOutput, useVideoOutput, VisionCamera, } from 'react-native-vision-camera' +import { scheduleOnRN } from 'react-native-worklets' interface DeviceSnapshot { requestedPosition: TargetCameraPosition @@ -112,6 +117,32 @@ function CameraDeviceProbe({ return null } +interface FrameOutputProbeProps { + onFrameOutput: (frameOutput: CameraFrameOutput) => void + onFrameReceived: () => void +} + +function FrameOutputProbe({ + onFrameOutput, + onFrameReceived, +}: FrameOutputProbeProps): null { + const frameOutput = useFrameOutput({ + targetResolution: CommonResolutions.HD_16_9, + pixelFormat: 'native', + onFrame(frame) { + 'worklet' + scheduleOnRN(onFrameReceived) + frame.dispose() + }, + }) + + useEffect(() => { + onFrameOutput(frameOutput) + }, [frameOutput, onFrameOutput]) + + return null +} + async function expectLatestDeviceSnapshot( onSnapshot: Mock<(snapshot: DeviceSnapshot) => void>, position: TargetCameraPosition, @@ -131,11 +162,15 @@ async function expectLatestDeviceSnapshot( describe('VisionCamera - Hooks', () => { let factory: CameraDeviceFactory + let backDevice: CameraDevice beforeAll(async () => { await VisionCamera.requestCameraPermission() expect(VisionCamera.cameraPermissionStatus).toBe('authorized') factory = await VisionCamera.createDeviceFactory() + const back = factory.getDefaultCamera('back') + assert.exists(back, 'no back camera') + backDevice = back }) it('updates useCameraDevice when the requested position changes', async () => { @@ -643,4 +678,79 @@ describe('VisionCamera - Hooks', () => { expect(onError).not.toHaveBeenCalled() }) + + it('stops delivering Frames to useFrameOutput once the component unmounts', async () => { + const onFrameReceived = fn<() => void>() + let hookFrameOutput: CameraFrameOutput | undefined + const onFrameOutput = (frameOutput: CameraFrameOutput) => { + hookFrameOutput = frameOutput + } + + const { unmount } = await render( + , + ) + await waitUntil(() => hookFrameOutput != null, { timeout: 10_000 }) + assert.exists(hookFrameOutput, 'useFrameOutput did not produce an output') + + // The session outlives the component: a real app keeps the pipeline + // running (or re-attaches the same output) after a screen unmounts. + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const onSessionError = fn<(error: Error) => void>() + const errorSub = session.addOnErrorListener(onSessionError) + await session.configure([ + { + input: backDevice, + outputs: [ + { output: hookFrameOutput, mirrorMode: 'auto' }, + { output: photoOutput, mirrorMode: 'auto' }, + ], + constraints: [], + }, + ]) + await session.start() + + try { + await waitUntil( + () => { + const error = onSessionError.mock.lastCall?.[0] + if (error != null) throw error + return onFrameReceived.mock.calls.length >= 3 + }, + { timeout: 15_000 }, + ) + + unmount() + + // A photo capture only completes with the pipeline running, so it is + // the clock: Frames in flight at unmount land before it resolves. + const settlePhoto = await photoOutput.capturePhoto( + { flashMode: 'off', enableShutterSound: false }, + {}, + ) + settlePhoto.dispose() + const framesAfterUnmount = onFrameReceived.mock.calls.length + + const clockPhoto = await photoOutput.capturePhoto( + { flashMode: 'off', enableShutterSound: false }, + {}, + ) + expect(clockPhoto.width).toBeGreaterThan(0) + clockPhoto.dispose() + + expect(onSessionError).not.toHaveBeenCalled() + expect(onFrameReceived).toHaveBeenCalledTimes(framesAfterUnmount) + } finally { + errorSub.remove() + await session.stop() + } + }) }) diff --git a/packages/react-native-vision-camera/src/hooks/useDepthOutput.ts b/packages/react-native-vision-camera/src/hooks/useDepthOutput.ts index f88f1a9d6d..5100008eeb 100644 --- a/packages/react-native-vision-camera/src/hooks/useDepthOutput.ts +++ b/packages/react-native-vision-camera/src/hooks/useDepthOutput.ts @@ -106,6 +106,9 @@ export function useDepthOutput({ if (callback != null) callback(reason) else console.warn(`Depth Frame Dropped! Reason: ${reason}`) }) + return () => { + depthOutput.setOnDepthFrameDroppedCallback(undefined) + } }, [depthOutput]) // 4. Create Worklet Runtime for NativeThread @@ -116,6 +119,9 @@ export function useDepthOutput({ // 5. Update onDepth() callback if it changed useEffect(() => { runtime.setOnDepthFrameCallback(depthOutput, onDepth) + return () => { + runtime.setOnDepthFrameCallback(depthOutput, undefined) + } }, [runtime, depthOutput, onDepth]) // 6. Return :) diff --git a/packages/react-native-vision-camera/src/hooks/useFrameOutput.ts b/packages/react-native-vision-camera/src/hooks/useFrameOutput.ts index dd126ec813..09bc3b45ef 100644 --- a/packages/react-native-vision-camera/src/hooks/useFrameOutput.ts +++ b/packages/react-native-vision-camera/src/hooks/useFrameOutput.ts @@ -165,6 +165,11 @@ export function useFrameOutput({ if (callback != null) callback(reason) else console.warn(`Frame Dropped! Reason: ${reason}`) }) + return () => { + // The native output holds the callback (and everything it closes over) + // as a GC root until it is cleared. + frameOutput.setOnFrameDroppedCallback(undefined) + } }, [frameOutput]) // 4. Create Worklet Runtime for NativeThread @@ -175,6 +180,9 @@ export function useFrameOutput({ // 5. Update onFrame() callback if it changed useEffect(() => { runtime.setOnFrameCallback(frameOutput, onFrame) + return () => { + runtime.setOnFrameCallback(frameOutput, undefined) + } }, [runtime, frameOutput, onFrame]) // 6. Return :) diff --git a/packages/react-native-vision-camera/src/hooks/useObjectOutput.ts b/packages/react-native-vision-camera/src/hooks/useObjectOutput.ts index 3de09ef885..c698c6e122 100644 --- a/packages/react-native-vision-camera/src/hooks/useObjectOutput.ts +++ b/packages/react-native-vision-camera/src/hooks/useObjectOutput.ts @@ -49,6 +49,9 @@ export function useObjectOutput({ // 2. Update onObjectsScanned() callback if it changed useEffect(() => { objectOutput.setOnObjectsScannedCallback(onObjectsScanned) + return () => { + objectOutput.setOnObjectsScannedCallback(undefined) + } }, [objectOutput, onObjectsScanned]) // 3. Return :)