From 67108555d6875947552bf4255bd85cde14dd645c Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 13 Oct 2026 08:46:28 +0300 Subject: [PATCH 1/3] fix(native): answer a container's :active from its touches, not by making it pressable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registering a container — a size container from `container-name`, or a group a descendant selector names — wired `activeFamily` on the element, and that family's presence is what adds `onPress` and turns a `View` into a `Pressable`. So every container became a touch target: it claimed the JS responder, Android's `JSResponderHandler` then intercepted the touch and cancelled the native gestures of everything beneath it, and the element was announced as clickable and focusable. A container now tracks its `:active` in `containerActiveFamily`, which its queries read and which never adds press handlers. A container already pressed for its own reasons answers through that press; any other observes the touch events its subtree receives, which reach every ancestor without a responder claim. --- .../native/container-activity.test.tsx | 154 ++++++++++++++++++ src/__tests__/native/grouping.test.tsx | 6 +- src/native/conditions/container-query.ts | 4 +- src/native/react/interaction.ts | 16 ++ src/native/react/rules.ts | 21 ++- src/native/reactivity.ts | 5 + src/native/styles/index.ts | 19 +++ 7 files changed, 218 insertions(+), 7 deletions(-) create mode 100644 src/__tests__/native/container-activity.test.tsx diff --git a/src/__tests__/native/container-activity.test.tsx b/src/__tests__/native/container-activity.test.tsx new file mode 100644 index 00000000..44a66d23 --- /dev/null +++ b/src/__tests__/native/container-activity.test.tsx @@ -0,0 +1,154 @@ +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" }; + +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"); + expect(child).toHaveStyle(RED); + + fireEvent(container, "touchEnd"); + expect(child).not.toHaveStyle(RED); + + fireEvent(container, "touchStart"); + expect(child).toHaveStyle(RED); + + fireEvent(container, "touchCancel"); + 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"); + expect(child).toHaveStyle(RED); + + fireEvent(child, "touchEnd"); + 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"); + expect(onTouchStart).toHaveBeenCalledTimes(1); + expect(screen.getByTestId(childID)).toHaveStyle(RED); + + fireEvent(container, "touchEnd"); + 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..8ba7c374 100644 --- a/src/__tests__/native/grouping.test.tsx +++ b/src/__tests__/native/grouping.test.tsx @@ -56,7 +56,7 @@ test("group - active", () => { expect(child.props.style).toStrictEqual(undefined); - fireEvent(parent, "pressIn"); + fireEvent(parent, "touchStart"); expect(child.props.style).toStrictEqual({ backgroundColor: "#f00" }); }); @@ -77,9 +77,9 @@ test("group - active (animated) supplies changed and restored transition targets }); }; expectStyle("#000"); - fireEvent(screen.getByTestId(parentID), "pressIn"); + fireEvent(screen.getByTestId(parentID), "touchStart"); expectStyle("#f00"); - fireEvent(screen.getByTestId(parentID), "pressOut"); + fireEvent(screen.getByTestId(parentID), "touchEnd"); expectStyle("#000"); }); 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..911f13cc 100644 --- a/src/native/react/interaction.ts +++ b/src/native/react/interaction.ts @@ -3,6 +3,7 @@ import type { LayoutChangeEvent } from "react-native"; import { activeFamily, + containerActiveFamily, containerLayoutFamily, focusFamily, hoverFamily, @@ -23,6 +24,9 @@ export type InteractionType = | "onPress" | "onPressIn" | "onPressOut" + | "onTouchStart" + | "onTouchEnd" + | "onTouchCancel" | "onFocus" | "onBlur"; @@ -33,6 +37,9 @@ const defaultHandlers: Record = { onPress: () => {}, onPressIn: () => {}, onPressOut: () => {}, + onTouchStart: () => {}, + onTouchEnd: () => {}, + onTouchCancel: () => {}, onFocus: () => {}, onBlur: () => {}, }; @@ -76,9 +83,18 @@ 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": + containerActiveFamily(weakKey).set(true); + break; + case "onTouchEnd": + case "onTouchCancel": + containerActiveFamily(weakKey).set(false); break; case "onFocus": focusFamily(weakKey).set(true); diff --git a/src/native/react/rules.ts b/src/native/react/rules.ts index e0fe8d1a..afc9ca6c 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, @@ -115,7 +116,7 @@ export function updateRules( if (rule.c) { containers ??= inheritedContainers; - activeFamily(state.ruleEffectGetter); + observeContainerActivity(state, currentProps); } if ( @@ -163,7 +164,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); } @@ -265,6 +266,22 @@ export function updateRules( }; } +/** + * 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/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..e3094d6d 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, @@ -265,6 +266,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)) { From 06a724ec01d423fc6906ae49ea3a7844a8cdf202 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 13 Oct 2026 10:41:18 +0300 Subject: [PATCH 2/3] fix(native): keep a container :active until the last touch inside it ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A container followed one boolean, so with two fingers inside a group, lifting one ended its `:active` while the other still rested there. Each container now follows the touches that went down inside it, and is active until the last of them ends. The touches still on screen prune that set on every event, so a touch that went down elsewhere never keeps it active, and a touch whose end never arrived — its target left the screen first — is dropped at the next touch event. --- .../native/container-activity.test.tsx | 87 +++++++++++++++++-- src/__tests__/native/grouping.test.tsx | 18 +++- src/native/react/interaction.ts | 42 ++++++++- 3 files changed, 133 insertions(+), 14 deletions(-) diff --git a/src/__tests__/native/container-activity.test.tsx b/src/__tests__/native/container-activity.test.tsx index 44a66d23..6e8a9cab 100644 --- a/src/__tests__/native/container-activity.test.tsx +++ b/src/__tests__/native/container-activity.test.tsx @@ -7,6 +7,16 @@ 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; } @@ -64,16 +74,77 @@ test("a group answers :active from the touches its subtree receives, and stays o expect(container.props.onStartShouldSetResponder).toBeUndefined(); expect(child).not.toHaveStyle(RED); - fireEvent(container, "touchStart"); + fireEvent(container, "touchStart", touchEvent([1], [1])); expect(child).toHaveStyle(RED); - fireEvent(container, "touchEnd"); + fireEvent(container, "touchEnd", touchEvent([], [1])); expect(child).not.toHaveStyle(RED); - fireEvent(container, "touchStart"); + fireEvent(container, "touchStart", touchEvent([2], [2])); expect(child).toHaveStyle(RED); - fireEvent(container, "touchCancel"); + 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); }); @@ -90,10 +161,10 @@ test("a touch on a descendant activates the group, as every ancestor of the pres const child = screen.getByTestId(childID); - fireEvent(child, "touchStart"); + fireEvent(child, "touchStart", touchEvent([1], [1])); expect(child).toHaveStyle(RED); - fireEvent(child, "touchEnd"); + fireEvent(child, "touchEnd", touchEvent([], [1])); expect(child).not.toHaveStyle(RED); }); @@ -118,11 +189,11 @@ test("a group the touches reach keeps the touch handlers it was given", () => { const container = screen.getByTestId(containerID); - fireEvent(container, "touchStart"); + fireEvent(container, "touchStart", touchEvent([1], [1])); expect(onTouchStart).toHaveBeenCalledTimes(1); expect(screen.getByTestId(childID)).toHaveStyle(RED); - fireEvent(container, "touchEnd"); + fireEvent(container, "touchEnd", touchEvent([], [1])); expect(onTouchEnd).toHaveBeenCalledTimes(1); expect(screen.getByTestId(childID)).not.toHaveStyle(RED); }); diff --git a/src/__tests__/native/grouping.test.tsx b/src/__tests__/native/grouping.test.tsx index 8ba7c374..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, "touchStart"); + 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), "touchStart"); + fireEvent(screen.getByTestId(parentID), "touchStart", { + nativeEvent: { + touches: [{ identifier: 1 }], + changedTouches: [{ identifier: 1 }], + }, + }); expectStyle("#f00"); - fireEvent(screen.getByTestId(parentID), "touchEnd"); + fireEvent(screen.getByTestId(parentID), "touchEnd", { + nativeEvent: { touches: [], changedTouches: [{ identifier: 1 }] }, + }); expectStyle("#000"); }); diff --git a/src/native/react/interaction.ts b/src/native/react/interaction.ts index 911f13cc..86799aba 100644 --- a/src/native/react/interaction.ts +++ b/src/native/react/interaction.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -import type { LayoutChangeEvent } from "react-native"; +import type { GestureResponderEvent, LayoutChangeEvent } from "react-native"; import { activeFamily, @@ -15,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 = @@ -90,11 +122,15 @@ export function getInteractionHandler( containerActiveFamily(weakKey).set(false); break; case "onTouchStart": - containerActiveFamily(weakKey).set(true); + followContainerTouches( + weakKey, + event as GestureResponderEvent, + "down", + ); break; case "onTouchEnd": case "onTouchCancel": - containerActiveFamily(weakKey).set(false); + followContainerTouches(weakKey, event as GestureResponderEvent, "up"); break; case "onFocus": focusFamily(weakKey).set(true); From 222b55dff8520c64a9e2fc0157a0a2a5dd05e503 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 15 Oct 2026 12:37:07 +0300 Subject: [PATCH 3/3] feat(native): paint active and group-active on the UI thread through Reanimated's host pseudo styles --- .../native/ui-thread-pseudo-classes.test.tsx | 214 ++++++++ src/native/react/rules.ts | 140 +++++- src/native/react/ui-thread-pseudo.ts | 456 ++++++++++++++++++ src/native/react/useNativeCss.ts | 44 +- src/native/styles/index.ts | 27 ++ 5 files changed, 876 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/native/ui-thread-pseudo-classes.test.tsx create mode 100644 src/native/react/ui-thread-pseudo.ts 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/react/rules.ts b/src/native/react/rules.ts index afc9ca6c..6f41c8a3 100644 --- a/src/native/react/rules.ts +++ b/src/native/react/rules.ts @@ -19,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"); @@ -48,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; @@ -119,6 +129,45 @@ export function updateRules( 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 ( !testRule( rule, @@ -209,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, @@ -218,6 +274,8 @@ export function updateRules( pressable, variables, containers, + pressStylesObs: undefined, + groupPressStylesObs: undefined, }; } @@ -246,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, @@ -263,9 +339,65 @@ 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 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/styles/index.ts b/src/native/styles/index.ts index e3094d6d..bf56c077 100644 --- a/src/native/styles/index.ts +++ b/src/native/styles/index.ts @@ -185,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,