Skip to content

[Android] Clear the pending HOVER_EXIT finish when the hover handler is reset - #4593

Merged
m-bert merged 2 commits into
mainfrom
@mbert/hover-stale-finish-runnable
Oct 14, 2026
Merged

m-bert merged 2 commits into
mainfrom
@mbert/hover-stale-finish-runnable

Conversation

@m-bert

@m-bert m-bert commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator

Description

HoverGestureHandler posts finishRunnable with a 4 ms delay on HOVER_EXIT, so a touch-down (which synthesizes HOVER_EXIT right before ACTION_DOWN) can cancel it and the hover survives the press. The runnable was only ever cleared on ACTION_DOWN. If the handler finished by another route and got reset before the runnable fired, the runnable stayed armed and ended the next hover gesture that was recorded on the same handler.

This shows up when the UI thread stalls while the pointer leaves the window and comes back: the queued HOVER_EXIT, HOVER_ENTER (outside the view) and HOVER_MOVE (inside the view) flush together, the handler finishes, resets and re-activates within the 4 ms window, and the stale runnable then ends the fresh gesture. The view loses its hover state while the pointer is still resting on it, until the next move.

onReset now clears the handler's pending callbacks, the same way TapGestureHandler does.

Test plan

Hover a view with a stylus or mouse on Android, stall the UI thread for ~2 s (e.g. a busy-wait worklet scheduled on the UI runtime), and during the stall move the pointer out of the window, back in outside the view, then onto the view and hold still. Before: the view flickers to its non-hovered state and stays there after the stall. After: it stays hovered.

Tested on the following code
import React, { useCallback, useEffect, useState } from 'react';
import { StyleSheet, Switch, Text, View } from 'react-native';
import { GestureDetector, useHoverGesture } from 'react-native-gesture-handler';
import Animated, {
  Easing,
  useAnimatedStyle,
  useSharedValue,
  withRepeat,
  withTiming,
} from 'react-native-reanimated';
import { scheduleOnRN, scheduleOnUI } from 'react-native-worklets';

// Repro: Android HoverGestureHandler.onReset doesn't clear the 4 ms finishRunnable
// posted on HOVER_EXIT. Steps (mouse on Android):
//  1. enable the periodic stall, hover the box
//  2. while the spinner is frozen: move into the status bar and back onto the box, stop there
//  3. after the stall the box should stay highlighted - with the bug it goes dark
//     and the log shows `finalize` a few ms after `activate` (marked with <<<).

const STALL_MS = 2000;
const PERIOD_MS = 6000;

type Entry = { t: number; name: string };

function stallUiThread(ms: number) {
  'worklet';
  const end = Date.now() + ms;
  while (Date.now() < end) {
    // busy-wait on the UI thread so input events queue up
  }
}

export default function EmptyExample() {
  const active = useSharedValue(0);
  const spin = useSharedValue(0);
  const [log, setLog] = useState<Entry[]>([]);
  const [cursor, setCursor] = useState('-');
  const [stallEnabled, setStallEnabled] = useState(false);
  const [countdown, setCountdown] = useState(0);

  const addLog = useCallback((name: string, t: number) => {
    setLog((prev) => [...prev.slice(-13), { t, name }]);
  }, []);

  useEffect(() => {
    spin.value = withRepeat(
      withTiming(360, { duration: 1500, easing: Easing.linear }),
      -1,
      false
    );
  }, [spin]);

  useEffect(() => {
    if (!stallEnabled) {
      setCountdown(0);
      return;
    }
    let next = Date.now() + PERIOD_MS;
    const id = setInterval(() => {
      const left = next - Date.now();
      if (left <= 0) {
        next = Date.now() + PERIOD_MS;
        addLog('stall', Date.now());
        scheduleOnUI(stallUiThread, STALL_MS);
      }
      setCountdown(Math.max(0, Math.ceil(left / 1000)));
    }, 250);
    return () => clearInterval(id);
  }, [stallEnabled, addLog]);

  const boxHover = useHoverGesture({
    onBegin: () => {
      scheduleOnRN(addLog, 'begin', Date.now());
    },
    onActivate: () => {
      active.value = 1;
      scheduleOnRN(addLog, 'activate', Date.now());
    },
    onDeactivate: () => {
      scheduleOnRN(addLog, 'deactivate', Date.now());
    },
    onFinalize: () => {
      active.value = 0;
      scheduleOnRN(addLog, 'finalize', Date.now());
    },
  });

  const cursorHover = useHoverGesture({
    onUpdate: (e) => {
      scheduleOnRN(
        setCursor,
        `${Math.round(e.absoluteX)}, ${Math.round(e.absoluteY)}`
      );
    },
  });

  const boxStyle = useAnimatedStyle(() => ({
    backgroundColor: active.value ? '#2e7d32' : '#263238',
  }));
  const spinStyle = useAnimatedStyle(() => ({
    transform: [{ rotate: `${spin.value}deg` }],
  }));

  return (
    <GestureDetector gesture={cursorHover}>
      <View style={styles.container}>
        <View style={styles.row}>
          <Switch value={stallEnabled} onValueChange={setStallEnabled} />
          <Text style={styles.text}>
            {stallEnabled
              ? countdown > 0
                ? `UI stall in ${countdown}s`
                : 'UI STALLED'
              : 'Periodic 2s UI stall'}
          </Text>
          <Animated.View style={[styles.spinner, spinStyle]} />
        </View>
        <Text style={styles.text}>cursor (dp): {cursor}</Text>
        <GestureDetector gesture={boxHover}>
          <Animated.View style={[styles.box, boxStyle]} />
        </GestureDetector>
        <View style={styles.log}>
          {log.map((e, i) => {
            const prev = log[i - 1];
            const dt = prev ? e.t - prev.t : 0;
            const stale =
              e.name === 'finalize' && prev?.name === 'activate' && dt < 20;
            return (
              <Text key={e.t + e.name + i} style={styles.logLine}>
                {`+${dt} ms  ${e.name}${stale ? '   <<< stale finish' : ''}`}
              </Text>
            );
          })}
        </View>
      </View>
    </GestureDetector>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    paddingTop: 24,
  },
  row: {
    flexDirection: 'row',
    alignItems: 'center',
    gap: 12,
  },
  text: {
    fontSize: 16,
  },
  spinner: {
    width: 20,
    height: 20,
    backgroundColor: '#ef6c00',
  },
  box: {
    width: 200,
    height: 200,
    marginVertical: 24,
    borderRadius: 12,
  },
  log: {
    alignSelf: 'stretch',
    paddingHorizontal: 16,
  },
  logLine: {
    fontFamily: 'monospace',
    fontSize: 13,
  },
});

Copilot AI balanced review requested due to automatic review settings October 13, 2026 11:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Important

Review skipped

Review was skipped as selected files did not have any reviewable changes.

⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: dbc05f8b-dfea-4254-9377-646224dcb6d0
📥 Commits

Reviewing files that changed from the base of the PR and between b053a5a and d414471.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 3158d753-1fbc-4fa6-8847-4cc7f07d484f
📥 Commits

Reviewing files that changed from the base of the PR and between 53da095 and b053a5a.

📒 Files selected for processing (1)
  • packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/HoverGestureHandler.kt

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 6 remain after this review.

📝 Summary

Summary by CodeRabbit

  • Bug Fixes
    • Pending callbacks and messages are now cleared when hover gesture handling is reset. This prevents scheduled work from carrying over after a reset and improves the reliability of subsequent hover interactions.

HoverGestureHandler.onReset() now removes pending callbacks and messages from its handler after resetting stylus data.

Changes: Hover gesture reset

Layer / File(s) Summary
Clear pending handler work
packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/HoverGestureHandler.kt
onReset() removes pending callbacks and messages from the handler after resetting stylus data.

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to b053a

Reset cancels the stale hover-finish callback without affecting unrelated work, so no merge-blocking issue is identified.

Pre-merge checks: Passed 3
Passed Passed checks (3 passed)
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: clearing the pending HOVER_EXIT finish when HoverGestureHandler resets.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@m-bert
m-bert requested a review from j-piasecki October 13, 2026 11:54
@m-bert
m-bert merged commit cdb3c9d into main Oct 14, 2026
5 checks passed
@m-bert
m-bert deleted the @mbert/hover-stale-finish-runnable branch October 14, 2026 09:39
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.

3 participants