Skip to content

Commit 5fd06ba

Browse files
authored
[Web] Fix incorrectly calculated timeDelta (#4329)
## Description Time delta in `Rotation` gesture was incorrectly calculated 😢 ## Test plan <details> <summary>Well, I don't think it is necessary, but repro below:</summary> ```tsx // Repro for web Rotation velocity bug: RotationGestureDetector.timeDelta // returned `currentTime + previousTime` (a sum of two absolute DOM // timestamps) instead of their difference. RotationGestureHandler divides // the rotation delta by it, so the reported velocity was orders of // magnitude too small and kept shrinking the longer the page stayed open. // // The screen logs the velocity reported on rotation events next to a // reference velocity computed on the JS side from rotation deltas and // wall-clock time (both in rad/ms). Rotate with two fingers on a touch // device, or press "Simulate rotation" on desktop web — it dispatches a // synthetic two-finger 90° twist over ~0.7 s. // // Broken: ratio ~0.0001 or less (and drifting down). Fixed: ratio ~1. import React, { useRef, useState } from 'react'; import { Platform, StyleSheet, Text, View } from 'react-native'; import { GestureDetector, GestureHandlerRootView, useRotationGesture, } from 'react-native-gesture-handler'; export default function EmptyExample() { const [log, setLog] = useState<string[]>([]); const reference = useRef<{ rotation: number; time: number } | null>(null); const samples = useRef<{ reported: number; expected: number }[]>([]); // Samples are collected in refs and rendered once when the gesture ends — // a setState per update would re-render mid-gesture and reconfigure the // detector, cutting the rotation short. const rotation = useRotationGesture({ runOnJS: true, onActivate: (e) => { reference.current = { rotation: e.rotation, time: performance.now() }; samples.current = []; }, onUpdate: (e) => { const now = performance.now(); const previous = reference.current; if (!previous || now - previous.time < 30) { return; } const expected = (e.rotation - previous.rotation) / (now - previous.time); reference.current = { rotation: e.rotation, time: now }; if (Math.abs(expected) < 1e-6) { return; } samples.current.push({ reported: e.velocity, expected }); }, onFinalize: () => { const collected = samples.current; if (collected.length === 0) { return; } const mean = (values: number[]) => values.reduce((sum, value) => sum + value, 0) / values.length; const ratio = mean(collected.map((s) => s.reported)) / mean(collected.map((s) => s.expected)); setLog([ '--- rotation finished ---', ...collected .slice(-8) .map( (s) => `velocity=${s.reported.toExponential(3)} ` + `expected≈${s.expected.toExponential(3)} ` + `ratio=${(s.reported / s.expected).toFixed(4)}` ), `=== mean reported/expected ratio: ${ratio.toFixed(4)} ===`, ]); }, }); const simulateRotation = async () => { // Synthetic pointers are not "active pointers", so the browser throws // NotFoundError when the library calls setPointerCapture on them. // Swallow that in the demo — capture is irrelevant here. const globals = window as unknown as { __captureShimmed?: boolean }; if (!globals.__captureShimmed) { globals.__captureShimmed = true; for (const method of [ 'setPointerCapture', 'releasePointerCapture', ] as const) { const original = Element.prototype[method]; Element.prototype[method] = function (pointerId: number) { try { original.call(this, pointerId); } catch { // ignore NotFoundError for synthetic pointers } }; } } // GestureDetector does not forward the child ref on web — locate the // box through its testID instead. const node = document.querySelector<HTMLElement>( '[data-testid="rotationBox"]' ); if (!node) { return; } const rect = node.getBoundingClientRect(); const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; const radius = Math.min(rect.width, rect.height) / 3; const fire = ( type: string, pointerId: number, isPrimary: boolean, angle: number, side: 1 | -1 ) => node.dispatchEvent( new PointerEvent(type, { pointerId, pointerType: 'touch', isPrimary, clientX: centerX + side * radius * Math.cos(angle), clientY: centerY + side * radius * Math.sin(angle), buttons: 1, bubbles: true, cancelable: true, }) ); const nextFrame = () => new Promise<void>((resolve) => { requestAnimationFrame(() => resolve()); }); // Only one finger orbits while the other stays planted: one pointermove // per frame keeps consecutive event timestamps a full frame apart, like // real hardware. Moving both fingers would put two moves in the same // frame ~0.1 ms apart and make per-update velocity noisy. const totalAngle = Math.PI / 2; const steps = 40; fire('pointerdown', 101, true, 0, 1); fire('pointerdown', 102, false, 0, -1); for (let i = 1; i <= steps; i++) { await nextFrame(); fire('pointermove', 101, true, (totalAngle * i) / steps, 1); } fire('pointerup', 101, true, totalAngle, 1); fire('pointerup', 102, false, 0, -1); }; return ( <GestureHandlerRootView style={styles.container}> <Text style={styles.title}>Rotation velocity repro (web)</Text> <Text style={styles.hint}> 90° over ~0.7 s ≈ 2.4e-3 rad/ms.{'\n'} Broken: velocity ~1e-7, ratio ≈ 0. Fixed: ratio ≈ 1. </Text> <GestureDetector gesture={rotation}> <View style={styles.box} testID="rotationBox"> <Text style={styles.boxLabel}>rotate here</Text> </View> </GestureDetector> {Platform.OS === 'web' && ( <Text style={styles.button} onPress={() => { void simulateRotation(); }}> Simulate rotation </Text> )} <View style={styles.log}> {log.map((entry, i) => ( <Text key={i} style={styles.logLine}> {entry} </Text> ))} </View> </GestureHandlerRootView> ); } const styles = StyleSheet.create({ container: { flex: 1, alignItems: 'center', paddingTop: 60, }, title: { fontSize: 16, fontWeight: 'bold', }, hint: { textAlign: 'center', marginVertical: 12, color: '#666', }, box: { width: 260, height: 260, backgroundColor: 'mediumpurple', borderRadius: 16, justifyContent: 'center', alignItems: 'center', }, boxLabel: { color: 'white', fontSize: 18, }, button: { marginTop: 16, paddingVertical: 8, paddingHorizontal: 20, backgroundColor: '#4630eb', color: 'white', borderRadius: 8, overflow: 'hidden', fontSize: 15, }, log: { marginTop: 20, minHeight: 220, alignSelf: 'stretch', paddingHorizontal: 24, }, logLine: { fontFamily: 'monospace', fontSize: 12, }, }); ``` </details>
1 parent f922b3b commit 5fd06ba

1 file changed

Lines changed: 1 addition & 1 deletion

File tree

packages/react-native-gesture-handler/src/web/detectors/RotationGestureDetector.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,6 @@ export default class RotationGestureDetector
178178
}
179179

180180
public get timeDelta() {
181-
return this.currentTime + this.previousTime;
181+
return this.currentTime - this.previousTime;
182182
}
183183
}

0 commit comments

Comments
 (0)