diff --git a/.changeset/spring-precision-floor.md b/.changeset/spring-precision-floor.md new file mode 100644 index 0000000000..631de0b5c7 --- /dev/null +++ b/.changeset/spring-precision-floor.md @@ -0,0 +1,5 @@ +--- +'@react-spring/core': patch +--- + +Floor the spring's adaptive precision at the smallest difference doubles can represent around the values being animated. Previously, when a caller's layout math introduced tiny floating-point drift on the target (e.g. `Math.cos(Math.PI / 2)` returning `6e-17` instead of `0`, so a "logical 160" arrived as `159.99999999999997`), the adaptive precision collapsed to a value smaller than any delta the spring could express, so the animation never settled and the awaited `start()` promise never resolved. Closes #2208. diff --git a/packages/core/src/Controller.test.ts b/packages/core/src/Controller.test.ts index 3c26237dba..48b7d101f8 100644 --- a/packages/core/src/Controller.test.ts +++ b/packages/core/src/Controller.test.ts @@ -720,4 +720,31 @@ describe('Controller', () => { expect(onRest).toHaveBeenCalled() }) }) + + // Regression test for https://github.com/pmndrs/react-spring/issues/2208 + it('settles when a target differs from the current value by less than float precision', async () => { + // Caller-side trigonometry produces a tiny floating-point drift: + // `Math.cos(Math.PI / 2)` returns `6.12e-17` rather than exactly `0`, so + // the "logical 160" the caller intended becomes `159.99999999999997`. The + // current value is exactly `160`, and the difference is smaller than what + // doubles can represent at that magnitude — but not `=== 0`, so the + // spring previously entered animation with an unsatisfiable adaptive + // precision and never settled. + const driftedTarget = 160 * Math.cos(Math.PI + Math.PI / 2) + 160 + expect(driftedTarget).not.toBe(160) + expect(Math.abs(driftedTarget - 160)).toBeLessThan(1e-13) + + const ctrl = new Controller({ x: 160, y: 0, scale: 1 }) + + let resolved = false + const promise = ctrl.start({ x: driftedTarget, y: 110, scale: 0 }) + void promise.then(() => (resolved = true)) + + await global.advanceUntilIdle() + await flushMicroTasks() + + expect(resolved).toBe(true) + const result = await promise + expect(result.finished).toBe(true) + }) }) diff --git a/packages/core/src/SpringValue.ts b/packages/core/src/SpringValue.ts index 281f052cd8..2c1b5a3d81 100644 --- a/packages/core/src/SpringValue.ts +++ b/packages/core/src/SpringValue.ts @@ -223,9 +223,20 @@ export class SpringValue extends FrameValue { * TODO: make this value ~0.0001 by default in next breaking change * for more info see – https://github.com/pmndrs/react-spring/issues/1389 */ + // Floor the adaptive default at the smallest difference doubles can + // represent around the values being animated. Without this, callers + // whose layout math introduces floating-point drift (e.g. + // `Math.cos(Math.PI / 2)` returning `6e-17` instead of `0`) produce a + // precision smaller than any delta the spring can express, so the + // spring never settles. See #2208. const precision = config.precision || - (from == to ? 0.005 : Math.min(1, Math.abs(to - from) * 0.001)) + (from == to + ? 0.005 + : Math.max( + Math.max(Math.abs(to), Math.abs(from), 1) * Number.EPSILON, + Math.min(1, Math.abs(to - from) * 0.001) + )) // Duration easing if (!is.und(config.duration)) {