Skip to content

Skip the iOS ScrollView clip walk when no view clips its subviews - #59284

Open
hryhoriiK97 wants to merge 2 commits into
react:mainfrom
hryhoriiK97:ios-skip-clip-walk-without-clipping
Open

hryhoriiK97 wants to merge 2 commits into
react:mainfrom
hryhoriiK97:ios-skip-clip-walk-without-clipping

Conversation

@hryhoriiK97

@hryhoriiK97 hryhoriiK97 commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

RCTScrollViewComponentView updates clipped subviews in _remountChildren by calling updateClippedSubviewsWithClipRect:relativeToView: on its scroll view, after every mounting transaction and whenever the content offset has moved by more than kClippingLeeway while scrolling. The call walks every view in the scroll view's content. Only an RCTViewComponentView with removeClippedSubviews acts on it (it mounts and unmounts its children); every other view converts the rect and forwards it to its subviews.

Current behavior. removeClippedSubviews is off by default on iOS, so in most apps no view acts on the walk, but every ScrollView still runs it on every few points of scrolling. In RNTester, this walk is the largest main-thread cost while scrolling: from a sixth to almost half of the main thread's time on the screens below.

With this change. RCTViewComponentView keeps a count of live views with removeClippedSubviews on. It is updated when the prop changes, when a view is recycled and when it is deallocated, all on the main thread. _remountChildren returns early while the count is 0. When any view has removeClippedSubviews on, the walk runs as before. Turning the prop off doesn't depend on the walk either: the view remounts all its children as soon as the prop changes.

Changelog:

[IOS] [CHANGED] - A ScrollView no longer walks its content to update clipped subviews while no view has removeClippedSubviews on

Test Plan:

  • clang-format --dry-run --Werror on the changed files passes.
  • RCTViewComponentViewTests: all 24 tests pass on an iOS simulator. These tests are part of React-RCTFabric's test spec, which RNTester doesn't integrate, so I ran them by adding the file to the RNTesterUnitTests target locally (xcodebuild test -scheme RNTesterUnitTests -only-testing:RNTesterUnitTests/RCTViewComponentViewTests). The two new tests check that the count follows removeClippedSubviews across two views without counting a view twice, and that recycling and deallocating a clipping view take it off the count. With the decrement in dealloc removed, the second test fails.
  • RNTester built with xcodebuild in Release and run on a physical iPhone 15 Pro (iOS 26.6.1).
  • Same clipping. With a local check that kept walking on every call and, whenever the count was 0, confirmed that no visited view had removeClippedSubviews on, there were 0 mismatches over 102,997 walks (ScrollViewExample 89,199, TextExample 11,799, FlatList basic 1,999). In a screen that turns removeClippedSubviews on and off twice, with views mounted and unmounted in between, the count returned to 0 each time with 0 mismatches.
  • Measurement. The two arms are the same binary with the change switched by a launch argument, in the order before, after, after, before. Each launch opens the screen and does 5 flings down and 5 up, driven by Argent on the physical iPhone, under Instruments' Time Profiler. Values are the main thread's sampled time per launch, averaged over the 2 launches of each arm. iPhone 15 Pro, Release:
RNTester screen, no removeClippedSubviews before this PR change of which the walk (before)
TextExample 2,954 ms 1,342 ms −54.6% 1,326 ms
FlatList multicolumn 2,698 ms 1,498 ms −44.5% 1,144 ms
ScrollViewExample 2,426 ms 1,679 ms −30.8% 1,138 ms
FlatList basic 2,745 ms 1,963 ms −28.5% 791 ms
ScrollViewSimpleExample 1,362 ms 1,094 ms −19.7% 378 ms
SectionList scrollable 3,578 ms 3,356 ms −6.2% 572 ms

With the change, the walk took 0 ms on every screen. SectionList is mostly bound by the JS thread, and its main-thread total varied by ±10% between launches of the same arm.

RNTester Playground, 1000 cards inside a removeClippedSubviews view before (5 launches) this PR (6 launches) change
main thread per launch, average (range) 2,345 ms (2,151–2,428) 2,411 ms (2,338–2,449) within noise

With a clipping view present, both arms run the same walk and the only added work is one comparison per walk; the ranges overlap and the difference between launches comes from the flings.

🤖 Generated with Claude Code

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 21, 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 21, 2026

@javache javache left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm not a fan of using global state for this.

The subview clip pass should be shallow and only consider the children of the scrollview itself. Could we track this locally inside the scrollview's (content view?)?

@hryhoriiK97

Copy link
Copy Markdown
Contributor Author

Thanks @javache! I tried tracking it on the content view, but the pass can't be shallow today: a View with removeClippedSubviews only adds new children to _reactSubviews, and the ScrollView's clip pass is what attaches them. So <ScrollView><View removeClippedSubviews>{rows}</View></ScrollView> relies on the deep pass, and skipping it would leave the rows empty.

Options:

  1. A per-ScrollView count instead of a global one (clipping views register with their nearest ScrollView).
  2. A shallow pass, with nested clipping Views attaching their own children.

Which would you prefer?

@hryhoriiK97
hryhoriiK97 requested a review from javache October 21, 2026 11:07
@hryhoriiK97

Copy link
Copy Markdown
Contributor Author

@javache any thoughts on the two options above? I re-measured on an iPhone: this walk is still ~30% of the main thread while scrolling a FlatList. Happy to go with whichever you prefer.

Replace the global count of views with `removeClippedSubviews` with a
weak set on each ScrollView. A view with the prop on registers with its
nearest ScrollView while it is in a window, and unregisters when it
leaves the window, turns the prop off or is recycled. A ScrollView skips
its clipping pass while it is in a window and has no such views; outside
a window it keeps walking, since its content can be mounted before it is
shown.

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

Copy link
Copy Markdown
Contributor Author

@javache I've tried to implement a new approach based on your suggestion, but I didn't make the walk shallow. Each ScrollView now tracks the views with removeClippedSubviews inside it and skips the walk when there are none, so no global state.

The walk stays deep because it's what mounts the visible children of clipping views, also nested ones. Happy to make it shallow as a follow-up if you prefer.

kdwkr added a commit to scatterlab/react-native that referenced this pull request Nov 11, 2026
…회하지 않게 한다 (#15)

* Fix iOS removeClippedSubviews being lost on recycled views (react#58915)

Summary:
On iOS, `prepareForRecycle` resets `_removeClippedSubviews` to `NO` but keeps `_props`, and `updateProps:oldProps:` only set the flag when `removeClippedSubviews` differed from `_props`. So a recycled view whose previous props also had `removeClippedSubviews` never turned clipping back on, and a remounted `ScrollView`/`FlatList` kept every row attached. This compares against `_removeClippedSubviews` itself, and adds a test.

Fixes react#58914. Repro: https://github.com/mozzius/view-remove-clipped-subviews-recycle-repro

## Changelog:

[IOS] [FIXED] - removeClippedSubviews is kept on recycled views

Pull Request resolved: react#58915

Test Plan:
In the repro (a `ScrollView` with `removeClippedSubviews` and 100 rows, unmounted and mounted again), the recycled content view keeps all 100/100 rows attached on every remount without this change (7/7, including 3/3 on unmodified 0.87.1), and 7/100 with it (3/3), the same as on first mount. The new `RCTViewComponentViewTests` case, run inside the repro app against 0.87.1 built from source, fails without the change and passes with it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed By: sammy-SC

Differential Revision: D123878627

Pulled By: cipolleschi

fbshipit-source-id: 21d792698d74805f61a9a93f5b944d8f5abcd7f3
(cherry picked from commit ff78e95)

* fix(scatterlab): removeClippedSubviews 를 켠 뷰가 없으면 ScrollView 가 콘텐츠를 순회하지 않게 한다

iOS 의 `RCTScrollViewComponentView` 는 mounting transaction 이 끝날 때마다, 그리고 스크롤이
44pt 움직일 때마다 `_remountChildren` 으로 콘텐츠 전체를 재귀 순회한다. 순회가 실제로 일을
하는 곳은 `removeClippedSubviews` 가 켜진 `RCTViewComponentView` 뿐이고, 나머지 뷰는 rect 를
변환해 자식에게 넘기기만 한다. 옵저버 통지는 surface 의 모든 ScrollView 로 가므로, 그 prop 을
켠 뷰가 하나도 없는 앱은 커밋마다 마운트된 ScrollView 전부의 서브트리를 헛돈다.

`RCTViewComponentView` 가 `_removeClippedSubviews` 가 켜진 살아 있는 뷰 수를 세고,
`_remountChildren` 은 그 수가 0 이면 반환한다. 0 이 아니면 지금과 똑같이 순회한다.

- 카운터는 props 가 아니라 플래그 자체의 전이를 따른다. 플래그를 바꾸는 두 곳(`updateProps`,
  `prepareForRecycle`)이 `RCTSetRemoveClippedSubviews` 하나를 거치고, 켜진 채 해제되는 뷰는
  `dealloc` 이 뺀다. `prepareForRecycle` 은 플래그만 내리고 `_props` 는 남기므로, props 기준으로
  세면 재활용된 뷰를 두 번 빼게 된다. 수가 실제보다 작으면 clipping 뷰의 자식이 화면에 붙지
  않는다.
- 가드는 `_remountChildren` 에만 둔다. `_remountChildrenIfNeeded` 의
  `_contentOffsetWhenClipped` 갱신은 그대로라, 수가 0 인 동안 건너뛴 순회가 아무것도 바꾸지
  않는다는 것만으로 이전과 상태가 같다.
- 카운터는 `RCTViewComponentView.mm` 한 곳에 `std::atomic` 으로 두고 함수로 읽는다.

상류 react#59284(open)가 같은 목적이다.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>

* test(scatterlab): 클리핑 뷰 카운터와 ScrollView 순회 가드를 검증한다

- 낡은 props 를 가진 재활용 뷰가 카운터를 두 번 내리지 않는다.
- 같은 값 반복·이중 `prepareForRecycle`·켜진 채 `dealloc` 뒤에 카운터가 제자리로 온다.
- 카운터가 0 이면 `_remountChildren` 이 순회하지 않고, 다른 ScrollView 에 속한 뷰가
  `removeClippedSubviews` 를 켜면 순회한다.
- clipping 뷰에 새로 마운트된 자식 중 보이는 것은 ScrollView 순회가 붙인다.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>

* chore(scatterlab): 클리핑 순회 가드와 상류 react#58915 가 건드린 파일을 허용 목록에 올린다

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>

* test(scatterlab): 순회 생략 테스트의 카운터 0 전제에 이유와 실패 메시지를 붙인다

순회 생략은 프로세스에 clipping 뷰가 하나도 없을 때만 관찰되므로 baseline 이 아니라 0 을
요구한다. 전제가 깨지면 원인이 바로 보이도록 메시지를 단다.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>

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.

2 participants