Skip to content

Commit 831a1bd

Browse files
authored
fix(core): floor adaptive precision at float resolution to let springs settle on sub-precision drift (#2520)
1 parent 79804ba commit 831a1bd

3 files changed

Lines changed: 44 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@react-spring/core': patch
3+
---
4+
5+
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.

packages/core/src/Controller.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -720,4 +720,31 @@ describe('Controller', () => {
720720
expect(onRest).toHaveBeenCalled()
721721
})
722722
})
723+
724+
// Regression test for https://github.com/pmndrs/react-spring/issues/2208
725+
it('settles when a target differs from the current value by less than float precision', async () => {
726+
// Caller-side trigonometry produces a tiny floating-point drift:
727+
// `Math.cos(Math.PI / 2)` returns `6.12e-17` rather than exactly `0`, so
728+
// the "logical 160" the caller intended becomes `159.99999999999997`. The
729+
// current value is exactly `160`, and the difference is smaller than what
730+
// doubles can represent at that magnitude — but not `=== 0`, so the
731+
// spring previously entered animation with an unsatisfiable adaptive
732+
// precision and never settled.
733+
const driftedTarget = 160 * Math.cos(Math.PI + Math.PI / 2) + 160
734+
expect(driftedTarget).not.toBe(160)
735+
expect(Math.abs(driftedTarget - 160)).toBeLessThan(1e-13)
736+
737+
const ctrl = new Controller({ x: 160, y: 0, scale: 1 })
738+
739+
let resolved = false
740+
const promise = ctrl.start({ x: driftedTarget, y: 110, scale: 0 })
741+
void promise.then(() => (resolved = true))
742+
743+
await global.advanceUntilIdle()
744+
await flushMicroTasks()
745+
746+
expect(resolved).toBe(true)
747+
const result = await promise
748+
expect(result.finished).toBe(true)
749+
})
723750
})

packages/core/src/SpringValue.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,9 +223,20 @@ export class SpringValue<T = any> extends FrameValue<T> {
223223
* TODO: make this value ~0.0001 by default in next breaking change
224224
* for more info see – https://github.com/pmndrs/react-spring/issues/1389
225225
*/
226+
// Floor the adaptive default at the smallest difference doubles can
227+
// represent around the values being animated. Without this, callers
228+
// whose layout math introduces floating-point drift (e.g.
229+
// `Math.cos(Math.PI / 2)` returning `6e-17` instead of `0`) produce a
230+
// precision smaller than any delta the spring can express, so the
231+
// spring never settles. See #2208.
226232
const precision =
227233
config.precision ||
228-
(from == to ? 0.005 : Math.min(1, Math.abs(to - from) * 0.001))
234+
(from == to
235+
? 0.005
236+
: Math.max(
237+
Math.max(Math.abs(to), Math.abs(from), 1) * Number.EPSILON,
238+
Math.min(1, Math.abs(to - from) * 0.001)
239+
))
229240

230241
// Duration easing
231242
if (!is.und(config.duration)) {

0 commit comments

Comments
 (0)