Skip to content

feat(native): paint active and group-active on the UI thread through Reanimated's host pseudo styles - #484

Open
YevheniiKotyrlo wants to merge 3 commits into
nativewind:mainfrom
YevheniiKotyrlo:feat/active-on-the-ui-thread
Open

YevheniiKotyrlo wants to merge 3 commits into
nativewind:mainfrom
YevheniiKotyrlo:feat/active-on-the-ui-thread

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Problem

active: is tracked on the JS thread: the element becomes a press target, and onPressIn / onPressOut set state that re-renders it with the pressed style, so on a busy JS thread the press feedback lags the finger. group-active: waits the same way, through the container's touch observation.

Solution

When Reanimated exposes setPseudoStyles (software-mansion/react-native-reanimated#10900), a rule whose only press condition is :active is resolved apart from the resting rules and registered on the element's host view, so the UI thread paints it:

  • the element's own active: registers as :active-deepest, the innermost element under the finger, as the JS path behaves today;
  • group-active: registers as :active on a group the container hosts, so a press anywhere on the container - its padding included - paints the descendant.

The pressed style is the element's resolved style with the press rules applied, diffed against the resting one, so variables, specificity and !important resolve exactly as they do now, and the press follows the element's own transition. A disabled element registers nothing, and a registration is sent again only when its styles change.

The element no longer becomes a Pressable, so a tap shows the pressed style only while the finger is down, where the JS path held it for Pressable's 130 ms minimum.

A rule stays on the JS path when it also needs hover or focus, declares variables or containers - except the text-inheritance variables on Text, whose nested runs inherit natively - or when the installed Reanimated has no setPseudoStyles, so an app on a released Reanimated behaves exactly as before.

Tests

src/__tests__/native/ui-thread-pseudo-classes.test.tsx mocks Reanimated's API and asserts what each element registers: an own press, a group press on Text, a resting value React renders, an inline style that outranks the press, a disabled element, the element's transition, an unmount, a rerender that changes nothing, a text colour that stays on the JS path on a view, and a container no element follows.

Verification

On Ubuntu with the workflow's steps - build, lint, typecheck, node --test node/__tests__/*.mjs, the web export, the unstaged-changes checks and commitlint - everything passes, and yarn test passes 1353 of 1354 tests: Typography - Hyphens › hyphens-none failed once in the full run and passes on its own in three runs out of three.

On Android release builds - a Galaxy S23 (Android 14, API 34) and an Android 16 (API 36) emulator, with the Reanimated PR above - active:, and group-active: pressed on the container's padding, painted while the press handler blocked the JS thread for 2.5 s and returned to rest on release, while a view beside each, painted from React state set in the same onPressIn, stayed at rest: in the pressed frames the largest channel change from rest was 208 against 0. The frames are in the evidence comment.

Moving the press to the UI thread costs no mount time. On release builds with both PRs, on the Galaxy S23 and a Mi MIX 3 (Android 10), a hundred Pressables whose active:bg-* registers on the UI thread mounted in 49.1 and 133.1 ms, against 49.2 and 142.3 ms for rows whose press stays on the JS path (written hover:active:bg-*, which keeps it there) and 49.0 and 130.2 ms for rows with no press style - the median of three runs, each the median of eleven mounts. Re-rendering stayed within the runs' spread, and unmounting took 3.2 and 2.6 ms longer than on the JS path.

Not run: the iOS build.

Base

Stacked on #482, whose container touch observation this builds on: the branch is #482's two commits plus one.

…king it pressable

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.
…ends

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.
@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

Device evidence — at rest / pressed with the JS thread blocked

React state — the left card is painted from React state the press sets in onPressIn, the way active: is applied today; while the press blocks the JS thread, it stays at rest.

With this PR — the right card's active: and group-active: classes are registered through Reanimated's setPseudoStyles and paint while the JS thread is blocked.

at rest pressed, JS thread blocked
active:
group-active:, pressed on the container's padding

Android 16 (API 36) emulator at 1140×2400, release build with this PR and the Reanimated PR it needs. The press lands on the right card, or in the second row on the padding of the group/… container around it; its onPressIn sets the left card's React state and then blocks the JS thread for 2.5 s. In the pressed frames the right card's fill differs from rest by 208 in its largest channel, and the left card's by 0. The same check passes on a Galaxy S23 (Android 14, API 34).

@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 15, 2026 13:57

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant