Skip to content

Commit 008ac6e

Browse files
committed
fix: apply IntersectionObserver rootMargin after clipping the observation root
The spec clips overflow ancestors *until* the root, then intersects with the root intersection rectangle, which includes rootMargin. We were clipping the target to the root's own overflow (ScrollView, overflow: hidden) first, so rootMargin expanded rootBounds but not the intersection area. Skip overflow clipping on the specified ancestor for IntersectionObserver. Intermediate clippers are unchanged.
1 parent fea5e11 commit 008ac6e

4 files changed

Lines changed: 104 additions & 7 deletions

File tree

packages/react-native/ReactCommon/react/renderer/core/LayoutableShadowNode.cpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,8 @@ LayoutMetrics LayoutableShadowNode::computeRelativeLayoutMetrics(
181181
resultFrame.origin += currentShadowNode->getContentOriginOffset(true);
182182
}
183183

184-
if (policy.enableOverflowClipping) {
184+
if (policy.enableOverflowClipping &&
185+
(i != size - 1 || policy.clipSpecifiedAncestor)) {
185186
auto overflowInset = currentShadowNode->getLayoutMetrics().overflowInset;
186187
auto overflowRect = insetBy(
187188
currentFrame * currentShadowNode->getTransform(), overflowInset);

packages/react-native/ReactCommon/react/renderer/core/LayoutableShadowNode.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ class LayoutableShadowNode : public ShadowNode {
4141
bool includeTransform{true};
4242
bool includeViewportOffset{false};
4343
bool enableOverflowClipping{false};
44+
// When enableOverflowClipping is true, also clip against the ancestor
45+
// passed as the reference node. IntersectionObserver sets this false:
46+
// the spec clips ancestors *until* the root, then intersects with the
47+
// rootMargin-expanded root intersection rectangle.
48+
bool clipSpecifiedAncestor{true};
4449
};
4550

4651
using UnsharedList = std::vector<LayoutableShadowNode *>;

packages/react-native/ReactCommon/react/renderer/observers/intersection/IntersectionObserver.cpp

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,8 @@ static Rect getClippedTargetBoundingRect(
156156
targetAncestors,
157157
{/* .includeTransform = */ .includeTransform = true,
158158
/* .includeViewportOffset = */ .includeViewportOffset = true,
159-
/* .applyParentClipping = */ .enableOverflowClipping = true});
159+
/* .applyParentClipping = */ .enableOverflowClipping = true,
160+
/* .clipSpecifiedAncestor = */ .clipSpecifiedAncestor = false});
160161

161162
return layoutMetrics == EmptyLayoutMetrics ? Rect{} : layoutMetrics.frame;
162163
}
@@ -205,9 +206,10 @@ static std::optional<Rect> computeIntersection(
205206
return std::nullopt;
206207
}
207208

208-
// Coordinates of the target after clipping the parts hidden by a parent,
209-
// until till the root (e.g.: in scroll views, or in views with a parent with
210-
// overflow: hidden)
209+
// Clip against ancestors *until* the observation root (scroll views or
210+
// overflow: hidden between target and root). The root itself is not a
211+
// clipper here; intersecting with rootMarginBoundingRect is the spec's
212+
// final clip against the root intersection rectangle.
211213
auto clippedTargetFromRoot =
212214
getClippedTargetBoundingRect(targetToRootAncestors);
213215

packages/react-native/src/private/webapis/intersectionobserver/__tests__/IntersectionObserver-itest.js

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3862,7 +3862,7 @@ describe('IntersectionObserver', () => {
38623862
x: 0,
38633863
y: 50,
38643864
width: 100,
3865-
height: 50,
3865+
height: 60,
38663866
});
38673867
expectRectEquals(entries[0].boundingClientRect, {
38683868
x: 0,
@@ -3878,10 +3878,99 @@ describe('IntersectionObserver', () => {
38783878
});
38793879

38803880
expect(entries[0]).toBeInstanceOf(IntersectionObserverEntry);
3881-
expect(entries[0].intersectionRatio).toBe(0.5);
3881+
expect(entries[0].intersectionRatio).toBe(0.6);
38823882
expect(entries[0].isIntersecting).toBe(true);
38833883
expect(entries[0].target).toBe(node);
38843884
});
3885+
3886+
it('should apply rootMargin past a clipping ScrollView root', () => {
3887+
const nodeRef = React.createRef<HostInstance>();
3888+
const scrollNodeRef = React.createRef<HostInstance>();
3889+
3890+
const root = Fantom.createRoot({
3891+
viewportWidth: 1000,
3892+
viewportHeight: 1000,
3893+
});
3894+
Fantom.runTask(() => {
3895+
root.render(
3896+
<ScrollView style={{width: 100, height: 100}} ref={scrollNodeRef}>
3897+
<View
3898+
style={{width: 50, height: 50, marginTop: 150}}
3899+
ref={nodeRef}
3900+
/>
3901+
</ScrollView>,
3902+
);
3903+
});
3904+
const node = ensureReactNativeElement(nodeRef.current);
3905+
const scrollNode = ensureReactNativeElement(scrollNodeRef.current);
3906+
3907+
const intersectionObserverCallback = jest.fn();
3908+
3909+
Fantom.runTask(() => {
3910+
observer = new IntersectionObserver(intersectionObserverCallback, {
3911+
root: scrollNode,
3912+
// $FlowExpectedError[prop-missing] rootMargin is not even defined in Flow.
3913+
rootMargin: '0px 0px 150px 0px',
3914+
threshold: [0.01],
3915+
});
3916+
observer.observe(node);
3917+
});
3918+
3919+
expect(intersectionObserverCallback).toHaveBeenCalledTimes(1);
3920+
const [entries] = intersectionObserverCallback.mock.lastCall;
3921+
expect(entries.length).toBe(1);
3922+
expect(entries[0].isIntersecting).toBe(true);
3923+
expect(entries[0].intersectionRatio).toBe(1);
3924+
expectRectEquals(entries[0].rootBounds, {
3925+
x: 0,
3926+
y: 0,
3927+
width: 100,
3928+
height: 250,
3929+
});
3930+
expectRectEquals(entries[0].intersectionRect, {
3931+
x: 0,
3932+
y: 150,
3933+
width: 50,
3934+
height: 50,
3935+
});
3936+
});
3937+
3938+
it('should still clip at an intermediate ScrollView when root is the viewport', () => {
3939+
const nodeRef = React.createRef<HostInstance>();
3940+
3941+
const root = Fantom.createRoot({
3942+
viewportWidth: 1000,
3943+
viewportHeight: 1000,
3944+
});
3945+
Fantom.runTask(() => {
3946+
root.render(
3947+
<ScrollView style={{width: 100, height: 100}}>
3948+
<View
3949+
style={{width: 50, height: 50, marginTop: 150}}
3950+
ref={nodeRef}
3951+
/>
3952+
</ScrollView>,
3953+
);
3954+
});
3955+
const node = ensureReactNativeElement(nodeRef.current);
3956+
3957+
const intersectionObserverCallback = jest.fn();
3958+
3959+
Fantom.runTask(() => {
3960+
observer = new IntersectionObserver(intersectionObserverCallback, {
3961+
// $FlowExpectedError[prop-missing] rootMargin is not even defined in Flow.
3962+
rootMargin: '0px 0px 150px 0px',
3963+
threshold: [0.01],
3964+
});
3965+
observer.observe(node);
3966+
});
3967+
3968+
expect(intersectionObserverCallback).toHaveBeenCalledTimes(1);
3969+
const [entries] = intersectionObserverCallback.mock.lastCall;
3970+
expect(entries.length).toBe(1);
3971+
expect(entries[0].isIntersecting).toBe(false);
3972+
expect(entries[0].intersectionRatio).toBe(0);
3973+
});
38853974
});
38863975

38873976
describe('unobserve(target)', () => {

0 commit comments

Comments
 (0)