Skip to content

Commit b823b26

Browse files
lunaleapsmeta-codesync[bot]
authored andcommitted
Add support for rootMargin (#54176)
Summary: Pull Request resolved: #54176 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 Reviewed By: rubennorte Differential Revision: D84787370 fbshipit-source-id: 752a9a32becde73d1a92469f9b74240918519b24
1 parent ed75963 commit b823b26

File tree

11 files changed

+1880
-154
lines changed

11 files changed

+1880
-154
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: 108 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,101 @@
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+
// Parse a normalized rootMargin string that's always in the format:
22+
// "top right bottom left" where each value is either "Npx" or "N%"
23+
// The string is already validated and normalized by JS.
24+
// Returns a vector of 4 MarginValue structures (top, right, bottom, left).
25+
// Returns an empty vector if parsing fails.
26+
std::vector<MarginValue> parseNormalizedRootMargin(
27+
const std::string& marginStr) {
28+
// If unset or set to default, return empty so we don't apply any margins
29+
if (marginStr.empty() || marginStr == "0px 0px 0px 0px") {
30+
return {};
31+
}
32+
33+
std::vector<MarginValue> values;
34+
CSSSyntaxParser syntaxParser(marginStr);
35+
36+
// Parse exactly 4 space-separated values
37+
while (!syntaxParser.isFinished() && values.size() < 4) {
38+
syntaxParser.consumeWhitespace();
39+
if (syntaxParser.isFinished()) {
40+
break;
41+
}
42+
43+
auto parsed = parseNextCSSValue<CSSLength, CSSPercentage>(syntaxParser);
44+
45+
if (std::holds_alternative<CSSLength>(parsed)) {
46+
auto length = std::get<CSSLength>(parsed);
47+
// Only support px units for rootMargin (per W3C spec)
48+
if (length.unit != CSSLengthUnit::Px) {
49+
return {};
50+
}
51+
values.push_back({length.value, false});
52+
} else if (std::holds_alternative<CSSPercentage>(parsed)) {
53+
auto percentage = std::get<CSSPercentage>(parsed);
54+
values.push_back({percentage.value, true});
55+
} else {
56+
// Invalid token
57+
return {};
58+
}
59+
}
60+
61+
// Should have exactly 4 values since JS normalizes to this format
62+
if (values.size() != 4) {
63+
return {};
64+
}
65+
66+
return values;
67+
}
68+
69+
namespace {
70+
71+
// Convert margin values to actual pixel values based on root rect.
72+
// Per W3C spec: top/bottom percentages use height, left/right use width.
73+
EdgeInsets calculateRootMarginInsets(
74+
const std::vector<MarginValue>& margins,
75+
const Rect& rootRect) {
76+
Float top = margins[0].isPercentage
77+
? (margins[0].value / 100.0f) * rootRect.size.height
78+
: margins[0].value;
79+
Float right = margins[1].isPercentage
80+
? (margins[1].value / 100.0f) * rootRect.size.width
81+
: margins[1].value;
82+
Float bottom = margins[2].isPercentage
83+
? (margins[2].value / 100.0f) * rootRect.size.height
84+
: margins[2].value;
85+
Float left = margins[3].isPercentage
86+
? (margins[3].value / 100.0f) * rootRect.size.width
87+
: margins[3].value;
88+
89+
return EdgeInsets{left, top, right, bottom};
90+
}
91+
92+
} // namespace
93+
1794
IntersectionObserver::IntersectionObserver(
1895
IntersectionObserverObserverId intersectionObserverId,
1996
std::optional<ShadowNodeFamily::Shared> observationRootShadowNodeFamily,
2097
ShadowNodeFamily::Shared targetShadowNodeFamily,
2198
std::vector<Float> thresholds,
22-
std::optional<std::vector<Float>> rootThresholds)
99+
std::optional<std::vector<Float>> rootThresholds,
100+
std::vector<MarginValue> rootMargins)
23101
: intersectionObserverId_(intersectionObserverId),
24102
observationRootShadowNodeFamily_(
25103
std::move(observationRootShadowNodeFamily)),
26104
targetShadowNodeFamily_(std::move(targetShadowNodeFamily)),
27105
thresholds_(std::move(thresholds)),
28-
rootThresholds_(std::move(rootThresholds)) {}
106+
rootThresholds_(std::move(rootThresholds)),
107+
rootMargins_(std::move(rootMargins)) {}
29108

30109
static std::shared_ptr<const ShadowNode> getShadowNode(
31110
const ShadowNodeFamily::AncestorList& ancestors) {
@@ -113,13 +192,14 @@ static std::optional<Rect> intersectOrNull(
113192
// https://w3c.github.io/IntersectionObserver/#calculate-intersection-rect-algo
114193
static std::optional<Rect> computeIntersection(
115194
const Rect& rootBoundingRect,
195+
const Rect& rootMarginBoundingRect,
116196
const Rect& targetBoundingRect,
117197
const ShadowNodeFamily::AncestorList& targetToRootAncestors,
118198
bool hasExplicitRoot) {
119199
// Use intersectOrNull to properly distinguish between edge-adjacent
120200
// (valid intersection) and separated rectangles (no intersection)
121201
auto absoluteIntersectionRect =
122-
intersectOrNull(rootBoundingRect, targetBoundingRect);
202+
intersectOrNull(rootMarginBoundingRect, targetBoundingRect);
123203
if (!absoluteIntersectionRect.has_value()) {
124204
return std::nullopt;
125205
}
@@ -130,12 +210,14 @@ static std::optional<Rect> computeIntersection(
130210
auto clippedTargetFromRoot =
131211
getClippedTargetBoundingRect(targetToRootAncestors);
132212

213+
// Use root origin (without rootMargins) to translate coordinates of
214+
// clippedTarget from relative to root, to top-level coordinate system
133215
auto clippedTargetBoundingRect = hasExplicitRoot ? Rect{
134-
.origin=rootBoundingRect.origin + clippedTargetFromRoot.origin,
216+
.origin= rootBoundingRect.origin + clippedTargetFromRoot.origin,
135217
.size=clippedTargetFromRoot.size}
136218
: clippedTargetFromRoot;
137219

138-
return intersectOrNull(rootBoundingRect, clippedTargetBoundingRect);
220+
return intersectOrNull(rootMarginBoundingRect, clippedTargetBoundingRect);
139221
}
140222

141223
static Float getHighestThresholdCrossed(
@@ -167,6 +249,18 @@ IntersectionObserver::updateIntersectionObservation(
167249
? getBoundingRect(rootAncestors)
168250
: getRootNodeBoundingRect(rootShadowNode);
169251

252+
auto rootMarginBoundingRect = rootBoundingRect;
253+
254+
// Apply rootMargin to expand/contract the root bounding rect
255+
if (!rootMargins_.empty()) {
256+
// Calculate the actual insets based on current root dimensions
257+
// (converts percentages to pixels)
258+
auto insets = calculateRootMarginInsets(rootMargins_, rootBoundingRect);
259+
// Use outsetBy to expand the root rect (positive values expand, negative
260+
// contract)
261+
rootMarginBoundingRect = outsetBy(rootBoundingRect, insets);
262+
}
263+
170264
auto targetAncestors = targetShadowNodeFamily_->getAncestors(rootShadowNode);
171265

172266
// Absolute coordinates of the target
@@ -175,7 +269,7 @@ IntersectionObserver::updateIntersectionObservation(
175269
if ((hasExplicitRoot && rootAncestors.empty()) || targetAncestors.empty()) {
176270
// If observation root or target is not a descendant of `rootShadowNode`
177271
return setNotIntersectingState(
178-
rootBoundingRect, targetBoundingRect, {}, time);
272+
rootMarginBoundingRect, targetBoundingRect, {}, time);
179273
}
180274

181275
auto targetToRootAncestors = hasExplicitRoot
@@ -184,6 +278,7 @@ IntersectionObserver::updateIntersectionObservation(
184278

185279
auto intersection = computeIntersection(
186280
rootBoundingRect,
281+
rootMarginBoundingRect,
187282
targetBoundingRect,
188283
targetToRootAncestors,
189284
hasExplicitRoot);
@@ -203,31 +298,31 @@ IntersectionObserver::updateIntersectionObservation(
203298

204299
if (!intersection.has_value()) {
205300
return setNotIntersectingState(
206-
rootBoundingRect, targetBoundingRect, intersectionRect, time);
301+
rootMarginBoundingRect, targetBoundingRect, intersectionRect, time);
207302
}
208303

209304
auto highestThresholdCrossed =
210305
getHighestThresholdCrossed(intersectionRatio, thresholds_);
211306

212307
auto highestRootThresholdCrossed = -1.0f;
213308
if (rootThresholds_.has_value()) {
214-
Float rootBoundingRectArea =
215-
rootBoundingRect.size.width * rootBoundingRect.size.height;
216-
Float rootThresholdIntersectionRatio = rootBoundingRectArea == 0
309+
Float rootMarginBoundingRectArea =
310+
rootMarginBoundingRect.size.width * rootMarginBoundingRect.size.height;
311+
Float rootThresholdIntersectionRatio = rootMarginBoundingRectArea == 0
217312
? 0
218-
: intersectionRectArea / rootBoundingRectArea;
313+
: intersectionRectArea / rootMarginBoundingRectArea;
219314
highestRootThresholdCrossed = getHighestThresholdCrossed(
220315
rootThresholdIntersectionRatio, rootThresholds_.value());
221316
}
222317

223318
if (highestThresholdCrossed == -1.0f &&
224319
highestRootThresholdCrossed == -1.0f) {
225320
return setNotIntersectingState(
226-
rootBoundingRect, targetBoundingRect, intersectionRect, time);
321+
rootMarginBoundingRect, targetBoundingRect, intersectionRect, time);
227322
}
228323

229324
return setIntersectingState(
230-
rootBoundingRect,
325+
rootMarginBoundingRect,
231326
targetBoundingRect,
232327
intersectionRect,
233328
highestThresholdCrossed,

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@ namespace facebook::react {
1818

1919
using IntersectionObserverObserverId = int32_t;
2020

21+
// Structure to hold a margin value that can be either pixels or percentage
22+
struct MarginValue {
23+
Float value;
24+
bool isPercentage;
25+
};
26+
27+
// Parse a normalized rootMargin string that's always in the format:
28+
// "top right bottom left" where each value is either "Npx" or "N%"
29+
// The string is already validated and normalized by JS.
30+
// Returns a vector of 4 MarginValue structures (top, right, bottom, left).
31+
std::vector<MarginValue> parseNormalizedRootMargin(
32+
const std::string& marginStr);
33+
2134
struct IntersectionObserverEntry {
2235
IntersectionObserverObserverId intersectionObserverId;
2336
ShadowNodeFamily::Shared shadowNodeFamily;
@@ -41,7 +54,8 @@ class IntersectionObserver {
4154
std::optional<ShadowNodeFamily::Shared> observationRootShadowNodeFamily,
4255
ShadowNodeFamily::Shared targetShadowNodeFamily,
4356
std::vector<Float> thresholds,
44-
std::optional<std::vector<Float>> rootThresholds = std::nullopt);
57+
std::optional<std::vector<Float>> rootThresholds,
58+
std::vector<MarginValue> rootMargins);
4559

4660
// Partially equivalent to
4761
// https://w3c.github.io/IntersectionObserver/#update-intersection-observations-algo
@@ -84,6 +98,8 @@ class IntersectionObserver {
8498
ShadowNodeFamily::Shared targetShadowNodeFamily_;
8599
std::vector<Float> thresholds_;
86100
std::optional<std::vector<Float>> rootThresholds_;
101+
// Parsed and expanded rootMargin values (top, right, bottom, left)
102+
std::vector<MarginValue> rootMargins_;
87103
mutable IntersectionObserverState state_ =
88104
IntersectionObserverState::Initial();
89105
};

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

Lines changed: 9 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

@@ -53,12 +54,19 @@ void IntersectionObserverManager::observe(
5354
std::unique_lock lock(observersMutex_);
5455

5556
auto& observers = observersBySurfaceId_[surfaceId];
57+
58+
// Parse rootMargin string into MarginValue structures
59+
// Default to "0px 0px 0px 0px" if not provided
60+
auto parsedRootMargin =
61+
parseNormalizedRootMargin(rootMargin.value_or("0px 0px 0px 0px"));
62+
5663
observers.emplace_back(std::make_unique<IntersectionObserver>(
5764
intersectionObserverId,
5865
observationRootShadowNodeFamily,
5966
shadowNodeFamily,
6067
std::move(thresholds),
61-
std::move(rootThresholds)));
68+
std::move(rootThresholds),
69+
std::move(parsedRootMargin)));
6270

6371
observersPendingInitialization_.emplace_back(observers.back().get());
6472
}

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)