Skip to content

fix(🍏): ask a display link made after a recycle or a trip for the full rate - #4350

Open
YevheniiKotyrlo wants to merge 2 commits into
wcandillon:mainfrom
YevheniiKotyrlo:fix/display-link-full-rate-after-trip
Open

YevheniiKotyrlo wants to merge 2 commits into
wcandillon:mainfrom
YevheniiKotyrlo:fix/display-link-full-rate-after-trip

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Problem

On a ProMotion iPhone, a canvas presents at 60 Hz once its SkiaView was recycled, or taken out of its superview and put back. requestFullFrameRate (#4273) asks a display link for the display's full rate once per view:

if (_fullFrameRateRequested) {
  return;
}

stopDisplayLink invalidates the link when the view leaves its superview or is recycled, and the next scheduleFrame makes a new one, which then never asks; on the iPhone 14 Pro #4273 measured, such a link fires at 60 Hz. Fabric recycles SkiaViews, so a canvas mounted into a recycled view presents at half the display's rate, and so does one a scroll view with removeClippedSubviews takes away and brings back.

Solution

requestFullFrameRate runs where scheduleFrame makes a link, and from didMoveToWindow for a link made while its view had no window, in place of every scheduleFrame behind _fullFrameRateRequested. Each link asks for itself, so the link made after a recycle or a trip asks for the full rate like the first one, and the flag goes with nothing to reset. A frame whose link exists no longer calls the request.

Tests

None in the repo can show it: CI's iOS simulator has no 120 Hz display, and no test target reaches the display link. On an iPad Pro, the link made after a recycle asks for 120 where main's asks for nothing, as does the one made after a trip on #4324's head; the iPad runs either at 120, and no ProMotion iPhone was available, so the rate a canvas presents at on one is not measured with this PR. What scheduleFrame spends on the display link a frame goes from 1.48 to 0.88 µs on the iPad and from 1.13 to 0.63 µs on a 60 Hz iPhone 12 Pro Max: the evidence comment.

Verification

clang-format clean; the example's iOS Release build. Every changed line is outside macOS, so CI's macOS build compiles SkiaView.mm as on main.

Related

Split out of #4324, whose views come back to their superview with a new display link. #4328 moves SkiaView.mm's display link and its full-rate request into a window's presentation; whichever lands second carries this fix into it.

Base

main feee40afc.

…l rate

requestFullFrameRate asked once per view: stopDisplayLink invalidated the
link when the view left its superview or was recycled, but kept
_fullFrameRateRequested, so the next link never asked and ran at 60 Hz on
a ProMotion display. Fabric recycles SkiaViews, so a canvas mounted into a
recycled view presented at half the display's rate, as did one a scroll
view that clips its subviews took away and brought back. stopDisplayLink
now clears the flag with the link it invalidates.
@YevheniiKotyrlo

YevheniiKotyrlo commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor Author

Measured

A canvas animating on every UI frame, measured as #4273 measured it, with a probe in SkiaView.mm that logs one reading a second: the display link's ticks, its preferredFrameRateRange.maximum, and the time scheduleFrame spends on the display link on a frame. Release builds; this PR at 9b5f5ca, main at feee40a; three runs or more a side, alternating within each table.

A new link asks for the full rate. iPad Pro 12.9" (4th generation, iPadOS 27, 120 Hz):

preferredFrameRateRange.maximum main this PR
first mount 120 120
mounted into the SkiaView Fabric recycled 0 120

When a scroll view with removeClippedSubviews takes the view out and puts it back, main's view draws nothing (the defect #4324 fixes), so that case is measured on #4324's head (627fdc0). The #4324 column is a separate run of 627fdc0 alone, not alternated with the column beside it; both read the link's range the same way:

preferredFrameRateRange.maximum #4324 #4324 with this PR
put back by the scroll view 0 120
mounted into the recycled view 0 120

A link made before its view has a window, which a probe-only build forces by scheduling a frame at the end of willMoveToSuperview:, reads 120 once the view is in one; with didMoveToWindow taken out, it reads 0. iPad Pro, three runs a side, every reading.

The iPad runs a link that asks for nothing at 120 Hz anyway, so the rate it presents at does not move there; the iPhone 14 Pro in #4273 runs such a link at 60. No ProMotion iPhone was available here, so this PR's rate on one is not measured: its ticks a second after a recycle would show it. An iPhone 12 Pro Max (iOS 27, 60 Hz) asks for nothing on either side and ticks at 60.

A frame whose link exists no longer pays for the request. What scheduleFrame spends on the display link on such a frame, the median of the one-second medians, with the range over runs:

µs a frame main this PR
iPad Pro (120 Hz) 1.48 (1.42–1.63) 0.88 (0.79–0.96)
iPad Pro, after a recycle 1.54 (1.17–1.83) 0.88 (0.75–0.88)
iPhone 12 Pro Max (60 Hz) 1.13 (1.04–1.13) 0.63 (0.63–0.79)
iPhone 12 Pro Max, after a recycle 2.04 (1.83–2.13) 0.85 (0.67–0.92)

This PR's range sits below main's lowest run on every row: main calls requestFullFrameRate on every frame to check _fullFrameRateRequested, and this PR calls it only where a link is made and from didMoveToWindow. Making a link after a recycle or after the scroll view puts the view back costs more than on main, which does not ask there: 9 and 13 µs on the iPad, 5 on the iPhone, once per link.

Checking the link's range on every frame instead

With requestFullFrameRate returning while _displayLink.preferredFrameRateRange.maximum != 0, called from scheduleFrame on every frame, timed around that call alone (so main's numbers here are smaller than in the table above, which times all of scheduleFrame's display-link work), mounted and after a recycle: 1.25 and 1.54 µs on the iPad against main's 0.58 and 0.67, and 9.2 and 17.1 µs on the iPhone 12 Pro Max against 0.42 and 0.75. A 60 Hz display never gets a range, so each frame reads the window's screen again.

The probe

Evidence only: https://github.com/YevheniiKotyrlo/react-native-skia-evidence/tree/main/pr-4350. add-tick-counter.py adds the probe to SkiaView.mm, which writes its readings to the app's Documents; probe-entry.js (mounted, put back by the scroll view, mounted into the recycled view) is the bundle's entry, and --window-less-link adds the frame scheduled before the window. Every reading of every run is in runs/.

@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 28, 2026 16:24
YevheniiKotyrlo added a commit to YevheniiKotyrlo/react-native-skia that referenced this pull request Oct 28, 2026
…ic-canvas-image-view

wcandillon#4324 now only keeps a view's native view, and what it shows, out of its
superview: its recording slots, colour helpers, display link reset and
view registry cleanup became wcandillon#4348, wcandillon#4349, wcandillon#4350 and wcandillon#4351. This branch
carries wcandillon#4324 as it stands, and wcandillon#4349, whose helpers the region readers
in setup.tsx build on; stopDisplayLink leaves the reset to wcandillon#4350.
@wcandillon
wcandillon self-requested a review October 29, 2026 05:02

@wcandillon wcandillon left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good catch. Can we remove the _fullFrameRate flag instead?
Something like this:

 - (void)requestFullFrameRate {
 #if !TARGET_OS_VISION
-  if (_fullFrameRateRequested) {
+  if (_displayLink.preferredFrameRateRange.maximum != 0) {
     return;
   }
   UIScreen *screen = self.window.screen;
   if (screen == nil) {
     return;
   }
-  _fullFrameRateRequested = true;
   const float maxFps = (float)screen.maximumFramesPerSecond;
   if (maxFps > 60) {
     _displayLink.preferredFrameRateRange =
         CAFrameRateRangeMake(60, maxFps, maxFps);
   }
 #endif
 }

@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as draft October 29, 2026 10:42
requestFullFrameRate runs where scheduleFrame makes a link, and in
didMoveToWindow for a link made before its view had a window, in place of
every scheduleFrame behind _fullFrameRateRequested, so the flag and
stopDisplayLink's reset go. Checking the link's preferredFrameRateRange on
every frame instead would read the range each frame, and on a 60 Hz
display, which never gets one, the window's screen too.
@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

Checking the range on every frame added time to every frame: on the iPad the range's getter took 0.7–0.9 µs a frame, and on an iPhone 12 Pro Max, a 60 Hz display that requestFullFrameRate sets no range for, the check never returned early, so each frame read the window's screen again, 8.8–16.3 µs (measured). 9b5f5ca drops _fullFrameRateRequested without a check per frame: a link asks for the full rate where scheduleFrame makes it, and from didMoveToWindow if its view had no window yet. The link made after a recycle or a trip still asks for 120, and once its link exists a frame spends 0.88 µs on the display link where main spends 1.48 on the iPad, and 0.63 where it spends 1.13 on the iPhone.

@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 29, 2026 15:27

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants