Skip to content

Commit 6cdf571

Browse files
lunaleapsfacebook-github-bot
authored andcommitted
Add support for rootMargin (facebook#54176)
Summary: Changelog: [Internal] - Add support for `rootMargin` for IntersectionObserver. Reference: - https://www.w3.org/TR/intersection-observer/#dom-intersectionobserver-rootmargin - https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/rootMargin Differential Revision: D84787370
1 parent a63262f commit 6cdf571

File tree

10 files changed

+1763
-172
lines changed

10 files changed

+1763
-172
lines changed

packages/react-native/ReactCommon/react/nativemodule/intersectionobserver/NativeIntersectionObserver.cpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ jsi::Object NativeIntersectionObserver::observeV2(
6464

6565
auto thresholds = options.thresholds;
6666
auto rootThresholds = options.rootThresholds;
67+
auto rootMargin = options.rootMargin;
6768
auto& uiManager = getUIManagerFromRuntime(runtime);
6869

6970
intersectionObserverManager_.observe(
@@ -72,6 +73,7 @@ jsi::Object NativeIntersectionObserver::observeV2(
7273
shadowNodeFamily,
7374
thresholds,
7475
rootThresholds,
76+
rootMargin,
7577
uiManager);
7678

7779
return tokenFromShadowNodeFamily(runtime, shadowNodeFamily);

packages/react-native/ReactCommon/react/nativemodule/intersectionobserver/NativeIntersectionObserver.h

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ using NativeIntersectionObserverObserveOptions =
3030
// thresholds
3131
std::vector<Float>,
3232
// rootThresholds
33-
std::optional<std::vector<Float>>>;
33+
std::optional<std::vector<Float>>,
34+
// rootMargin
35+
std::optional<std::string>>;
3436

3537
template <>
3638
struct Bridging<NativeIntersectionObserverObserveOptions>

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

Lines changed: 122 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,115 @@
1010
#include <react/renderer/core/LayoutMetrics.h>
1111
#include <react/renderer/core/LayoutableShadowNode.h>
1212
#include <react/renderer/core/ShadowNodeFamily.h>
13+
#include <react/renderer/css/CSSLength.h>
14+
#include <react/renderer/css/CSSPercentage.h>
15+
#include <react/renderer/css/CSSValueParser.h>
16+
#include <react/renderer/graphics/RectangleEdges.h>
1317
#include <utility>
1418

1519
namespace facebook::react {
1620

21+
namespace {
22+
23+
// Structure to hold a margin value that can be either pixels or percentage
24+
struct MarginValue {
25+
Float value;
26+
bool isPercentage;
27+
};
28+
29+
// Parse a CSS-style margin string (e.g., "10px 20px 30px 40px") into EdgeInsets
30+
// Follows W3C spec:
31+
// https://w3c.github.io/IntersectionObserver/#dom-intersectionobserverinit-rootmargin
32+
std::optional<EdgeInsets> parseRootMargin(
33+
const std::string& marginStr,
34+
const Rect& rootRect) {
35+
if (marginStr.empty()) {
36+
return std::nullopt;
37+
}
38+
39+
std::vector<MarginValue> values;
40+
CSSSyntaxParser syntaxParser(marginStr);
41+
42+
// Parse up to 4 space-separated length/percentage values
43+
while (!syntaxParser.isFinished()) {
44+
syntaxParser.consumeWhitespace();
45+
if (syntaxParser.isFinished()) {
46+
break;
47+
}
48+
49+
auto parsed = parseNextCSSValue<CSSLength, CSSPercentage>(syntaxParser);
50+
51+
if (std::holds_alternative<CSSLength>(parsed)) {
52+
auto length = std::get<CSSLength>(parsed);
53+
// Only support px units for rootMargin (per W3C spec)
54+
if (length.unit != CSSLengthUnit::Px) {
55+
return std::nullopt;
56+
}
57+
values.push_back({length.value, false});
58+
} else if (std::holds_alternative<CSSPercentage>(parsed)) {
59+
auto percentage = std::get<CSSPercentage>(parsed);
60+
values.push_back({percentage.value, true});
61+
} else {
62+
// Invalid token, stop parsing
63+
return std::nullopt;
64+
}
65+
}
66+
67+
// CSS margin shorthand: 1 value = all, 2 values = vertical/horizontal,
68+
// 3 values = top/horizontal/bottom, 4 values = top/right/bottom/left
69+
std::vector<MarginValue> expandedValues;
70+
switch (values.size()) {
71+
case 1:
72+
expandedValues = {values[0], values[0], values[0], values[0]};
73+
break;
74+
case 2:
75+
expandedValues = {values[0], values[1], values[0], values[1]};
76+
break;
77+
case 3:
78+
expandedValues = {values[0], values[1], values[2], values[1]};
79+
break;
80+
case 4:
81+
expandedValues = {values[0], values[1], values[2], values[3]};
82+
break;
83+
default:
84+
return std::nullopt;
85+
}
86+
87+
// Calculate actual pixel values, converting percentages based on root
88+
// dimensions Per W3C spec: top/bottom percentages use height, left/right use
89+
// width
90+
Float top = expandedValues[0].isPercentage
91+
? (expandedValues[0].value / 100.0f) * rootRect.size.height
92+
: expandedValues[0].value;
93+
Float right = expandedValues[1].isPercentage
94+
? (expandedValues[1].value / 100.0f) * rootRect.size.width
95+
: expandedValues[1].value;
96+
Float bottom = expandedValues[2].isPercentage
97+
? (expandedValues[2].value / 100.0f) * rootRect.size.height
98+
: expandedValues[2].value;
99+
Float left = expandedValues[3].isPercentage
100+
? (expandedValues[3].value / 100.0f) * rootRect.size.width
101+
: expandedValues[3].value;
102+
103+
return EdgeInsets{left, top, right, bottom};
104+
}
105+
106+
} // namespace
107+
17108
IntersectionObserver::IntersectionObserver(
18109
IntersectionObserverObserverId intersectionObserverId,
19110
std::optional<ShadowNodeFamily::Shared> observationRootShadowNodeFamily,
20111
ShadowNodeFamily::Shared targetShadowNodeFamily,
21112
std::vector<Float> thresholds,
22-
std::optional<std::vector<Float>> rootThresholds)
113+
std::optional<std::vector<Float>> rootThresholds,
114+
std::optional<std::string> rootMargin)
23115
: intersectionObserverId_(intersectionObserverId),
24116
observationRootShadowNodeFamily_(
25117
std::move(observationRootShadowNodeFamily)),
26118
targetShadowNodeFamily_(std::move(targetShadowNodeFamily)),
27119
thresholds_(std::move(thresholds)),
28-
rootThresholds_(std::move(rootThresholds)) {}
120+
rootThresholds_(std::move(rootThresholds)),
121+
rootMargin_(std::move(rootMargin)) {}
29122

30123
static std::shared_ptr<const ShadowNode> getShadowNode(
31124
const ShadowNodeFamily::AncestorList& ancestors) {
@@ -113,13 +206,14 @@ static std::optional<Rect> intersectOrNull(
113206
// https://w3c.github.io/IntersectionObserver/#calculate-intersection-rect-algo
114207
static std::optional<Rect> computeIntersection(
115208
const Rect& rootBoundingRect,
209+
const Rect& rootMarginBoundingRect,
116210
const Rect& targetBoundingRect,
117211
const ShadowNodeFamily::AncestorList& targetToRootAncestors,
118212
bool hasExplicitRoot) {
119213
// Use intersectOrNull to properly distinguish between edge-adjacent
120214
// (valid intersection) and separated rectangles (no intersection)
121215
auto absoluteIntersectionRect =
122-
intersectOrNull(rootBoundingRect, targetBoundingRect);
216+
intersectOrNull(rootMarginBoundingRect, targetBoundingRect);
123217
if (!absoluteIntersectionRect.has_value()) {
124218
return std::nullopt;
125219
}
@@ -130,12 +224,14 @@ static std::optional<Rect> computeIntersection(
130224
auto clippedTargetFromRoot =
131225
getClippedTargetBoundingRect(targetToRootAncestors);
132226

227+
// Use root origin (without rootMargins) to translate coordinates of
228+
// clippedTarget from relative to root, to top-level coordinate system
133229
auto clippedTargetBoundingRect = hasExplicitRoot ? Rect{
134-
.origin=rootBoundingRect.origin + clippedTargetFromRoot.origin,
230+
.origin= rootBoundingRect.origin + clippedTargetFromRoot.origin,
135231
.size=clippedTargetFromRoot.size}
136232
: clippedTargetFromRoot;
137233

138-
return intersectOrNull(rootBoundingRect, clippedTargetBoundingRect);
234+
return intersectOrNull(rootMarginBoundingRect, clippedTargetBoundingRect);
139235
}
140236

141237
static Float getHighestThresholdCrossed(
@@ -167,6 +263,18 @@ IntersectionObserver::updateIntersectionObservation(
167263
? getBoundingRect(rootAncestors)
168264
: getRootNodeBoundingRect(rootShadowNode);
169265

266+
auto rootMarginBoundingRect = rootBoundingRect;
267+
268+
// Apply rootMargin to expand/contract the root bounding rect
269+
if (rootMargin_.has_value()) {
270+
auto parsedMargin = parseRootMargin(rootMargin_.value(), rootBoundingRect);
271+
if (parsedMargin.has_value()) {
272+
// Use outsetBy to expand the root rect (positive values expand, negative
273+
// contract)
274+
rootMarginBoundingRect = outsetBy(rootBoundingRect, parsedMargin.value());
275+
}
276+
}
277+
170278
auto targetAncestors = targetShadowNodeFamily_->getAncestors(rootShadowNode);
171279

172280
// Absolute coordinates of the target
@@ -175,7 +283,7 @@ IntersectionObserver::updateIntersectionObservation(
175283
if ((hasExplicitRoot && rootAncestors.empty()) || targetAncestors.empty()) {
176284
// If observation root or target is not a descendant of `rootShadowNode`
177285
return setNotIntersectingState(
178-
rootBoundingRect, targetBoundingRect, {}, time);
286+
rootMarginBoundingRect, targetBoundingRect, {}, time);
179287
}
180288

181289
auto targetToRootAncestors = hasExplicitRoot
@@ -184,6 +292,7 @@ IntersectionObserver::updateIntersectionObservation(
184292

185293
auto intersection = computeIntersection(
186294
rootBoundingRect,
295+
rootMarginBoundingRect,
187296
targetBoundingRect,
188297
targetToRootAncestors,
189298
hasExplicitRoot);
@@ -203,31 +312,31 @@ IntersectionObserver::updateIntersectionObservation(
203312

204313
if (!intersection.has_value()) {
205314
return setNotIntersectingState(
206-
rootBoundingRect, targetBoundingRect, intersectionRect, time);
315+
rootMarginBoundingRect, targetBoundingRect, intersectionRect, time);
207316
}
208317

209318
auto highestThresholdCrossed =
210319
getHighestThresholdCrossed(intersectionRatio, thresholds_);
211320

212321
auto highestRootThresholdCrossed = -1.0f;
213322
if (rootThresholds_.has_value()) {
214-
Float rootBoundingRectArea =
215-
rootBoundingRect.size.width * rootBoundingRect.size.height;
216-
Float rootThresholdIntersectionRatio = rootBoundingRectArea == 0
323+
Float rootMarginBoundingRectArea =
324+
rootMarginBoundingRect.size.width * rootMarginBoundingRect.size.height;
325+
Float rootThresholdIntersectionRatio = rootMarginBoundingRectArea == 0
217326
? 0
218-
: intersectionRectArea / rootBoundingRectArea;
327+
: intersectionRectArea / rootMarginBoundingRectArea;
219328
highestRootThresholdCrossed = getHighestThresholdCrossed(
220329
rootThresholdIntersectionRatio, rootThresholds_.value());
221330
}
222331

223332
if (highestThresholdCrossed == -1.0f &&
224333
highestRootThresholdCrossed == -1.0f) {
225334
return setNotIntersectingState(
226-
rootBoundingRect, targetBoundingRect, intersectionRect, time);
335+
rootMarginBoundingRect, targetBoundingRect, intersectionRect, time);
227336
}
228337

229338
return setIntersectingState(
230-
rootBoundingRect,
339+
rootMarginBoundingRect,
231340
targetBoundingRect,
232341
intersectionRect,
233342
highestThresholdCrossed,

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ class IntersectionObserver {
4141
std::optional<ShadowNodeFamily::Shared> observationRootShadowNodeFamily,
4242
ShadowNodeFamily::Shared targetShadowNodeFamily,
4343
std::vector<Float> thresholds,
44-
std::optional<std::vector<Float>> rootThresholds = std::nullopt);
44+
std::optional<std::vector<Float>> rootThresholds = std::nullopt,
45+
std::optional<std::string> rootMargin = std::nullopt);
4546

4647
// Partially equivalent to
4748
// https://w3c.github.io/IntersectionObserver/#update-intersection-observations-algo
@@ -84,6 +85,7 @@ class IntersectionObserver {
8485
ShadowNodeFamily::Shared targetShadowNodeFamily_;
8586
std::vector<Float> thresholds_;
8687
std::optional<std::vector<Float>> rootThresholds_;
88+
std::optional<std::string> rootMargin_;
8789
mutable IntersectionObserverState state_ =
8890
IntersectionObserverState::Initial();
8991
};

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ void IntersectionObserverManager::observe(
4444
const ShadowNodeFamily::Shared& shadowNodeFamily,
4545
std::vector<Float> thresholds,
4646
std::optional<std::vector<Float>> rootThresholds,
47+
std::optional<std::string> rootMargin,
4748
const UIManager& /*uiManager*/) {
4849
TraceSection s("IntersectionObserverManager::observe");
4950

@@ -58,7 +59,8 @@ void IntersectionObserverManager::observe(
5859
observationRootShadowNodeFamily,
5960
shadowNodeFamily,
6061
std::move(thresholds),
61-
std::move(rootThresholds)));
62+
std::move(rootThresholds),
63+
std::move(rootMargin)));
6264

6365
observersPendingInitialization_.emplace_back(observers.back().get());
6466
}

packages/react-native/ReactCommon/react/renderer/observers/intersection/IntersectionObserverManager.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ class IntersectionObserverManager final
3131
const ShadowNodeFamily::Shared& shadowNode,
3232
std::vector<Float> thresholds,
3333
std::optional<std::vector<Float>> rootThresholds,
34+
std::optional<std::string> rootMargin,
3435
const UIManager& uiManager);
3536

3637
void unobserve(

0 commit comments

Comments
 (0)