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/preserve-string-precision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@react-spring/shared': patch
---

Preserve decimal precision when interpolating between stringified numbers. Previously, `useSpring({ from: '0.00', to: '1.50' })` would render `'0'` at rest and lose precision mid-tween. The string interpolator now returns each keyframe verbatim when the input lands exactly on a range value, and pads mid-animation results to the shared decimal count of the keyframes (when every keyframe has the same non-zero fractional length). Closes #1461.
94 changes: 94 additions & 0 deletions packages/shared/src/createInterpolator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,100 @@ describe('Interpolation', () => {
expect(interpolation(0.5)).toBe('grayscale(50%)')
})

// https://github.com/pmndrs/react-spring/issues/1461
describe('preserves keyframe formatting at exact range values', () => {
it('keeps trailing zeros on floats', () => {
const interpolation = createInterpolator({
range: [0, 1],
output: ['0.00', '1.50'],
})

expect(interpolation(0)).toBe('0.00')
expect(interpolation(1)).toBe('1.50')
})

it('keeps trailing zeros across multi-stop ranges', () => {
const interpolation = createInterpolator({
range: [0, 0.5, 1],
output: ['0.00', '0.50', '1.00'],
})

expect(interpolation(0)).toBe('0.00')
expect(interpolation(0.5)).toBe('0.50')
expect(interpolation(1)).toBe('1.00')
})

it('keeps non-numeric template text intact', () => {
const interpolation = createInterpolator({
range: [0, 1],
output: ['0 kr', '1.500 kr'],
})

expect(interpolation(0)).toBe('0 kr')
expect(interpolation(1)).toBe('1.500 kr')
})
})

// https://github.com/pmndrs/react-spring/issues/1461
describe('preserves decimal precision mid-animation', () => {
it('pads to the shared decimal count of the keyframes', () => {
const interpolation = createInterpolator({
range: [0, 1],
output: ['0.00', '1.50'],
})

// value(0.4) = 0.6 — without padding this would be '0.6'
expect(interpolation(0.4)).toBe('0.60')
// value(0.2) = 0.3 — without padding this would be '0.3'
expect(interpolation(0.2)).toBe('0.30')
})

it('rounds when the raw value has more precision than the template', () => {
const interpolation = createInterpolator({
range: [0, 1],
output: ['0.00', '1.00'],
})

// value(1/3) ≈ 0.333… — round to 2 dp
expect(interpolation(1 / 3)).toBe('0.33')
})

it('pads each number-position independently', () => {
const interpolation = createInterpolator({
range: [0, 1],
output: ['0.00 0px', '1.50 100px'],
})

// pos 0: decimals match (2,2) → pad to 2
// pos 1: decimals match (0,0) → pad to 0
expect(interpolation(0.5)).toBe('0.75 50px')
expect(interpolation(0.4)).toBe('0.60 40px')
})

it('skips padding when keyframes have differing decimal counts', () => {
// Regression guard: the existing '-100.5deg → 100deg' contract must
// keep emitting '-0.25deg' at the midpoint, not '-0.3deg'.
const interpolation = createInterpolator({
range: [0, 1],
output: ['-100.5deg', '100deg'],
})

expect(interpolation(0.5)).toBe('-0.25deg')
})

it('does not pad whole-number keyframes (preserves fractional alpha)', () => {
// Regression guard: rgba alpha goes 0 → 1 as whole numbers, but
// mid-animation we must keep the fractional value (e.g. 0.5),
// otherwise toFixed(0) would force-round it to 1.
const interpolation = createInterpolator({
range: [0, 1],
output: ['rgba(0, 0, 0, 0)', 'rgba(3, 3, 3, 1)'],
})

expect(interpolation(0.5)).toBe('rgba(2, 2, 2, 0.5)')
})
})

describe('CSS Variables', () => {
const originalGetComputedStyle = window.getComputedStyle

Expand Down
35 changes: 31 additions & 4 deletions packages/shared/src/stringInterpolation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,19 +67,46 @@ export const createStringInterpolator = (
createInterpolator({ ...config, output })
)

const inputRange = config.range || [0, 1]

// Per number-position, the shared fractional-digit count across all
// keyframes — or `null` when keyframes disagree or every keyframe is
// a whole number. Whole-number keyframes are left untouched so that
// values like the alpha channel in `rgba(…, 0)` → `rgba(…, 1)` keep
// their natural sub-frame precision.
const decimalCounts = output[0].match(numberRegex)!.map((_, pos) => {
const counts = output.map(value => {
const token = value.match(numberRegex)![pos]
const dot = token.indexOf('.')
return dot === -1 ? 0 : token.length - dot - 1
})
return counts.every(c => c === counts[0]) && counts[0] > 0
? counts[0]
: null
})

// Use the first `output` as a template for each call
return (input: number) => {
// When `input` lands exactly on a keyframe, return that keyframe's
// output verbatim so token-level formatting (trailing zeros, signs,
// locale separators) survives the round-trip through `numberRegex`.
const keyIdx = inputRange.indexOf(input)
if (keyIdx !== -1) return output[keyIdx]

// Convert numbers to units if available (allows for ["0", "100%"])
const missingUnit =
!unitRegex.test(output[0]) &&
output.find(value => unitRegex.test(value))?.replace(numberRegex, '')

let i = 0
return output[0]
.replace(
numberRegex,
() => `${interpolators[i++](input)}${missingUnit || ''}`
)
.replace(numberRegex, () => {
const pos = i++
const value = interpolators[pos](input)
const decimals = decimalCounts[pos]
const formatted = decimals != null ? value.toFixed(decimals) : value
return `${formatted}${missingUnit || ''}`
})
.replace(rgbaRegex, rgbaRound)
}
}
Loading