Skip to content

Commit b398ccb

Browse files
feat: Prioritize pseudoselectors over renders (#9775)
## Summary Currently changing the style of an object with pseudoselector during render will overwrite the effect of pseudoselector. It's not something that happens on web - pseudoselector (tagged with !important as in our web implementation) should be more important. This PR tries to fix that. ## Test plan <img width="351" height="97" alt="image" src="https://github.com/user-attachments/assets/376ec1fc-13ad-47ac-87c9-5fa7bb2b68f0" /> --------- Co-authored-by: Mateusz Łopaciński <lop.mateusz.2001@gmail.com>
1 parent 511a0a0 commit b398ccb

12 files changed

Lines changed: 211 additions & 30 deletions

File tree

apps/common-app/src/apps/css/examples/transitions/routes.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@ const routes = {
100100
name: ':active',
101101
Component: pseudoSelectors.Active,
102102
},
103+
PseudoActiveBlocksRender: {
104+
name: 'selectors block render transition',
105+
Component: pseudoSelectors.ActiveBlocksRender,
106+
},
103107
PseudoActiveDeepest: {
104108
name: ':active-deepest',
105109
Component: pseudoSelectors.ActiveDeepest,
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { useEffect, useState } from 'react';
2+
import { StyleSheet } from 'react-native';
3+
import Animated from 'react-native-reanimated';
4+
5+
import {
6+
Screen,
7+
Scroll,
8+
Section,
9+
VerticalExampleCard,
10+
} from '@/apps/css/components';
11+
import { colors, radius, sizes, spacing } from '@/theme';
12+
13+
export default function ActiveBlocksRender() {
14+
const [phase, setPhase] = useState(0);
15+
16+
useEffect(() => {
17+
const interval = setInterval(() => {
18+
setPhase((prev) => (prev + 1) % 2);
19+
}, 1000);
20+
return () => clearInterval(interval);
21+
}, []);
22+
23+
const renderColor = phase === 0 ? colors.danger : colors.primaryLight;
24+
25+
return (
26+
<Screen>
27+
<Scroll contentContainerStyle={styles.content} withBottomBarSpacing>
28+
<Section
29+
description="backgroundColor is driven by two sources at once: a render-driven transition that toggles red <-> light blue every 1000ms via the 'default' value, and a ':active' selector that sets dark navy. Press and hold the box: while ':active' is active, the incoming render transitions on backgroundColor should be blocked and the box should hold dark navy (no red/blue flicker). Release and the looping render transition resumes."
30+
title="Selector blocks render transition">
31+
<VerticalExampleCard
32+
title="backgroundColor: render loop vs :active"
33+
code={`const renderColor = phase === 0 ? colors.danger : colors.primaryLight;
34+
<Animated.View
35+
style={{
36+
backgroundColor: {
37+
default: renderColor,
38+
':active': colors.primaryDark,
39+
},
40+
transitionDuration: '900ms',
41+
transitionTimingFunction: 'linear',
42+
}}
43+
onStartShouldSetResponder={() => true}
44+
/>`}
45+
collapsedCode={`backgroundColor: {
46+
default: renderColor,
47+
':active': colors.primaryDark,
48+
},`}>
49+
<Animated.View
50+
style={[
51+
styles.box,
52+
{
53+
backgroundColor: {
54+
':active': colors.primaryDark,
55+
default: renderColor,
56+
},
57+
transitionDuration: '900ms',
58+
transitionTimingFunction: 'linear',
59+
},
60+
]}
61+
onStartShouldSetResponder={() => true}
62+
/>
63+
</VerticalExampleCard>
64+
</Section>
65+
</Scroll>
66+
</Screen>
67+
);
68+
}
69+
70+
const styles = StyleSheet.create({
71+
box: {
72+
borderRadius: radius.md,
73+
height: sizes.md,
74+
width: sizes.md,
75+
},
76+
content: {
77+
gap: spacing.xs,
78+
},
79+
});

apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Active from './Active';
2+
import ActiveBlocksRender from './ActiveBlocksRender';
23
import ActiveDeepest from './ActiveDeepest';
34
import ArbitraryWebSelectors from './ArbitraryWebSelectors';
45
import Focus from './Focus';
@@ -11,6 +12,7 @@ import Planets from './Planets';
1112

1213
export default {
1314
Active,
15+
ActiveBlocksRender,
1416
ActiveDeepest,
1517
ArbitraryWebSelectors,
1618
Focus,

packages/react-native-reanimated/Common/cpp/reanimated/CSS/core/transition/CSSTransition.cpp

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ TransitionProperties CSSTransition::getProperties() const {
3232
folly::dynamic CSSTransition::run(jsi::Runtime &rt, CSSTransitionConfig &&config, const folly::dynamic &lastUpdates) {
3333
const auto timestamp = loop_->resolveTimestamp();
3434

35+
for (const auto &propertyName : pseudoLockedProperties_) {
36+
config.changedPropertiesSettings.erase(propertyName);
37+
config.changedProperties.erase(propertyName);
38+
std::erase(config.removedProperties, propertyName);
39+
}
40+
3541
auto loopConfig = platformTransitionProxy_->processConfig(rt, getViewTag(), config, routing_, timestamp);
3642
if (loopConfig.empty()) {
3743
return folly::dynamic::object();
@@ -72,6 +78,10 @@ folly::dynamic CSSTransition::computeCurrentLoopStyle() {
7278
return loopTransition_->computeCurrentStyle(shadowNode_);
7379
}
7480

81+
void CSSTransition::setPseudoLockedProperties(TransitionProperties properties) {
82+
pseudoLockedProperties_ = std::move(properties);
83+
}
84+
7585
void CSSTransition::cancel() {
7686
if (loopTransition_) {
7787
loop_->remove(loopTransition_);

packages/react-native-reanimated/Common/cpp/reanimated/CSS/core/transition/CSSTransition.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ class CSSTransition {
5252
folly::dynamic run(const PropertyValueDynamicDiffsMap &propertyDiffs, const folly::dynamic &lastUpdates);
5353
void cancel();
5454

55+
void setPseudoLockedProperties(TransitionProperties properties);
56+
5557
private:
5658
const std::shared_ptr<const ShadowNode> shadowNode_;
5759
const std::shared_ptr<ViewStylesRepository> viewStylesRepository_;
@@ -60,6 +62,7 @@ class CSSTransition {
6062
Observer &observer_;
6163

6264
CSSTransitionRouting routing_;
65+
TransitionProperties pseudoLockedProperties_;
6366
std::shared_ptr<CSSLoopTransition> loopTransition_;
6467

6568
CSSLoopTransition &ensureLoopTransition();

packages/react-native-reanimated/Common/cpp/reanimated/CSS/interpolation/values/ValueInterpolator.cpp

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@ bool ValueInterpolator::updateKeyframes(jsi::Runtime &rt, const jsi::Value &from
5555
auto to = toValue.isUndefined() ? defaultStyleValue_ : createValue(rt, toValue);
5656

5757
const auto equalsReversingAdjustedStartValue = reversingAdjustedStartValue_ && (*to == *reversingAdjustedStartValue_);
58-
reversingAdjustedStartValue_ = keyframes_.empty() ? from : keyframes_[1].value.value();
58+
// https://drafts.csswg.org/css-transitions/#reversing
59+
reversingAdjustedStartValue_ =
60+
(equalsReversingAdjustedStartValue && !keyframes_.empty()) ? keyframes_[1].value.value() : from;
5961

6062
keyframes_ = {ValueKeyframe{0, std::move(from)}, ValueKeyframe{1, std::move(to)}};
6163

@@ -67,7 +69,8 @@ bool ValueInterpolator::updateKeyframes(const folly::dynamic &fromValue, const f
6769
auto to = toValue.isNull() ? defaultStyleValue_ : createValue(toValue);
6870

6971
const auto equalsReversingAdjustedStartValue = reversingAdjustedStartValue_ && (*to == *reversingAdjustedStartValue_);
70-
reversingAdjustedStartValue_ = keyframes_.empty() ? from : keyframes_[1].value.value();
72+
reversingAdjustedStartValue_ =
73+
(equalsReversingAdjustedStartValue && !keyframes_.empty()) ? keyframes_[1].value.value() : from;
7174

7275
keyframes_ = {ValueKeyframe{0, std::move(from)}, ValueKeyframe{1, std::move(to)}};
7376

packages/react-native-reanimated/Common/cpp/reanimated/CSS/registries/CSSTransitionsRegistry.cpp

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ void CSSTransitionsRegistry::run(
3939
recordInitialUpdate(transition, initialUpdate);
4040
}
4141

42+
void CSSTransitionsRegistry::setPseudoLockedProperties(const Tag viewTag, const TransitionProperties &properties) {
43+
react_native_assert(UpdatesRegistryManager::isLockedByCurrentThread());
44+
const auto it = registry_.find(viewTag);
45+
if (it != registry_.end()) {
46+
it->second->setPseudoLockedProperties(properties);
47+
}
48+
}
49+
4250
void CSSTransitionsRegistry::flushUpdates(UpdatesBatch &updatesBatch) {
4351
react_native_assert(UpdatesRegistryManager::isLockedByCurrentThread());
4452
const auto tags = std::exchange(updatedTags_, {});

packages/react-native-reanimated/Common/cpp/reanimated/CSS/registries/CSSTransitionsRegistry.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ class CSSTransitionsRegistry : public UpdatesRegistry {
2828
CSSTransitionConfig &&config);
2929
void run(const std::shared_ptr<const ShadowNode> &shadowNode, const PropertyValueDynamicDiffsMap &propertyDiffs);
3030

31+
void setPseudoLockedProperties(Tag viewTag, const TransitionProperties &properties);
32+
3133
void flushUpdates(UpdatesBatch &updatesBatch);
3234
#if REACT_NATIVE_VERSION_MINOR >= 85
3335
void flushUpdates(UpdatesBatchAnimatedProps &updatesBatch);

packages/react-native-reanimated/Common/cpp/reanimated/PseudoStyles/PseudoStylesRegistry.cpp

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,16 @@ void PseudoStylesRegistry::registerPseudoStyles(
5050
auto &entry = registry_[tag];
5151
entry.shadowNode = shadowNode;
5252
entry.defaults = defaults;
53+
std::vector<PseudoSelector> newSelectors;
5354
for (const auto &registration : selectors) {
55+
if (!entry.selectors.contains(registration.selector)) {
56+
newSelectors.push_back(registration.selector);
57+
}
5458
entry.selectors[registration.selector] = {registration.selectorStyle};
5559
}
5660
entry.precomputedStyles = recomputeAllStyles(entry);
5761

58-
for (const auto &registration : selectors) {
59-
const auto selector = registration.selector;
62+
for (const auto selector : newSelectors) {
6063
attachFn_(
6164
tag,
6265
selector,
@@ -100,9 +103,23 @@ void PseudoStylesRegistry::onSelectorStateChanged(Tag tag, PseudoSelector select
100103
const auto &fromStyle = entry.precomputedStyles[oldMask];
101104
const auto &toStyle = entry.precomputedStyles[entry.activeMask];
102105

106+
css::TransitionProperties lockedProperties;
107+
for (const auto &[sel, data] : entry.selectors) {
108+
if (!(entry.activeMask & (1u << static_cast<int>(sel)))) {
109+
continue;
110+
}
111+
for (const auto &[propKey, val] : data.selectorStyle.items()) {
112+
if (!val.isNull()) {
113+
lockedProperties.insert(propKey.asString());
114+
}
115+
}
116+
}
117+
cssTransitionsRegistry_->setPseudoLockedProperties(tag, lockedProperties);
118+
103119
css::PropertyValueDynamicDiffsMap valueChanges;
104120
for (const auto &[propKey, toVal] : toStyle.items()) {
105121
const auto propName = propKey.asString();
122+
// Fallback only - the platform/loop prefer the live current value when one exists.
106123
const folly::dynamic &fromVal = fromStyle.count(propName) ? fromStyle[propName] : toVal;
107124
valueChanges.emplace(propName, std::make_pair(fromVal, toVal));
108125
}

packages/react-native-reanimated/apple/reanimated/apple/CSS/REACSSPlatformTransitions.mm

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@
2727
// and the reversing snapshot handle interruptions; settings are reused by the
2828
// toggle path.
2929
struct ActiveTransition {
30-
PlatformValue adjustedStart;
30+
// Unset after a mid-flight interruption - the live start value can't match any target.
31+
std::optional<PlatformValue> adjustedStart;
3132
PlatformValue adjustedEnd;
3233
ReversingState reversing;
3334
CSSTransitionPropertySettings settings;
@@ -65,24 +66,36 @@ - (void)applyForTag:(Tag)viewTag
6566
toValue:(const PlatformValue &)toValue
6667
settings:(const CSSTransitionPropertySettings &)settings
6768
timestamp:(double)timestamp
69+
persistent:(BOOL)persistent
6870
{
6971
auto &properties = _active[viewTag];
7072
const auto activeIt = properties.find(propertyName);
7173
// Targeting the in-flight transition's start value means this is a reversal.
7274
const ActiveTransition *previous =
73-
(activeIt != properties.end() && toValue == activeIt->second.adjustedStart) ? &activeIt->second : nullptr;
75+
(activeIt != properties.end() && activeIt->second.adjustedStart && toValue == *activeIt->second.adjustedStart)
76+
? &activeIt->second
77+
: nullptr;
7478
ReversingState reversing = previous
7579
? reverseShorten(previous->reversing, timestamp, settings.duration, settings.delay, settings.easingConfig)
7680
: makeReversingState(timestamp, settings.duration, settings.delay, settings.easingConfig);
7781

78-
const PlatformValue adjustedStart = previous ? previous->adjustedEnd : fromValue;
82+
// https://drafts.csswg.org/css-transitions/#reversing
83+
std::optional<PlatformValue> adjustedStart;
84+
if (previous) {
85+
adjustedStart = previous->adjustedEnd;
86+
} else if (activeIt == properties.end()) {
87+
adjustedStart = fromValue;
88+
} else if (timestamp >= activeIt->second.reversing.startTimestamp + activeIt->second.reversing.duration) {
89+
adjustedStart = activeIt->second.adjustedEnd;
90+
}
7991
[self animateTag:viewTag
8092
propertyName:propertyName
8193
fromValue:fromValue
8294
toValue:toValue
8395
durationMs:reversing.duration
8496
startTimeMs:reversing.startTimestamp
85-
easing:settings.easingConfig];
97+
easing:settings.easingConfig
98+
persistent:persistent];
8699
properties[propertyName] = ActiveTransition{adjustedStart, toValue, std::move(reversing), settings};
87100
}
88101

@@ -104,7 +117,8 @@ - (BOOL)applyTransitionForTag:(Tag)viewTag
104117
fromValue:*from
105118
toValue:*to
106119
settings:settings
107-
timestamp:timestamp];
120+
timestamp:timestamp
121+
persistent:NO];
108122
return YES;
109123
}
110124

@@ -135,7 +149,8 @@ - (BOOL)applyDynamicTransitionForTag:(Tag)viewTag
135149
fromValue:*from
136150
toValue:*to
137151
settings:settings
138-
timestamp:timestamp];
152+
timestamp:timestamp
153+
persistent:YES];
139154
return YES;
140155
}
141156

@@ -146,6 +161,7 @@ - (void)animateTag:(Tag)viewTag
146161
durationMs:(double)durationMs
147162
startTimeMs:(double)startTimeMs
148163
easing:(const EasingConfig &)easing
164+
persistent:(BOOL)persistent
149165
{
150166
// Capture everything up front; CALayer access must happen on the main thread.
151167
NSString *keyPath = caLayerKeyPathForCSSProperty(propertyName);
@@ -167,11 +183,11 @@ - (void)animateTag:(Tag)viewTag
167183
}
168184

169185
CABasicAnimation *anim = [CABasicAnimation animationWithKeyPath:keyPath];
170-
// On interruption, start from the live presentation value; the implicit
171-
// fromValue would race RN's model commit.
186+
// On interruption, continue from the live presentation value, falling back to the
187+
// model - never to fromId, which would snap a quick tap to the settled pseudo target.
172188
if ([[layer animationForKey:keyPath] isKindOfClass:[CABasicAnimation class]]) {
173189
id presentationValue = [[layer presentationLayer] valueForKeyPath:keyPath];
174-
anim.fromValue = presentationValue ?: fromId;
190+
anim.fromValue = presentationValue ?: [layer valueForKeyPath:keyPath];
175191
} else {
176192
anim.fromValue = fromId;
177193
}
@@ -181,17 +197,16 @@ - (void)animateTag:(Tag)viewTag
181197
// speed/timeOffset (e.g. RN Screens during navigation) from shifting it.
182198
anim.beginTime = [layer convertTime:beginTime fromLayer:nil];
183199
anim.timingFunction = timing;
184-
// Backwards fill paints fromValue during the delay window; the animation
185-
// self-removes on completion and the layer reads the model below.
186-
anim.fillMode = kCAFillModeBackwards;
187-
anim.removedOnCompletion = YES;
188-
189-
// Commit toValue to the model and add the animation in one transaction
190-
// (implicit actions off): on auto-removal the layer shows the final model
191-
// value with no snap, and recycled layers carry no stale animated state.
200+
anim.fillMode = persistent ? kCAFillModeBoth : kCAFillModeBackwards;
201+
anim.removedOnCompletion = persistent ? NO : YES;
202+
203+
// Non-persistent transitions commit toValue to the model so the layer settles there on
204+
// self-removal; persistent (pseudo) ones hold their value via fillMode and keep the base model.
192205
[CATransaction begin];
193206
[CATransaction setDisableActions:YES];
194-
[layer setValue:toId forKeyPath:keyPath];
207+
if (!persistent) {
208+
[layer setValue:toId forKeyPath:keyPath];
209+
}
195210
[layer addAnimation:anim forKey:keyPath];
196211
[CATransaction commit];
197212
});

0 commit comments

Comments
 (0)