diff --git a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp index e84624567aad..a3fcfa76cf81 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,7 +255,7 @@ jsi::Value UIManagerBinding::get( return jsi::Value::undefined(); } - return valueFromShadowNode( + auto node = objectFromShadowNode( runtime, uiManager->createNode( tagFromValue(arguments[0]), @@ -254,6 +264,13 @@ jsi::Value UIManagerBinding::get( 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 + // 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(); } @@ -339,7 +356,7 @@ jsi::Value UIManagerBinding::get( // rolled out // validateArgumentCount(runtime, methodName, paramCount, count); - return valueFromShadowNode( + return objectFromShadowNode( runtime, uiManager->cloneNode( *Bridging>::fromJs( @@ -365,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( @@ -393,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( @@ -820,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) 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, + ); + }); +});