Fix NaN in spring-animated SVG polygon points (#2791)#3757
Conversation
Document that the spring generator emits NaN when a physics parameter
(stiffness/mass) is 0 or an explicit undefined, and that this NaN
propagates through the complex-value mixer into an SVG polygon's points
list ("NaN,NaN NaN,NaN ...").
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…2791) getSpringOptions builds its parameters with `{ ...defaults, ...options }`, so an explicit `stiffness: undefined` (e.g. an optional prop forwarded into `transition`) overrides the default, and `0`/negative values pass straight through. Those then divide and feed Math.sqrt() during spring resolution, producing NaN spring output. For complex values such as an SVG <polygon>'s `points` attribute this surfaces as an invalid "NaN,NaN NaN,NaN ..." point list and console errors. Guard stiffness and mass to a positive, finite value, falling back to the defaults otherwise. The guard runs once at resolution time, not in the per-frame hot path. Fixes #2791 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Greptile SummaryThis PR fixes intermittent
Confidence Score: 3/5The stiffness/mass guards and tests are correct, but damping is subject to the same undefined-clobber path and has no guard, leaving the same class of NaN bug reachable. The stiffness and mass guards are sound and the new tests reproduce the exact reported failure. However, damping is subject to the identical spread-clobber mechanism: undefined overwrites the default 10, then dampingRatio = undefined / positive = NaN propagates through every spring branch. A user forwarding an optional transition prop with damping: undefined would still see NaN output after this fix. packages/motion-dom/src/animation/generators/spring.ts needs a damping guard matching the ones added for stiffness and mass. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["spring(options)"] --> B["getSpringOptions()"]
B --> C["Set defaults: stiffness=100, damping=10, mass=1"]
C --> D["...options spread\n(may clobber defaults with undefined)"]
D --> E{Physics or\nduration spring?}
E -->|duration/bounce| F["findSpring() derive stiffness/damping"]
E -->|visualDuration| G["Compute stiffness from visualDuration"]
E -->|physics| H["Use spread values as-is"]
F --> I
G --> I
H --> I
I["NaN guards (NEW)"]
I --> J{"stiffness > 0?"}
J -->|No: 0 / neg / NaN / undefined| K["stiffness = 100 default"]
J -->|Yes| L["Keep stiffness"]
K --> M{"mass > 0?"}
L --> M
M -->|No| N["mass = 1 default"]
M -->|Yes| O["Keep mass"]
N --> P["Compute dampingRatio, undampedAngularFreq"]
O --> P
P --> Q["Select spring regime underdamped / critical / overdamped"]
Q --> R["next(t) finite spring value"]
Reviews (1): Last reviewed commit: "Fix NaN in spring animations with falsy/..." | Re-trigger Greptile |
| if (!(springOptions.stiffness > 0)) { | ||
| springOptions.stiffness = springDefaults.stiffness | ||
| } | ||
| if (!(springOptions.mass > 0)) { | ||
| springOptions.mass = springDefaults.mass | ||
| } |
There was a problem hiding this comment.
damping: undefined not guarded — same NaN path as the fixed bug
stiffness and mass are guarded, but damping goes through the exact same spread-clobber mechanism and isn't protected. If a caller passes transition={{ damping: optionalProp }} where optionalProp is undefined, the ...options spread clobbers springDefaults.damping (10), leaving springOptions.damping = undefined. Then dampingRatio = undefined / (2 * Math.sqrt(stiffness * mass)) evaluates to NaN, which makes every branch comparison (NaN < 1, NaN === 1) false, so the overdamped path runs with a NaN dampedAngularFreq — producing NaN outputs for every frame.
A guard like if (!(springOptions.damping >= 0)) springOptions.damping = springDefaults.damping would cover undefined, NaN, and negative damping (note: >= 0 rather than > 0 because zero damping is a valid underdamped spring). The new test suite would benefit from a damping: undefined case as well.
| if (!(springOptions.stiffness > 0)) { | ||
| springOptions.stiffness = springDefaults.stiffness | ||
| } | ||
| if (!(springOptions.mass > 0)) { | ||
| springOptions.mass = springDefaults.mass | ||
| } |
There was a problem hiding this comment.
The comment says "non-positive or non-finite" but
Infinity satisfies Infinity > 0, so an Infinity stiffness or mass slips through the guard. With stiffness = Infinity, undampedAngularFreq = Math.sqrt(Infinity / mass) = Infinity, and A = (velocity + 0 * Infinity * delta) / Infinity = NaN, still producing NaN. Using isFinite alongside the positivity check closes this gap.
| if (!(springOptions.stiffness > 0)) { | |
| springOptions.stiffness = springDefaults.stiffness | |
| } | |
| if (!(springOptions.mass > 0)) { | |
| springOptions.mass = springDefaults.mass | |
| } | |
| if (!(springOptions.stiffness > 0) || !isFinite(springOptions.stiffness)) { | |
| springOptions.stiffness = springDefaults.stiffness | |
| } | |
| if (!(springOptions.mass > 0) || !isFinite(springOptions.mass)) { | |
| springOptions.mass = springDefaults.mass | |
| } |
Bug
Animating an SVG
<polygon>'spointswithtype: "spring"intermittently writes an invalid point list and floods the console:(With non-spring types the NaNs don't appear.) Reported in #2791.
Cause
The all-
NaNpoint list (with an otherwise correct comma/space template) can only occur if the spring's progress output isNaN. Tracing the spring generator:pointsstring),JSAnimationanimates a0 → 100progress spring and maps it back through the complex-value mixer. The mixer is not at fault — it produces finite output even with spring overshoot (progress> 1/< 0) and mismatched point counts.getSpringOptions()resolves physics parameters with{ ...defaults, ...options }. An explicitstiffness: undefined— e.g. an optional prop forwarded intotransition— clobbers the default, and0/negative values pass straight through. Those values then divide and feedMath.sqrt()during resolution (dampingRatio = damping / (2 * Math.sqrt(stiffness * mass)),undampedAngularFreq = sqrt(stiffness / mass)), yieldingNaNspring values for every frame.A
NaNvelocity, by contrast, is harmless — it's sanitised byvelocity || 0.Fix
Guard
stiffnessandmassto a positive, finite value ingetSpringOptions(), falling back to the defaults otherwise. The check runs once at resolution time, not in the per-frame hot path.This catches
0, negative,NaN, andundefinedwhile leaving valid values untouched.Tests
Failing-first (verified to fail on the unfixed code, pass after the fix):
packages/motion-dom/.../generators/__tests__/spring.test.ts—spring()withstiffness/massof0or explicitundefinedno longer returnsNaN.packages/motion-dom/.../__tests__/JSAnimation.test.ts— a spring over a polygonpointsstring never emits"NaN". Before the fix this produced exactly"NaN,NaN NaN,NaN NaN,NaN", matching the screenshot in the issue.The unit tests reproduce the precise symptom and are the regression gate; the root cause is environment-independent spring math, so no Cypress E2E is required. Full
motion-dom(476) andframer-motionclient (799) suites pass.Uncertainty
The original CodeSandbox reproduction is no longer reachable, so the exact
transitionthe reporter used couldn't be confirmed. The fix targets the only input class that reproduces the reported"NaN,NaN …"output — a non-positive/undefined spring physics parameter — and the precise symptom is reproduced and gated by the new tests. The separateExpected number, "undefined"message noted in the issue is a distinct, smaller symptom (an unresolved origin keyframe rendering asundefined) and is intentionally left out of scope to keep this fix minimal.Fixes #2791
🤖 Generated with Claude Code