From 90e402c2bb7392a5113f49299a795286495cc0e9 Mon Sep 17 00:00:00 2001 From: Julio Cesar Rocha Date: Wed, 7 Oct 2026 19:34:49 -0700 Subject: [PATCH 1/5] Fix TurboModule property wrapper leak Cache generated TurboModule JSI properties per runtime and add a Desktop integration regression test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../RNTesterHeadlessTests.cpp | 4 +++ .../TurboModulesProvider.cpp | 25 ++++++++++++++++++- vnext/overrides.json | 4 +++ .../TurboModulePropertyCacheTest.js | 22 ++++++++++++++++ 4 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 vnext/src-win/IntegrationTests/TurboModulePropertyCacheTest.js diff --git a/vnext/Desktop.IntegrationTests/RNTesterHeadlessTests.cpp b/vnext/Desktop.IntegrationTests/RNTesterHeadlessTests.cpp index c819059c280..c225e7dfafb 100644 --- a/vnext/Desktop.IntegrationTests/RNTesterHeadlessTests.cpp +++ b/vnext/Desktop.IntegrationTests/RNTesterHeadlessTests.cpp @@ -70,6 +70,10 @@ TEST_CLASS (RNTesterHeadlessTests) { RunTest(L"IntegrationTests/DummyTest"); } + TEST_METHOD(TurboModulePropertyCache) { + RunTest(L"IntegrationTests/TurboModulePropertyCacheTest"); + } + TEST_METHOD(Fetch) { RunTest(L"IntegrationTests/FetchTest"); } diff --git a/vnext/Microsoft.ReactNative/TurboModulesProvider.cpp b/vnext/Microsoft.ReactNative/TurboModulesProvider.cpp index 28991974022..b435343f691 100644 --- a/vnext/Microsoft.ReactNative/TurboModulesProvider.cpp +++ b/vnext/Microsoft.ReactNative/TurboModulesProvider.cpp @@ -166,9 +166,29 @@ class TurboModuleImpl : public facebook::react::TurboModule { return m_hostObjectWrapper->get(runtime, propName); } - // it is not safe to assume that "runtime" never changes, so members are not cached here std::string key = propName.utf8(runtime); + if (m_cachedRuntime != &runtime) { + m_cachedRuntime = &runtime; + m_propertyCache = + std::make_unique(facebook::jsi::Object::create(runtime, facebook::jsi::Value::null())); + } + + auto value = m_propertyCache->getProperty(runtime, propName); + if (!value.isUndefined()) { + return value; + } + + value = CreateProperty(runtime, propName, key); + if (!value.isUndefined()) { + m_propertyCache->setProperty(runtime, propName, value); + } + + return value; + } + private: + facebook::jsi::Value + CreateProperty(facebook::jsi::Runtime &runtime, const facebook::jsi::PropNameID &propName, const std::string &key) { if (key == "getConstants" && !m_moduleBuilder->ConstantProviders().empty()) { // try to find getConstants if there is any constant return facebook::jsi::Function::createFromHostFunction( @@ -471,8 +491,11 @@ class TurboModuleImpl : public facebook::react::TurboModule { winrt::com_ptr m_moduleBuilder; IInspectable m_providedModule; std::unordered_map> m_eventEmitters; + std::unique_ptr m_propertyCache; std::shared_ptr m_hostObjectWrapper; std::weak_ptr m_longLivedObjectCollection; + // Non-owning identity used only to invalidate JSI values when the runtime changes. + facebook::jsi::Runtime *m_cachedRuntime{}; }; /*------------------------------------------------------------------------------- diff --git a/vnext/overrides.json b/vnext/overrides.json index b16d4427ef5..d0512ea460c 100644 --- a/vnext/overrides.json +++ b/vnext/overrides.json @@ -268,6 +268,10 @@ "type": "platform", "file": "src-win/IntegrationTests/LoggingTest.js" }, + { + "type": "platform", + "file": "src-win/IntegrationTests/TurboModulePropertyCacheTest.js" + }, { "type": "platform", "file": "src-win/IntegrationTests/WebSocketArrayBufferTest.js" diff --git a/vnext/src-win/IntegrationTests/TurboModulePropertyCacheTest.js b/vnext/src-win/IntegrationTests/TurboModulePropertyCacheTest.js new file mode 100644 index 00000000000..9d1cd83efe6 --- /dev/null +++ b/vnext/src-win/IntegrationTests/TurboModulePropertyCacheTest.js @@ -0,0 +1,22 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * @format + */ + +'use strict'; + +const {TurboModuleRegistry} = require('react-native'); + +const TestModule = TurboModuleRegistry.get('TestModule'); + +if (!TestModule) { + throw new Error('TestModule is not available'); +} + +const passed = + TestModule.markTestCompleted === TestModule.markTestCompleted && + TestModule.verifySnapshot === TestModule.verifySnapshot && + TestModule.shouldResolve === TestModule.shouldResolve; + +TestModule.markTestPassed(passed); From e63fc431f6a782b97f61c7ae86621c120879b011 Mon Sep 17 00:00:00 2001 From: Julio Cesar Rocha Date: Wed, 7 Oct 2026 20:15:50 -0700 Subject: [PATCH 2/5] Change files --- ...ative-windows-8abca6d7-5b1b-4e52-b025-1a6c5222b89d.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/react-native-windows-8abca6d7-5b1b-4e52-b025-1a6c5222b89d.json diff --git a/change/react-native-windows-8abca6d7-5b1b-4e52-b025-1a6c5222b89d.json b/change/react-native-windows-8abca6d7-5b1b-4e52-b025-1a6c5222b89d.json new file mode 100644 index 00000000000..95c5eff43b3 --- /dev/null +++ b/change/react-native-windows-8abca6d7-5b1b-4e52-b025-1a6c5222b89d.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Fix TurboModule property wrapper leak", + "packageName": "react-native-windows", + "email": "email not defined", + "dependentChangeType": "patch" +} From a04991b17b0be1343b688e959c25b6f6b462a607 Mon Sep 17 00:00:00 2001 From: Julio Cesar Rocha Date: Sat, 24 Oct 2026 00:21:27 -0700 Subject: [PATCH 3/5] Change files --- ...ct-native-windows-2f7f8ddd-d029-440c-903f-a66737129e31.json} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename change/{react-native-windows-8abca6d7-5b1b-4e52-b025-1a6c5222b89d.json => react-native-windows-2f7f8ddd-d029-440c-903f-a66737129e31.json} (57%) diff --git a/change/react-native-windows-8abca6d7-5b1b-4e52-b025-1a6c5222b89d.json b/change/react-native-windows-2f7f8ddd-d029-440c-903f-a66737129e31.json similarity index 57% rename from change/react-native-windows-8abca6d7-5b1b-4e52-b025-1a6c5222b89d.json rename to change/react-native-windows-2f7f8ddd-d029-440c-903f-a66737129e31.json index 95c5eff43b3..9611274fc58 100644 --- a/change/react-native-windows-8abca6d7-5b1b-4e52-b025-1a6c5222b89d.json +++ b/change/react-native-windows-2f7f8ddd-d029-440c-903f-a66737129e31.json @@ -1,6 +1,6 @@ { "type": "prerelease", - "comment": "Fix TurboModule property wrapper leak", + "comment": "Revert TurboModule property caching and add opt-in native lifetime integration tests", "packageName": "react-native-windows", "email": "email not defined", "dependentChangeType": "patch" From 917044ab951f34cbe8b96d70e301bf086d6d850a Mon Sep 17 00:00:00 2001 From: Julio Cesar Rocha Date: Sat, 24 Oct 2026 00:38:13 -0700 Subject: [PATCH 4/5] Replace TurboModule cache mitigation with lifetime test infrastructure Revert property caching and add opt-in native integration coverage through react-native-win32.dll. RNW builds pass; lifetime test compilation and execution await instrumented V8-JSI output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../React.Windows.Desktop.DLL.vcxproj | 3 + .../RNTesterHeadlessTests.cpp | 4 - ...t.Windows.Desktop.IntegrationTests.vcxproj | 12 ++ .../TurboModuleLifetimeTestHooks.cpp | 24 +++ .../TurboModuleLifetimeTests.cpp | 158 ++++++++++++++++++ .../TurboModulesProvider.cpp | 25 --- vnext/overrides.json | 4 - .../TurboModulePropertyCacheTest.js | 22 --- 8 files changed, 197 insertions(+), 55 deletions(-) create mode 100644 vnext/Desktop.IntegrationTests/TurboModuleLifetimeTestHooks.cpp create mode 100644 vnext/Desktop.IntegrationTests/TurboModuleLifetimeTests.cpp delete mode 100644 vnext/src-win/IntegrationTests/TurboModulePropertyCacheTest.js diff --git a/vnext/Desktop.DLL/React.Windows.Desktop.DLL.vcxproj b/vnext/Desktop.DLL/React.Windows.Desktop.DLL.vcxproj index e3689a7623c..674cc7658fc 100644 --- a/vnext/Desktop.DLL/React.Windows.Desktop.DLL.vcxproj +++ b/vnext/Desktop.DLL/React.Windows.Desktop.DLL.vcxproj @@ -150,6 +150,9 @@ Create + + $(ReactNativeWindowsDir)Microsoft.ReactNative;$(ReactNativeWindowsDir)Common;$(IntDir)..\React.Windows.Desktop\Generated Files;%(AdditionalIncludeDirectories) + diff --git a/vnext/Desktop.IntegrationTests/RNTesterHeadlessTests.cpp b/vnext/Desktop.IntegrationTests/RNTesterHeadlessTests.cpp index c225e7dfafb..c819059c280 100644 --- a/vnext/Desktop.IntegrationTests/RNTesterHeadlessTests.cpp +++ b/vnext/Desktop.IntegrationTests/RNTesterHeadlessTests.cpp @@ -70,10 +70,6 @@ TEST_CLASS (RNTesterHeadlessTests) { RunTest(L"IntegrationTests/DummyTest"); } - TEST_METHOD(TurboModulePropertyCache) { - RunTest(L"IntegrationTests/TurboModulePropertyCacheTest"); - } - TEST_METHOD(Fetch) { RunTest(L"IntegrationTests/FetchTest"); } diff --git a/vnext/Desktop.IntegrationTests/React.Windows.Desktop.IntegrationTests.vcxproj b/vnext/Desktop.IntegrationTests/React.Windows.Desktop.IntegrationTests.vcxproj index 3f206877319..2fc983c47e7 100644 --- a/vnext/Desktop.IntegrationTests/React.Windows.Desktop.IntegrationTests.vcxproj +++ b/vnext/Desktop.IntegrationTests/React.Windows.Desktop.IntegrationTests.vcxproj @@ -113,6 +113,9 @@ + + $(V8LifetimeTestOutput)\build\native\include;%(AdditionalIncludeDirectories) + @@ -157,6 +160,15 @@ + + + + + + + + + diff --git a/vnext/Desktop.IntegrationTests/TurboModuleLifetimeTestHooks.cpp b/vnext/Desktop.IntegrationTests/TurboModuleLifetimeTestHooks.cpp new file mode 100644 index 00000000000..5df76f80d94 --- /dev/null +++ b/vnext/Desktop.IntegrationTests/TurboModuleLifetimeTestHooks.cpp @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#include "pch.h" +#include +#include +#include + +// Compiled into react-native-win32.dll only for EnableV8LifetimeTests builds. +extern "C" __declspec(dllexport) void __cdecl RNWCreateTurboModuleForLifetimeTest( + facebook::jsi::Runtime *runtime, + facebook::jsi::Object *result) { + namespace React = winrt::Microsoft::ReactNative; + React::TurboModulesProvider provider; + provider.AddModuleProvider( + L"LifetimeTest", + [](React::IReactModuleBuilder const &builder) -> winrt::Windows::Foundation::IInspectable { + builder.AddSyncMethod( + L"value", [](React::IJSValueReader const &, React::IJSValueWriter const &writer) { writer.WriteInt64(42); }); + return winrt::box_value(true); + }, + false); + *result = facebook::jsi::Object::createFromHostObject(*runtime, provider.getModule("LifetimeTest", nullptr)); +} diff --git a/vnext/Desktop.IntegrationTests/TurboModuleLifetimeTests.cpp b/vnext/Desktop.IntegrationTests/TurboModuleLifetimeTests.cpp new file mode 100644 index 00000000000..7aa91ab875a --- /dev/null +++ b/vnext/Desktop.IntegrationTests/TurboModuleLifetimeTests.cpp @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#include +#include +#include +#include +#include + +#include +#include +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; +namespace jsi = facebook::jsi; + +namespace Microsoft::React::Test { +namespace { + +using Stats = v8runtime::HostProxyLifetimeStats; +using CreateModule = void(__cdecl *)(jsi::Runtime *, jsi::Object *); +using CreateRuntime = jsi::Runtime *(__cdecl *)(); +using GetStats = void(__cdecl *)(Stats *) noexcept; + +template +T GetExport(HMODULE library, char const *name) { + auto address = GetProcAddress(library, name); + Assert::IsTrue(address != nullptr, L"Missing lifetime-test export; build both DLLs with lifetime tests enabled."); + return reinterpret_cast(address); +} + +class LifetimeTestRuntime { + public: + LifetimeTestRuntime() { + auto rnw = GetModuleHandleW(L"react-native-win32.dll"); + Assert::IsNotNull(rnw, L"The test must load react-native-win32.dll."); + wchar_t path[MAX_PATH]; + auto length = GetModuleFileNameW(rnw, path, MAX_PATH); + Assert::IsTrue(length != 0 && length < MAX_PATH, L"Cannot locate the RNW DLL."); + auto v8Path = std::filesystem::path(path).parent_path() / L"v8jsi.dll"; + m_v8.reset( + LoadLibraryExW(v8Path.c_str(), nullptr, LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS)); + Assert::IsNotNull(m_v8.get(), L"Cannot load the instrumented v8jsi.dll beside react-native-win32.dll."); +#if defined(_M_IX86) + constexpr char createModuleName[] = "_RNWCreateTurboModuleForLifetimeTest"; + constexpr char createRuntimeName[] = "_v8jsi_create_runtime_for_lifetime_test"; + constexpr char getStatsName[] = "_v8jsi_get_host_proxy_lifetime_stats"; +#else + constexpr char createModuleName[] = "RNWCreateTurboModuleForLifetimeTest"; + constexpr char createRuntimeName[] = "v8jsi_create_runtime_for_lifetime_test"; + constexpr char getStatsName[] = "v8jsi_get_host_proxy_lifetime_stats"; +#endif + m_createModule = GetExport(rnw, createModuleName); + m_createRuntime = GetExport(m_v8.get(), createRuntimeName); + m_getStats = GetExport(m_v8.get(), getStatsName); + } + + std::unique_ptr Create() const { + return std::unique_ptr{m_createRuntime()}; + } + + jsi::Object Module(jsi::Runtime &runtime) const { + jsi::Object module(runtime); + m_createModule(&runtime, &module); + return module; + } + + Stats Snapshot() const { + Stats stats{}; + m_getStats(&stats); + return stats; + } + + private: + std::unique_ptr, decltype(&FreeLibrary)> m_v8{nullptr, &FreeLibrary}; + CreateModule m_createModule{}; + CreateRuntime m_createRuntime{}; + GetStats m_getStats{}; +}; + +void ExpectReclaimed(Stats before, Stats after) { + Assert::AreEqual( + after.proxiesAllocated - before.proxiesAllocated, + after.proxiesFreed - before.proxiesFreed, + L"Native proxy allocations were not freed."); + Assert::AreEqual( + after.trackersAllocated - before.trackersAllocated, + after.trackersFreed - before.trackersFreed, + L"Lifetime trackers were not freed."); +} + +} // namespace + +// Run this class alone in a test process: V8 counters are process-wide. +TEST_CLASS (TurboModuleLifetimeTests) { + TEST_METHOD_INITIALIZE(Initialize) { + winrt::init_apartment(winrt::apartment_type::multi_threaded); + } + + TEST_METHOD_CLEANUP(Cleanup) { + winrt::uninit_apartment(); + } + + TEST_METHOD(PropertyReadsReclaimProxiesAndTrackers) { + LifetimeTestRuntime factory; + auto baseline = factory.Snapshot(); + auto runtime = factory.Create(); + { + auto module = factory.Module(*runtime); + auto retained = module.getPropertyAsFunction(*runtime, "value"); + { + auto other = module.getPropertyAsFunction(*runtime, "value"); + Assert::IsFalse( + jsi::Object::strictEquals(*runtime, retained, other), L"The property cache must not mask this test."); + } + runtime->instrumentation().collectGarbage("lifetime test warmup"); + + constexpr uint64_t batchSize = 10000; + for (size_t batch = 0; batch != 10; ++batch) { + auto before = factory.Snapshot(); + for (uint64_t i = 0; i != batchSize; ++i) { + auto method = module.getProperty(*runtime, "value"); + Assert::IsTrue(method.isObject()); + } + runtime->instrumentation().collectGarbage("TurboModule property read batch"); + auto after = factory.Snapshot(); + Assert::AreEqual( + batchSize, after.proxiesAllocated - before.proxiesAllocated, L"Reads did not allocate distinct proxies."); + Assert::AreEqual(batchSize, after.trackersAllocated - before.trackersAllocated); + ExpectReclaimed(before, after); + Assert::AreEqual(42.0, retained.call(*runtime).getNumber(), L"GC reclaimed a retained function prematurely."); + } + } + runtime->instrumentation().collectGarbage("release retained module and function"); + ExpectReclaimed(baseline, factory.Snapshot()); + runtime.reset(); + ExpectReclaimed(baseline, factory.Snapshot()); + } + + TEST_METHOD(RuntimeTeardownReclaimsReachableWrappers) { + LifetimeTestRuntime factory; + for (size_t iteration = 0; iteration != 5; ++iteration) { + auto baseline = factory.Snapshot(); + auto runtime = factory.Create(); + { + auto module = factory.Module(*runtime); + runtime->global().setProperty(*runtime, "retainedModule", module); + runtime->global().setProperty(*runtime, "retainedMethod", module.getProperty(*runtime, "value")); + } + auto live = factory.Snapshot(); + Assert::AreEqual(uint64_t{2}, live.proxiesAllocated - baseline.proxiesAllocated); + runtime.reset(); + ExpectReclaimed(baseline, factory.Snapshot()); + } + } +}; + +} // namespace Microsoft::React::Test diff --git a/vnext/Microsoft.ReactNative/TurboModulesProvider.cpp b/vnext/Microsoft.ReactNative/TurboModulesProvider.cpp index b435343f691..86620f93277 100644 --- a/vnext/Microsoft.ReactNative/TurboModulesProvider.cpp +++ b/vnext/Microsoft.ReactNative/TurboModulesProvider.cpp @@ -167,28 +167,6 @@ class TurboModuleImpl : public facebook::react::TurboModule { } std::string key = propName.utf8(runtime); - if (m_cachedRuntime != &runtime) { - m_cachedRuntime = &runtime; - m_propertyCache = - std::make_unique(facebook::jsi::Object::create(runtime, facebook::jsi::Value::null())); - } - - auto value = m_propertyCache->getProperty(runtime, propName); - if (!value.isUndefined()) { - return value; - } - - value = CreateProperty(runtime, propName, key); - if (!value.isUndefined()) { - m_propertyCache->setProperty(runtime, propName, value); - } - - return value; - } - - private: - facebook::jsi::Value - CreateProperty(facebook::jsi::Runtime &runtime, const facebook::jsi::PropNameID &propName, const std::string &key) { if (key == "getConstants" && !m_moduleBuilder->ConstantProviders().empty()) { // try to find getConstants if there is any constant return facebook::jsi::Function::createFromHostFunction( @@ -491,11 +469,8 @@ class TurboModuleImpl : public facebook::react::TurboModule { winrt::com_ptr m_moduleBuilder; IInspectable m_providedModule; std::unordered_map> m_eventEmitters; - std::unique_ptr m_propertyCache; std::shared_ptr m_hostObjectWrapper; std::weak_ptr m_longLivedObjectCollection; - // Non-owning identity used only to invalidate JSI values when the runtime changes. - facebook::jsi::Runtime *m_cachedRuntime{}; }; /*------------------------------------------------------------------------------- diff --git a/vnext/overrides.json b/vnext/overrides.json index d0512ea460c..b16d4427ef5 100644 --- a/vnext/overrides.json +++ b/vnext/overrides.json @@ -268,10 +268,6 @@ "type": "platform", "file": "src-win/IntegrationTests/LoggingTest.js" }, - { - "type": "platform", - "file": "src-win/IntegrationTests/TurboModulePropertyCacheTest.js" - }, { "type": "platform", "file": "src-win/IntegrationTests/WebSocketArrayBufferTest.js" diff --git a/vnext/src-win/IntegrationTests/TurboModulePropertyCacheTest.js b/vnext/src-win/IntegrationTests/TurboModulePropertyCacheTest.js deleted file mode 100644 index 9d1cd83efe6..00000000000 --- a/vnext/src-win/IntegrationTests/TurboModulePropertyCacheTest.js +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * Licensed under the MIT License. - * @format - */ - -'use strict'; - -const {TurboModuleRegistry} = require('react-native'); - -const TestModule = TurboModuleRegistry.get('TestModule'); - -if (!TestModule) { - throw new Error('TestModule is not available'); -} - -const passed = - TestModule.markTestCompleted === TestModule.markTestCompleted && - TestModule.verifySnapshot === TestModule.verifySnapshot && - TestModule.shouldResolve === TestModule.shouldResolve; - -TestModule.markTestPassed(passed); From 1af2036b531b39a3c910143c73827f631f3f9e34 Mon Sep 17 00:00:00 2001 From: Julio Cesar Rocha Date: Tue, 27 Oct 2026 20:26:45 -0700 Subject: [PATCH 5/5] uninit_apartment in TurboModuleLifetimeTests --- vnext/Desktop.IntegrationTests/TurboModuleLifetimeTests.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/vnext/Desktop.IntegrationTests/TurboModuleLifetimeTests.cpp b/vnext/Desktop.IntegrationTests/TurboModuleLifetimeTests.cpp index 7aa91ab875a..fd8061c8dea 100644 --- a/vnext/Desktop.IntegrationTests/TurboModuleLifetimeTests.cpp +++ b/vnext/Desktop.IntegrationTests/TurboModuleLifetimeTests.cpp @@ -94,6 +94,7 @@ void ExpectReclaimed(Stats before, Stats after) { // Run this class alone in a test process: V8 counters are process-wide. TEST_CLASS (TurboModuleLifetimeTests) { TEST_METHOD_INITIALIZE(Initialize) { + winrt::uninit_apartment(); winrt::init_apartment(winrt::apartment_type::multi_threaded); }