diff --git a/apps/common-app/src/apps/css/examples/transitions/routes.ts b/apps/common-app/src/apps/css/examples/transitions/routes.ts
index 38b2b1a9c584..75905bd66118 100644
--- a/apps/common-app/src/apps/css/examples/transitions/routes.ts
+++ b/apps/common-app/src/apps/css/examples/transitions/routes.ts
@@ -100,6 +100,10 @@ const routes = {
name: ':active',
Component: pseudoSelectors.Active,
},
+ PseudoActiveBlocksRender: {
+ name: ':active blocks render transition',
+ Component: pseudoSelectors.ActiveBlocksRender,
+ },
PseudoActiveDeepest: {
name: ':active-deepest',
Component: pseudoSelectors.ActiveDeepest,
@@ -124,9 +128,18 @@ const routes = {
name: 'Arbitrary web selectors',
Component: pseudoSelectors.ArbitraryWebSelectors,
},
- PseudoShowcase: {
+ Showcase: {
name: 'Showcase',
- Component: pseudoSelectors.Showcase,
+ routes: {
+ PseudoPlanets: {
+ name: 'Planets',
+ Component: pseudoSelectors.Planets,
+ },
+ PseudoForm: {
+ name: 'Form',
+ Component: pseudoSelectors.Form,
+ },
+ },
},
},
},
diff --git a/apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/ActiveBlocksRender.tsx b/apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/ActiveBlocksRender.tsx
new file mode 100644
index 000000000000..2df677291b45
--- /dev/null
+++ b/apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/ActiveBlocksRender.tsx
@@ -0,0 +1,80 @@
+import { useEffect, useState } from 'react';
+import { StyleSheet } from 'react-native';
+import Animated from 'react-native-reanimated';
+
+import {
+ Screen,
+ Scroll,
+ Section,
+ VerticalExampleCard,
+} from '@/apps/css/components';
+import { colors, radius, sizes, spacing } from '@/theme';
+
+export default function ActiveBlocksRender() {
+ const [phase, setPhase] = useState(0);
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setPhase((prev) => (prev + 1) % 2);
+ }, 1000);
+ return () => clearInterval(interval);
+ }, []);
+
+ const renderColor = phase === 0 ? colors.danger : colors.primaryLight;
+
+ return (
+
+
+
+ true}
+/>`}
+ collapsedCode={`backgroundColor: {
+ default: renderColor,
+ ':active': colors.primaryDark,
+},`}>
+ true}
+ />
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ box: {
+ borderRadius: radius.md,
+ height: sizes.md,
+ width: sizes.md,
+ },
+ content: {
+ gap: spacing.xs,
+ },
+});
diff --git a/apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/Showcase.tsx b/apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/Form.tsx
similarity index 99%
rename from apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/Showcase.tsx
rename to apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/Form.tsx
index f5a6eee3c27f..0234bd293cce 100644
--- a/apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/Showcase.tsx
+++ b/apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/Form.tsx
@@ -139,7 +139,7 @@ function Card({
);
}
-export default function Showcase() {
+export default function Form() {
return (
diff --git a/apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/Planets.tsx b/apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/Planets.tsx
new file mode 100644
index 000000000000..67e548ddcfb3
--- /dev/null
+++ b/apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/Planets.tsx
@@ -0,0 +1,151 @@
+import { StyleSheet, Text } from 'react-native';
+import Animated, { css } from 'react-native-reanimated';
+
+import {
+ Screen,
+ Scroll,
+ Section,
+ VerticalExampleCard,
+} from '@/apps/css/components';
+import { colors, spacing } from '@/theme';
+
+const ORBIT_RX = 90;
+const ORBIT_RY = 40;
+const ORBIT_DURATION = '12s';
+const ORBIT_STEPS = 36;
+
+const PLANETS = [
+ { color: colors.danger, name: 'Mars', size: 44 },
+ { color: colors.primary, name: 'Venus', size: 60 },
+ { color: colors.primaryDark, name: 'Jupiter', size: 78 },
+];
+
+const MAX_PLANET_SIZE = Math.max(...PLANETS.map((planet) => planet.size));
+const PLANE_WIDTH = 2 * ORBIT_RX + MAX_PLANET_SIZE;
+const PLANE_HEIGHT = 2 * ORBIT_RY + MAX_PLANET_SIZE;
+const CENTER_X = PLANE_WIDTH / 2;
+const CENTER_Y = PLANE_HEIGHT / 2;
+
+function makeOrbit(offsetDeg: number, size: number) {
+ const frames: Record = {};
+ for (let step = 0; step <= ORBIT_STEPS; step++) {
+ const percent = ((step / ORBIT_STEPS) * 100).toFixed(4);
+ const angle = ((offsetDeg + (360 * step) / ORBIT_STEPS) * Math.PI) / 180;
+ frames[`${percent}%`] = {
+ left: CENTER_X + ORBIT_RX * Math.cos(angle) - size / 2,
+ top: CENTER_Y + ORBIT_RY * Math.sin(angle) - size / 2,
+ };
+ }
+ return css.keyframes(frames);
+}
+
+const ORBITS = PLANETS.map((planet, index) =>
+ makeOrbit((360 / PLANETS.length) * index, planet.size)
+);
+
+export default function Planets() {
+ return (
+
+
+
+ true}>
+ {name}
+`}
+ collapsedCode={`opacity: {
+ default: 0.02,
+ ':active': 1,
+},`}>
+
+
+ {PLANETS.map((planet, index) => (
+
+ true}>
+ {planet.name}
+
+
+ ))}
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ content: {
+ gap: spacing.xs,
+ },
+ name: {
+ color: colors.white,
+ fontSize: 12,
+ fontWeight: '600',
+ textAlign: 'center',
+ },
+ nameWrapper: {
+ alignItems: 'center',
+ bottom: 0,
+ justifyContent: 'center',
+ left: 0,
+ position: 'absolute',
+ right: 0,
+ top: 0,
+ },
+ orbitPlane: {
+ height: PLANE_HEIGHT,
+ width: PLANE_WIDTH,
+ },
+ orbiter: {
+ position: 'absolute',
+ shadowOffset: { height: 0, width: 0 },
+ },
+ stage: {
+ alignItems: 'center',
+ height: PLANE_HEIGHT + spacing.xl,
+ justifyContent: 'center',
+ },
+});
diff --git a/apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/index.ts b/apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/index.ts
index 7f50d2207ef9..6c3c8bb2ac86 100644
--- a/apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/index.ts
+++ b/apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/index.ts
@@ -1,21 +1,25 @@
import Active from './Active';
+import ActiveBlocksRender from './ActiveBlocksRender';
import ActiveDeepest from './ActiveDeepest';
import ArbitraryWebSelectors from './ArbitraryWebSelectors';
import Focus from './Focus';
import FocusWithin from './FocusWithin';
+import Form from './Form';
import Hover from './Hover';
import HoverWithLoop from './HoverWithLoop';
import PerStateTransitionConfig from './PerStateTransitionConfig';
-import Showcase from './Showcase';
+import Planets from './Planets';
export default {
Active,
+ ActiveBlocksRender,
ActiveDeepest,
ArbitraryWebSelectors,
Focus,
FocusWithin,
+ Form,
Hover,
HoverWithLoop,
PerStateTransitionConfig,
- Showcase,
+ Planets,
};
diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/CSS/core/transition/CSSTransition.cpp b/packages/react-native-reanimated/Common/cpp/reanimated/CSS/core/transition/CSSTransition.cpp
index 2e8022088abc..c26a7b9c96e0 100644
--- a/packages/react-native-reanimated/Common/cpp/reanimated/CSS/core/transition/CSSTransition.cpp
+++ b/packages/react-native-reanimated/Common/cpp/reanimated/CSS/core/transition/CSSTransition.cpp
@@ -36,6 +36,12 @@ TransitionProperties CSSTransition::getProperties() const {
folly::dynamic CSSTransition::run(jsi::Runtime &rt, CSSTransitionConfig &&config, const folly::dynamic &lastUpdates) {
const auto timestamp = loop_->resolveTimestamp();
+ for (const auto &propertyName : pseudoLockedProperties_) {
+ config.changedPropertiesSettings.erase(propertyName);
+ config.changedProperties.erase(propertyName);
+ std::erase(config.removedProperties, propertyName);
+ }
+
// CSSTransition owns routing: platform-routed props run immediately on the platform
// transition; the loop-routed remainder is applied to the loop transition below.
auto processed = platformTransitionProxy_->processConfig(std::move(config), routing_);
@@ -100,6 +106,10 @@ folly::dynamic CSSTransition::computeCurrentLoopStyle() {
return loopTransition_->computeCurrentStyle(shadowNode_);
}
+void CSSTransition::setPseudoLockedProperties(TransitionProperties properties) {
+ pseudoLockedProperties_ = std::move(properties);
+}
+
void CSSTransition::cancel() {
if (loopTransition_) {
loop_->remove(loopTransition_);
diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/CSS/core/transition/CSSTransition.h b/packages/react-native-reanimated/Common/cpp/reanimated/CSS/core/transition/CSSTransition.h
index bee13a233e7c..3ed910835f2b 100644
--- a/packages/react-native-reanimated/Common/cpp/reanimated/CSS/core/transition/CSSTransition.h
+++ b/packages/react-native-reanimated/Common/cpp/reanimated/CSS/core/transition/CSSTransition.h
@@ -55,6 +55,8 @@ class CSSTransition {
folly::dynamic run(const PropertyValueDynamicDiffsMap &propertyDiffs, const folly::dynamic &lastUpdates);
void cancel();
+ void setPseudoLockedProperties(TransitionProperties properties);
+
private:
const std::shared_ptr shadowNode_;
const std::shared_ptr viewStylesRepository_;
@@ -63,6 +65,7 @@ class CSSTransition {
Observer &observer_;
CSSTransitionRouting routing_;
+ TransitionProperties pseudoLockedProperties_;
std::unique_ptr platformTransition_;
std::shared_ptr loopTransition_;
diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/CSS/registries/CSSTransitionsRegistry.cpp b/packages/react-native-reanimated/Common/cpp/reanimated/CSS/registries/CSSTransitionsRegistry.cpp
index 6dc38cc4f3e8..d2374cbddacd 100644
--- a/packages/react-native-reanimated/Common/cpp/reanimated/CSS/registries/CSSTransitionsRegistry.cpp
+++ b/packages/react-native-reanimated/Common/cpp/reanimated/CSS/registries/CSSTransitionsRegistry.cpp
@@ -39,6 +39,14 @@ void CSSTransitionsRegistry::run(
recordInitialUpdate(transition, initialUpdate);
}
+void CSSTransitionsRegistry::setPseudoLockedProperties(const Tag viewTag, const TransitionProperties &properties) {
+ react_native_assert(UpdatesRegistryManager::isLockedByCurrentThread());
+ const auto it = registry_.find(viewTag);
+ if (it != registry_.end()) {
+ it->second->setPseudoLockedProperties(properties);
+ }
+}
+
void CSSTransitionsRegistry::flushUpdates(UpdatesBatch &updatesBatch) {
react_native_assert(UpdatesRegistryManager::isLockedByCurrentThread());
const auto tags = std::exchange(updatedTags_, {});
diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/CSS/registries/CSSTransitionsRegistry.h b/packages/react-native-reanimated/Common/cpp/reanimated/CSS/registries/CSSTransitionsRegistry.h
index f422641317a5..b9a74fc2ec53 100644
--- a/packages/react-native-reanimated/Common/cpp/reanimated/CSS/registries/CSSTransitionsRegistry.h
+++ b/packages/react-native-reanimated/Common/cpp/reanimated/CSS/registries/CSSTransitionsRegistry.h
@@ -28,6 +28,8 @@ class CSSTransitionsRegistry : public UpdatesRegistry {
CSSTransitionConfig &&config);
void run(const std::shared_ptr &shadowNode, const PropertyValueDynamicDiffsMap &propertyDiffs);
+ void setPseudoLockedProperties(Tag viewTag, const TransitionProperties &properties);
+
void flushUpdates(UpdatesBatch &updatesBatch);
#if REACT_NATIVE_VERSION_MINOR >= 85
void flushUpdates(UpdatesBatchAnimatedProps &updatesBatch);
diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/PseudoStyles/PseudoStylesRegistry.cpp b/packages/react-native-reanimated/Common/cpp/reanimated/PseudoStyles/PseudoStylesRegistry.cpp
index d68df6e4ccd7..0eb69a5cfe53 100644
--- a/packages/react-native-reanimated/Common/cpp/reanimated/PseudoStyles/PseudoStylesRegistry.cpp
+++ b/packages/react-native-reanimated/Common/cpp/reanimated/PseudoStyles/PseudoStylesRegistry.cpp
@@ -55,17 +55,20 @@ void PseudoStylesRegistry::registerPseudoStyle(
auto &entry = registry_[tag];
entry.shadowNode = shadowNode;
+ const bool isNewSelector = !entry.selectors.contains(selector);
entry.selectors[selector] = {selectorStyle, defaultStyle};
entry.precomputedStyles = recomputeAllStyles(entry);
- attachFn_(
- tag,
- selector,
- [weakThis = std::weak_ptr(shared_from_this()), tag, selector](bool isActive) {
- if (auto strongThis = weakThis.lock()) {
- strongThis->onSelectorStateChanged(tag, selector, isActive);
- }
- });
+ if (isNewSelector) {
+ attachFn_(
+ tag,
+ selector,
+ [weakThis = std::weak_ptr(shared_from_this()), tag, selector](bool isActive) {
+ if (auto strongThis = weakThis.lock()) {
+ strongThis->onSelectorStateChanged(tag, selector, isActive);
+ }
+ });
+ }
}
void PseudoStylesRegistry::remove(Tag tag) {
@@ -100,6 +103,19 @@ void PseudoStylesRegistry::onSelectorStateChanged(Tag tag, PseudoSelector select
const auto &fromStyle = entry.precomputedStyles[oldMask];
const auto &toStyle = entry.precomputedStyles[entry.activeMask];
+ css::TransitionProperties lockedProperties;
+ for (const auto &[sel, data] : entry.selectors) {
+ if (!(entry.activeMask & (1u << static_cast(sel)))) {
+ continue;
+ }
+ for (const auto &[propKey, val] : data.selectorStyle.items()) {
+ if (!val.isNull()) {
+ lockedProperties.insert(propKey.asString());
+ }
+ }
+ }
+ cssTransitionsRegistry_->setPseudoLockedProperties(tag, lockedProperties);
+
css::PropertyValueDynamicDiffsMap valueChanges;
for (const auto &[propKey, toVal] : toStyle.items()) {
const auto propName = propKey.asString();
diff --git a/packages/react-native-reanimated/src/css/native/managers/CSSPseudoStylesManager.ts b/packages/react-native-reanimated/src/css/native/managers/CSSPseudoStylesManager.ts
index 8fc9b62e6790..b7ff2df34f5d 100644
--- a/packages/react-native-reanimated/src/css/native/managers/CSSPseudoStylesManager.ts
+++ b/packages/react-native-reanimated/src/css/native/managers/CSSPseudoStylesManager.ts
@@ -47,17 +47,25 @@ export default class CSSPseudoStylesManager implements ICSSPseudoStylesManager {
) {
return;
}
+ const removedSelector = hasRemovedNativeSelector(
+ this.prevPseudoStylesBySelector,
+ pseudoStylesBySelector
+ );
+
this.prevPseudoStylesBySelector = pseudoStylesBySelector;
this.prevTransitionProperties = transitionProperties;
- if (this.isRegistered) {
- this.detach();
- }
-
if (!pseudoStylesBySelector) {
+ if (this.isRegistered) {
+ this.detach();
+ }
return;
}
+ if (this.isRegistered && removedSelector) {
+ this.detach();
+ }
+
const normalizedTransition = transitionProperties
? normalizeCSSTransitionProperties(transitionProperties)
: null;
@@ -140,6 +148,21 @@ export default class CSSPseudoStylesManager implements ICSSPseudoStylesManager {
}
}
+function hasRemovedNativeSelector(
+ prev: PseudoStylesBySelector | null,
+ next: PseudoStylesBySelector | null
+): boolean {
+ if (!prev) {
+ return false;
+ }
+ const nextKeys = next ? new Set(Object.keys(next)) : new Set();
+ return Object.keys(prev).some(
+ (selector) =>
+ NATIVE_PSEUDO_SELECTORS.has(selector as NativePseudoSelectorKey) &&
+ !nextKeys.has(selector)
+ );
+}
+
function nullifyUndefinedValues(style: UnknownRecord): void {
for (const key in style) {
if (style[key] === undefined) {
diff --git a/packages/react-native-reanimated/src/css/native/managers/__tests__/CSSPseudoStylesManager.test.ts b/packages/react-native-reanimated/src/css/native/managers/__tests__/CSSPseudoStylesManager.test.ts
index ac02d2911bd4..73afafda9b74 100644
--- a/packages/react-native-reanimated/src/css/native/managers/__tests__/CSSPseudoStylesManager.test.ts
+++ b/packages/react-native-reanimated/src/css/native/managers/__tests__/CSSPseudoStylesManager.test.ts
@@ -326,7 +326,7 @@ describe('CSSPseudoStylesManager', () => {
expect(unregisterPseudoStyle).not.toHaveBeenCalled();
});
- test('detaches and re-registers when selector style changes', () => {
+ test('updates in place without detaching when only selector style changes', () => {
pushStyle(manager, {
opacity: { default: 0, ':hover': 1 },
});
@@ -336,7 +336,7 @@ describe('CSSPseudoStylesManager', () => {
opacity: { default: 0, ':hover': 0.5 },
});
- expect(unregisterPseudoStyle).toHaveBeenCalledWith(viewTag);
+ expect(unregisterPseudoStyle).not.toHaveBeenCalled();
expect(registerPseudoStyle).toHaveBeenCalledWith(
shadowNodeWrapper,
expect.objectContaining({
@@ -345,7 +345,7 @@ describe('CSSPseudoStylesManager', () => {
);
});
- test('detaches and re-registers when only the transition changes', () => {
+ test('updates in place without detaching when only the transition changes', () => {
pushStyle(manager, {
opacity: { default: 0, ':hover': 1 },
transitionProperty: 'opacity',
@@ -359,7 +359,7 @@ describe('CSSPseudoStylesManager', () => {
transitionDuration: '500ms',
});
- expect(unregisterPseudoStyle).toHaveBeenCalledWith(viewTag);
+ expect(unregisterPseudoStyle).not.toHaveBeenCalled();
expect(registerPseudoStyle).toHaveBeenCalledWith(
shadowNodeWrapper,
expect.objectContaining({
@@ -369,6 +369,20 @@ describe('CSSPseudoStylesManager', () => {
})
);
});
+
+ test('detaches and re-registers when a selector is removed', () => {
+ pushStyle(manager, {
+ opacity: { default: 0, ':hover': 1, ':active': 0.5 },
+ });
+ jest.clearAllMocks();
+
+ pushStyle(manager, {
+ opacity: { default: 0, ':hover': 1 },
+ });
+
+ expect(unregisterPseudoStyle).toHaveBeenCalledWith(viewTag);
+ expect(registerPseudoStyle).toHaveBeenCalled();
+ });
});
describe('detach (via removing pseudo styles)', () => {