FormidableLabs / FormidableLabs/victory-native-xl
Bug: CartesianChart can replay stale bootstrapped touch after quick tap on iOS
- 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
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