Skip to content

[Web] Fix incorrectly calculated timeDelta#4329

Merged
m-bert merged 1 commit into
mainfrom
@mbert/rotation-fix
Jul 22, 2026
Merged

[Web] Fix incorrectly calculated timeDelta#4329
m-bert merged 1 commit into
mainfrom
@mbert/rotation-fix

Conversation

@m-bert

@m-bert m-bert commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Description

Time delta in Rotation gesture was incorrectly calculated 😢

Test plan

Well, I don't think it is necessary, but repro below:
// 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,
  },
});

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.

Pull request overview

Fixes the web Rotation gesture’s timeDelta calculation so rotation velocity is computed from elapsed time (difference of timestamps) instead of an incorrect sum of absolute timestamps.

Changes:

  • Correct RotationGestureDetector.timeDelta to return currentTime - previousTime, aligning it with how other detectors (e.g. scale/pinch) compute deltas.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@m-bert
m-bert merged commit 5fd06ba into main Jul 22, 2026
3 checks passed
@m-bert
m-bert deleted the @mbert/rotation-fix branch July 22, 2026 13:05
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