Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/spring-precision-floor.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 27 additions & 0 deletions packages/core/src/Controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
13 changes: 12 additions & 1 deletion packages/core/src/SpringValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,9 +223,20 @@ export class SpringValue<T = any> extends FrameValue<T> {
* 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)) {
Expand Down
Loading