From 2898b48fbca1e05264bb333d0eb17a5e22e02b4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Tue, 24 Oct 2023 08:57:34 -0700 Subject: [PATCH] Implement atomic reporting of UI changes in Runtime Scheduler (#41157) Summary: This is internal until we're ready to test this outside of Meta. Changelog: [internal] ## Context In the new architecture, every time we commit a new tree in React we send a transaction to the host platform to make all the necessary mutations in the underlying native views. This can be bad for user experience, because the user might see quick changes to the UI in succession for related changes. It also breaks the semantics of things like layout effects and ref callbacks, which are core to the React programming model and should work across all platforms. The main semantic that this behavior breaks in React Native is that layout effects are supposed to be blocking for paint. That means that any state updates (or UI mutations) done in layout effects should be applied to the UI atomically with the original changes that triggered them, so users see a single update where the final state is applied. This doesn't work in React Native as none of the commits coming from React are blocked waiting for effects, and instead they're all mounted/applied as they come. This isn't only a problem for React, but also for future Web-like APIs that rely on microtasks. Those are also assumed to block paint in browsers, and we don't support that behavior either. ## Changes Now that we're adding support for a well-defined event loop in React Native, we can add a new step to notify UI changes to the host platform in specific points in time, after macrotasks and microtasks are done (the "Update the rendering" step defined on the [Web specification](https://html.spec.whatwg.org/multipage/webappapis.html#event-loop-processing-model)). This implements that step in the new `RuntimeScheduler`. This works by batching all the notifications from the `UIManager` to `MountingCoordinator` and calling all those methods from `RuntimeScheduler` at the right time. There will be cases where the notifications will be to mount the same tree multiple times, but the mounting coordinator already handles this correctly (would mount the last version of the tree for each surface ID the first time, and be a no-op the other times). This change will reduce the amount of mount operations we do on the main thread, which means that we could potentially remove the push model from Android if performance is acceptable with this. NOTE: This only works with the modern runtime scheduler, and only makes sense when used with microtasks enabled too and background executor disabled. Reviewed By: javache Differential Revision: D49536327 --- .../runtimescheduler/RuntimeScheduler.cpp | 6 +++ .../runtimescheduler/RuntimeScheduler.h | 7 +++ .../RuntimeScheduler_Legacy.cpp | 9 ++++ .../RuntimeScheduler_Legacy.h | 3 ++ .../RuntimeScheduler_Modern.cpp | 45 +++++++++++++++++-- .../RuntimeScheduler_Modern.h | 13 ++++++ .../tests/RuntimeSchedulerTest.cpp | 44 ++++++++++++++---- .../react/renderer/scheduler/Scheduler.cpp | 30 +++++-------- 8 files changed, 128 insertions(+), 29 deletions(-) diff --git a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler.cpp b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler.cpp index 0a6d3608dc2bea..df44f0523a6639 100644 --- a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler.cpp +++ b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler.cpp @@ -84,4 +84,10 @@ void RuntimeScheduler::callExpiredTasks(jsi::Runtime& runtime) { return runtimeSchedulerImpl_->callExpiredTasks(runtime); } +void RuntimeScheduler::scheduleRenderingUpdate( + RuntimeSchedulerRenderingUpdate&& renderingUpdate) { + return runtimeSchedulerImpl_->scheduleRenderingUpdate( + std::move(renderingUpdate)); +} + } // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler.h b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler.h index 3152dfc882f463..40482a9872a838 100644 --- a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler.h +++ b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler.h @@ -13,6 +13,8 @@ namespace facebook::react { +using RuntimeSchedulerRenderingUpdate = std::function; + // This is a temporary abstract class for RuntimeScheduler forks to implement // (and use them interchangeably). class RuntimeSchedulerBase { @@ -32,6 +34,8 @@ class RuntimeSchedulerBase { virtual SchedulerPriority getCurrentPriorityLevel() const noexcept = 0; virtual RuntimeSchedulerTimePoint now() const noexcept = 0; virtual void callExpiredTasks(jsi::Runtime& runtime) = 0; + virtual void scheduleRenderingUpdate( + RuntimeSchedulerRenderingUpdate&& renderingUpdate) = 0; }; // This is a proxy for RuntimeScheduler implementation, which will be selected @@ -130,6 +134,9 @@ class RuntimeScheduler final : RuntimeSchedulerBase { */ void callExpiredTasks(jsi::Runtime& runtime) override; + void scheduleRenderingUpdate( + RuntimeSchedulerRenderingUpdate&& renderingUpdate) override; + private: // Actual implementation, stored as a unique pointer to simplify memory // management. diff --git a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Legacy.cpp b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Legacy.cpp index 316929c8b0b9fe..812eac2456d8c0 100644 --- a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Legacy.cpp +++ b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Legacy.cpp @@ -142,6 +142,15 @@ void RuntimeScheduler_Legacy::callExpiredTasks(jsi::Runtime& runtime) { currentPriority_ = previousPriority; } +void RuntimeScheduler_Legacy::scheduleRenderingUpdate( + RuntimeSchedulerRenderingUpdate&& renderingUpdate) { + SystraceSection s("RuntimeScheduler::scheduleRenderingUpdate"); + + if (renderingUpdate != nullptr) { + renderingUpdate(); + } +} + #pragma mark - Private void RuntimeScheduler_Legacy::scheduleWorkLoopIfNecessary() { diff --git a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Legacy.h b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Legacy.h index f15ab40b364cae..d4ad98d59a1f43 100644 --- a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Legacy.h +++ b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Legacy.h @@ -110,6 +110,9 @@ class RuntimeScheduler_Legacy final : public RuntimeSchedulerBase { */ void callExpiredTasks(jsi::Runtime& runtime) override; + void scheduleRenderingUpdate( + RuntimeSchedulerRenderingUpdate&& renderingUpdate) override; + private: std::priority_queue< std::shared_ptr, diff --git a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Modern.cpp b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Modern.cpp index e9e8cd89ea670b..033d5a2219e451 100644 --- a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Modern.cpp +++ b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Modern.cpp @@ -17,7 +17,13 @@ namespace facebook::react { namespace { -// Looping on \c drainMicrotasks until it completes or hits the retries bound. +/** + * This is partially equivalent to the "Perform a microtask checkpoint" step in + * the Web event loop. See + * https://html.spec.whatwg.org/multipage/webappapis.html#perform-a-microtask-checkpoint. + * + * Iterates on \c drainMicrotasks until it completes or hits the retries bound. + */ void executeMicrotasks(jsi::Runtime& runtime) { SystraceSection s("RuntimeScheduler::executeMicrotasks"); @@ -175,6 +181,19 @@ void RuntimeScheduler_Modern::callExpiredTasks(jsi::Runtime& runtime) { startWorkLoop(runtime, true); } +void RuntimeScheduler_Modern::scheduleRenderingUpdate( + RuntimeSchedulerRenderingUpdate&& renderingUpdate) { + SystraceSection s("RuntimeScheduler::scheduleRenderingUpdate"); + + if (CoreFeatures::blockPaintForUseLayoutEffect) { + pendingRenderingUpdates_.push(renderingUpdate); + } else { + if (renderingUpdate != nullptr) { + renderingUpdate(); + } + } +} + #pragma mark - Private void RuntimeScheduler_Modern::scheduleTask(std::shared_ptr task) { @@ -278,11 +297,31 @@ void RuntimeScheduler_Modern::executeTask( executeMacrotask(runtime, task, didUserCallbackTimeout); if (CoreFeatures::enableMicrotasks) { + // "Perform a microtask checkpoint" step. executeMicrotasks(runtime); } - // TODO report long tasks - // TODO update rendering + if (CoreFeatures::blockPaintForUseLayoutEffect) { + // "Update the rendering" step. + updateRendering(); + } +} + +/** + * This is partially equivalent to the "Update the rendering" step in the Web + * event loop. See + * https://html.spec.whatwg.org/multipage/webappapis.html#update-the-rendering. + */ +void RuntimeScheduler_Modern::updateRendering() { + SystraceSection s("RuntimeScheduler::updateRendering"); + + while (!pendingRenderingUpdates_.empty()) { + auto& pendingRenderingUpdate = pendingRenderingUpdates_.front(); + if (pendingRenderingUpdate != nullptr) { + pendingRenderingUpdate(); + } + pendingRenderingUpdates_.pop(); + } } void RuntimeScheduler_Modern::executeMacrotask( diff --git a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Modern.h b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Modern.h index 609ea34b76bd7b..34c8eb4cf16cbf 100644 --- a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Modern.h +++ b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Modern.h @@ -121,6 +121,15 @@ class RuntimeScheduler_Modern final : public RuntimeSchedulerBase { */ void callExpiredTasks(jsi::Runtime& runtime) override; + /** + * Schedules a function that notifies or applies UI changes in the host + * platform, to be executed during the "Update the rendering" step of the + * event loop. If the step is not enabled, the function is executed + * immediately. + */ + void scheduleRenderingUpdate( + RuntimeSchedulerRenderingUpdate&& renderingUpdate) override; + private: std::atomic syncTaskRequests_{0}; @@ -167,6 +176,8 @@ class RuntimeScheduler_Modern final : public RuntimeSchedulerBase { std::shared_ptr task, bool didUserCallbackTimeout) const; + void updateRendering(); + /* * Returns a time point representing the current point in time. May be called * from multiple threads. @@ -178,6 +189,8 @@ class RuntimeScheduler_Modern final : public RuntimeSchedulerBase { * scheduled. */ bool isWorkLoopScheduled_{false}; + + std::queue pendingRenderingUpdates_; }; } // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/tests/RuntimeSchedulerTest.cpp b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/tests/RuntimeSchedulerTest.cpp index ced061c61f93f8..8897a223d05b87 100644 --- a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/tests/RuntimeSchedulerTest.cpp +++ b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/tests/RuntimeSchedulerTest.cpp @@ -124,17 +124,41 @@ TEST_P(RuntimeSchedulerTest, scheduleSingleTask) { EXPECT_EQ(stubQueue_->size(), 0); } -TEST_P(RuntimeSchedulerTest, scheduleSingleTaskWithMicrotasks) { +TEST_P(RuntimeSchedulerTest, scheduleNonBatchedRenderingUpdate) { + CoreFeatures::blockPaintForUseLayoutEffect = false; + + bool didRunRenderingUpdate = false; + + runtimeScheduler_->scheduleRenderingUpdate( + [&]() { didRunRenderingUpdate = true; }); + + EXPECT_TRUE(didRunRenderingUpdate); +} + +TEST_P( + RuntimeSchedulerTest, + scheduleSingleTaskWithMicrotasksAndBatchedRenderingUpdate) { // Only for modern runtime scheduler if (!GetParam()) { return; } - bool didRunTask = false; - bool didRunMicrotask = false; + CoreFeatures::blockPaintForUseLayoutEffect = true; + + uint nextOperationPosition = 1; + + uint taskPosition = 0; + uint microtaskPosition = 0; + uint updateRenderingPosition = 0; auto callback = createHostFunctionFromLambda([&](bool /* unused */) { - didRunTask = true; + taskPosition = nextOperationPosition; + nextOperationPosition++; + + runtimeScheduler_->scheduleRenderingUpdate([&]() { + updateRenderingPosition = nextOperationPosition; + nextOperationPosition++; + }); auto microtaskCallback = jsi::Function::createFromHostFunction( *runtime_, @@ -144,7 +168,8 @@ TEST_P(RuntimeSchedulerTest, scheduleSingleTaskWithMicrotasks) { const jsi::Value& /*unused*/, const jsi::Value* arguments, size_t /*unused*/) -> jsi::Value { - didRunMicrotask = true; + microtaskPosition = nextOperationPosition; + nextOperationPosition++; return jsi::Value::undefined(); }); @@ -162,13 +187,16 @@ TEST_P(RuntimeSchedulerTest, scheduleSingleTaskWithMicrotasks) { runtimeScheduler_->scheduleTask( SchedulerPriority::NormalPriority, std::move(callback)); - EXPECT_FALSE(didRunTask); + EXPECT_EQ(taskPosition, 0); + EXPECT_EQ(microtaskPosition, 0); + EXPECT_EQ(updateRenderingPosition, 0); EXPECT_EQ(stubQueue_->size(), 1); stubQueue_->tick(); - EXPECT_TRUE(didRunTask); - EXPECT_TRUE(didRunMicrotask); + EXPECT_EQ(taskPosition, 1); + EXPECT_EQ(microtaskPosition, 2); + EXPECT_EQ(updateRenderingPosition, 3); EXPECT_EQ(stubQueue_->size(), 0); } diff --git a/packages/react-native/ReactCommon/react/renderer/scheduler/Scheduler.cpp b/packages/react-native/ReactCommon/react/renderer/scheduler/Scheduler.cpp index df3c9ada438899..1777b73374dd8e 100644 --- a/packages/react-native/ReactCommon/react/renderer/scheduler/Scheduler.cpp +++ b/packages/react-native/ReactCommon/react/renderer/scheduler/Scheduler.cpp @@ -294,24 +294,18 @@ void Scheduler::uiManagerDidFinishTransaction( SystraceSection s("Scheduler::uiManagerDidFinishTransaction"); if (delegate_ != nullptr) { - if (CoreFeatures::blockPaintForUseLayoutEffect) { - auto weakRuntimeScheduler = - contextContainer_->find>( - "RuntimeScheduler"); - auto runtimeScheduler = weakRuntimeScheduler.has_value() - ? weakRuntimeScheduler.value().lock() - : nullptr; - if (runtimeScheduler && !mountSynchronously) { - runtimeScheduler->scheduleTask( - SchedulerPriority::UserBlockingPriority, - [delegate = delegate_, - mountingCoordinator = - std::move(mountingCoordinator)](jsi::Runtime&) { - delegate->schedulerDidFinishTransaction(mountingCoordinator); - }); - } else { - delegate_->schedulerDidFinishTransaction(mountingCoordinator); - } + auto weakRuntimeScheduler = + contextContainer_->find>( + "RuntimeScheduler"); + auto runtimeScheduler = weakRuntimeScheduler.has_value() + ? weakRuntimeScheduler.value().lock() + : nullptr; + if (runtimeScheduler && !mountSynchronously) { + runtimeScheduler->scheduleRenderingUpdate( + [delegate = delegate_, + mountingCoordinator = std::move(mountingCoordinator)]() { + delegate->schedulerDidFinishTransaction(mountingCoordinator); + }); } else { delegate_->schedulerDidFinishTransaction(mountingCoordinator); }