Fix NaN in spring-animated SVG polygon points (#2791) - #3757
Conversation
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 | |
| } |
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>
Reworks the guard added in the previous commit: - Cover `damping` as well as `stiffness`/`mass`. An explicit `damping: undefined` reproduced the original bug in full — NaN points and a spring that never reports done, so the frameloop spins forever. - Reject non-finite values. `!(x > 0)` let `Infinity` through, which still resolved to NaN despite the comment claiming otherwise. - Resolve physics *before* choosing between physics- and duration-based resolution. `stiffness: 0` counted as "physics specified" via `isSpringType`, so a provided `duration`/`bounce` was silently discarded. - Replace stiffness and damping as a pair when duration resolution degenerates, so the relationship between them is never left half-overwritten. - Treat `visualDuration` with `!== undefined` rather than truthiness. - Warn on invalid physics rather than silently substituting defaults. `damping: 0` remains valid — it's a perpetually oscillating spring, and only stiffness and mass are divisors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8f53804 to
02d3fe4
Compare
|
Addressed code review feedback and rebased onto FixedThe original guard was incomplete — it ran too late and covered the wrong set of values:
TestsThe NaN-guard suite now tables all three physics keys against every way each can be invalid ( Verified these are real gates: reverting Other findings, not changed
Checks
|
MILLERMARRU
left a comment
There was a problem hiding this comment.
The spring physics logic itself is solid. Tracing through resolvePhysics, the object-spread ordering was the real bug: the old code spread ...options after the property defaults, and in JS an explicitly present stiffness: undefined key (which is exactly what happens when a component forwards an optional prop that wasn't set) survives a spread and overwrites the default rather than falling through to it. Moving the physics resolution to compute stiffness ?? springDefaults.stiffness etc. after the spread, and doing it for all three physics keys together before deciding between physics- and duration-based resolution, closes both the original NaN report and the more subtle case the tests call out, where stiffness: 0 used to be treated as "physics was specified" and silently discarded a valid duration/visualDuration.
One thing that looks like it'll break the build though: spring.ts imports warning from motion-utils, but that package's public entry only re-exports warn-once.ts, which exports warnOnce and hasWarned, no warning. I checked the full file listing under packages/motion-utils/src and the git history on warn-once.ts, there's never been a plain warning export there. This PR also doesn't touch anything under motion-utils per the file list, so as it stands import { warning } from "motion-utils" has nothing to resolve to.
It also looks like warnOnce's actual behavior wouldn't give the test at the bottom (invalid physics warns rather than failing silently) what it expects anyway, even if the name were swapped in as a quick fix. warnOnce dedupes by the exact message string process-wide via a module-level Set, and every resolvePhysics call in this file uses the same message text regardless of which key or value triggered it. The parameterized "does not produce NaN" tests earlier in the same file already exercise stiffness: 0-equivalent invalid values multiple times before that later test runs, so under real warnOnce semantics the message would already be marked as warned by the time that assertion runs, and console.warn wouldn't fire a second time for it. That reads like the intent here is a warning that fires on every occurrence, which isn't what either existing export in motion-utils does today.
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