pmndrs / react-spring

✌️ A spring physics based React animation library
http://www.react-spring.dev/
MIT License
28.01k stars 1.19k forks source link

[bug]: incorrect SVG rotation transform generated #2317

Open wchargin opened 2 weeks ago

wchargin commented 2 weeks ago

Which react-spring target are you using?

What version of react-spring are you using?

@react-spring/web@9.7.3

What's Wrong?

I am calling useSpring with transform: "rotate(...)" where the from and to transforms are both valid, but the resulting transform that react-spring gives is rotate(0.566259767453662rotate(, , ), 2rotate(, , ), 20rotate(, , )), which is an invalid value that fails to render properly.

To Reproduce

Download this HTML file and open it in your browser: https://gist.github.com/wchargin/0a49246fcb9792904878013c53987d1f

You'll see the following:

https://github.com/user-attachments/assets/a0b53340-35c7-4a53-8762-c909b237d05a

This HTML file draws a little speedometer needle and animates it from facing left to facing right. But when it gets to facing straight up, the animation stalls and only recovers once it's no longer animating at all:

<!doctype html>

<div id="root"></div>

<script type="module">
  import React, { useEffect, useState } from "https://esm.sh/react@18.2.0";
  import ReactDOM from "https://esm.sh/react-dom@18.2.0";
  import { animated, useSpring } from "https://esm.sh/@react-spring/web@9.7.3";
  import { usePrevious } from "https://esm.sh/@uidotdev/usehooks@2.4.1";

  const USE_SPRING = true;

  const h = React.createElement;

  const rescale = (fromMin, fromMax, speed, toMin, toMax) => {
    const fromRange = fromMax - fromMin;
    const toRange = toMax - toMin;
    return toMin + (speed - fromMin) * (toRange / fromRange);
  };

  const MIN_SPEED = 0;
  const MAX_SPEED = 5;
  const MIN_ANGLE = -90;
  const MAX_ANGLE = 90;
  const NEEDLE_WIDTH = 3.72;
  const NEEDLE_HEIGHT = 22;
  const ROTATION_Y = NEEDLE_HEIGHT - 2;

  const getAngle = (speed) => {
    return rescale(MIN_SPEED, MAX_SPEED, speed, MIN_ANGLE, MAX_ANGLE);
  };

  const App = () => {
    const [speed, setSpeed] = useState(0);
    const prevSpeed = usePrevious(speed);

    useEffect(() => {
      const id = setInterval(() => {
        const delta = 1 / 50;
        setSpeed((speed) => {
          if (speed > 5) {
            clearInterval(id);
          }
          return speed + delta;
        });
      }, 20);
    }, []);

    const springArgs = {
      config: {
        duration: 50,
      },
      from: {
        transform: `rotate(${getAngle(prevSpeed)}, ${Math.round(NEEDLE_WIDTH / 2)}, ${ROTATION_Y})`,
      },
      to: {
        transform: `rotate(${getAngle(speed)}, ${Math.round(NEEDLE_WIDTH / 2)}, ${ROTATION_Y})`,
      },
    };
    console.log(springArgs.from.transform, springArgs.to.transform);
    const animationProps = useSpring(springArgs);

    return h(
      "svg",
      {},
      h(
        "g",
        { transform: "translate(100,0) scale(5)" },
        h(animated.path, {
          width: NEEDLE_WIDTH,
          height: NEEDLE_HEIGHT,
          d: "M1.5,0L1.5,0c0,0,2.3,17.5,2.1,20s-3.1,2.8-3.6,0S1.5,0,1.5,0z",
          transform: USE_SPRING
            ? animationProps.transform
            : springArgs.to.transform,
        }),
      ),
    );
  };

  ReactDOM.createRoot(root).render(h(App));
</script>

You can set USE_SPRING = false to always use the "latest" transform value, which moves properly. This demonstrates that it's not an issue with the input that I'm passing to useSpring.

Tested in Firefox 130 and Chrome 128 on macOS.

Expected Behaviour

The needle should animate smoothly without crashing in the middle.

Link to repo

https://gist.github.com/wchargin/0a49246fcb9792904878013c53987d1f

wchargin commented 2 weeks ago

Extra tags for searching: rotation transform with extra commas and blank values / spaces, rotation transform with very small numbers.

I'm using value = Math.round(value * 100) / 100 as a workaround, but that's obviously pretty shoddy.