Skip to content

fix(ios): keep clipping and corner radius on a recycled EaseView - #73

Open
el-lsan wants to merge 2 commits into
appandflow:mainfrom
el-lsan:fix/ios-recycle-keeps-clipping
Open

el-lsan wants to merge 2 commits into
appandflow:mainfrom
el-lsan:fix/ios-recycle-keeps-clipping

Conversation

@el-lsan

@el-lsan el-lsan commented Nov 8, 2026

Copy link
Copy Markdown

Summary

Fixes #72.

On iOS, a recycled EaseView kept no clipping and had square corners when it was mounted again with the same overflow: 'hidden' and borderRadius.

prepareForRecycle reset layer properties that RCTViewComponentView owns: masksToBounds, cornerRadius, backgroundColor, borders, shadow, opacity and transform. RCTViewComponentView keeps _props across recycling and diffs the next props against it, so a style value that did not change is never applied again. The layer kept the reset value.

This change resets only the properties in the previous props' animatedProperties mask. Those belong to EaseView, and the next first-mount update sets them again. masksToBounds is never animated, so it is no longer touched.

Resetting _props to the defaults was also tried. It fixes clipping, but then values like a stale backgroundColor stick on the reused view, because the next props look unchanged against the defaults.

Test Plan

  • Added example/app/issues/72: a pill with overflow: 'hidden' and borderRadius around a larger red square, and an Unmount / Mount again button. Without the fix, the second mount shows the whole red square with square corners. With the fix it stays a rounded pill.
  • Checked the same code in a React Native 0.88.1 app (release build, iOS 27 simulator), before and after the change:

Before and after

  • In that app, after heavy navigation, a dump of the view tree shows every recycled EaseView with overflow: 'hidden' keeps clipsToBounds = YES and its corner radius.
  • clang-format --dry-run --Werror and Prettier pass on the changed files. I could not run yarn lint and yarn test locally; the change is native only, apart from the new example screen.

prepareForRecycle reset layer properties that RCTViewComponentView owns
(masksToBounds, cornerRadius, backgroundColor, borders, shadow, opacity,
transform). RCTViewComponentView keeps _props across recycling and only
applies a style value again when it changes, so a view mounted again with
the same overflow: 'hidden' and borderRadius stayed unclipped with square
corners.

Reset only the properties in the previous props' animatedProperties mask,
and never masksToBounds. Adds a reproducer under example/app/issues.
@vercel

vercel Bot commented Nov 8, 2026

Copy link
Copy Markdown

@el-lsan is attempting to deploy a commit to the appandflow Team on Vercel.

A member of the Team first needs to authorize it.

@floco-af floco-af left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for the report and the fix, @el-lsan. The diagnosis checks out: RCTViewComponentView keeps _props across recycling (it says so in updateProps: "prepareForRecycle resets _removeClippedSubviews but keeps _props"). Style values like overflow, borderRadius and backgroundColor are only re-applied when they differ from _props, so resetting them in EaseView.prepareForRecycle leaves a same-style remount unclipped. Resetting only the layer properties in the previous animatedProperties mask is a sound direction, and the first-mount path (applyFirstMountProps / the non-initial path that sets animate* targets) sets every masked property again.

One thing I'd change: anchorPoint. prepareForRecycle still resets _transformOriginX/Y to 0.5 unconditionally, but the PR now resets layer.anchorPoint only when the mask has a transform. transformOrigin is passed to native independently of animate (src/EaseView.tsx always sends transformOriginX/Y), and updateProps calls updateAnchorPoint whenever it differs from the default. So a view with e.g. transformOrigin={{x: 0, y: 0}} that only animates opacity gets a non-default anchorPoint that is never reset on recycle. The next mount with the default origin sees _transformOriginX == 0.5 already, skips updateAnchorPoint, and keeps the stale anchor, which shifts the view's position. Resetting anchorPoint unconditionally (EaseView owns it, RCTViewComponentView doesn't touch it) avoids that:

self.layer.anchorPoint = CGPointMake(0.5, 0.5);
if (mask & kMaskAnyTransform) {
  self.layer.transform = CATransform3DIdentity;
}

Minor: the mask comes from the last props only. If a property was animated earlier and later dropped from animate, its layer value isn't reset. That's no worse than before this change for a live view, so I wouldn't block on it.

What I checked

  • Read the diff against ios/EaseView.mm and the RN RCTViewComponentView.mm on main.
  • On the PR branch: yarn lint (eslint, tsc, example tsc) and yarn test (90 tests) pass. The change is native-only apart from the example screen.
  • I could not run the iOS repro here, so I haven't independently confirmed the before/after screenshots. They look consistent with the code path above.

Verdict: the approach is right; I'd fix the anchorPoint case before merging. cc @janicduplessis

anchorPoint follows transformOrigin, which is sent independently of
animate, and _transformOriginX/Y are reset on every recycle. Resetting the
anchor only when a transform was animated left a view with a custom
transformOrigin that animated something else with a stale anchor on reuse.
Adds that case to the issue appandflow#72 reproducer.
@el-lsan

el-lsan commented Nov 8, 2026

Copy link
Copy Markdown
Author

@floco-af
Good catch, thanks. You're right: _transformOriginX/Y is reset on every recycle, but the anchor was only reset when a transform was animated, so a view with a custom transformOrigin that animates something else kept a stale anchor after reuse. I pushed a commit that resets anchorPoint every time, as you suggested, and added that case to the reproducer screen (case B).

I tested equivalent reproductions of both cases in an iOS 27 simulator release build, against three versions of EaseView.mm: upstream 0.9.0, the first version of this PR, and the current one.

recycle-three-builds

The remount, upstream 0.9.0 on the left and this PR on the right:

recycle-before-after.mp4

On the minor point: you're right, and it's a small regression against upstream. A property animated earlier and later dropped from animate keeps its animated value on the layer, and this change no longer resets it on recycle. Resetting every property the view ever animated would bring the original bug back for a property that was animated and then set as a plain style. Covering both would need the view to know which layer values React Native set and which it set itself, so I'd leave it out of this PR.

@floco-af floco-af left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks @el-lsan, the follow-up commit (e63c5b7) does what I asked: anchorPoint is now reset unconditionally in prepareForRecycle, and the diff otherwise matches the earlier version. The three-build comparison (upstream 0.9.0 / first version / current) with case B covers the stale-anchor case well.

On the dropped-from-animate property: agreed, leaving it out of this PR is fine. Telling React Native's layer values apart from ones EaseView set itself is a bigger change, so it can be a separate issue if it ever shows up in practice.

What I checked

  • Read the new commit against ios/EaseView.mm.
  • On the PR head: yarn lint and yarn test (90 tests) pass.
  • I could not run the iOS repro here (no Xcode on this host), so I haven't independently confirmed the screenshots and recording. They are consistent with the code path.

Verdict: looks good to me. cc @janicduplessis for the final review.

@floco-af

floco-af commented Nov 8, 2026

Copy link
Copy Markdown

Ran the #72 repro on a real iOS (Fabric) build, before and after this PR.

Setup: iPhone 18 Pro simulator, iOS 27.0, on the team Mac mini, built and driven with stim and agent-device. Example app, screen "Issue #72 — Recycled clipping" (this PR's repro screen, copied into the main build locally, not committed).

  • Before: main @ 9d76ecb
  • After: this PR @ e63c5b7 (fix(ios): always reset anchorPoint on recycle)

Before — main @ 9d76ecb

Case A reproduces. First mount is a correct rounded pill; after Unmount A, Mount A again (mount #2, and again at #4) the whole 300x300 red square shows with square corners and no clipping.

main, case A first mount: rounded pill
main, case A mount #2: unclipped red square, square corners

Recording (Unmount A, Mount A again, mount #4 shows the unclipped square):

main-caseA-remount.mp4

Case B does not reproduce on main. Main already resets anchorPoint unconditionally in prepareForRecycle, so at mount #2 (default origin, scale 0.6) the square is centred in the outline:

main, case B mount #2: square centred, correct

After — PR @ e63c5b7

Case A fixed. After remounting, the pill keeps its overflow: hidden and borderRadius (mount #2 and #4):

PR, case A mount #2: rounded pill
PR, case A mount #4: rounded pill

Recording (Unmount A, Mount A again, still a pill):

pr73-caseA-remount.mp4

Case B still correct (no regression). Recording of mount #3 (origin top-left, opacity), unmount, then mount #4 (default origin, scale 0.6); the scaled square is centred in the outline:

PR case B recording
PR, case B mount #4: square centred

Checks on the PR head

  • yarn lint: passes (exit 0)
  • yarn test: 3 suites, 90 tests pass

Summary: bug reproduced on main for case A only; this PR fixes it, and case B is unaffected. I did not push anything to this branch.

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.

iOS: a recycled EaseView loses overflow: 'hidden' and borderRadius

2 participants