Skip to content

Skip layer invalidation when an iOS View only changes position - #59168

Open
hryhoriiK97 wants to merge 1 commit into
react:mainfrom
hryhoriiK97:ios-view-skip-position-invalidate
Open

hryhoriiK97 wants to merge 1 commit into
react:mainfrom
hryhoriiK97:ios-view-skip-position-invalidate

Conversation

@hryhoriiK97

@hryhoriiK97 hryhoriiK97 commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

RCTViewComponentView (React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm) applies a view's borders, shadow path, background colour and image layers, box shadows, outline, filters and clipping masks in invalidateLayer, which runs from finalizeUpdates: whenever _needsInvalidateLayer is set.

Current behavior. updateLayoutMetrics:oldLayoutMetrics: sets _needsInvalidateLayer unconditionally. The mounting layer calls it whenever any layout metric differs, including the frame origin alone, so a view that merely moved runs the whole invalidation: it resolves border metrics, recreates the shadow path, re-applies about ten layer properties, re-rasterizes the border image when borders are non-uniform, and removes and recreates the box-shadow, outline, background-image and mask sublayers. When the content above a list changes height, every card below it pays this although nothing it draws has changed.

With this change. The layout metrics are compared with the stored ones with the origin masked out. If only the origin changed, the invalidation is not requested; every other change (size, content insets, border widths, overflow inset, display type, layout direction, scale factor) still invalidates as before, and so does a recycled view, whose stored metrics are EmptyLayoutMetrics. Everything invalidateLayer derives is in the view's own coordinate space (bounds, layer.bounds, padding frame), with one exception: the iOS 17 hover shape is built from self.frame to work around a UIKit bug, so views with cursor: pointer keep invalidating on moves. The subview and sublayer frame updates in updateLayoutMetrics: are unchanged.

Why. Position-only layout updates are the common case in a reflow: inserting a row, expanding an accordion, a keyboard-avoiding shift, or a LayoutAnimation moving content. The cost scales with the number of styled views below the change.

Changelog:

[IOS] [FIXED] - Views no longer rebuild their layer styling (borders, shadows, background and mask layers) when only their position changes

Test Plan:

  • node scripts/clang-format.js --check on the changed file passes.
  • RNTester built with xcodebuild in Release and run on a physical iPhone 15 Pro (iOS 26.6.1).
  • In screenshots of a reflowed list of 1000 cards (border radius, 1pt border, solid background, shadow props) and of the same list with boxShadow, the list area is byte-identical before and after.
  • Measurement. RNTester Playground with 1000 cards mounted in a ScrollView, each a View wrapping a Text; a "reflow" toggles a spacer above the list between 0 and 40pt, so every card moves and none changes size. Three contents: plain (unstyled wrapper, which Fabric flattens, so only the Text views remain), card (borderRadius: 8, borderWidth: 1, solid backgroundColor, shadowColor/Opacity/Radius/Offset) and the same card with boxShadow instead. Timed from Fabric's transaction telemetry (mount phase), a timer around invalidateLayer, a run-loop observer after the CoreAnimation commit (mount end to display), and the touchend Event Timing entry for the press (press to mount end). The two arms are the same binary with the new check switched by an environment variable; both include Skip redrawing iOS Text when only its position changes #59155 so that text redraws do not dominate the reflow. The presses were driven by Argent on the physical iPhone, with no accessibility polling between presses. iPhone 15 Pro, Release, medians of 5 reflows:
reflow with 1000 cards (shadow props) before this PR change
layer invalidations 1001 13
invalidateLayer total (main thread) 59.2 ms 1.5 ms −97%
mount phase (main thread) 61.5 ms 3.8 ms −94%
mount end to display 5.3 ms 1.4 ms
press to mount end 114.6 ms 69.9 ms −39%
reflow with 1000 cards (boxShadow) before this PR change
layer invalidations 2001 10
invalidateLayer total (main thread) 66.6 ms 0.9 ms −99%
mount phase (main thread) 70.4 ms 4.0 ms −94%
mount end to display 18.3 ms 0.1 ms −99%
press to mount end 126.8 ms 42.5 ms −66%

Each arm was run twice. The repeat of the before arm gave 114.1 ms and 132.1 ms press to mount end for the two card styles (58.2 ms and 66.9 ms of invalidation), the repeat of the after arm 52.3 ms and 53.2 ms (1.1 ms and 0.9 ms), so the saving is 39 to 68% of the press across both runs and both card styles. The remaining invalidations are views whose size or props do change in that commit, such as the harness's own status labels. The plain list is unchanged (0.8 ms of invalidation per reflow before, under 0.1 ms after, press to mount end within noise at 41 and 46 ms), as are mounting and unmounting: mounting the 1000 cards measures 141 ms in the mount phase in both arms, with the same 2001 invalidations.

Not exercised: the hover shape for cursor: pointer, which needs a pointer device; by design those views invalidate on moves exactly as before.

🤖 Generated with Claude Code

`RCTViewComponentView` requested a full `invalidateLayer` on every layout
metrics update, including updates where only the frame origin changed.
Everything the invalidation derives is in the view's own coordinates, so a
view that merely moved rebuilt its shadow path, border rendering and
background, box-shadow and mask layers for nothing. The hover shape is the
exception, since iOS evaluates it in the superview's space, so views with
`cursor: pointer` keep invalidating on moves.

Co-Authored-By: Claude Fable 5.1 <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 18, 2026
@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 18, 2026

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.

1 participant