From 67108555d6875947552bf4255bd85cde14dd645c Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 13 Oct 2026 08:46:28 +0300 Subject: [PATCH 1/2] 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/2] 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);