Skip to content

Reuse solid border images across iOS Views with the same border - #59206

Open
hryhoriiK97 wants to merge 2 commits into
react:mainfrom
hryhoriiK97:ios-cache-border-image
Open

hryhoriiK97 wants to merge 2 commits into
react:mainfrom
hryhoriiK97:ios-cache-border-image

Conversation

@hryhoriiK97

@hryhoriiK97 hryhoriiK97 commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

RCTViewComponentView (React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm) draws a View's border into a bitmap whenever Core Animation can't draw it: in particular a visible border on a View that doesn't clip its content, since Core Animation draws a layer's border above its sublayers while CSS draws it behind the content. That is the common card, button and input style with borderRadius and borderWidth. The bitmap comes from RCTGetSolidBorderImage (React/Views/RCTBorderDrawing.m), which runs on every invalidateLayer, so on every mount and on every size or border change. Views whose borders Core Animation draws, or that have no border, never reach this function.

Current behavior. Each call renders a new image with UIGraphicsImageRenderer. In the common case the image is stretchable: its size and pixels depend only on the corner radii, border widths and colors, not on the View's size. A list of identical cards still draws the same image once per card.

With this change. Stretchable images are kept in an NSCache of at most 64 entries, keyed by everything the drawing reads: the corner radii, the border widths as passed in (the corner insets are computed from them before pixel rounding), the four border colors, the background color and drawToEdge. Views with the same border reuse one image instead of each drawing its own. The key is a small class that hashes the radii and widths itself, because an NSArray hashes by its element count and NSValue gives every struct of a custom type the same hash; it compares all inputs exactly. Images drawn with a dynamic color are not cached, because they depend on the trait collection; a color counts as static when resolving it returns the same object, which is how UIKit treats non-dynamic colors. Non-stretchable images and dashed or dotted borders are drawn as before. The drawing code is unchanged, so the pixels are too.

The 64 entries count distinct border styles, not Views, since the key doesn't include the View's size. When an app uses more styles than that, NSCache evicts older entries and a miss draws the image as before.

Changelog:

[IOS] [CHANGED] - Views with the same solid border share one border image instead of each drawing its own

Test Plan:

  • clang-format --dry-run --Werror on the changed file passes.
  • RNTester built with xcodebuild in Release and run on a physical iPhone 15 Pro (iOS 26.6.1).
  • Same output.
    • Screenshots of a list of 1000 bordered cards after mounting are byte-identical in the list area with and without the cache, both when all cards share a border and when every card has a different border radius.
    • In the RNTester Border examples, every screen is byte-identical once the scroll positions are aligned, apart from the blur under the translucent tab bar and one box with translucent borders whose pixels differ by at most 3 levels. That box also differs between two runs without the cache.
    • On the iOS simulator, the static-color check accepts RGB, Display P3 and clear colors and rejects a dynamic provider color, systemRedColor and labelColor, in both light and dark appearance.
  • Measurement. RNTester Playground mounting items in a ScrollView, each a View wrapping a Text, with four contents: plain (no border or shadow), card (borderRadius: 8, borderWidth: 1, solid backgroundColor, shadowColor/Opacity/Radius/Offset), the same card with boxShadow instead, and the card with a different borderRadius on every item, so no two items share a border image and every lookup misses. Timed: the main-thread mount phase of the transaction (TransactionTelemetry), a timer around invalidateLayer, and press-to-mount from the Event Timing touchend entry. The two arms are the same binary with the cache switched by a launch argument, in the order before, after, after, before. The presses were driven by Argent on the physical iPhone. iPhone 15 Pro, Release, medians:
20 items, 20 mounts per arm before this PR change
cards: mount phase 6.14 ms 2.28 ms −63%
cards: invalidateLayer 3.58 ms 0.51 ms −86%
boxShadow cards: mount phase 4.25 ms 2.50 ms −41%
plain: mount phase 1.19 ms 1.29 ms
1000 items, 10 mounts per arm before this PR change
cards: mount phase 80.3 ms 28.1 ms −65%
cards: invalidateLayer 58.4 ms 7.0 ms −88%
cards: press-to-mount 354.9 ms 297.7 ms −16%
boxShadow cards: mount phase 84.4 ms 31.7 ms −62%
boxShadow cards: press-to-mount 366.2 ms 315.8 ms −14%
cards with a different border on every item: mount phase 96.0 ms 84.9 ms −12%
plain: mount phase 21.2 ms 21.1 ms

At 20 items, press-to-mount differs by a few milliseconds in both directions between the arms, within its noise.

The same idea was proposed in #15281 in 2017 and closed as stale.

🤖 Generated with Claude Code

A stretchable solid border image depends only on the corner radii, border
widths and colors, not on the View's size, yet every invalidateLayer drew a
new one. Keep stretchable images in an NSCache keyed by those inputs so Views
with the same border share one image. Images drawn with dynamic colors are
not cached, since they depend on the trait collection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@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 19, 2026
@facebook-github-tools facebook-github-tools Bot added p: Software Mansion Partner: Software Mansion Partner Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. labels Oct 19, 2026
An NSArray key hashes by its element count and NSValue gives every struct of a
custom type the same hash, so lookups compared against every cached key. Use a
small key class that hashes the radii and widths itself and compares all
inputs exactly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hryhoriiK97

Copy link
Copy Markdown
Contributor Author

Updated: the cache key is now a small class with a real hash (an NSArray key hashes by count, and NSValue gives every custom struct the same hash). Added a worst case where every card has a different border: mount is 12% faster with the cache rather than slower, and Views without a border image don't reach this code (plain items are unchanged).

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. p: Software Mansion Partner: Software Mansion Partner 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.

1 participant