diff --git a/src/__tests__/native/container-activity.test.tsx b/src/__tests__/native/container-activity.test.tsx
new file mode 100644
index 00000000..6e8a9cab
--- /dev/null
+++ b/src/__tests__/native/container-activity.test.tsx
@@ -0,0 +1,225 @@
+import { fireEvent, render, screen } from "@testing-library/react-native";
+import { View } from "react-native-css/components/View";
+import { registerCSS } from "react-native-css/jest";
+
+const containerID = "container";
+const childID = "child";
+
+const RED = { color: "#f00" };
+
+/** A touch event as React Native delivers it: every touch still on screen, and the touches this event changed. */
+function touchEvent(onScreen: number[], changed: number[]) {
+ return {
+ nativeEvent: {
+ touches: onScreen.map((identifier) => ({ identifier })),
+ changedTouches: changed.map((identifier) => ({ identifier })),
+ },
+ };
+}
+
+test("a size container takes no part in responder negotiation", () => {
+ registerCSS(`
+ .container { container-name: my-container; container-type: inline-size; }
+ @container my-container (width > 100px) {
+ .child { color: red; }
+ }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(
+ screen.getByTestId(containerID).props.onStartShouldSetResponder,
+ ).toBeUndefined();
+});
+
+test("a group its descendants ask only about an attribute takes no part in responder negotiation", () => {
+ registerCSS(`
+ .group\\/item[data-state="on"] .child { color: red; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId(childID)).toHaveStyle(RED);
+ expect(
+ screen.getByTestId(containerID).props.onStartShouldSetResponder,
+ ).toBeUndefined();
+});
+
+test("a group answers :active from the touches its subtree receives, and stays out of responder negotiation", () => {
+ registerCSS(`
+ .group\\/item:active .child { color: red; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ const container = screen.getByTestId(containerID);
+ const child = screen.getByTestId(childID);
+
+ expect(container.props.onStartShouldSetResponder).toBeUndefined();
+ expect(child).not.toHaveStyle(RED);
+
+ fireEvent(container, "touchStart", touchEvent([1], [1]));
+ expect(child).toHaveStyle(RED);
+
+ fireEvent(container, "touchEnd", touchEvent([], [1]));
+ expect(child).not.toHaveStyle(RED);
+
+ fireEvent(container, "touchStart", touchEvent([2], [2]));
+ expect(child).toHaveStyle(RED);
+
+ fireEvent(container, "touchCancel", touchEvent([], [2]));
+ expect(child).not.toHaveStyle(RED);
+});
+
+test("a group stays :active until the last touch that went down inside it ends", () => {
+ registerCSS(`
+ .group\\/item:active .child { color: red; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ const container = screen.getByTestId(containerID);
+ const child = screen.getByTestId(childID);
+
+ fireEvent(container, "touchStart", touchEvent([1], [1]));
+ fireEvent(container, "touchStart", touchEvent([1, 2], [2]));
+ fireEvent(container, "touchEnd", touchEvent([2], [1]));
+ expect(child).toHaveStyle(RED);
+
+ fireEvent(container, "touchEnd", touchEvent([], [2]));
+ expect(child).not.toHaveStyle(RED);
+});
+
+test("a touch that went down outside the group does not keep it :active", () => {
+ registerCSS(`
+ .group\\/item:active .child { color: red; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ const container = screen.getByTestId(containerID);
+
+ fireEvent(container, "touchStart", touchEvent([7, 1], [1]));
+ fireEvent(container, "touchEnd", touchEvent([7], [1]));
+ expect(screen.getByTestId(childID)).not.toHaveStyle(RED);
+});
+
+test("a touch whose end never reached the group is dropped at the next touch event", () => {
+ registerCSS(`
+ .group\\/item:active .child { color: red; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ const container = screen.getByTestId(containerID);
+ const child = screen.getByTestId(childID);
+
+ fireEvent(container, "touchStart", touchEvent([1], [1]));
+ fireEvent(container, "touchStart", touchEvent([2], [2]));
+ fireEvent(container, "touchEnd", touchEvent([], [2]));
+ expect(child).not.toHaveStyle(RED);
+});
+
+test("a touch on a descendant activates the group, as every ancestor of the pressed element is :active", () => {
+ registerCSS(`
+ .group\\/item:active .child { color: red; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ const child = screen.getByTestId(childID);
+
+ fireEvent(child, "touchStart", touchEvent([1], [1]));
+ expect(child).toHaveStyle(RED);
+
+ fireEvent(child, "touchEnd", touchEvent([], [1]));
+ expect(child).not.toHaveStyle(RED);
+});
+
+test("a group the touches reach keeps the touch handlers it was given", () => {
+ registerCSS(`
+ .group\\/item:active .child { color: red; }
+ `);
+
+ const onTouchStart = jest.fn();
+ const onTouchEnd = jest.fn();
+
+ render(
+
+
+ ,
+ );
+
+ const container = screen.getByTestId(containerID);
+
+ fireEvent(container, "touchStart", touchEvent([1], [1]));
+ expect(onTouchStart).toHaveBeenCalledTimes(1);
+ expect(screen.getByTestId(childID)).toHaveStyle(RED);
+
+ fireEvent(container, "touchEnd", touchEvent([], [1]));
+ expect(onTouchEnd).toHaveBeenCalledTimes(1);
+ expect(screen.getByTestId(childID)).not.toHaveStyle(RED);
+});
+
+test("a group pressed for its own reasons answers :active through its press", () => {
+ registerCSS(`
+ .group\\/item:active .child { color: red; }
+ `);
+
+ const onPress = jest.fn();
+
+ render(
+
+
+ ,
+ );
+
+ const container = screen.getByTestId(containerID);
+ const child = screen.getByTestId(childID);
+
+ fireEvent(container, "pressIn");
+ expect(child).toHaveStyle(RED);
+
+ fireEvent(container, "pressOut");
+ expect(child).not.toHaveStyle(RED);
+
+ fireEvent.press(container);
+ expect(onPress).toHaveBeenCalledTimes(1);
+});
diff --git a/src/__tests__/native/grouping.test.tsx b/src/__tests__/native/grouping.test.tsx
index 1484e4b8..91d2892b 100644
--- a/src/__tests__/native/grouping.test.tsx
+++ b/src/__tests__/native/grouping.test.tsx
@@ -56,7 +56,12 @@ test("group - active", () => {
expect(child.props.style).toStrictEqual(undefined);
- fireEvent(parent, "pressIn");
+ fireEvent(parent, "touchStart", {
+ nativeEvent: {
+ touches: [{ identifier: 1 }],
+ changedTouches: [{ identifier: 1 }],
+ },
+ });
expect(child.props.style).toStrictEqual({ backgroundColor: "#f00" });
});
@@ -77,9 +82,16 @@ test("group - active (animated) supplies changed and restored transition targets
});
};
expectStyle("#000");
- fireEvent(screen.getByTestId(parentID), "pressIn");
+ fireEvent(screen.getByTestId(parentID), "touchStart", {
+ nativeEvent: {
+ touches: [{ identifier: 1 }],
+ changedTouches: [{ identifier: 1 }],
+ },
+ });
expectStyle("#f00");
- fireEvent(screen.getByTestId(parentID), "pressOut");
+ fireEvent(screen.getByTestId(parentID), "touchEnd", {
+ nativeEvent: { touches: [], changedTouches: [{ identifier: 1 }] },
+ });
expectStyle("#000");
});
diff --git a/src/__tests__/native/ui-thread-pseudo-classes.test.tsx b/src/__tests__/native/ui-thread-pseudo-classes.test.tsx
new file mode 100644
index 00000000..dd944a83
--- /dev/null
+++ b/src/__tests__/native/ui-thread-pseudo-classes.test.tsx
@@ -0,0 +1,214 @@
+import { render, screen } from "@testing-library/react-native";
+import { Text } from "react-native-css/components/Text";
+import { View } from "react-native-css/components/View";
+import { registerCSS, testID } from "react-native-css/jest";
+
+const parentID = "parent";
+const childID = "child";
+
+interface RegisteredConfig {
+ defaultStyle?: Record;
+ selectors?: { selector: string; group?: number; style: object }[];
+ hostsGroup?: number;
+ transition?: Record;
+}
+
+const mockSetPseudoStyles = jest.fn<
+ undefined,
+ [{ props: { testID?: string } }, RegisteredConfig | null]
+>();
+let mockGroupCount = 0;
+
+jest.mock("react-native-reanimated", () => ({
+ default: {},
+ createAnimatedComponent: (component: unknown) => component,
+ createPseudoGroup: () => {
+ mockGroupCount += 1;
+ return mockGroupCount;
+ },
+ setPseudoStyles: (...args: Parameters) => {
+ mockSetPseudoStyles(...args);
+ },
+}));
+
+/** The configs registered on the element rendered with `id`. */
+const registrationsOf = (id: string): (RegisteredConfig | null)[] =>
+ mockSetPseudoStyles.mock.calls
+ .filter(([instance]) => instance.props.testID === id)
+ .map(([, config]) => config);
+
+beforeEach(() => {
+ mockSetPseudoStyles.mockClear();
+});
+
+test("active registers the pressed style on the host view instead of a press handler", () => {
+ registerCSS(`
+ .press { background-color: blue; }
+ .press:active { background-color: red; }
+ `);
+
+ render();
+
+ expect(screen.getByTestId(testID).props).toStrictEqual({
+ testID,
+ children: undefined,
+ style: { backgroundColor: "#00f" },
+ });
+ expect(registrationsOf(testID)).toStrictEqual([
+ {
+ defaultStyle: { backgroundColor: "#00f" },
+ selectors: [
+ {
+ selector: ":active-deepest",
+ style: { backgroundColor: "#f00" },
+ },
+ ],
+ },
+ ]);
+});
+
+test("a render that changes nothing registers nothing again", () => {
+ registerCSS(`
+ .press { background-color: blue; }
+ .press:active { background-color: red; }
+ `);
+
+ const { rerender } = render();
+ rerender();
+
+ expect(registrationsOf(testID)).toHaveLength(1);
+});
+
+test("active with no resting value rests at whatever React renders", () => {
+ registerCSS(`.press:active { background-color: red; }`);
+
+ render();
+
+ expect(registrationsOf(testID)).toStrictEqual([
+ {
+ defaultStyle: { backgroundColor: undefined },
+ selectors: [
+ {
+ selector: ":active-deepest",
+ style: { backgroundColor: "#f00" },
+ },
+ ],
+ },
+ ]);
+});
+
+test("an inline style outranks the pressed style, so nothing is registered", () => {
+ registerCSS(`.press:active { background-color: red; }`);
+
+ render(
+ ,
+ );
+
+ expect(registrationsOf(testID)).toStrictEqual([]);
+});
+
+test("a disabled element registers no press", () => {
+ registerCSS(`
+ .press { background-color: blue; }
+ .press:active { background-color: red; }
+ `);
+
+ render();
+
+ expect(registrationsOf(testID)).toStrictEqual([]);
+});
+
+test("a press follows the element's transition", () => {
+ registerCSS(`
+ .press {
+ background-color: blue;
+ transition-property: background-color;
+ transition-duration: 150ms;
+ }
+ .press:active { background-color: red; }
+ `);
+
+ render();
+
+ expect(registrationsOf(testID)).toStrictEqual([
+ expect.objectContaining({
+ selectors: [
+ {
+ selector: ":active-deepest",
+ style: { backgroundColor: "#f00" },
+ },
+ ],
+ transition: expect.objectContaining({
+ transitionProperty: expect.anything(),
+ transitionDuration: expect.anything(),
+ }),
+ }),
+ ]);
+});
+
+test("unmounting the element unregisters its press", () => {
+ registerCSS(`.press:active { background-color: red; }`);
+
+ const { unmount } = render();
+ unmount();
+
+ expect(registrationsOf(testID).at(-1)).toBeNull();
+});
+
+test("a press that changes inherited text stays in JavaScript on a view", () => {
+ registerCSS(`
+ .text-color { color: blue; }
+ .text-color:active { color: red; }
+ `);
+
+ render();
+
+ expect(registrationsOf(testID)).toStrictEqual([]);
+ expect(screen.getByTestId(testID).props.onClick).toEqual(
+ expect.any(Function),
+ );
+});
+
+test("group-active on text follows the group the container hosts", () => {
+ registerCSS(`
+ .my-class { color: blue; }
+ .group\\/item:active .my-class { color: red; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ const [childRegistration] = registrationsOf(childID);
+ const group = childRegistration?.selectors?.[0]?.group;
+
+ expect(group).toEqual(expect.any(Number));
+ expect(childRegistration).toStrictEqual({
+ defaultStyle: { color: "#00f" },
+ selectors: [{ selector: ":active", group, style: { color: "#f00" } }],
+ });
+ expect(registrationsOf(parentID)).toStrictEqual([
+ { defaultStyle: {}, selectors: [], hostsGroup: group },
+ ]);
+});
+
+test("a container no element follows hosts no group", () => {
+ registerCSS(`
+ .my-class { color: blue; }
+ .group\\/item:active .my-class { color: red; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(registrationsOf(parentID)).toStrictEqual([]);
+});
diff --git a/src/native/conditions/container-query.ts b/src/native/conditions/container-query.ts
index e25df564..78e78b3a 100644
--- a/src/native/conditions/container-query.ts
+++ b/src/native/conditions/container-query.ts
@@ -8,7 +8,7 @@ import type {
} from "react-native-css/compiler";
import {
- activeFamily,
+ containerActiveFamily,
containerHeightFamily,
containerWidthFamily,
focusFamily,
@@ -72,7 +72,7 @@ function testContainerPseudoCondition(
if (query.h && !get(hoverFamily(containerKey))) {
return false;
}
- if (query.a && !get(activeFamily(containerKey))) {
+ if (query.a && !get(containerActiveFamily(containerKey))) {
return false;
}
if (query.f && !get(focusFamily(containerKey))) {
diff --git a/src/native/react/interaction.ts b/src/native/react/interaction.ts
index d412b15e..86799aba 100644
--- a/src/native/react/interaction.ts
+++ b/src/native/react/interaction.ts
@@ -1,8 +1,9 @@
/* eslint-disable */
-import type { LayoutChangeEvent } from "react-native";
+import type { GestureResponderEvent, LayoutChangeEvent } from "react-native";
import {
activeFamily,
+ containerActiveFamily,
containerLayoutFamily,
focusFamily,
hoverFamily,
@@ -14,6 +15,38 @@ const mainCache = new WeakMap<
Map void>>
>();
+/** The touches each container follows: those that went down inside it and are still on screen. */
+const containerTouches = new WeakMap>();
+
+/**
+ * A container is `:active` until the last touch that went down inside it ends. A touch whose
+ * end never reached it — its target left the screen first — is dropped at the next touch event,
+ * because it is no longer among the touches on screen.
+ */
+function followContainerTouches(
+ weakKey: WeakKey,
+ event: GestureResponderEvent,
+ phase: "down" | "up",
+) {
+ const onScreen = new Set(
+ event.nativeEvent.touches.map((touch) => touch.identifier),
+ );
+ const following = new Set(
+ [...(containerTouches.get(weakKey) ?? [])].filter((identifier) =>
+ onScreen.has(identifier),
+ ),
+ );
+ for (const touch of event.nativeEvent.changedTouches) {
+ if (phase === "down") {
+ following.add(touch.identifier);
+ } else {
+ following.delete(touch.identifier);
+ }
+ }
+ containerTouches.set(weakKey, following);
+ containerActiveFamily(weakKey).set(following.size > 0);
+}
+
type Handler = (event: unknown) => void;
export type InteractionType =
@@ -23,6 +56,9 @@ export type InteractionType =
| "onPress"
| "onPressIn"
| "onPressOut"
+ | "onTouchStart"
+ | "onTouchEnd"
+ | "onTouchCancel"
| "onFocus"
| "onBlur";
@@ -33,6 +69,9 @@ const defaultHandlers: Record = {
onPress: () => {},
onPressIn: () => {},
onPressOut: () => {},
+ onTouchStart: () => {},
+ onTouchEnd: () => {},
+ onTouchCancel: () => {},
onFocus: () => {},
onBlur: () => {},
};
@@ -76,9 +115,22 @@ export function getInteractionHandler(
break;
case "onPressIn":
activeFamily(weakKey).set(true);
+ containerActiveFamily(weakKey).set(true);
break;
case "onPressOut":
activeFamily(weakKey).set(false);
+ containerActiveFamily(weakKey).set(false);
+ break;
+ case "onTouchStart":
+ followContainerTouches(
+ weakKey,
+ event as GestureResponderEvent,
+ "down",
+ );
+ break;
+ case "onTouchEnd":
+ case "onTouchCancel":
+ followContainerTouches(weakKey, event as GestureResponderEvent, "up");
break;
case "onFocus":
focusFamily(weakKey).set(true);
diff --git a/src/native/react/rules.ts b/src/native/react/rules.ts
index e0fe8d1a..6f41c8a3 100644
--- a/src/native/react/rules.ts
+++ b/src/native/react/rules.ts
@@ -9,6 +9,7 @@ import { getDeepPath } from "../objects";
import {
activeFamily,
cleanupEffect,
+ containerActiveFamily,
containerLayoutFamily,
focusFamily,
hoverFamily,
@@ -18,7 +19,12 @@ import {
type VariableContextValue,
} from "../reactivity";
import { stylesFamily } from "../styles";
-import type { ComponentState, Config } from "./useNativeCss";
+import {
+ classifyUiThreadPressRule,
+ resolveUiThreadPseudoApi,
+ targetsElementStyle,
+} from "./ui-thread-pseudo";
+import type { ComponentState, Config, GroupPressStyles } from "./useNativeCss";
export const INLINE_RULE_SYMBOL = Symbol("react-native-css.inlineRule");
@@ -47,8 +53,13 @@ export function updateRules(
let animated = false;
let pressable = false;
+ const pressOnUiThread = resolveUiThreadPseudoApi() !== undefined;
+ let ownPressRules: StyleRule[] | undefined;
+ let groupPressRules: Map | undefined;
+
for (const config of state.configs) {
const source = currentProps?.[config.source];
+ const pressTarget = pressOnUiThread && targetsElementStyle(config);
const shallowTarget = Array.isArray(config.target)
? config.target[0]
: config.target;
@@ -115,7 +126,46 @@ export function updateRules(
if (rule.c) {
containers ??= inheritedContainers;
- activeFamily(state.ruleEffectGetter);
+ observeContainerActivity(state, currentProps);
+ }
+
+ // A press painted on the UI thread is resolved apart from the resting rules
+ const pressRule = pressTarget
+ ? classifyUiThreadPressRule(
+ rule,
+ inheritedContainers,
+ state.rendersText === true,
+ )
+ : undefined;
+
+ if (pressRule) {
+ if (pressRule.kind === "group") {
+ guards.push(["c", pressRule.name, pressRule.registration]);
+ }
+
+ if (
+ !testRule(
+ pressRule.rule,
+ state.ruleEffectGetter,
+ currentProps,
+ guards,
+ inheritedContainers,
+ )
+ ) {
+ continue;
+ }
+
+ if (pressRule.kind === "own") {
+ ownPressRules ??= [];
+ ownPressRules.push(pressRule.rule);
+ } else {
+ groupPressRules ??= new Map();
+ const container = pressRule.registration.key;
+ const rulesOfGroup = groupPressRules.get(container) ?? [];
+ rulesOfGroup.push(pressRule.rule);
+ groupPressRules.set(container, rulesOfGroup);
+ }
+ continue;
}
if (
@@ -163,7 +213,7 @@ export function updateRules(
// Enable hover/active/focus/layout handlers
hoverFamily(state.ruleEffectGetter);
- activeFamily(state.ruleEffectGetter);
+ observeContainerActivity(state, currentProps);
focusFamily(state.ruleEffectGetter);
containerLayoutFamily(state.ruleEffectGetter);
}
@@ -208,7 +258,14 @@ export function updateRules(
pressable = activeFamily.has(state.ruleEffectGetter);
- if (!rules.size && !state.stylesObs && !inlineVariables.size) {
+ if (
+ !rules.size &&
+ !state.stylesObs &&
+ !inlineVariables.size &&
+ !ownPressRules &&
+ !groupPressRules
+ ) {
+ releasePressStyles(state, undefined, undefined);
return {
...state,
currentProps,
@@ -217,6 +274,8 @@ export function updateRules(
pressable,
variables,
containers,
+ pressStylesObs: undefined,
+ groupPressStylesObs: undefined,
};
}
@@ -245,13 +304,31 @@ export function updateRules(
// We will subscribe within the render using the StyleEffect
guards.push(...stylesObs.get().guards);
+ const pressStylesObs =
+ ownPressRules && derivePressStylesObs(state, rules, ownPressRules, guards);
+ const groupPressStylesObs =
+ groupPressRules &&
+ Array.from(
+ groupPressRules,
+ ([container, rulesOfGroup]): GroupPressStyles => ({
+ container,
+ stylesObs: derivePressStylesObs(state, rules, rulesOfGroup, guards),
+ }),
+ );
+
// If these are the same styles with no inline variables, we can skip the update
- if (state.stylesObs === stylesObs && !inlineVariables.size) {
+ if (
+ state.stylesObs === stylesObs &&
+ !inlineVariables.size &&
+ state.pressStylesObs === pressStylesObs &&
+ isSameGroupPressStyles(state.groupPressStylesObs, groupPressStylesObs)
+ ) {
return state;
}
- // Remove this component from the old observer
+ // Remove this component from the old observers
state.stylesObs?.unsubscribe(state.styleEffect);
+ releasePressStyles(state, pressStylesObs, groupPressStylesObs);
return {
...state,
@@ -262,9 +339,81 @@ export function updateRules(
guards,
animated,
pressable,
+ pressStylesObs,
+ groupPressStylesObs,
};
}
+/** The styles of an element whose press condition holds, resolved over its resting rules. */
+function derivePressStylesObs(
+ state: ComponentState,
+ rules: Set,
+ pressRules: StyleRule[],
+ guards: RenderGuard[],
+) {
+ const pressedRules = new Set(rules);
+ for (const rule of pressRules) {
+ pressedRules.add(rule);
+ }
+
+ const stylesObs = stylesFamily(
+ generateStateHash(state, pressedRules),
+ pressedRules,
+ );
+ guards.push(...stylesObs.get().guards);
+ return stylesObs;
+}
+
+function isSameGroupPressStyles(
+ previous: readonly GroupPressStyles[] | undefined,
+ next: readonly GroupPressStyles[] | undefined,
+) {
+ if (previous === next) {
+ return true;
+ }
+ if (!previous || !next || previous.length !== next.length) {
+ return false;
+ }
+ return previous.every(
+ (group, index) =>
+ group.container === next[index]?.container &&
+ group.stylesObs === next[index]?.stylesObs,
+ );
+}
+
+function releasePressStyles(
+ state: ComponentState,
+ pressStylesObs: ComponentState["pressStylesObs"],
+ groupPressStylesObs: ComponentState["groupPressStylesObs"],
+) {
+ if (state.pressStylesObs !== pressStylesObs) {
+ state.pressStylesObs?.unsubscribe(state.styleEffect);
+ }
+ for (const group of state.groupPressStylesObs ?? []) {
+ if (
+ !groupPressStylesObs?.some((next) => next.stylesObs === group.stylesObs)
+ ) {
+ group.stylesObs.unsubscribe(state.styleEffect);
+ }
+ }
+}
+
+/**
+ * A container answers `:active` for its queries through the press it already
+ * handles, or else through the touches its subtree receives, so registering a
+ * container never makes it a touch target.
+ */
+function observeContainerActivity(
+ state: ComponentState,
+ props: ComponentState["currentProps"],
+) {
+ if (props?.onPress) {
+ activeFamily(state.ruleEffectGetter);
+ } else {
+ containerActiveFamily(state.ruleEffectGetter);
+ }
+}
+
/**
* Create variations of a style rule based on the config.
* Cache for reference equality.
diff --git a/src/native/react/ui-thread-pseudo.ts b/src/native/react/ui-thread-pseudo.ts
new file mode 100644
index 00000000..6ec31abc
--- /dev/null
+++ b/src/native/react/ui-thread-pseudo.ts
@@ -0,0 +1,456 @@
+/* eslint-disable */
+import type { StyleRule } from "react-native-css/compiler";
+
+import { DEFAULT_CONTAINER_NAME } from "../conditions/container-query";
+import { weakFamily, type ContainerContextValue } from "../reactivity";
+import { getStyledTarget } from "../styles";
+import type { ComponentState, Config } from "./useNativeCss";
+
+/**
+ * `active:` and `group-active:` painted on the UI thread.
+ *
+ * Reanimated's `setPseudoStyles` registers pseudo-class styles on a host view,
+ * so a press is painted by the platform's own touch tracking instead of a React
+ * state change: no render, no commit, and no injected `onPress` that turns a
+ * `View` into a touch target. The element still resolves its pressed style here,
+ * from the same rules and cascade, and hands Reanimated the difference.
+ */
+
+type PressSelector = ":active" | ":active-deepest";
+
+interface ReanimatedPseudoSelector {
+ selector: PressSelector;
+ group?: number;
+ style: Record;
+}
+
+interface ReanimatedPseudoConfig {
+ defaultStyle?: Record;
+ selectors?: ReanimatedPseudoSelector[];
+ hostsGroup?: number;
+ transition?: Record;
+}
+
+interface ReanimatedPseudoApi {
+ setPseudoStyles: (
+ instance: object,
+ config: ReanimatedPseudoConfig | null,
+ ) => void;
+ createPseudoGroup: () => number;
+}
+
+let pseudoApi: ReanimatedPseudoApi | null | undefined;
+
+/** Reanimated's host pseudo-style API, or `undefined` when the installed Reanimated has none. */
+export function resolveUiThreadPseudoApi(): ReanimatedPseudoApi | undefined {
+ if (pseudoApi === undefined) {
+ pseudoApi = null;
+ try {
+ const reanimated = require("react-native-reanimated");
+ if (
+ typeof reanimated?.setPseudoStyles === "function" &&
+ typeof reanimated?.createPseudoGroup === "function"
+ ) {
+ pseudoApi = reanimated;
+ }
+ } catch {
+ // Reanimated is an optional peer; without it the press state stays in JavaScript.
+ }
+ }
+ return pseudoApi ?? undefined;
+}
+
+/** A press condition the UI thread resolves: the element's own `:active`, or an ancestor group's (`group-active:`). */
+export type UiThreadPressRule =
+ | { kind: "own"; rule: StyleRule }
+ | {
+ kind: "group";
+ rule: StyleRule;
+ registration: ContainerContextValue[string];
+ name: string;
+ };
+
+const withoutPseudoClasses = weakFamily(
+ (rule: StyleRule): StyleRule => ({ ...rule, p: undefined }),
+);
+
+const withoutContainerQueries = weakFamily(
+ (rule: StyleRule): StyleRule => ({ ...rule, cq: undefined }),
+);
+
+/**
+ * The UI-thread form of a rule whose only press-dependent condition is
+ * `:active`, or `undefined` for a rule that stays in JavaScript: one that also
+ * needs hover or focus, registers a container, reaches its group through more
+ * than one container, or names a group that is not an ancestor, which the
+ * resting path answers by never matching.
+ *
+ * A rule that publishes variables stays too, because what descendants inherit
+ * changes only through a render. Text is the exception for the variables this
+ * runtime publishes itself: its nested runs inherit natively.
+ */
+export function classifyUiThreadPressRule(
+ rule: StyleRule,
+ inheritedContainers: ContainerContextValue,
+ rendersText: boolean,
+): UiThreadPressRule | undefined {
+ if (rule.c || (rule.v && !(rendersText && publishesOnlyOwnText(rule.v)))) {
+ return undefined;
+ }
+
+ if (rule.p) {
+ return isActiveOnly(rule.p)
+ ? { kind: "own", rule: withoutPseudoClasses(rule) }
+ : undefined;
+ }
+
+ const query = rule.cq?.length === 1 ? rule.cq[0] : undefined;
+ if (!query?.p || query.m || query.a || !isActiveOnly(query.p)) {
+ return undefined;
+ }
+
+ const name = query.n ?? DEFAULT_CONTAINER_NAME;
+ const registration = inheritedContainers[name];
+ if (!registration) {
+ return undefined;
+ }
+
+ return {
+ kind: "group",
+ rule: withoutContainerQueries(rule),
+ registration,
+ name,
+ };
+}
+
+function isActiveOnly(query: NonNullable) {
+ return Boolean(query.a) && !query.h && !query.f;
+}
+
+function publishesOnlyOwnText(variables: NonNullable) {
+ return variables.every(([name]) => name.startsWith("__rn-css-"));
+}
+
+/********************************* Host views *********************************/
+
+export interface PressSelectorStyle {
+ selector: PressSelector;
+ /** The container whose press this selector follows, for `group-active:`. */
+ groupContainer?: WeakKey;
+ style: Record;
+}
+
+export interface PressRegistration {
+ selectors: PressSelectorStyle[];
+ defaultStyle: Record;
+ transition?: Record;
+ /** This element's own container key, when it may host a group. */
+ hostsGroupContainer?: WeakKey;
+}
+
+export interface PressHost {
+ /** The registration this element's latest render resolved. */
+ registration: PressRegistration | undefined;
+ /** Registers the latest registration on the attached view. */
+ flush(): void;
+ /** The ref to put on the element, composed with the consumer's own. */
+ refFor(
+ consumerRef: unknown,
+ ): (instance: object | null) => (() => void) | void;
+}
+
+const groupTokens = new WeakMap();
+const hostsByContainer = new WeakMap();
+
+/**
+ * The group a container's followers name. It is created for the first follower,
+ * and a container that is already mounted re-registers then, so a follower that
+ * mounts after its group still finds it hosted.
+ */
+function resolveGroupToken(
+ api: ReanimatedPseudoApi,
+ container: WeakKey,
+): number {
+ let token = groupTokens.get(container);
+ if (token === undefined) {
+ token = api.createPseudoGroup();
+ groupTokens.set(container, token);
+ hostsByContainer.get(container)?.flush();
+ }
+ return token;
+}
+
+export function createPressHost(): PressHost {
+ let instance: object | undefined;
+ let flushed: ReanimatedPseudoConfig | undefined;
+ const composedRefs = new WeakMap<
+ object,
+ (instance: object | null) => (() => void) | void
+ >();
+
+ const host: PressHost = {
+ registration: undefined,
+ flush() {
+ const api = resolveUiThreadPseudoApi();
+ if (!api || !instance) {
+ return;
+ }
+ const config = toReanimatedConfig(api, host.registration);
+ if (!config) {
+ if (flushed) {
+ api.setPseudoStyles(instance, { selectors: [] });
+ flushed = undefined;
+ }
+ return;
+ }
+ if (flushed && isSameStyleValue(flushed, config)) {
+ return;
+ }
+ api.setPseudoStyles(instance, config);
+ flushed = config;
+ },
+ refFor(consumerRef) {
+ if (typeof consumerRef !== "function" && !isRefObject(consumerRef)) {
+ return attach;
+ }
+ let composed = composedRefs.get(consumerRef);
+ if (!composed) {
+ composed = (next) => {
+ const releaseConsumer = assignRef(consumerRef, next);
+ const releaseHost = attach(next);
+ return () => {
+ releaseHost?.();
+ if (typeof releaseConsumer === "function") {
+ releaseConsumer();
+ } else {
+ assignRef(consumerRef, null);
+ }
+ };
+ };
+ composedRefs.set(consumerRef, composed);
+ }
+ return composed;
+ },
+ };
+
+ function attach(next: object | null) {
+ if (next === null) {
+ return;
+ }
+ instance = next;
+ host.flush();
+ return () => {
+ if (flushed) {
+ resolveUiThreadPseudoApi()?.setPseudoStyles(next, null);
+ flushed = undefined;
+ }
+ if (instance === next) {
+ instance = undefined;
+ }
+ };
+ }
+
+ return host;
+}
+
+/** Makes `host` the press host a container's late followers re-register through. */
+export function publishContainerHost(container: WeakKey, host: PressHost) {
+ hostsByContainer.set(container, host);
+}
+
+function toReanimatedConfig(
+ api: ReanimatedPseudoApi,
+ registration: PressRegistration | undefined,
+): ReanimatedPseudoConfig | undefined {
+ if (!registration) {
+ return undefined;
+ }
+
+ const hostsGroup =
+ registration.hostsGroupContainer === undefined
+ ? undefined
+ : groupTokens.get(registration.hostsGroupContainer);
+
+ if (!registration.selectors.length && hostsGroup === undefined) {
+ return undefined;
+ }
+
+ const selectors = registration.selectors.map(
+ ({ selector, groupContainer, style }): ReanimatedPseudoSelector =>
+ groupContainer === undefined
+ ? { selector, style }
+ : { selector, style, group: resolveGroupToken(api, groupContainer) },
+ );
+
+ return {
+ defaultStyle: registration.defaultStyle,
+ selectors,
+ ...(hostsGroup !== undefined && { hostsGroup }),
+ ...(registration.transition && { transition: registration.transition }),
+ };
+}
+
+function isRefObject(value: unknown): value is { current: unknown } {
+ return typeof value === "object" && value !== null && "current" in value;
+}
+
+function assignRef(ref: unknown, value: object | null): unknown {
+ if (typeof ref === "function") {
+ return ref(value);
+ }
+ if (isRefObject(ref)) {
+ ref.current = value;
+ }
+ return undefined;
+}
+
+/******************************* Style resolution ******************************/
+
+const TRANSITION_PROPERTIES: readonly string[] = [
+ "transitionProperty",
+ "transitionDuration",
+ "transitionTimingFunction",
+ "transitionDelay",
+ "transitionBehavior",
+];
+
+/** One style object from a style prop that may nest arrays, flattened the way React Native does. */
+export function flattenStyle(style: unknown): Record {
+ const flat: Record = {};
+ mergeStyleInto(flat, style);
+ return flat;
+}
+
+function mergeStyleInto(target: Record, style: unknown) {
+ if (Array.isArray(style)) {
+ for (const item of style) {
+ mergeStyleInto(target, item);
+ }
+ } else if (style && typeof style === "object") {
+ Object.assign(target, style);
+ }
+}
+
+/** The properties a pressed style changes from the resting one, or `undefined` when it changes none. */
+export function diffPressedStyle(
+ resting: Record,
+ pressed: Record,
+): Record | undefined {
+ let changed: Record | undefined;
+ for (const property of Object.keys(pressed)) {
+ if (
+ TRANSITION_PROPERTIES.includes(property) ||
+ isSameStyleValue(resting[property], pressed[property])
+ ) {
+ continue;
+ }
+ changed ??= {};
+ changed[property] = pressed[property];
+ }
+ return changed;
+}
+
+/** The resting style's transition, which a press follows the way a class change would. */
+export function pickTransition(
+ resting: Record,
+): Record | undefined {
+ let transition: Record | undefined;
+ for (const property of TRANSITION_PROPERTIES) {
+ if (resting[property] !== undefined) {
+ transition ??= {};
+ transition[property] = resting[property];
+ }
+ }
+ return transition;
+}
+
+function isSameStyleValue(left: unknown, right: unknown): boolean {
+ if (left === right) {
+ return true;
+ }
+ if (
+ typeof left !== "object" ||
+ typeof right !== "object" ||
+ left === null ||
+ right === null
+ ) {
+ return false;
+ }
+ return JSON.stringify(left) === JSON.stringify(right);
+}
+
+/** Whether the element refuses presses, so it must not paint one. */
+export function isPressDisabled(props: Record | undefined | null) {
+ return (
+ props?.disabled === true ||
+ props?.["aria-disabled"] === true ||
+ props?.accessibilityState?.disabled === true
+ );
+}
+
+/******************************** Registration ********************************/
+
+/** Whether a config styles the element's own view, the one a press is painted on. */
+export function targetsElementStyle(config: Config) {
+ return (
+ config.target === "style" ||
+ (Array.isArray(config.target) &&
+ config.target.length === 1 &&
+ config.target[0] === "style")
+ );
+}
+
+/** What the element registers for its presses: each pressed style as its change from the resting one. */
+export function resolvePressRegistration(
+ state: ComponentState,
+ props: Record | undefined | null,
+ styledProps: Record | undefined,
+): PressRegistration | undefined {
+ const config = state.configs.find(targetsElementStyle);
+ if (!config) {
+ return undefined;
+ }
+
+ const resting = flattenStyle(styledProps?.style);
+ const disabled = isPressDisabled(props);
+ const selectors: PressSelectorStyle[] = [];
+
+ if (state.pressStylesObs && !disabled) {
+ const changed = diffPressedStyle(
+ resting,
+ flattenStyle(getStyledTarget(state, state.pressStylesObs, props, config)),
+ );
+ if (changed) {
+ selectors.push({ selector: ":active-deepest", style: changed });
+ }
+ }
+
+ for (const group of state.groupPressStylesObs ?? []) {
+ const changed = diffPressedStyle(
+ resting,
+ flattenStyle(getStyledTarget(state, group.stylesObs, props, config)),
+ );
+ if (changed) {
+ selectors.push({
+ selector: ":active",
+ groupContainer: group.container,
+ style: changed,
+ });
+ }
+ }
+
+ const defaultStyle: Record = {};
+ for (const { style } of selectors) {
+ for (const property of Object.keys(style)) {
+ defaultStyle[property] = resting[property];
+ }
+ }
+
+ return {
+ selectors,
+ defaultStyle,
+ transition: pickTransition(resting),
+ hostsGroupContainer:
+ state.containers && !disabled ? state.ruleEffectGetter : undefined,
+ };
+}
diff --git a/src/native/react/useNativeCss.ts b/src/native/react/useNativeCss.ts
index b65165e5..36581f2a 100644
--- a/src/native/react/useNativeCss.ts
+++ b/src/native/react/useNativeCss.ts
@@ -4,11 +4,12 @@ import {
Fragment,
useContext,
useEffect,
+ useInsertionEffect,
useRef,
useState,
type ComponentType,
} from "react";
-import { Image, Pressable, StyleSheet, View } from "react-native";
+import { Image, Pressable, StyleSheet, Text, View } from "react-native";
import { VariableContext } from "react-native-css/native-internal";
@@ -26,6 +27,12 @@ import {
import { animatedComponentFamily } from "../reanimated";
import { getStyledProps, stylesFamily } from "../styles";
import { updateRules } from "./rules";
+import {
+ createPressHost,
+ publishContainerHost,
+ resolvePressRegistration,
+ type PressHost,
+} from "./ui-thread-pseudo";
export type Config = {
source: string;
@@ -57,6 +64,20 @@ export type ComponentState = {
animated?: boolean;
pressable?: undefined | boolean;
+
+ /** The styles of the element's own `:active`, painted on the UI thread */
+ pressStylesObs?: ReturnType;
+ /** The styles of each ancestor group's `:active`, painted on the UI thread */
+ groupPressStylesObs?: readonly GroupPressStyles[];
+ /** Registers those styles on the element's host view */
+ pressHost: PressHost;
+ /** Whether the element is React Native's Text, whose nested runs inherit its style natively */
+ rendersText?: boolean;
+};
+
+export type GroupPressStyles = {
+ container: WeakKey;
+ stylesObs: ReturnType;
};
/**
@@ -102,6 +123,8 @@ export function useNativeCss(
inheritedContainers,
inheritedVariables,
pressable: type === View ? false : undefined,
+ pressHost: createPressHost(),
+ rendersText: type === Text,
},
originalProps,
inheritedVariables,
@@ -140,6 +163,11 @@ export function useNativeCss(
};
}, [state.ruleEffect, state.styleEffect]);
+ // Before the commit reaches the host view, so a resting style a render changes never meets a stale pressed one
+ useInsertionEffect(() => {
+ state.pressHost.flush();
+ });
+
// Check if our derived state has changed (e.g the className prop)
if (
testGuards(state, originalProps, inheritedVariables, inheritedContainers)
@@ -168,6 +196,20 @@ export function useNativeCss(
type === Image ? adaptImageProps : undefined,
);
+ if (state.pressStylesObs || state.groupPressStylesObs || state.containers) {
+ state.pressHost.registration = resolvePressRegistration(
+ state,
+ originalProps,
+ props,
+ );
+ if (state.containers) {
+ publishContainerHost(state.ruleEffectGetter, state.pressHost);
+ }
+ props = { ...props, ref: state.pressHost.refFor(originalProps?.ref) };
+ } else {
+ state.pressHost.registration = undefined;
+ }
+
if (type === View && props?.onPress) {
type = Pressable;
}
diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts
index a32ac64d..fc09b7a6 100644
--- a/src/native/reactivity.ts
+++ b/src/native/reactivity.ts
@@ -283,3 +283,8 @@ export const containerHeightFamily = weakFamily((key) => {
return read(containerLayoutFamily(key))?.height || 0;
});
});
+
+/** A container's `:active` as its queries read it, apart from `activeFamily`, whose presence makes an element pressable. */
+export const containerActiveFamily = weakFamily(() =>
+ observable(false),
+);
diff --git a/src/native/styles/index.ts b/src/native/styles/index.ts
index 6be03daa..bf56c077 100644
--- a/src/native/styles/index.ts
+++ b/src/native/styles/index.ts
@@ -6,6 +6,7 @@ import { getInteractionHandler } from "../react/interaction";
import type { ComponentState, Config } from "../react/useNativeCss";
import {
activeFamily,
+ containerActiveFamily,
containerLayoutFamily,
family,
focusFamily,
@@ -184,6 +185,33 @@ export const stylesFamily = family(
},
);
+/** The style a config resolves from `stylesObs`, merged with the element's inline style. */
+export function getStyledTarget(
+ state: ComponentState,
+ stylesObs: NonNullable,
+ inline: Record | undefined | null,
+ config: Config,
+) {
+ const styledProps = stylesObs.get(state.styleEffect);
+
+ let result = deepMergeConfig(
+ config,
+ nativeStyleMapping(config, styledProps?.normal),
+ inline,
+ true,
+ );
+
+ if (styledProps?.important) {
+ result = deepMergeConfig(
+ config,
+ result,
+ nativeStyleMapping(config, styledProps.important),
+ );
+ }
+
+ return result?.style;
+}
+
export function getStyledProps(
state: ComponentState,
inline: Record | undefined | null,
@@ -265,6 +293,24 @@ export function getStyledProps(
"onPressOut",
inline?.onPressOut,
);
+ } else if (containerActiveFamily.has(state.ruleEffectGetter)) {
+ // Touch events bubble to every ancestor without a responder claim, so observing them leaves the subtree's gestures alone.
+ result ??= {};
+ result.onTouchStart = getInteractionHandler(
+ state.ruleEffectGetter,
+ "onTouchStart",
+ inline?.onTouchStart,
+ );
+ result.onTouchEnd = getInteractionHandler(
+ state.ruleEffectGetter,
+ "onTouchEnd",
+ inline?.onTouchEnd,
+ );
+ result.onTouchCancel = getInteractionHandler(
+ state.ruleEffectGetter,
+ "onTouchCancel",
+ inline?.onTouchCancel,
+ );
}
if (focusFamily.has(state.ruleEffectGetter)) {