FormidableLabs / FormidableLabs/victory-native-xl

Bug: CartesianChart can replay stale bootstrapped touch after quick tap on iOS

Open
#656 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
1.2k
Forks
109
Avg merge
1d 15h
Merged PRs (30d)
3

Description

### Prerequisites

- [x] I have searched the open [issues](https://www.github.com/FormidableLabs/victory-native-xl/issues) to make sure I'm not opening a duplicate issue
- [x] I have read through the [docs](https://www.formidable.com/open-source/victory-native-xl/docs) before asking a question
- [x] I am using the latest version of victory-native-xl

### Describe Your Environment

**What version of victory-native-xl are you using?** (can be found by running `npm list --depth 0 victory-native`)

`victory-native@41.20.0`

I also checked the `41.20.3` source and the same `gestureState.bootstrap` lifecycle logic appears to still be present.

**What version of React and React Native are you using?**

React: `19.2.0`
React Native: `0.83.2`

**What version of Reanimated and React Native Skia are you using?**

React Native Reanimated: `4.2.1`
React Native Skia: `2.4.18`

**Are you using Expo or React Native CLI?**

Expo dev build.

**What platform are you on?** (e.g., iOS, Android)

iOS. We have not observed this behavior on Android.

### Describe the Problem

There appears to be a race condition in `CartesianChart` when using `chartPressState` together with `chartPressConfig.pan.activateAfterLongPress`.

For very quick taps around the long-press activation threshold, iOS / RNGH can emit callbacks in this order:

```text
onTouchesDown -> onTouchesUp -> onStart
```

Current `CartesianChart` stores the touch in `gestureState.bootstrap` during `onTouchesDown` while the pan gesture is not active yet.

If the finger is released before the pan gesture becomes active, `onTouchesUp` clears `touchMap`, but it does not remove that released touch from `gestureState.bootstrap`.

As a result, a later `onStart` can replay a stale bootstrapped touch through `handleTouch`. This can set `chartPressState.isActive` to `true` and update `matchedIndex` even though there are no active touches anymore.

In our app this causes the selected marker / active press indicator to briefly jump to an unintended point, jump back, or sometimes remain visually active after the finger has already been released.

**A video from our app showing the visible issue with the selected marker / crosshair state.**

https://github.com/user-attachments/assets/3ab6d350-8416-4a5c-b375-1721182ad498

**A video from the example app where I repeatedly tap the chart and the logs show `isActive: true` after the touch was already released.**

Source Code (ordinal-data.tsx)

```tsx
import * as React from "react";
import { CartesianChart, Line, useChartPressState } from "victory-native";
import {
Circle,
LinearGradient,
useFont,
vec,
} from "@shopify/react-native-skia";
import { SafeAreaView, ScrollView, StyleSheet, View } from "react-native";
import {
interpolateColor,
useDerivedValue,
useSharedValue,
} from "react-native-reanimated";
import { useEffect } from "react";
import { useDarkMode } from "react-native-dark";
import inter from "../assets/inter-medium.ttf";
import { appColors } from "../consts/colors";
import { InfoCard } from "../components/InfoCard";
import { AnimatedText } from "../components/AnimatedText";
import { Text } from "../components/Text";
import { descriptionForRoute } from "../consts/routes";

const colors = [appColors.tint, "#818cf8"];

export default function OrdinalDataScreen(props: { segment: string }) {
const description = descriptionForRoute(props.segment);
const font = useFont(inter, 12);
const { state } = useChartPressState({ x: "Nothing", y: { high: 0 } });
const activeX = state.x.value;
const day = useDerivedValue(() => activeX.value || "");
const isDark = useDarkMode();

return (


i || "",
formatYLabel: (i) => `${i}°`,
tickCount: { x: 7, y: 10 },
lineColor: isDark ? "#71717a" : "#d4d4d8",
labelColor: isDark ? appColors.text.dark : appColors.text.light,
}}
chartPressState={state}
chartPressConfig={{ pan: { activateAfterLongPress: 50 } }}
>
{({ chartBounds, points, yScale }) => {
return (
<>


{points.high.map(({ x, y, yValue }) => (

))}

);
}}




is selected.

{" "}
isActive is {state.isActive.value.toString()}.



{description}


);
}

const AnimatedCircle = ({
x,
y,
radius,
color,
}: {
x: number;
y: number;
radius: number;
color: string;
}) => {
const animatedRadius = useSharedValue(0);
useEffect(() => {
animatedRadius.value = radius;
}, [animatedRadius, radius]);
return ;
};

const DATA = [
{ day: "Mon", high: 50 + 20 * Math.random() },
{ day: "Tue", high: 50 + 20 * Math.random() },
{ day: "Wed", high: 50 + 20 * Math.random() },
{ day: "Thu", high: 50 + 20 * Math.random() },
{ day: "Fri", high: 50 + 20 * Math.random() },
{ day: "Sat", high: 50 + 20 * Math.random() },
{ day: "Sun", high: 50 + 20 * Math.random() },
];

const styles = StyleSheet.create({
safeView: {
flex: 1,
backgroundColor: appColors.viewBackground.light,
$dark: {
backgroundColor: appColors.viewBackground.dark,
},
},
chart: {
flex: 1,
},
selectionContainer: {
flexDirection: "row",
padding: 10,
justifyContent: "center",
},
animatedText: {
fontSize: 18,
color: appColors.text.light,
$dark: {
color: appColors.text.dark,
},
},
optionsScrollView: {
flex: 1,
backgroundColor: appColors.cardBackground.light,
$dark: {
backgroundColor: appColors.cardBackground.dark,
},
},
options: {
paddingHorizontal: 20,
paddingVertical: 15,
alignItems: "flex-start",
justifyContent: "flex-start",
},
});

```

https://github.com/user-attachments/assets/f9eaffe2-3ecc-4058-92d9-7173d3f6bdba

**Logs from the example app showing the race.**

```text
LOG [CartesianChart press race] onTouchesDown:before {"allTouches": [{"absoluteX": 264.66666666666663, "absoluteY": 357.3333333333333, "id": 0, "x": 264.66666666666663, "y": 241.33333333333331}], "bootstrapLength": 0, "bootstrapTouchIds": [], "changedTouches": [{"absoluteX": 264.66666666666663, "absoluteY": 357.3333333333333, "id": 0, "x": 264.66666666666663, "y": 241.33333333333331}], "isGestureActive": false, "numberOfTouches": 1, "pressStates": [{"index": 0, "isActive": false, "matchedIndex": 1, "xPosition": 116.24635850624621, "xValue": "Tue", "yIndex": 0}], "touchMap": {"0": undefined}}
LOG [CartesianChart press race] onTouchesDown:after {"allTouches": [{"absoluteX": 264.66666666666663, "absoluteY": 357.3333333333333, "id": 0, "x": 264.66666666666663, "y": 241.33333333333331}], "bootstrapLength": 1, "bootstrapTouchIds": [0], "changedTouches": [{"absoluteX": 264.66666666666663, "absoluteY": 357.3333333333333, "id": 0, "x": 264.66666666666663, "y": 241.33333333333331}], "isGestureActive": false, "numberOfTouches": 1, "pressStates": [{"index": 0, "isActive": false, "matchedIndex": 1, "xPosition": 116.24635850624621, "xValue": "Tue", "yIndex": 0}], "touchMap": {"0": undefined}}
LOG [CartesianChart press race] onTouchesUp:before {"allTouches": [{"absoluteX": 264.66666666666663, "absoluteY": 357.3333333333333, "id": 0, "x": 264.66666666666663, "y": 241.33333333333331}], "bootstrapLength": 1, "bootstrapTouchIds": [0], "changedTouches": [{"absoluteX": 264.66666666666663, "absoluteY": 357.3333333333333, "id": 0, "x": 264.66666666666663, "y": 241.33333333333331}], "isGestureActive": false, "numberOfTouches": 0, "pressStates": [{"index": 0, "isActive": false, "matchedIndex": 1, "xPosition": 116.24635850624621, "xValue": "Tue", "yIndex": 0}], "touchMap": {"0": undefined}}
LOG [CartesianChart press race] onTouchesUp:after {"allTouches": [{"absoluteX": 264.66666666666663, "absoluteY": 357.3333333333333, "id": 0, "x": 264.66666666666663, "y": 241.33333333333331}], "bootstrapLength": 1, "bootstrapTouchIds": [0], "changedTouches": [{"absoluteX": 264.66666666666663, "absoluteY": 357.3333333333333, "id": 0, "x": 264.66666666666663, "y": 241.33333333333331}], "isGestureActive": false, "numberOfTouches": 0, "pressStates": [{"index": 0, "isActive": false, "matchedIndex": 1, "xPosition": 116.24635850624621, "xValue": "Tue", "yIndex": 0}], "touchMap": {"0": undefined}}
LOG [CartesianChart press race] onFinalize:before {"allTouches": [], "bootstrapLength": 1, "bootstrapTouchIds": [0], "changedTouches": [], "isGestureActive": false, "numberOfTouches": undefined, "pressStates": [{"index": 0, "isActive": false, "matchedIndex": 1, "xPosition": 116.24635850624621, "xValue": "Tue", "yIndex": 0}], "touchMap": {"0": undefined}}
LOG [CartesianChart press race] onFinalize:after {"allTouches": [], "bootstrapLength": 0, "bootstrapTouchIds": [], "changedTouches": [], "isGestureActive": false, "numberOfTouches": undefined, "pressStates": [{"index": 0, "isActive": false, "matchedIndex": 1, "xPosition": 116.24635850624621, "xValue": "Tue", "yIndex": 0}], "touchMap": {"0": undefined}}
LOG [CartesianChart press race] onTouchesDown:before {"allTouches": [{"absoluteX": 264.66666666666663, "absoluteY": 357.3333333333333, "id": 0, "x": 264.66666666666663, "y": 241.33333333333331}], "bootstrapLength": 0, "bootstrapTouchIds": [], "changedTouches": [{"absoluteX": 264.66666666666663, "absoluteY": 357.3333333333333, "id": 0, "x": 264.66666666666663, "y": 241.33333333333331}], "isGestureActive": false, "numberOfTouches": 1, "pressStates": [{"index": 0, "isActive": false, "matchedIndex": 1, "xPosition": 116.24635850624621, "xValue": "Tue", "yIndex": 0}], "touchMap": {"0": undefined}}
LOG [CartesianChart press race] onTouchesDown:after {"allTouches": [{"absoluteX": 264.66666666666663, "absoluteY": 357.3333333333333, "id": 0, "x": 264.66666666666663, "y": 241.33333333333331}], "bootstrapLength": 1, "bootstrapTouchIds": [0], "changedTouches": [{"absoluteX": 264.66666666666663, "absoluteY": 357.3333333333333, "id": 0, "x": 264.66666666666663, "y": 241.33333333333331}], "isGestureActive": false, "numberOfTouches": 1, "pressStates": [{"index": 0, "isActive": false, "matchedIndex": 1, "xPosition": 116.24635850624621, "xValue": "Tue", "yIndex": 0}], "touchMap": {"0": undefined}}
LOG [CartesianChart press race] onTouchesUp:before {"allTouches": [{"absoluteX": 264.66666666666663, "absoluteY": 357.3333333333333, "id": 0, "x": 264.66666666666663, "y": 241.33333333333331}], "bootstrapLength": 1, "bootstrapTouchIds": [0], "changedTouches": [{"absoluteX": 264.66666666666663, "absoluteY": 357.3333333333333, "id": 0, "x": 264.66666666666663, "y": 241.33333333333331}], "isGestureActive": false, "numberOfTouches": 0, "pressStates": [{"index": 0, "isActive": false, "matchedIndex": 1, "xPosition": 116.24635850624621, "xValue": "Tue", "yIndex": 0}], "touchMap": {"0": undefined}}
LOG [CartesianChart press race] onTouchesUp:after {"allTouches": [{"absoluteX": 264.66666666666663, "absoluteY": 357.3333333333333, "id": 0, "x": 264.66666666666663, "y": 241.33333333333331}], "bootstrapLength": 1, "bootstrapTouchIds": [0], "changedTouches": [{"absoluteX": 264.66666666666663, "absoluteY": 357.3333333333333, "id": 0, "x": 264.66666666666663, "y": 241.33333333333331}], "isGestureActive": false, "numberOfTouches": 0, "pressStates": [{"index": 0, "isActive": false, "matchedIndex": 1, "xPosition": 116.24635850624621, "xValue": "Tue", "yIndex": 0}], "touchMap": {"0": undefined}}
LOG [CartesianChart press race] onFinalize:before {"allTouches": [], "bootstrapLength": 1, "bootstrapTouchIds": [0], "changedTouches": [], "isGestureActive": false, "numberOfTouches": undefined, "pressStates": [{"index": 0, "isActive": false, "matchedIndex": 1, "xPosition": 116.24635850624621, "xValue": "Tue", "yIndex": 0}], "touchMap": {"0": undefined}}
LOG [CartesianChart press race] onFinalize:after {"allTouches": [], "bootstrapLength": 0, "bootstrapTouchIds": [], "changedTouches": [], "isGestureActive": false, "numberOfTouches": undefined, "pressStates": [{"index": 0, "isActive": false, "matchedIndex": 1, "xPosition": 116.24635850624621, "xValue": "Tue", "yIndex": 0}], "touchMap": {"0": undefined}}
LOG [CartesianChart press race] onTouchesDown:before {"allTouches": [{"absoluteX": 334, "absoluteY": 266.3333333333333, "id": 0, "x": 334, "y": 150.33333333333331}], "bootstrapLength": 0, "bootstrapTouchIds": [], "changedTouches": [{"absoluteX": 334, "absoluteY": 266.3333333333333, "id": 0, "x": 334, "y": 150.33333333333331}], "isGestureActive": false, "numberOfTouches": 1, "pressStates": [{"index": 0, "isActive": false, "matchedIndex": 1, "xPosition": 116.24635850624621, "xValue": "Tue", "yIndex": 0}], "touchMap": {"0": undefined}}
LOG [CartesianChart press race] onTouchesDown:after {"allTouches": [{"absoluteX": 334, "absoluteY": 266.3333333333333, "id": 0, "x": 334, "y": 150.33333333333331}], "bootstrapLength": 1, "bootstrapTouchIds": [0], "changedTouches": [{"absoluteX": 334, "absoluteY": 266.3333333333333, "id": 0, "x": 334, "y": 150.33333333333331}], "isGestureActive": false, "numberOfTouches": 1, "pressStates": [{"index": 0, "isActive": false, "matchedIndex": 1, "xPosition": 116.24635850624621, "xValue": "Tue", "yIndex": 0}], "touchMap": {"0": undefined}}
LOG [CartesianChart press race] onTouchesUp:before {"allTouches": [{"absoluteX": 334, "absoluteY": 266.3333333333333, "id": 0, "x": 334, "y": 150.33333333333331}], "bootstrapLength": 1, "bootstrapTouchIds": [0], "changedTouches": [{"absoluteX": 334, "absoluteY": 266.3333333333333, "id": 0, "x": 334, "y": 150.33333333333331}], "isGestureActive": false, "numberOfTouches": 0, "pressStates": [{"index": 0, "isActive": false, "matchedIndex": 1, "xPosition": 116.24635850624621, "xValue": "Tue", "yIndex": 0}], "touchMap": {"0": undefined}}
LOG [CartesianChart press race] onTouchesUp:after {"allTouches": [{"absoluteX": 334, "absoluteY": 266.3333333333333, "id": 0, "x": 334, "y": 150.33333333333331}], "bootstrapLength": 1, "bootstrapTouchIds": [0], "changedTouches": [{"absoluteX": 334, "absoluteY": 266.3333333333333, "id": 0, "x": 334, "y": 150.33333333333331}], "isGestureActive": false, "numberOfTouches": 0, "pressStates": [{"index": 0, "isActive": false, "matchedIndex": 1, "xPosition": 116.24635850624621, "xValue": "Tue", "yIndex": 0}], "touchMap": {"0": undefined}}
LOG [CartesianChart press race] onFinalize:before {"allTouches": [], "bootstrapLength": 1, "bootstrapTouchIds": [0], "changedTouches": [], "isGestureActive": false, "numberOfTouches": undefined, "pressStates": [{"index": 0, "isActive": false, "matchedIndex": 1, "xPosition": 116.24635850624621, "xValue": "Tue", "yIndex": 0}], "touchMap": {"0": undefined}}
LOG [CartesianChart press race] onFinalize:after {"allTouches": [], "bootstrapLength": 0, "bootstrapTouchIds": [], "changedTouches": [], "isGestureActive": false, "numberOfTouches": undefined, "pressStates": [{"index": 0, "isActive": false, "matchedIndex": 1, "xPosition": 116.24635850624621, "xValue": "Tue", "yIndex": 0}], "touchMap": {"0": undefined}}
LOG [CartesianChart press race] onTouchesDown:before {"allTouches": [{"absoluteX": 334, "absoluteY": 266.3333333333333, "id": 0, "x": 334, "y": 150.33333333333331}], "bootstrapLength": 0, "bootstrapTouchIds": [], "changedTouches": [{"absoluteX": 334, "absoluteY": 266.3333333333333, "id": 0, "x": 334, "y": 150.33333333333331}], "isGestureActive": false, "numberOfTouches": 1, "pressStates": [{"index": 0, "isActive": false, "matchedIndex": 1, "xPosition": 116.24635850624621, "xValue": "Tue", "yIndex": 0}], "touchMap": {"0": undefined}}
LOG [CartesianChart press race] onTouchesDown:after {"allTouches": [{"absoluteX": 334, "absoluteY": 266.3333333333333, "id": 0, "x": 334, "y": 150.33333333333331}], "bootstrapLength": 1, "bootstrapTouchIds": [0], "changedTouches": [{"absoluteX": 334, "absoluteY": 266.3333333333333, "id": 0, "x": 334, "y": 150.33333333333331}], "isGestureActive": false, "numberOfTouches": 1, "pressStates": [{"index": 0, "isActive": false, "matchedIndex": 1, "xPosition": 116.24635850624621, "xValue": "Tue", "yIndex": 0}], "touchMap": {"0": undefined}}
LOG [CartesianChart press race] onTouchesUp:before {"allTouches": [{"absoluteX": 334, "absoluteY": 266.3333333333333, "id": 0, "x": 334, "y": 150.33333333333331}], "bootstrapLength": 1, "bootstrapTouchIds": [0], "changedTouches": [{"absoluteX": 334, "absoluteY": 266.3333333333333, "id": 0, "x": 334, "y": 150.33333333333331}], "isGestureActive": false, "numberOfTouches": 0, "pressStates": [{"index": 0, "isActive": false, "matchedIndex": 1, "xPosition": 116.24635850624621, "xValue": "Tue", "yIndex": 0}], "touchMap": {"0": undefined}}
LOG [CartesianChart press race] onTouchesUp:after {"allTouches": [{"absoluteX": 334, "absoluteY": 266.3333333333333, "id": 0, "x": 334, "y": 150.33333333333331}], "bootstrapLength": 1, "bootstrapTouchIds": [0], "changedTouches": [{"absoluteX": 334, "absoluteY": 266.3333333333333, "id": 0, "x": 334, "y": 150.33333333333331}], "isGestureActive": false, "numberOfTouches": 0, "pressStates": [{"index": 0, "isActive": false, "matchedIndex": 1, "xPosition": 116.24635850624621, "xValue": "Tue", "yIndex": 0}], "touchMap": {"0": undefined}}
LOG [CartesianChart press race] onStart:before {"allTouches": [], "bootstrapLength": 1, "bootstrapTouchIds": [0], "changedTouches": [], "isGestureActive": false, "numberOfTouches": undefined, "pressStates": [{"index": 0, "isActive": false, "matchedIndex": 1, "xPosition": 116.24635850624621, "xValue": "Tue", "yIndex": 0}], "touchMap": {"0": undefined}}
LOG [CartesianChart press race] onStart:after {"allTouches": [], "bootstrapLength": 1, "bootstrapTouchIds": [0], "changedTouches": [], "isGestureActive": true, "numberOfTouches": undefined, "pressStates": [{"index": 0, "isActive": true, "matchedIndex": 5, "xPosition": 310.70676697059093, "xValue": "Sat", "yIndex": 0}], "touchMap": {"0": 0}}

```

The important part in the logs is this state:

```text
onTouchesUp:after
numberOfTouches: 0
isGestureActive: false
bootstrapLength: 1
bootstrapTouchIds: [0]

onStart:before
allTouches: []
isGestureActive: false
bootstrapLength: 1
bootstrapTouchIds: [0]
```

At this point the finger has already been released and there are no active touches, but `gestureState.bootstrap` still contains the released touch. The next `onStart` then replays it.

**Expected behavior:** [What you expect to happen]

A touch that has already been released before pan activation should not be replayed from `gestureState.bootstrap`.

After `onTouchesUp` with `numberOfTouches: 0`, there should not be any stale bootstrapped touches left that can later activate `chartPressState`.

**Actual behavior:** [What actually happens]

The released touch remains in `gestureState.bootstrap`.

If `onStart` fires afterwards, it replays that stale touch via `handleTouch`, which can update `matchedIndex` and set `chartPressState.isActive` to `true` even though the finger is no longer down.

### Additional Information

The issue seems to be in the small state machine around `gestureState.bootstrap` inside `CartesianChart`.

A possible fix:

1. In `onTouchesUp`, if the pan gesture is not active yet, remove the released touches from `gestureState.bootstrap`.
2. If `onTouchesUp` reports `numberOfTouches === 0`, clear `gestureState.bootstrap` entirely because there is no finger left that can validly activate the pending press.
3. In `onStart`, consume `gestureState.bootstrap` once by copying it to a local variable and clearing it before replaying entries. This prevents delayed or repeated `onStart` callbacks from replaying the same stale touch.
4. As a safety net, `onFinalize` can also reset `isActive` for active press states, so cancelled / failed gestures do not leave the chart in an active visual state.

The core invariant is: `onStart` should only replay bootstrapped touches that are still down. A touch released before activation should be invalidated before `onStart` can process it.

Contributor guide

Open the contributing guide

Research direction

Start at the CartesianChart touch-handling entry points, especially gestureState.bootstrap and the onTouchesDown, onTouchesUp, onStart, and onFinalize flow. Reproduce the quick-tap sequence on iOS with chartPressConfig.pan.activateAfterLongPress; done means a released touch is not replayed and chartPressState does not become active or change matchedIndex without an active touch.

Written by the indexing model from the issue text.

Assessment

Tech stack
ios, react-native, typescript
Domain
frontend, mobile
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.