From ae819f6f6e4572668eb790f5bf867249a0d92ecd Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Wed, 23 Sep 2026 13:08:58 -0700 Subject: [PATCH] Bound the display-phase event beat induce to one per run loop turn (#58642) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: The beat an Apple display phase induces runs the whole event loop tick on the calling thread, mounting included. A view whose layout metrics change during that mount can emit another synchronous request from inside the display that is servicing the first one, and Core Animation honours a `setNeedsDisplay` made during a display by running the commit's layout and display phases again, with no bound. One Core Animation commit can therefore perform an unbounded number of blocking JavaScript round trips. `AppleEventBeat` now induces at most once per run loop turn from the display phase. The run loop observer, which runs before Core Animation's commit observer, opens each turn. Requests arriving after that first induce keep the ordinary observer timing — what they had before the display-phase induce existed — so this bounds the tail without giving up the guarantee the display-phase induce was added for: the first layout-driven request of a frame is still processed in that frame. Measured on an iPhone 11 / iOS 26.0 simulator with a virtualized list whose placeholder is taller than its content, so making one row visible pulls siblings into the viewport. Identical scenario, 13 synchronous requests in every arm: | arm | worst single main-thread block | induces in one commit | | --- | --- | --- | | display-phase induce, unbounded | 145.8 ms | 11 | | no display-phase induce | 15.5 ms | n/a | | display-phase induce, bounded (this change) | 14.9 ms | 1 | A separate probe established that Core Animation itself imposes no bound: a zero-sized layer that re-dirties itself from inside its own `display` ran 200 display passes in one commit, stopped only by the probe's own cap. Changelog: [iOS][Fixed] - Process at most one synchronous event beat per Core Animation commit, so a mount performed during the display phase cannot re-enter it without bound Differential Revision: D120951592 --- .../react-native/React/Fabric/AppleEventBeat.h | 14 ++++++++++++++ .../react-native/React/Fabric/AppleEventBeat.mm | 7 +++++++ 2 files changed, 21 insertions(+) diff --git a/packages/react-native/React/Fabric/AppleEventBeat.h b/packages/react-native/React/Fabric/AppleEventBeat.h index 71ea0a112dc..4a11927bafb 100644 --- a/packages/react-native/React/Fabric/AppleEventBeat.h +++ b/packages/react-native/React/Fabric/AppleEventBeat.h @@ -70,6 +70,20 @@ class AppleEventBeat : public EventBeat, public RunLoopObserver::Delegate { WindowLayerResolver windowLayerResolver_; NSMapTable *layers_; void (^onDisplay_)(void); + + /* + * Whether a display phase has already induced in the current run loop turn. + * + * The beat a display phase induces runs the whole event loop tick on the + * main thread, mounting included, so a `VirtualView` whose layout metrics + * change during that mount emits another synchronous request from inside the + * display it is servicing. Core Animation honours `setNeedsDisplay` made + * during a display by running the commit's layout and display phases again, + * with no bound, so without this flag one commit can perform an unbounded + * number of blocking JavaScript round trips. Requests that arrive after the + * first induce keep the ordinary run loop observer timing. + */ + mutable bool didInduceInCurrentTurn_{false}; }; } // namespace facebook::react diff --git a/packages/react-native/React/Fabric/AppleEventBeat.mm b/packages/react-native/React/Fabric/AppleEventBeat.mm index 31323191962..2a4cb1588e7 100644 --- a/packages/react-native/React/Fabric/AppleEventBeat.mm +++ b/packages/react-native/React/Fabric/AppleEventBeat.mm @@ -69,6 +69,10 @@ - (void)display if (!owner) { return; } + if (this->didInduceInCurrentTurn_) { + return; + } + this->didInduceInCurrentTurn_ = true; this->induce(); }; @@ -117,6 +121,9 @@ - (void)display RunLoopObserver::Activity /*activity*/) const noexcept { react_native_assert(delegate == this); + // This observer runs before Core Animation's commit observer, so it is the + // start of the turn whose display phase `didInduceInCurrentTurn_` bounds. + didInduceInCurrentTurn_ = false; induce(); }