Conversation
Pap36
force-pushed
the
fix/clear-output-callbacks-on-unmount
branch
from
September 14, 2026 14:02
4ff63cc to
cf5f16a
Compare
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.
Pap36
force-pushed
the
fix/clear-output-callbacks-on-unmount
branch
from
September 14, 2026 14:15
cf5f16a to
a359ab7
Compare
Owner
Author
|
Preview only; submitted upstream as margelo#4192. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
useFrameOutput,useDepthOutputanduseObjectOutputhand JS callbacks to a native output (setOnFrameCallback,setOnFrameDroppedCallback, and their depth/object equivalents) and never clear them. Nitro keeps those callbacks alive as GC roots for as long as the native output exists, and the callbacks close over the hook'sframeOutputand its per-mount workletruntime. That is a cycle across the JSI boundary, so no garbage collection can ever break it: every mount of a component usinguseFrameOutputleaks a full worklet runtime plus whatever theonFrameworklet captured.In a dev build with Worklets Bundle Mode each leaked runtime holds its own compiled copy of the app bundle. On an iPhone 11 our scanner screen cost ~350 MB per open and nothing came back on unmount, so the second open hit the Jetsam limit. Release builds leak the same objects, just far smaller (bytecode is mmapped and shared), so it shows up as slow growth instead of a crash.
How
Return effect cleanups from the three hooks that set the native callbacks back to
undefined. That is the same call the Harness tests already make in theirfinallyblocks. Clearing on unmount (and on callback change, before the new one is set) lets the frame output, the runtime and the captured scope be collected normally.Verified on device (iPhone 11, iOS 26.6.1, dev client,
physFootprintsampled every 3 s):A
WeakRefprobe on the hook'sruntimeandframeOutputconfirmed the mechanism: both stayed alive across a forced full GC before the change, and the runtime was collected after it. The heap snapshot retainer path goes(GC roots) → (Custom) → onFrameDropped closure → hook environment → frameOutput / runtime.useDepthOutputanduseObjectOutputhave the identical pattern and get the same cleanup.Test
Adds a Harness test to
visioncamera.hooks.harness.tsxthat mounts a component callinguseFrameOutput, attaches its output to aCameraSessioncreated outside React (so the pipeline outlives the component), waits for Frames, unmounts, then captures two photos as the pipeline clock and asserts that no further Frames reached the unmountedonFrame.Ran locally on a Pixel 7 (Android 17) with
bun run test:harness:android -- --testPathPatterns=hooks:With the fix: all 7 tests in the file pass (1 pre-existing skip), the new one in ~1.2 s.
With
main's three hook files restored and the test kept: the new test fails atexpect(onFrameReceived).toHaveBeenCalledTimes(framesAfterUnmount), because Frames keep arriving afterunmount(). In one of the two control runs the app instead died on the frame thread withThat is the Android face of the same bug:
setOnFrameDroppedCallbackis a no-op there, so nothing pins the runtime, the GC eventually frees the worklet runtime, and CameraX's analyzer still calls into the deletedjsi::Functionon the next Frame. On iOS the drop callback pins everything and it leaks instead. Clearing the analyzer on unmount fixes both.Related
targetResolutionin output hooks margelo/react-native-vision-camera#4166 made this worse whentargetResolutionwas an inline literal: a fresh output, and therefore a fresh leaked runtime, on every render rather than every mount.