Skip to content

Pre-mount Android views in the time each frame leaves free, behind a flag - #59057

Open
YevheniiKotyrlo wants to merge 9 commits into
react:mainfrom
YevheniiKotyrlo:fix/android-premount-frame-budget
Open

YevheniiKotyrlo wants to merge 9 commits into
react:mainfrom
YevheniiKotyrlo:fix/android-premount-frame-budget

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

MountItemDispatcher.dispatchPreMountItems pre-mounts views at the start of each DISPATCH_UI frame callback, before the frame's traversal, until frameTimeNanos + FRAME_TIME_NS / 2: 8.33 ms on every display. While JavaScript creates views faster than the UI thread pre-allocates them, that is all of a 120 Hz frame, so frames run past the next vsync, and half of a 60 Hz frame, so the rest of the frame sits idle while the views wait for the mount.

Behind a new flag, enablePremountItemsBetweenFramesAndroid (off by default), PreMountItemsBetweenFrames pre-mounts in the time each frame leaves free instead:

  • After the frame's traversal, in slices of at most 1 ms posted to the UI thread's looper, until the next vsync: the frame's time plus the display's frame interval (DisplayFrameInterval, read from DisplayManager), or a 60 Hz frame while the display reports no rate. A frame, an input event or any other message queued meanwhile waits one slice at most. The slices are asynchronous messages, like the Choreographer's vsync, since a view that invalidates while it draws, such as a ScrollView during a fling, leaves the next frame's traversal barrier in the queue between frames.
  • Before the traversal, for as long as the previous frame's draw slept waiting on the render thread, less one slice and at most half a frame. While the render thread is behind, frames leave no time after them, and that sleep is the free time. It's measured as the UI thread's wall time less its CPU time (Debug.threadCpuTimeNanos()), so a frame busy on the CPU never gets any.
  • A commit whose mount items arrive while the slices run is mounted by the next slice, as it is today once the frame's pre-mount budget ends.

A slice still queued when the frame callback pauses is removed, a slice that throws stops mounting like a throwing frame callback, and a frame whose mount fails queues none. With the flag off nothing changes. The C++ drain of the pre-allocation queue (drainPreallocateViewsQueue) still runs at the start of each frame, outside any budget, and on main it can wait there for the JS thread's executeMount (#59244).

I tried idle handlers first: a vsync and an input event reach the main queue only when the looper polls, so an idle handler can't see the next frame coming. In a probe, MessageQueue.isIdle() stayed true across frames while a handler ran, on every device and system image I tried (API 26 to 37), and a handler that yielded only to queued messages drained a 150 ms backlog in one freeze.

In release RNTester on a Galaxy S23 (Android 14) and a RedMagic NX809J (Android 16) at 120 Hz and a Mi MIX 3 (Android 10) at 60 Hz, with 2400 views mounted beside a native spring, the flag pre-mounts 1.3 to 2.4 times as many views before the commit, loses no vsync before it where main loses 1.2 to 2.8 per burst at 120 Hz, and loses 2.2 to 3.9 fewer vsyncs per burst in all. When 160 translucent layers keep the render thread past each vsync, it pre-mounts at least as many views as main, and on the Mi MIX 3 it draws the mount 53 ms sooner. The traces, every arm and the scene are in the comment below.

Changelog:

[Internal] - Add enablePremountItemsBetweenFramesAndroid, which pre-mounts Android views in the time each frame leaves free instead of for half of a 60 Hz frame before its traversal

Test Plan:

PreMountItemsBetweenFramesTest (new) runs the scheduler under Robolectric with the main looper paused, React Native's SystemClock mocked and a fake thread clock: nothing pre-mounts inside the frame; pre-mounting stops at the next vsync at 60, 120 and 144 Hz after 0, 2 and 5 ms of frame work; a message queued during a backlog runs between two slices; a traversal barrier left in the queue doesn't hold the slices back; a commit that lands during the slices, or during the slice that runs the last item, mounts before the next frame; a stale or missing refresh rate still yields every 1 ms; a frame that ran past the next vsync pre-mounts nothing after it; frames whose draw sleeps until the next vsync get that time before their traversal, at most half a frame, for as long as they keep sleeping and items are left, while a frame busy on the CPU, a thread clock that can't tell, or the first frame after vsyncs that ran no frame callback gets none; cancelling removes every queued slice and the measured time; a failing item reaches the exception handler.

FabricUIManagerPreMountTest (new) drives the real DISPATCH_UI frame callback: with the flag off, nine 1 ms items run inside the frame, as today; with it on, none inside and 17 before the next 60 Hz vsync, and nine inside the frame after one whose draw slept; pausing cancels the queued slice; a frame whose mount fails pre-mounts nothing after it. MountItemDispatcherTest (new) pins today's 8.33 ms from the frame's start, the deadline-bounded step, and a mount batch running every pre-mount item left before itself. DisplayFrameIntervalTest (new) covers reading the default display's frame from 1 to 240 Hz, following a change the display reports, and reporting none while the display reports no rate.

With pre-mounting left where it is today, the scheduler's cases fail for the placement, nine items running inside the frame and none after it; with slices posted as ordinary messages, the barrier case fails with expected:<[17]> but was:<[2]>; with the slice queued before the frame's mount items, the failure case fails with expected:<[0]> but was:<[3]>; with a commit left for the next frame, both commit cases fail; without pre-mounting before the traversal, four frames whose draw slept pre-mount [0, 0, 0, 0] items before their traversals against [0, 9, 9, 9].

  • ./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest -PenableWarningsAsErrors=true: 801 tests, 0 failures
  • ./gradlew :packages:react-native:ReactAndroid:compileReleaseKotlin :packages:react-native:ReactAndroid:compileReleaseJavaWithJavac -PenableWarningsAsErrors=true: clean
  • node ./scripts/format-kotlin.js --check, node ./scripts/format-java.js --check, yarn featureflags --verify-unchanged, yarn flow-check, yarn test-typescript-legacy and the feature flags' Jest suite: clean
  • The generated C++ feature flag sources compile with Apple clang 21

yarn test-generated-typescript fails on Windows, where build-types names each module's default export after its absolute path; the generated types hold no feature flag module. I didn't run the full Android build, the template and HelloWorld app builds, the iOS builds or the e2e jobs locally.

@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 Oct 14, 2026
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 14, 2026 11:36
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Oct 14, 2026
@javache

javache commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

The refresh rate can change dynamically, so forever locking to the smallest time may be suboptimal - can we try to use the screen's current refresh rate?

@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

Updated in 35f8355: pre-mounting now takes half of the default display's current frame, read from DisplayManager when the display reports a change rather than on every frame, so a drop from 120 Hz back to 60 Hz restores the 60 Hz budget. MountItemDispatcherTest covers that switch and DisplayFrameIntervalTest the listener.

@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

Follow-up in 3d780fb: below 60 Hz the budget stays at today's 8.33 ms instead of half of the longer frame, since an adaptive panel idling at 1 Hz would otherwise pre-mount for 500 ms, and a display that reports no refresh rate gets 8.33 ms as well.

@javache

javache commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

an adaptive panel idling at 1 Hz would otherwise pre-mount for 500 ms

While 500ms is extreme, I do think it's reasonable for us to use more than 8ms here when the system has reduced frame rate (eg in low power mode?). Maybe make the max the equivalent of 30hz / 16ms?

@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

9e2fa9c caps the budget at half of a 30 Hz frame (16.67 ms) instead of 8.33 ms; a display that reports no refresh rate keeps 8.33 ms.

@meta-codesync

meta-codesync Bot commented Oct 16, 2026

Copy link
Copy Markdown

@javache has imported this pull request. If you are a Meta employee, you can view this in D125663479.

@rubennorte

Copy link
Copy Markdown
Member

Sorry for chiming in late. I think this goes in the right direction but I did similar explorations in the past and we could end up regressing current apps in production by under-preallocating views for common 120fps devices, which now would preallocate views for half the time they were doing later. This could be good for scroll performance and animations, but could also be bad for initial load and navigations.

Given how sensitive this code path is, if we're going to update it I think we should try to get the best solution possible. In this case, I'd suggest trying idle handlers in the main thread message queue. Instead of checking if there's enough time in an arbitrary deadline, we could just check if there's anything in the queue pending execution (like choreographer callbacks). If there are, we would yield as soon as possible (improving the performance vs. now as we wouldn't go over more items than necessary). If there aren't, we would continue preallocating views, making this more efficient than it is now.

Would you be open to trying this approach? In any case, we would need to validate this via a feature flag because it's a very sensitive codepath for performance.

Thanks!

@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as draft October 19, 2026 16:02
With enablePremountItemsBetweenFramesAndroid on, the DISPATCH_UI frame
callback no longer pre-mounts views before the frame's traversal for half
of a 60 Hz frame. PreMountItemsBetweenFrames queues that work for after
the frame and runs it in slices of at most 1 ms posted to the UI thread's
looper, stopping at the next vsync: the frame's time plus the default
display's frame interval (DisplayFrameInterval), or a 60 Hz frame while
the display reports no rate. A frame, an input event or any other message
waits for one slice at most, so a stale frame interval delays a frame by
one slice and one item at most. A commit still runs every pre-mount item
left before its mount items, and a failing slice stops mounting the way a
failing frame callback does.
With enablePremountItemsBetweenFramesAndroid on, the DISPATCH_UI
callback queued its pre-mount slice before tryDispatchMountItems. A
frame whose dispatch failed before the batch drained the pre-mount
queue, such as a UIManagerListener throwing in willMountItems, still
pre-mounted after mounting had stopped. The callback now runs the
frame's mount items first and queues what is left after them, so a
failed frame leaves nothing to pre-mount, as with the flag off.

FabricUIManagerPreMountTest drives the real frame callback with React
Native's clock under the test's control: with the flag off, nine 1 ms
items run inside the frame and none after it; with it on, none inside
and 17 before the next 60 Hz vsync; pausing cancels a queued slice; and
a frame whose mount fails pre-mounts nothing after it.
A view that invalidates while it draws, such as a ScrollView during a
fling, schedules the next frame's traversal from inside the current
one, and its sync barrier stays in the main queue until that frame.
Slices posted as ordinary messages waited behind it, so during a fling
only the first slice after each frame ran: on a Galaxy S23 and a Mi
MIX 3, RNTester pre-mounted about 1 ms per frame while a fling mounted
2400 views, against 6.6 to 7.7 ms with the flag off. Slices are now
asynchronous, as the Choreographer's own vsync messages are, and still
stop at the next vsync.

PreMountItemsBetweenFramesTest posts the barrier
ViewRootImpl.scheduleTraversals posts once a frame has queued its
slice; with ordinary messages only the first slice runs (2 items
instead of 17).
With enablePremountItemsBetweenFramesAndroid on, the frame callback
dispatches its mount items before it queues the slices, so a commit whose
mount items arrived while the slices ran waited for the next frame callback.
With the flag off, the frame's pre-mount loop runs before
tryDispatchMountItems, and a commit that lands during it mounts in that frame.

A slice now dispatches the mount items it finds waiting, and the slice that
runs out of pre-mount items queues one more when a commit landed during it.
The scheduler test's looper stops after 1000 tasks, so a slice that never
stops fails the test instead of hanging it.
While the render thread waits on SurfaceFlinger for a buffer, the UI thread
sleeps in each frame's draw until the render thread takes the frame, so the
frames leave no time after them and pre-mounting between frames stalls until
the commit's mount batch. With the flag off, the frame callback pre-mounts
before the traversal, and that sleep absorbs it.

The first slice after a frame measures how long the UI thread slept since the
frame's callbacks ran, from the thread's CPU time, and the next frame
pre-mounts before its traversal for that sleep plus what it pre-mounted there,
less one slice and at most half a frame. A frame that sleeps less than half a
slice ends it, and a frame busy on the CPU never starts it.
Debug.threadCpuTimeNanos() reports -1 where the system cannot tell a thread's
CPU time. The frame's sleep would then read as its whole wall time, so a frame
busy on the CPU would hand the next one time before its traversal; a negative
reading now leaves the next traversal alone.
With time left before the traversal and no pre-mount items, the frame read
the empty call's few microseconds as pre-mounting and queued a slice to
measure its sleep, so frames that kept waiting on the render thread after a
burst queued one slice each for as long as they waited.
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/android-premount-frame-budget branch from 9e2fa9c to 6700008 Compare October 20, 2026 17:12
@YevheniiKotyrlo

YevheniiKotyrlo commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor Author

Release RNTester traces: main against enablePremountItemsBetweenFramesAndroid

Release RNTester (arm64-v8a, Hermes) built from main at 202d939 with the flag off, and from this PR at beb6a37 with the flag on, both with the four scenes below added to the View examples; the commits after it change only the flag's description and what happens when the thread clock reports -1, once the pre-mount queue is empty, and after a vsync that ran no frame callback, which never happens here: at the stable release level the frame callback re-arms every vsync. Each scene ran for 24 s after a 9 s warm-up, in rounds main, flag, flag, main, with the CPUs back near idle before each one. Traced with Perfetto and the frame timeline on Android 14 and 16, and with atrace on Android 10, which has no frame timeline.

"Vsyncs lost" counts the vsyncs the UI thread drew no frame for; where the frame timeline exists, the vsyncs SurfaceFlinger repeated on screen follow in brackets. "Run past the next vsync" is how far a frame's UI-thread work, its pre-mounting included, ended after the following vsync. "Drawn" counts from the burst's first new shadow node on the JS thread to the end of the frame that draws its mount. Medians, with ranges in brackets.

2400 views beside a native spring, main → flag on:

Galaxy S23, Android 14, 120 Hz RedMagic NX809J, Android 16, 120 Hz Mi MIX 3, Android 10, 60 Hz
views pre-mounted before the commit 556 → 724 1672 → 2400 667 → 1618
vsyncs lost before the commit, per burst 1.2 (0.4) → 0.0 (0.0) 2.8 (1.0) → 0.0 (0.0) 0.1 → 0.1
the mount and the frame that draws it (ms) 82.0 → 77.6 44.4 → 36.0 213.5 → 148.9
drawn (ms) 147.9 → 145.9 128.9 → 131.1 391.6 → 335.2
vsyncs lost per burst, commit and mount included 9.8 (9.9) → 7.6 (7.6) 6.6 (6.6) → 3.1 (3.1) 11.5 → 7.6

The same over 160 translucent full-window layers, which keep the render thread busy past each vsync — the UI thread sleeps a median 10.6–11.0 ms (S23), 10.1 ms (RedMagic) and 30.3–30.7 ms (Mi MIX 3) of each traversal waiting for it:

Galaxy S23 RedMagic NX809J Mi MIX 3
views pre-mounted before the commit 235 → 296 1205 → 1225 3 → 624
drawn (ms) 157.7 → 163.7 152.9 → 154.3 458.6 → 405.2
vsyncs lost per burst, commit and mount included 17.2 (17.7) → 17.8 (17.7) 11.4 (11.4) → 9.4 (9.2) 8.9 → 7.0

On the Mi MIX 3 main's budget, counted from the vsync, has passed before its late frames reach it. Drawn times follow the round about as much as the build: on the S23's layered arm main's two rounds drew at 156 and 166 ms and the flag's at 165 and 163, and on the RedMagic's spring arm at 111 and 146 ms against 131 and 133.

Every arm on each handset

Galaxy S23, Android 14, 120 Hz

2400 views beside a native spring; 14 bursts on main, 14 with the flag on:

main flag on
views pre-mounted before the commit 556 (204–805) 724 (601–903)
longest run past the next vsync before the commit (ms) 0.49 (0.38–4.50) 0.05 (0.02–0.08)
vsyncs lost before the commit, per burst 1.2 (0.4) 0.0 (0.0)
views left for the mount 1845 (1595–2196) 1676 (1497–1799)
the mount and the frame that draws it (ms) 82.0 (74.1–100.9) 77.6 (70.6–87.5)
from the render's start to the drawn mount (ms) 147.9 (139.3–161.7) 145.9 (143.1–159.0)
vsyncs lost per burst, commit and mount included 9.8 (9.9) 7.6 (7.6)

600 views beside a native spring; 14 bursts on main, 15 with the flag on:

main flag on
views pre-mounted before the commit 228 (63–293) 247 (214–288)
longest run past the next vsync before the commit (ms) 0.50 (0.32–1.71) 0.04 (0.00–0.12)
vsyncs lost before the commit, per burst 0.5 (0.0) 0.0 (0.0)
views left for the mount 373 (307–537) 353 (312–386)
the mount and the frame that draws it (ms) 32.7 (22.1–40.3) 27.0 (13.8–31.6)
from the render's start to the drawn mount (ms) 60.2 (41.1–75.0) 57.9 (44.3–66.9)
vsyncs lost per burst, commit and mount included 2.9 (2.8) 1.4 (1.2)

2400 views on a ScrollView fling; 8 bursts on main, 8 with the flag on:

main flag on
views pre-mounted before the commit 671 (537–942) 716 (609–802)
longest run past the next vsync before the commit (ms) 0.91 (0.74–1.16) 0.04 (0.02–0.05)
vsyncs lost before the commit, per burst 0.6 (0.4) 0.0 (0.0)
views left for the mount 1729 (1458–1863) 1685 (1598–1791)
the mount and the frame that draws it (ms) 83.0 (70.1–91.5) 77.1 (74.2–82.3)
from the render's start to the drawn mount (ms) 151.4 (144.9–163.2) 148.8 (144.4–154.1)
vsyncs lost per burst, commit and mount included 9.1 (10.4) 7.6 (8.3)

2400 views over 160 translucent full-window layers; 14 bursts on main, 14 with the flag on:

main flag on
views pre-mounted before the commit 235 (75–340) 296 (215–358)
longest run past the next vsync before the commit (ms) 10.52 (4.63–13.75) 9.66 (7.22–13.99)
vsyncs lost before the commit, per burst 3.3 (1.7) 3.1 (1.8)
views left for the mount 2165 (2060–2325) 2105 (2042–2185)
the mount and the frame that draws it (ms) 92.8 (86.7–111.1) 94.6 (83.1–106.1)
from the render's start to the drawn mount (ms) 157.7 (149.9–179.4) 163.7 (151.4–187.3)
vsyncs lost per burst, commit and mount included 17.2 (17.7) 17.8 (17.7)

RedMagic NX809J, Android 16, 120 Hz

2400 views beside a native spring; 14 bursts on main, 14 with the flag on:

main flag on
views pre-mounted before the commit 1672 (703–2400) 2400 (2400–2400)
longest run past the next vsync before the commit (ms) 0.29 (0.00–0.84) 0.04 (0.03–0.12)
vsyncs lost before the commit, per burst 2.8 (1.0) 0.0 (0.0)
views left for the mount 729 (0–1697) 0 (0–0)
the mount and the frame that draws it (ms) 44.4 (25.9–58.5) 36.0 (26.2–41.2)
from the render's start to the drawn mount (ms) 128.9 (93.1–166.7) 131.1 (122.0–159.3)
vsyncs lost per burst, commit and mount included 6.6 (6.6) 3.1 (3.1)

600 views beside a native spring; 15 bursts on main, 14 with the flag on:

main flag on
views pre-mounted before the commit 291 (237–507) 509 (449–573)
longest run past the next vsync before the commit (ms) 0.34 (0.00–0.96) 0.06 (0.02–0.18)
vsyncs lost before the commit, per burst 1.2 (0.2) 0.0 (0.0)
views left for the mount 309 (93–363) 91 (27–151)
the mount and the frame that draws it (ms) 25.6 (17.1–30.7) 14.3 (12.3–19.8)
from the render's start to the drawn mount (ms) 66.6 (55.5–77.3) 56.9 (38.0–66.5)
vsyncs lost per burst, commit and mount included 2.9 (2.7) 0.1 (0.1)

2400 views on a ScrollView fling; 8 bursts on main, 8 with the flag on:

main flag on
views pre-mounted before the commit 1477 (1083–2400) 2400 (2400–2400)
longest run past the next vsync before the commit (ms) 0.50 (0.33–1.15) 0.04 (0.02–0.12)
vsyncs lost before the commit, per burst 2.5 (1.6) 0.0 (0.0)
views left for the mount 923 (0–1317) 0 (0–0)
the mount and the frame that draws it (ms) 41.3 (26.0–45.9) 31.1 (24.4–33.8)
from the render's start to the drawn mount (ms) 113.8 (100.7–121.1) 107.8 (105.5–122.8)
vsyncs lost per burst, commit and mount included 5.9 (6.9) 2.5 (3.1)

2400 views over 160 translucent full-window layers; 14 bursts on main, 14 with the flag on:

main flag on
views pre-mounted before the commit 1205 (904–1541) 1225 (1025–1417)
longest run past the next vsync before the commit (ms) 7.90 (5.76–11.75) 7.37 (5.67–10.22)
vsyncs lost before the commit, per burst 5.6 (3.0) 2.7 (2.3)
views left for the mount 1195 (859–1496) 1175 (983–1375)
the mount and the frame that draws it (ms) 52.2 (42.8–58.7) 51.6 (47.4–66.6)
from the render's start to the drawn mount (ms) 152.9 (128.9–174.1) 154.3 (141.3–197.4)
vsyncs lost per burst, commit and mount included 11.4 (11.4) 9.4 (9.2)

Mi MIX 3, Android 10, 60 Hz

2400 views beside a native spring; 14 bursts on main, 14 with the flag on:

main flag on
views pre-mounted before the commit 667 (482–840) 1618 (1213–1759)
longest run past the next vsync before the commit (ms) 0.00 (0.00–10.47) 0.00 (0.00–12.32)
vsyncs lost before the commit, per burst 0.1 0.1
views left for the mount 1734 (1560–1918) 782 (641–1187)
the mount and the frame that draws it (ms) 213.5 (200.4–224.9) 148.9 (137.3–181.7)
from the render's start to the drawn mount (ms) 391.6 (371.2–416.7) 335.2 (321.7–375.2)
vsyncs lost per burst, commit and mount included 11.5 7.6

600 views beside a native spring; 14 bursts on main, 15 with the flag on:

main flag on
views pre-mounted before the commit 157 (27–201) 363 (216–476)
longest run past the next vsync before the commit (ms) 0.00 (0.00–0.00) 0.00 (0.00–0.08)
vsyncs lost before the commit, per burst 0.0 0.0
views left for the mount 444 (399–573) 237 (124–384)
the mount and the frame that draws it (ms) 60.2 (55.9–81.2) 44.5 (35.6–59.5)
from the render's start to the drawn mount (ms) 123.5 (102.4–143.3) 112.9 (101.1–127.0)
vsyncs lost per burst, commit and mount included 2.3 0.9

2400 views on a ScrollView fling; 6 bursts on main, 6 with the flag on:

main flag on
views pre-mounted before the commit 682 (611–779) 1597 (1536–1667)
longest run past the next vsync before the commit (ms) 0.00 (0.00–0.00) 0.00 (0.00–0.00)
vsyncs lost before the commit, per burst 0.0 0.0
views left for the mount 1718 (1621–1789) 803 (733–864)
the mount and the frame that draws it (ms) 203.8 (197.4–218.4) 148.4 (140.9–150.6)
from the render's start to the drawn mount (ms) 372.4 (365.9–384.1) 317.0 (309.4–330.1)
vsyncs lost per burst, commit and mount included 11.2 7.3

2400 views over 160 translucent full-window layers; 14 bursts on main, 14 with the flag on:

main flag on
views pre-mounted before the commit 3 (0–184) 624 (602–631)
longest run past the next vsync before the commit (ms) 21.51 (14.60–44.42) 16.57 (11.66–45.58)
vsyncs lost before the commit, per burst 0.1 0.1
views left for the mount 2397 (2216–2400) 1777 (1769–1798)
the mount and the frame that draws it (ms) 264.9 (244.3–296.0) 215.0 (206.9–252.5)
from the render's start to the drawn mount (ms) 458.6 (418.9–483.7) 405.2 (389.5–442.5)
vsyncs lost per burst, commit and mount included 8.9 7.0
The scene: four arms added to RNTester's View examples

Each arm mounts and unmounts count items of two Views. premount-burst-* toggles them every 1.5 s beside a native-driven spring that redraws every vsync; premount-fling-* toggles them on each fling of a horizontal ScrollView, started from an idle screen; premount-gpu160-* is the burst arm over 160 translucent full-window Views, which keep the render thread busy past each vsync.

function PremountBurstSpinner(): React.Node {
  const rotation = React.useRef(new Animated.Value(0)).current;
  const spin = React.useRef(
    rotation.interpolate({inputRange: [0, 2], outputRange: ['0deg', '360deg']}),
  ).current;
  React.useEffect(() => {
    const spring = Animated.spring(rotation, {
      toValue: 1,
      stiffness: 10,
      damping: 0.01,
      mass: 1,
      useNativeDriver: true,
    });
    spring.start();
    return () => spring.stop();
  }, [rotation]);
  return (
    <Animated.View
      style={{
        width: 48,
        height: 48,
        backgroundColor: '#527FE4',
        transform: [{rotate: spin}],
      }}
    />
  );
}

function PremountBurstItems({
  count,
  mounted,
}: {
  count: number,
  mounted: boolean,
}): React.Node {
  return (
    <View style={{flexDirection: 'row', flexWrap: 'wrap', marginTop: 8}}>
      {mounted
        ? Array.from({length: count}, (_, index) => (
            <View
              key={index}
              style={{
                width: 8,
                height: 8,
                margin: 1,
                backgroundColor: '#e5e7eb',
              }}>
              <View style={{width: 4, height: 4, backgroundColor: '#7c3aed'}} />
            </View>
          ))
        : null}
    </View>
  );
}

function PremountBurstBlock({count}: {count: number}): React.Node {
  const [mounted, setMounted] = useState(false);
  React.useEffect(() => {
    const interval = setInterval(() => setMounted(previous => !previous), 1500);
    return () => clearInterval(interval);
  }, []);
  return <PremountBurstItems count={count} mounted={mounted} />;
}

function PremountBurstExample({count}: {count: number}): React.Node {
  return (
    <View testID="premount-burst">
      <PremountBurstSpinner />
      <PremountBurstBlock count={count} />
    </View>
  );
}

const PREMOUNT_FLING_COLUMNS = Array.from({length: 400}, (_, index) => (
  <View
    key={index}
    style={{
      width: 48,
      height: 96,
      backgroundColor: index % 2 === 0 ? '#527FE4' : '#7c3aed',
    }}
  />
));

function PremountFlingExample({count}: {count: number}): React.Node {
  const [mounted, setMounted] = useState(false);
  return (
    <View testID="premount-fling">
      <ScrollView
        horizontal={true}
        testID="premount-fling-scroller"
        style={{height: 96}}
        onMomentumScrollBegin={() => setMounted(previous => !previous)}>
        {PREMOUNT_FLING_COLUMNS}
      </ScrollView>
      <PremountBurstItems count={count} mounted={mounted} />
    </View>
  );
}

function PremountGpuExample({
  count,
  layers,
}: {
  count: number,
  layers: number,
}): React.Node {
  const {height} = useWindowDimensions();
  return (
    <View testID="premount-gpu" style={{height}}>
      {Array.from({length: layers}, (_, index) => (
        <View
          key={index}
          pointerEvents="none"
          style={[
            StyleSheet.absoluteFill,
            {backgroundColor: 'rgba(124, 58, 237, 0.02)'},
          ]}
        />
      ))}
      <PremountBurstSpinner />
      <PremountBurstBlock count={count} />
    </View>
  );
}

@YevheniiKotyrlo YevheniiKotyrlo changed the title Budget Android pre-mounting by the display's frame, not a 60 Hz one Pre-mount Android views in the time each frame leaves free, behind a flag Oct 20, 2026
@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

Thanks, I tried idle handlers first. A vsync and an input event only reach the main queue when the looper polls, so a running idle handler can't see the next frame coming: MessageQueue.isIdle() stayed true across frames on every device and system image I tried, and draining until something was queued froze the UI thread for the whole backlog.

So I've reworked this PR behind enablePremountItemsBetweenFramesAndroid, off by default, to pre-mount in the time each frame leaves free: after the frame in 1 ms slices until the next vsync, and before the traversal for as long as the previous frame's draw waited on the render thread. On a Galaxy S23 and a RedMagic at 120 Hz and a Mi MIX 3 at 60 Hz it pre-mounts more views before each commit than today and loses fewer vsyncs per burst; the traces are in the comment above.

The description named only the slices after each frame. A frame that
follows one whose draw slept also pre-mounts before its traversal, so the
description now says so, as PreMountItemsBetweenFrames does.
…sured

With disableIdleMountItemFrameCallbackRearmAndroid on, the frame callback
stops re-arming once nothing is left to mount, and the sleep a slice had
measured after the last frame waited for the next burst's first frame,
which spent it before its traversal however many vsyncs had passed. The
measured time now lapses once a frame interval has passed since it was
measured, so only the frame right after the measured one spends it.

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. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants