Skip to content

Bound the display-phase event beat induce to one per run loop turn (#58642) - #58642

Open
Abbondanzo wants to merge 1 commit into
react:mainfrom
Abbondanzo:export-D120951592
Open

Abbondanzo wants to merge 1 commit into
react:mainfrom
Abbondanzo:export-D120951592

Conversation

@Abbondanzo

@Abbondanzo Abbondanzo commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

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

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Sep 22, 2026
@meta-codesync

meta-codesync Bot commented Sep 22, 2026

Copy link
Copy Markdown

@Abbondanzo has exported this pull request. If you are a Meta employee, you can view the originating Diff in D120951592.

@meta-codesync meta-codesync Bot changed the title Bound the display-phase event beat induce to one per run loop turn Bound the display-phase event beat induce to one per run loop turn (#58642) Sep 23, 2026
Abbondanzo added a commit to Abbondanzo/react-native that referenced this pull request Sep 23, 2026
…eact#58642)

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
Abbondanzo added a commit to Abbondanzo/react-native that referenced this pull request Sep 23, 2026
…eact#58642)

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
Abbondanzo added a commit to Abbondanzo/react-native that referenced this pull request Sep 23, 2026
…eact#58642)

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
…eact#58642)

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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. meta-exported p: Facebook Partner: Facebook Partner

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant