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)) {