From 008ac6e182fa2bc4f6e4d23347e636b3b0bbb5cc Mon Sep 17 00:00:00 2001 From: Edward Kimmel Date: Thu, 27 Aug 2026 16:53:32 -0400 Subject: [PATCH] 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. --- .../renderer/core/LayoutableShadowNode.cpp | 3 +- .../renderer/core/LayoutableShadowNode.h | 5 + .../intersection/IntersectionObserver.cpp | 10 +- .../__tests__/IntersectionObserver-itest.js | 93 ++++++++++++++++++- 4 files changed, 104 insertions(+), 7 deletions(-) diff --git a/packages/react-native/ReactCommon/react/renderer/core/LayoutableShadowNode.cpp b/packages/react-native/ReactCommon/react/renderer/core/LayoutableShadowNode.cpp index 515ee47a39f2..73e685becf39 100644 --- a/packages/react-native/ReactCommon/react/renderer/core/LayoutableShadowNode.cpp +++ b/packages/react-native/ReactCommon/react/renderer/core/LayoutableShadowNode.cpp @@ -181,7 +181,8 @@ LayoutMetrics LayoutableShadowNode::computeRelativeLayoutMetrics( resultFrame.origin += currentShadowNode->getContentOriginOffset(true); } - if (policy.enableOverflowClipping) { + if (policy.enableOverflowClipping && + (i != size - 1 || policy.clipSpecifiedAncestor)) { auto overflowInset = currentShadowNode->getLayoutMetrics().overflowInset; auto overflowRect = insetBy( currentFrame * currentShadowNode->getTransform(), overflowInset); diff --git a/packages/react-native/ReactCommon/react/renderer/core/LayoutableShadowNode.h b/packages/react-native/ReactCommon/react/renderer/core/LayoutableShadowNode.h index e983dce2ad31..4bfa038a71e9 100644 --- a/packages/react-native/ReactCommon/react/renderer/core/LayoutableShadowNode.h +++ b/packages/react-native/ReactCommon/react/renderer/core/LayoutableShadowNode.h @@ -41,6 +41,11 @@ class LayoutableShadowNode : public ShadowNode { bool includeTransform{true}; bool includeViewportOffset{false}; bool enableOverflowClipping{false}; + // When enableOverflowClipping is true, also clip against the ancestor + // passed as the reference node. IntersectionObserver sets this false: + // the spec clips ancestors *until* the root, then intersects with the + // rootMargin-expanded root intersection rectangle. + bool clipSpecifiedAncestor{true}; }; using UnsharedList = std::vector; diff --git a/packages/react-native/ReactCommon/react/renderer/observers/intersection/IntersectionObserver.cpp b/packages/react-native/ReactCommon/react/renderer/observers/intersection/IntersectionObserver.cpp index fec2944ba21b..c62b66b05c97 100644 --- a/packages/react-native/ReactCommon/react/renderer/observers/intersection/IntersectionObserver.cpp +++ b/packages/react-native/ReactCommon/react/renderer/observers/intersection/IntersectionObserver.cpp @@ -156,7 +156,8 @@ static Rect getClippedTargetBoundingRect( targetAncestors, {/* .includeTransform = */ .includeTransform = true, /* .includeViewportOffset = */ .includeViewportOffset = true, - /* .applyParentClipping = */ .enableOverflowClipping = true}); + /* .applyParentClipping = */ .enableOverflowClipping = true, + /* .clipSpecifiedAncestor = */ .clipSpecifiedAncestor = false}); return layoutMetrics == EmptyLayoutMetrics ? Rect{} : layoutMetrics.frame; } @@ -205,9 +206,10 @@ static std::optional computeIntersection( return std::nullopt; } - // Coordinates of the target after clipping the parts hidden by a parent, - // until till the root (e.g.: in scroll views, or in views with a parent with - // overflow: hidden) + // Clip against ancestors *until* the observation root (scroll views or + // overflow: hidden between target and root). The root itself is not a + // clipper here; intersecting with rootMarginBoundingRect is the spec's + // final clip against the root intersection rectangle. auto clippedTargetFromRoot = getClippedTargetBoundingRect(targetToRootAncestors); diff --git a/packages/react-native/src/private/webapis/intersectionobserver/__tests__/IntersectionObserver-itest.js b/packages/react-native/src/private/webapis/intersectionobserver/__tests__/IntersectionObserver-itest.js index ac6eda8c618a..66e1007a1d8a 100644 --- a/packages/react-native/src/private/webapis/intersectionobserver/__tests__/IntersectionObserver-itest.js +++ b/packages/react-native/src/private/webapis/intersectionobserver/__tests__/IntersectionObserver-itest.js @@ -3862,7 +3862,7 @@ describe('IntersectionObserver', () => { x: 0, y: 50, width: 100, - height: 50, + height: 60, }); expectRectEquals(entries[0].boundingClientRect, { x: 0, @@ -3878,10 +3878,99 @@ describe('IntersectionObserver', () => { }); expect(entries[0]).toBeInstanceOf(IntersectionObserverEntry); - expect(entries[0].intersectionRatio).toBe(0.5); + expect(entries[0].intersectionRatio).toBe(0.6); expect(entries[0].isIntersecting).toBe(true); expect(entries[0].target).toBe(node); }); + + it('should apply rootMargin past a clipping ScrollView root', () => { + const nodeRef = React.createRef(); + const scrollNodeRef = React.createRef(); + + const root = Fantom.createRoot({ + viewportWidth: 1000, + viewportHeight: 1000, + }); + Fantom.runTask(() => { + root.render( + + + , + ); + }); + const node = ensureReactNativeElement(nodeRef.current); + const scrollNode = ensureReactNativeElement(scrollNodeRef.current); + + const intersectionObserverCallback = jest.fn(); + + Fantom.runTask(() => { + observer = new IntersectionObserver(intersectionObserverCallback, { + root: scrollNode, + // $FlowExpectedError[prop-missing] rootMargin is not even defined in Flow. + rootMargin: '0px 0px 150px 0px', + threshold: [0.01], + }); + observer.observe(node); + }); + + expect(intersectionObserverCallback).toHaveBeenCalledTimes(1); + const [entries] = intersectionObserverCallback.mock.lastCall; + expect(entries.length).toBe(1); + expect(entries[0].isIntersecting).toBe(true); + expect(entries[0].intersectionRatio).toBe(1); + expectRectEquals(entries[0].rootBounds, { + x: 0, + y: 0, + width: 100, + height: 250, + }); + expectRectEquals(entries[0].intersectionRect, { + x: 0, + y: 150, + width: 50, + height: 50, + }); + }); + + it('should still clip at an intermediate ScrollView when root is the viewport', () => { + const nodeRef = React.createRef(); + + const root = Fantom.createRoot({ + viewportWidth: 1000, + viewportHeight: 1000, + }); + Fantom.runTask(() => { + root.render( + + + , + ); + }); + const node = ensureReactNativeElement(nodeRef.current); + + const intersectionObserverCallback = jest.fn(); + + Fantom.runTask(() => { + observer = new IntersectionObserver(intersectionObserverCallback, { + // $FlowExpectedError[prop-missing] rootMargin is not even defined in Flow. + rootMargin: '0px 0px 150px 0px', + threshold: [0.01], + }); + observer.observe(node); + }); + + expect(intersectionObserverCallback).toHaveBeenCalledTimes(1); + const [entries] = intersectionObserverCallback.mock.lastCall; + expect(entries.length).toBe(1); + expect(entries[0].isIntersecting).toBe(false); + expect(entries[0].intersectionRatio).toBe(0); + }); }); describe('unobserve(target)', () => {