From d6e487879ac57dd2998bfab36996e30117bbfae6 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Fri, 30 Oct 2026 23:54:28 +0100 Subject: [PATCH 1/2] Report the native memory of created shadow nodes to the JS GC The JS object that createNode returns owns the new shadow node (with its props, family and event emitter), so that native memory is only freed when the GC collects the object. The GC didn't know about it: it saw small JS objects and rarely ran a full collection, so the shadow nodes of unmounted trees piled up. Report a 2 KB estimate per created node with setExternalMemoryPressure, as the Blob module already does for blob data, so that the GC collects them in time. A View with a few style props takes about 2.4 KB on Android (measured with heapprofd), a bit less on iOS. Co-Authored-By: Claude Opus 5.5 --- .../renderer/uimanager/UIManagerBinding.cpp | 36 +++++++--- .../ShadowNodeExternalMemory-itest.js | 66 +++++++++++++++++++ 2 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 packages/react-native/src/private/__tests__/ShadowNodeExternalMemory-itest.js diff --git a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp index e84624567aad..3b82be921bee 100644 --- a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp +++ b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp @@ -23,6 +23,16 @@ namespace facebook::react { +namespace { + +// Native memory that the JS object returned by `createNode` keeps alive: the +// shadow node with its props, family and event emitter. A View with a few +// style props takes about 2.4 KB on Android (measured with heapprofd), a bit +// less on iOS, which doesn't keep the props as `folly::dynamic`. +constexpr size_t kCreatedShadowNodeNativeMemory = 2048; + +} // namespace + void UIManagerBinding::createAndInstallIfNeeded( jsi::Runtime& runtime, const std::shared_ptr& uiManager) { @@ -245,15 +255,23 @@ jsi::Value UIManagerBinding::get( return jsi::Value::undefined(); } - return valueFromShadowNode( - runtime, - uiManager->createNode( - tagFromValue(arguments[0]), - stringFromValue(runtime, arguments[1]), - surfaceIdFromValue(runtime, arguments[2]), - RawProps(runtime, arguments[3]), - std::move(instanceHandle)), - true); + auto node = valueFromShadowNode( + runtime, + uiManager->createNode( + tagFromValue(arguments[0]), + stringFromValue(runtime, arguments[1]), + surfaceIdFromValue(runtime, arguments[2]), + RawProps(runtime, arguments[3]), + std::move(instanceHandle)), + true) + .getObject(runtime); + // The shadow node is freed only when the GC collects this object, + // so tell the GC about the native memory it keeps alive. Without + // this, unmounted shadow trees can pile up until the JS heap + // itself grows enough for a full collection. + node.setExternalMemoryPressure( + runtime, kCreatedShadowNodeNativeMemory); + return node; } catch (const std::logic_error& ex) { LOG(FATAL) << "logic_error in createNode: " << ex.what(); } diff --git a/packages/react-native/src/private/__tests__/ShadowNodeExternalMemory-itest.js b/packages/react-native/src/private/__tests__/ShadowNodeExternalMemory-itest.js new file mode 100644 index 000000000000..7980597ee43d --- /dev/null +++ b/packages/react-native/src/private/__tests__/ShadowNodeExternalMemory-itest.js @@ -0,0 +1,66 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @fantom_mode * + * @flow strict-local + * @format + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; +import * as React from 'react'; +import {View} from 'react-native'; + +// Must match `kCreatedShadowNodeNativeMemory` in UIManagerBinding.cpp. +const CREATED_SHADOW_NODE_NATIVE_MEMORY = 2048; + +function getExternalMemoryAfterGC(): number { + global.gc(); + const stats = nullthrows( + nullthrows(global.HermesInternal).getInstrumentedStats, + )(); + const externalBytes = stats.js_externalBytes; + if (typeof externalBytes !== 'number') { + throw new Error('Expected js_externalBytes to be a number'); + } + return externalBytes; +} + +describe('shadow node external memory', () => { + it('reports the native memory of created shadow nodes to the GC until they are collected', () => { + const before = getExternalMemoryAfterGC(); + + let root: ?Fantom.Root = Fantom.createRoot(); + Fantom.runTask(() => { + nullthrows(root).render( + + {Array.from({length: 100}, (_, i) => ( + + ))} + , + ); + }); + + // The 100 children and their parent are alive, so their native memory + // stays reported after a GC. + expect(getExternalMemoryAfterGC() - before).toBeGreaterThanOrEqual( + 101 * CREATED_SHADOW_NODE_NATIVE_MEMORY, + ); + + Fantom.runTask(() => { + nullthrows(root).destroy(); + }); + root = null; + + // Once the tree is unmounted and collected, its memory isn't reported + // anymore. + expect(getExternalMemoryAfterGC() - before).toBeLessThan( + CREATED_SHADOW_NODE_NATIVE_MEMORY, + ); + }); +}); From 5501acb123da21e7267371906a91bcbafe71cf3b Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Mon, 2 Nov 2026 19:05:26 +0100 Subject: [PATCH 2/2] Rename valueFromShadowNode to objectFromShadowNode Return the jsi::Object directly, so createNode can set the external memory pressure on it without converting a jsi::Value back to an object. The other callers return it from host functions that return jsi::Value, which converts implicitly. Co-Authored-By: Claude Opus 5.5 --- .../renderer/uimanager/UIManagerBinding.cpp | 27 +++++++++---------- .../react/renderer/uimanager/primitives.h | 2 +- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp index 3b82be921bee..a3fcfa76cf81 100644 --- a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp +++ b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp @@ -255,16 +255,15 @@ jsi::Value UIManagerBinding::get( return jsi::Value::undefined(); } - auto node = valueFromShadowNode( - runtime, - uiManager->createNode( - tagFromValue(arguments[0]), - stringFromValue(runtime, arguments[1]), - surfaceIdFromValue(runtime, arguments[2]), - RawProps(runtime, arguments[3]), - std::move(instanceHandle)), - true) - .getObject(runtime); + auto node = objectFromShadowNode( + runtime, + uiManager->createNode( + tagFromValue(arguments[0]), + stringFromValue(runtime, arguments[1]), + surfaceIdFromValue(runtime, arguments[2]), + RawProps(runtime, arguments[3]), + std::move(instanceHandle)), + true); // The shadow node is freed only when the GC collects this object, // so tell the GC about the native memory it keeps alive. Without // this, unmounted shadow trees can pile up until the JS heap @@ -357,7 +356,7 @@ jsi::Value UIManagerBinding::get( // rolled out // validateArgumentCount(runtime, methodName, paramCount, count); - return valueFromShadowNode( + return objectFromShadowNode( runtime, uiManager->cloneNode( *Bridging>::fromJs( @@ -383,7 +382,7 @@ jsi::Value UIManagerBinding::get( size_t count) -> jsi::Value { validateArgumentCount(runtime, methodName, paramCount, count); - return valueFromShadowNode( + return objectFromShadowNode( runtime, uiManager->cloneNode( *Bridging>::fromJs( @@ -411,7 +410,7 @@ jsi::Value UIManagerBinding::get( // validateArgumentCount(runtime, methodName, paramCount, count); bool hasChildrenArg = count == 3; - return valueFromShadowNode( + return objectFromShadowNode( runtime, uiManager->cloneNode( *Bridging>::fromJs( @@ -838,7 +837,7 @@ jsi::Value UIManagerBinding::get( return jsi::Value::null(); } - return valueFromShadowNode(runtime, shadowNode); + return objectFromShadowNode(runtime, shadowNode); }); } diff --git a/packages/react-native/ReactCommon/react/renderer/uimanager/primitives.h b/packages/react-native/ReactCommon/react/renderer/uimanager/primitives.h index 0a3d8e5f57cd..690f5a7cd53e 100644 --- a/packages/react-native/ReactCommon/react/renderer/uimanager/primitives.h +++ b/packages/react-native/ReactCommon/react/renderer/uimanager/primitives.h @@ -43,7 +43,7 @@ struct ShadowNodeListWrapper : public jsi::NativeState { std::shared_ptr>> shadowNodeList; }; -inline static jsi::Value valueFromShadowNode( +inline static jsi::Object objectFromShadowNode( jsi::Runtime &runtime, std::shared_ptr shadowNode, bool assignRuntimeShadowNodeReference = false)