Skip to content

Commit 802e1a7

Browse files
lunaleapsmeta-codesync[bot]
authored andcommitted
Support edge-adjacent intersections (#54175)
Summary: Pull Request resolved: #54175 Changelog: [Internal] - Fix a bug with IntersectionObserver where we weren't considering edge-adjacent intersections. So it is valid for `intersectionRatio` to be 0, but still be intersecting. Reviewed By: mdvacca Differential Revision: D84787414 fbshipit-source-id: ef6ab35be594f5b734c8c25d2475ee2a6dba1fe5
1 parent 9d6fa7d commit 802e1a7

File tree

2 files changed

+325
-17
lines changed

2 files changed

+325
-17
lines changed

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

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -81,26 +81,47 @@ static Rect getClippedTargetBoundingRect(
8181
return layoutMetrics == EmptyLayoutMetrics ? Rect{} : layoutMetrics.frame;
8282
}
8383

84+
// Distinguishes between edge-adjacent vs. no intersection
85+
static std::optional<Rect> intersectOrNull(
86+
const Rect& rect1,
87+
const Rect& rect2) {
88+
auto result = Rect::intersect(rect1, rect2);
89+
// Check if the result has zero area (could be empty or degenerate)
90+
if (result.size.width == 0 || result.size.height == 0) {
91+
// Check if origin is within both rectangles (touching case)
92+
bool originInRect1 = result.origin.x >= rect1.getMinX() &&
93+
result.origin.x <= rect1.getMaxX() &&
94+
result.origin.y >= rect1.getMinY() &&
95+
result.origin.y <= rect1.getMaxY();
96+
97+
bool originInRect2 = result.origin.x >= rect2.getMinX() &&
98+
result.origin.x <= rect2.getMaxX() &&
99+
result.origin.y >= rect2.getMinY() &&
100+
result.origin.y <= rect2.getMaxY();
101+
102+
if (!originInRect1 || !originInRect2) {
103+
// No actual intersection - rectangles are separated
104+
return std::nullopt;
105+
}
106+
}
107+
108+
// Valid intersection (including degenerate edge/corner cases)
109+
return result;
110+
}
111+
84112
// Partially equivalent to
85-
// https://w3c.github.io/IntersectionObserver/#compute-the-intersection
86-
static Rect computeIntersection(
113+
// https://w3c.github.io/IntersectionObserver/#calculate-intersection-rect-algo
114+
static std::optional<Rect> computeIntersection(
87115
const Rect& rootBoundingRect,
88116
const Rect& targetBoundingRect,
89117
const ShadowNodeFamily::AncestorList& targetToRootAncestors,
90118
bool hasExplicitRoot) {
119+
// Use intersectOrNull to properly distinguish between edge-adjacent
120+
// (valid intersection) and separated rectangles (no intersection)
91121
auto absoluteIntersectionRect =
92-
Rect::intersect(rootBoundingRect, targetBoundingRect);
93-
94-
Float absoluteIntersectionRectArea = absoluteIntersectionRect.size.width *
95-
absoluteIntersectionRect.size.height;
96-
97-
Float targetBoundingRectArea =
98-
targetBoundingRect.size.width * targetBoundingRect.size.height;
99-
100-
// Finish early if there is not intersection between the root and the target
101-
// before we do any clipping.
102-
if (absoluteIntersectionRectArea == 0 || targetBoundingRectArea == 0) {
103-
return {};
122+
intersectOrNull(rootBoundingRect, targetBoundingRect);
123+
if (!absoluteIntersectionRect.has_value()) {
124+
return std::nullopt;
104125
}
105126

106127
// Coordinates of the target after clipping the parts hidden by a parent,
@@ -114,7 +135,7 @@ static Rect computeIntersection(
114135
.size=clippedTargetFromRoot.size}
115136
: clippedTargetFromRoot;
116137

117-
return Rect::intersect(rootBoundingRect, clippedTargetBoundingRect);
138+
return intersectOrNull(rootBoundingRect, clippedTargetBoundingRect);
118139
}
119140

120141
static Float getHighestThresholdCrossed(
@@ -161,12 +182,15 @@ IntersectionObserver::updateIntersectionObservation(
161182
? targetShadowNodeFamily_->getAncestors(*getShadowNode(rootAncestors))
162183
: targetAncestors;
163184

164-
auto intersectionRect = computeIntersection(
185+
auto intersection = computeIntersection(
165186
rootBoundingRect,
166187
targetBoundingRect,
167188
targetToRootAncestors,
168189
hasExplicitRoot);
169190

191+
auto intersectionRect =
192+
intersection.has_value() ? intersection.value() : Rect{};
193+
170194
Float targetBoundingRectArea =
171195
targetBoundingRect.size.width * targetBoundingRect.size.height;
172196
auto intersectionRectArea =
@@ -177,7 +201,7 @@ IntersectionObserver::updateIntersectionObservation(
177201
? 0
178202
: intersectionRectArea / targetBoundingRectArea;
179203

180-
if (intersectionRatio == 0) {
204+
if (!intersection.has_value()) {
181205
return setNotIntersectingState(
182206
rootBoundingRect, targetBoundingRect, intersectionRect, time);
183207
}

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

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1868,6 +1868,290 @@ describe('IntersectionObserver', () => {
18681868
});
18691869
});
18701870

1871+
describe('intersectionRect is relative to root', () => {
1872+
it('should report intersectionRect for implicit root', () => {
1873+
const node1Ref = React.createRef<HostInstance>();
1874+
1875+
const root = Fantom.createRoot({
1876+
viewportWidth: 1000,
1877+
viewportHeight: 1000,
1878+
});
1879+
1880+
Fantom.runTask(() => {
1881+
root.render(
1882+
<>
1883+
<View
1884+
style={{
1885+
position: 'absolute',
1886+
top: -50,
1887+
width: 100,
1888+
height: 100,
1889+
}}
1890+
ref={node1Ref}
1891+
/>
1892+
</>,
1893+
);
1894+
});
1895+
1896+
const node1 = ensureReactNativeElement(node1Ref.current);
1897+
1898+
const intersectionObserverCallback = jest.fn();
1899+
1900+
Fantom.runTask(() => {
1901+
observer = new IntersectionObserver(intersectionObserverCallback);
1902+
observer.observe(node1);
1903+
});
1904+
1905+
expect(intersectionObserverCallback).toHaveBeenCalledTimes(1);
1906+
const [entries, reportedObserver] =
1907+
intersectionObserverCallback.mock.lastCall;
1908+
1909+
expect(reportedObserver).toBe(observer);
1910+
expect(entries.length).toBe(1);
1911+
expect(entries[0]).toBeInstanceOf(IntersectionObserverEntry);
1912+
expect(entries[0].isIntersecting).toBe(true);
1913+
expect(entries[0].intersectionRatio).toBe(0.5);
1914+
expect(entries[0].target).toBe(node1);
1915+
1916+
expectRectEquals(entries[0].intersectionRect, {
1917+
x: 0,
1918+
y: 0,
1919+
width: 100,
1920+
height: 50,
1921+
});
1922+
expectRectEquals(entries[0].boundingClientRect, {
1923+
x: 0,
1924+
y: -50,
1925+
width: 100,
1926+
height: 100,
1927+
});
1928+
expectRectEquals(entries[0].rootBounds, {
1929+
x: 0,
1930+
y: 0,
1931+
width: 1000,
1932+
height: 1000,
1933+
});
1934+
});
1935+
1936+
it('should report intersectionRect for explicit root', () => {
1937+
const node1Ref = React.createRef<HostInstance>();
1938+
const node2Ref = React.createRef<HostInstance>();
1939+
1940+
const root = Fantom.createRoot({
1941+
viewportWidth: 1000,
1942+
viewportHeight: 1000,
1943+
});
1944+
1945+
Fantom.runTask(() => {
1946+
root.render(
1947+
<>
1948+
<View
1949+
style={{
1950+
position: 'relative',
1951+
marginTop: -50,
1952+
width: 500,
1953+
height: 500,
1954+
}}
1955+
ref={node1Ref}>
1956+
<View
1957+
style={{
1958+
position: 'absolute',
1959+
width: 100,
1960+
height: 100,
1961+
top: -50,
1962+
}}
1963+
ref={node2Ref}
1964+
/>
1965+
</View>
1966+
</>,
1967+
);
1968+
});
1969+
1970+
const node1 = ensureReactNativeElement(node1Ref.current);
1971+
const node2 = ensureReactNativeElement(node2Ref.current);
1972+
1973+
const intersectionObserverCallback = jest.fn();
1974+
1975+
Fantom.runTask(() => {
1976+
observer = new IntersectionObserver(intersectionObserverCallback, {
1977+
root: node1,
1978+
});
1979+
1980+
observer.observe(node2);
1981+
});
1982+
1983+
expect(intersectionObserverCallback).toHaveBeenCalledTimes(1);
1984+
const [entries, reportedObserver] =
1985+
intersectionObserverCallback.mock.lastCall;
1986+
1987+
expect(reportedObserver).toBe(observer);
1988+
expect(entries.length).toBe(1);
1989+
1990+
expect(entries[0]).toBeInstanceOf(IntersectionObserverEntry);
1991+
expectRectEquals(entries[0].intersectionRect, {
1992+
x: 0,
1993+
y: -50,
1994+
width: 100,
1995+
height: 50,
1996+
});
1997+
expectRectEquals(entries[0].boundingClientRect, {
1998+
x: 0,
1999+
y: -100,
2000+
width: 100,
2001+
height: 100,
2002+
});
2003+
expectRectEquals(entries[0].rootBounds, {
2004+
x: 0,
2005+
y: -50,
2006+
width: 500,
2007+
height: 500,
2008+
});
2009+
expect(entries[0].isIntersecting).toBe(true);
2010+
expect(entries[0].intersectionRatio).toBe(0.5);
2011+
expect(entries[0].target).toBe(node2);
2012+
});
2013+
});
2014+
2015+
describe('edge-adjacent intersection', () => {
2016+
it('should report edge intersect with implicit root', () => {
2017+
const node1Ref = React.createRef<HostInstance>();
2018+
2019+
const root = Fantom.createRoot({
2020+
viewportWidth: 1000,
2021+
viewportHeight: 1000,
2022+
});
2023+
2024+
Fantom.runTask(() => {
2025+
root.render(
2026+
<>
2027+
<View
2028+
style={{
2029+
position: 'absolute',
2030+
top: -100,
2031+
width: 100,
2032+
height: 100,
2033+
}}
2034+
ref={node1Ref}
2035+
/>
2036+
</>,
2037+
);
2038+
});
2039+
2040+
const node1 = ensureReactNativeElement(node1Ref.current);
2041+
2042+
const intersectionObserverCallback = jest.fn();
2043+
2044+
Fantom.runTask(() => {
2045+
observer = new IntersectionObserver(intersectionObserverCallback);
2046+
observer.observe(node1);
2047+
});
2048+
2049+
expect(intersectionObserverCallback).toHaveBeenCalledTimes(1);
2050+
const [entries, reportedObserver] =
2051+
intersectionObserverCallback.mock.lastCall;
2052+
2053+
expect(reportedObserver).toBe(observer);
2054+
expect(entries.length).toBe(1);
2055+
expect(entries[0]).toBeInstanceOf(IntersectionObserverEntry);
2056+
expect(entries[0].isIntersecting).toBe(true);
2057+
expect(entries[0].intersectionRatio).toBe(0);
2058+
expect(entries[0].target).toBe(node1);
2059+
2060+
expectRectEquals(entries[0].intersectionRect, {
2061+
x: 0,
2062+
y: 0,
2063+
width: 100,
2064+
height: 0,
2065+
});
2066+
expectRectEquals(entries[0].boundingClientRect, {
2067+
x: 0,
2068+
y: -100,
2069+
width: 100,
2070+
height: 100,
2071+
});
2072+
expectRectEquals(entries[0].rootBounds, {
2073+
x: 0,
2074+
y: 0,
2075+
width: 1000,
2076+
height: 1000,
2077+
});
2078+
});
2079+
2080+
it('should report edge-adjacent views with zero intersection area', () => {
2081+
const node1Ref = React.createRef<HostInstance>();
2082+
const node2Ref = React.createRef<HostInstance>();
2083+
2084+
const root = Fantom.createRoot({
2085+
viewportWidth: 1000,
2086+
viewportHeight: 1000,
2087+
});
2088+
2089+
Fantom.runTask(() => {
2090+
root.render(
2091+
<>
2092+
<View
2093+
style={{
2094+
position: 'relative',
2095+
marginTop: 100,
2096+
marginLeft: 100,
2097+
width: 100,
2098+
height: 100,
2099+
}}
2100+
ref={node1Ref}>
2101+
<View
2102+
style={{position: 'absolute', width: 50, height: 50, top: -50}}
2103+
ref={node2Ref}
2104+
/>
2105+
</View>
2106+
</>,
2107+
);
2108+
});
2109+
2110+
const node1 = ensureReactNativeElement(node1Ref.current);
2111+
const node2 = ensureReactNativeElement(node2Ref.current);
2112+
2113+
const intersectionObserverCallback = jest.fn();
2114+
2115+
Fantom.runTask(() => {
2116+
observer = new IntersectionObserver(intersectionObserverCallback, {
2117+
root: node1,
2118+
});
2119+
2120+
observer.observe(node2);
2121+
});
2122+
2123+
expect(intersectionObserverCallback).toHaveBeenCalledTimes(1);
2124+
const [entries, reportedObserver] =
2125+
intersectionObserverCallback.mock.lastCall;
2126+
2127+
expect(reportedObserver).toBe(observer);
2128+
expect(entries.length).toBe(1);
2129+
expect(entries[0]).toBeInstanceOf(IntersectionObserverEntry);
2130+
expect(entries[0].isIntersecting).toBe(true);
2131+
expect(entries[0].intersectionRatio).toBe(0);
2132+
expect(entries[0].target).toBe(node2);
2133+
2134+
expectRectEquals(entries[0].intersectionRect, {
2135+
x: 100,
2136+
y: 100,
2137+
width: 50,
2138+
height: 0,
2139+
});
2140+
expectRectEquals(entries[0].boundingClientRect, {
2141+
x: 100,
2142+
y: 50,
2143+
width: 50,
2144+
height: 50,
2145+
});
2146+
expectRectEquals(entries[0].rootBounds, {
2147+
x: 100,
2148+
y: 100,
2149+
width: 100,
2150+
height: 100,
2151+
});
2152+
});
2153+
});
2154+
18712155
describe('clipping behavior', () => {
18722156
it('should report intersection for clipping ancestor', () => {
18732157
const nodeRef = React.createRef<HostInstance>();

0 commit comments

Comments
 (0)