fix: prevent animated line flicker with round/square strokeLinecap#7022
fix: prevent animated line flicker with round/square strokeLinecap#7022ckifer merged 4 commits intorecharts:mainfrom
Conversation
…echarts#6941) When animating Line, Rectangle, and Trapezoid draw-on via strokeDasharray, the gap was set to (totalLength - curLength), making the dash pattern cycle equal to the path length. Floating-point imprecision in the SVG renderer's string-to-float roundtrip could cause the cycle to land a hair short of the path length, starting a phantom repeat dash at the endpoint. With strokeLinecap="round" or "square", this repeat dash rendered as a visible flickering dot at the line's final destination during animation. Fix: use totalLength as the gap instead of (totalLength - curLength). The cycle is now always longer than the path, so no repeat can occur. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
WalkthroughUpdated stroke-dasharray construction and animation targets: Line.tsx adds helpers to build dash patterns using the path totalLength as trailing gaps; Rectangle and Trapezoid animations now target a trailing gap equal to totalLength. Tests and a single manifest line updated to match the new semantics. Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Suggested reviewers
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
test/cartesian/Line.animation.spec.tsx (1)
237-243:⚠️ Potential issue | 🟡 MinorStale test description after expectation update
Line 237's
it(...)description still says"100, 0"but the expectation was updated to"100px 100px"(line 243). Similarly, lines 379 and 387–388 contain comments referencing"0px hidden"that now contradict the updated expectations at lines 382 and 392.📝 Proposed description and comment updates
- it('should set the stroke-dasharray to 100, 0 when the animation is completed', async () => { + it('should set the stroke-dasharray to 100, 100 when the animation is completed', async () => {- // after travelling 100% of the path, the stroke-dasharray should be 100px visible and 0px hidden + // after travelling 100% of the path, the stroke-dasharray should be 100px visible and 100px gap (totalLength) to avoid float precision artifacts- * After the animation is completed, the stroke-dasharray should remain 100px visible and 0px hidden. + * After the animation is completed, the stroke-dasharray should remain 100px visible with a totalLength gap.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@test/cartesian/Line.animation.spec.tsx` around lines 237 - 243, Update the stale test description and comments to match the changed expectation values: change the it(...) description that currently references "100, 0" to reflect "100px 100px" for the stroke-dasharray check in the test that uses renderTestCase(), animationManager.setAnimationProgress(1) and getLine(), and update the nearby comments that mention "0px hidden" to reflect the new expected values used in the assertions (the lines around the other tests using getLine() and animationManager). Ensure all human-readable strings and inline comments match the actual assertions (e.g., "100px 100px" instead of "100, 0" and replace "0px hidden" with the corresponding updated expectation).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Outside diff comments:
In `@test/cartesian/Line.animation.spec.tsx`:
- Around line 237-243: Update the stale test description and comments to match
the changed expectation values: change the it(...) description that currently
references "100, 0" to reflect "100px 100px" for the stroke-dasharray check in
the test that uses renderTestCase(), animationManager.setAnimationProgress(1)
and getLine(), and update the nearby comments that mention "0px hidden" to
reflect the new expected values used in the assertions (the lines around the
other tests using getLine() and animationManager). Ensure all human-readable
strings and inline comments match the actual assertions (e.g., "100px 100px"
instead of "100, 0" and replace "0px hidden" with the corresponding updated
expectation).
- Add JSDoc docstrings to generateSimpleStrokeDasharray, repeat, and getStrokeDasharray functions (docstring coverage fix) - Update stale test description "100, 0" → "100, 100" - Update stale comments referencing "0px hidden" to reflect totalLength gap Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
src/cartesian/Line.tsx (2)
433-435: Add explicit return type annotation.
getStrokeDasharraylacks a: stringreturn type. As per coding guidelines, return types should be explicit for non-trivial functions.-const getStrokeDasharray = (length: number, totalLength: number, lines: number[]) => { +const getStrokeDasharray = (length: number, totalLength: number, lines: number[]): string => {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/cartesian/Line.tsx` around lines 433 - 435, The function getStrokeDasharray (params: length, totalLength, lines) is missing an explicit string return type; update its signature to declare a return type of : string (e.g., const getStrokeDasharray = (length: number, totalLength: number, lines: number[]): string => ...) and ensure the implementation still returns a string value to satisfy the annotation.
407-416: Missing explicit return type; O(n²) spread is worth addressing for long paths with small dash patterns.Per coding guidelines, return types should be explicit. Additionally, the spread-inside-loop pattern allocates a new array on every iteration — for a tiny
lineLengthon a long pathcountcan reach thousands, making this quadratic per animation tick.♻️ Suggested improvement
-function repeat(lines: number[], count: number) { +function repeat(lines: number[], count: number): number[] { const linesUnit = lines.length % 2 !== 0 ? [...lines, 0] : lines; - let result: number[] = []; - - for (let i = 0; i < count; ++i) { - result = [...result, ...linesUnit]; - } - - return result; + return Array.from({ length: count }, () => linesUnit).flat(); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/cartesian/Line.tsx` around lines 407 - 416, The repeat function lacks an explicit return type and is O(n²) because it spreads linesUnit into result on every iteration; add an explicit return type (number[]) to repeat, compute linesUnit as now (append 0 if odd), then avoid repeated spread by preallocating or using a single push loop: compute totalLength = linesUnit.length * count and either create a new Array(totalLength) and fill by indexing with a pointer, or use a nested for loop that pushes each element of linesUnit into result once per repetition; ensure you update the named symbols repeat, linesUnit, result, count, and lines accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@test/cartesian/Line.animation.spec.tsx`:
- Around line 683-688: Update the stale inline comment that reads "0px hidden"
to "100px hidden" in the Line.animation.spec.tsx test so it matches the
assertion using fullyVisibleLine ('100px 100px'); there are two occurrences of
this stale phrasing (the comment just above the
expect(getLine(container)).toHaveAttribute('stroke-dasharray', '100px 100px')
assertion and the similar comment later in the file), so change both to state
"100px hidden".
---
Nitpick comments:
In `@src/cartesian/Line.tsx`:
- Around line 433-435: The function getStrokeDasharray (params: length,
totalLength, lines) is missing an explicit string return type; update its
signature to declare a return type of : string (e.g., const getStrokeDasharray =
(length: number, totalLength: number, lines: number[]): string => ...) and
ensure the implementation still returns a string value to satisfy the
annotation.
- Around line 407-416: The repeat function lacks an explicit return type and is
O(n²) because it spreads linesUnit into result on every iteration; add an
explicit return type (number[]) to repeat, compute linesUnit as now (append 0 if
odd), then avoid repeated spread by preallocating or using a single push loop:
compute totalLength = linesUnit.length * count and either create a new
Array(totalLength) and fill by indexing with a pointer, or use a nested for loop
that pushes each element of linesUnit into result once per repetition; ensure
you update the named symbols repeat, linesUnit, result, count, and lines
accordingly.
- Add explicit return types to repeat() and getStrokeDasharray() - Replace O(n²) spread-in-loop in repeat() with preallocated array - Fix all remaining stale "Xpx hidden" comments in animation tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/cartesian/Line.tsx (1)
431-453: Add an initial value toreducefor defensive correctness.
lines.reduce((pre, next) => pre + next)throwsTypeErroron an empty array. While callers currently always produce a non-emptylines(viaString.prototype.split), the function signaturelines: number[]makes no such guarantee, and a future refactor could pass an empty array silently.🛡️ Suggested defensive fix
- const lineLength = lines.reduce((pre, next) => pre + next); + const lineLength = lines.reduce((pre, next) => pre + next, 0);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/cartesian/Line.tsx` around lines 431 - 453, The reduce call in getStrokeDasharray can throw on an empty lines array; make it defensive by providing an initial value (0) to lines.reduce so lineLength is 0 for empty arrays, then keep the rest of the logic unchanged (function: getStrokeDasharray, variables: lines, lineLength).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@src/cartesian/Line.tsx`:
- Around line 431-453: The reduce call in getStrokeDasharray can throw on an
empty lines array; make it defensive by providing an initial value (0) to
lines.reduce so lineLength is 0 for empty arrays, then keep the rest of the
logic unchanged (function: getStrokeDasharray, variables: lines, lineLength).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/cartesian/Line.tsx (2)
407-453:⚠️ Potential issue | 🟠 MajorOdd-count
strokeDasharraypatterns animate incorrectly —0-padding diverges from the SVG spec's duplication rule.If an odd number of values are given, the entire value will be repeated in order to set an even number of values. Padding with
0instead produces a visually different cycle:
User prop SVG spec effective cycle repeatwith0-padding"4"4 4(dash 4 → gap 4)4 0(dash 4 → gap 0 = solid line)"7 3 2"7 3 2 7 3 27 3 2 0(merges2-dash and next7-dash)If a single value is set, both the dash length and gap length take that value and repeat the pattern. If two values are set, the first sets the dash and the second sets the gap. So
strokeDasharray="4"must animate with gaps equal to the dash length, not zero.There is a second, compounding issue:
getStrokeDasharraycomputeslineLengthfrom the originallinesrather than the normalized (doubled) pattern. Forlines=[4],lineLength=4while the true SVG cycle is8. This causescountto be double what's needed, so the animated pattern overlaps on itself.The complete fix is to normalize
linesingetStrokeDasharraybefore computinglineLength, after whichrepeatcan drop its special-case:🐛 Proposed fix
function repeat(lines: number[], count: number): number[] { - const linesUnit = lines.length % 2 !== 0 ? [...lines, 0] : lines; const result: number[] = []; for (let i = 0; i < count; ++i) { - result.push(...linesUnit); + result.push(...lines); } return result; }const getStrokeDasharray = (length: number, totalLength: number, lines: number[]): string => { - const lineLength = lines.reduce((pre, next) => pre + next, 0); + // Per SVG spec, an odd-count list is duplicated to yield an even-count cycle + // e.g. "4" → [4, 4] (dash=4, gap=4), not [4, 0] + const normalizedLines = lines.length % 2 !== 0 ? [...lines, ...lines] : lines; + const lineLength = normalizedLines.reduce((pre, next) => pre + next, 0); if (!lineLength) { return generateSimpleStrokeDasharray(totalLength, length); } const count = Math.floor(length / lineLength); const remainLength = length % lineLength; let remainLines: number[] = []; - for (let i = 0, sum = 0; i < lines.length; sum += lines[i] ?? 0, ++i) { - const lineValue = lines[i]; + for (let i = 0, sum = 0; i < normalizedLines.length; sum += normalizedLines[i] ?? 0, ++i) { + const lineValue = normalizedLines[i]; if (lineValue != null && sum + lineValue > remainLength) { - remainLines = [...lines.slice(0, i), remainLength - sum]; + remainLines = [...normalizedLines.slice(0, i), remainLength - sum]; break; } } const emptyLines = remainLines.length % 2 === 0 ? [0, totalLength] : [totalLength]; - return [...repeat(lines, count), ...remainLines, ...emptyLines].map(line => `${line}px`).join(', '); + return [...repeat(normalizedLines, count), ...remainLines, ...emptyLines].map(line => `${line}px`).join(', '); };🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/cartesian/Line.tsx` around lines 407 - 453, Normalize the dash pattern to an even-length sequence before any length math: in getStrokeDasharray, create a normalizedLines = lines.length % 2 !== 0 ? [...lines, ...lines] : lines, use normalizedLines (not the original lines) to compute lineLength and to drive the loop that builds remainLines, and pass normalizedLines into repeat; then simplify repeat(lines, count) to always repeat the provided array without injecting a 0 for odd lengths (i.e., remove the zero-padding special-case), keeping the rest of getStrokeDasharray logic (remainLines and emptyLines) unchanged.
407-453:⚠️ Potential issue | 🟠 MajorOdd-count
strokeDasharraypatterns animate incorrectly due to0-padding instead of SVG-spec duplication.The SVG spec states that if
stroke-dasharraycontains an odd number of values, the list is duplicated to make it even. The current code pads with0instead, violating the spec:
Input SVG spec effective cycle Current code ( linesUnit)"4"4 4(dash=4, gap=4)4 0(dash=4, gap=0 → solid line)"7 3 2"7 3 2 7 3 27 3 2 0(merges2and7dashes)Critically,
lineLengthingetStrokeDasharrayis computed from the originallines, not the normalizedlinesUnit. Forlines=[4],lineLength=4while the correct SVG cycle is8, causingcountto double and the pattern to render as a continuous solid block during animation before snapping to the dashed pattern on completion.Fix by normalizing
linesbefore computinglineLength:🐛 Proposed fix
function repeat(lines: number[], count: number): number[] { - const linesUnit = lines.length % 2 !== 0 ? [...lines, 0] : lines; const result: number[] = []; for (let i = 0; i < count; ++i) { - result.push(...linesUnit); + result.push(...lines); } return result; }const getStrokeDasharray = (length: number, totalLength: number, lines: number[]): string => { - const lineLength = lines.reduce((pre, next) => pre + next, 0); + // Per SVG spec, odd-count dasharray is duplicated to make an even-count cycle + const normalizedLines = lines.length % 2 !== 0 ? [...lines, ...lines] : lines; + const lineLength = normalizedLines.reduce((pre, next) => pre + next, 0); if (!lineLength) { return generateSimpleStrokeDasharray(totalLength, length); } const count = Math.floor(length / lineLength); const remainLength = length % lineLength; let remainLines: number[] = []; - for (let i = 0, sum = 0; i < lines.length; sum += lines[i] ?? 0, ++i) { - const lineValue = lines[i]; + for (let i = 0, sum = 0; i < normalizedLines.length; sum += normalizedLines[i] ?? 0, ++i) { + const lineValue = normalizedLines[i]; if (lineValue != null && sum + lineValue > remainLength) { - remainLines = [...lines.slice(0, i), remainLength - sum]; + remainLines = [...normalizedLines.slice(0, i), remainLength - sum]; break; } } const emptyLines = remainLines.length % 2 === 0 ? [0, totalLength] : [totalLength]; - return [...repeat(lines, count), ...remainLines, ...emptyLines].map(line => `${line}px`).join(', '); + return [...repeat(normalizedLines, count), ...remainLines, ...emptyLines].map(line => `${line}px`).join(', '); };🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/cartesian/Line.tsx` around lines 407 - 453, The odd-length dash handling is wrong: normalize the input pattern by duplicating the array when its length is odd (replace linesUnit = [...lines, 0] with linesUnit = lines.length % 2 !== 0 ? [...lines, ...lines] : lines) and then use that normalized array everywhere in getStrokeDasharray (compute lineLength from the normalized array, use normalized array for count, remainLines loop and for repeat), ensuring repeat(linesUnit, count) and subsequent logic reference the duplicated pattern instead of the original lines so the SVG-spec cycle length is correct.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Outside diff comments:
In `@src/cartesian/Line.tsx`:
- Around line 407-453: Normalize the dash pattern to an even-length sequence
before any length math: in getStrokeDasharray, create a normalizedLines =
lines.length % 2 !== 0 ? [...lines, ...lines] : lines, use normalizedLines (not
the original lines) to compute lineLength and to drive the loop that builds
remainLines, and pass normalizedLines into repeat; then simplify repeat(lines,
count) to always repeat the provided array without injecting a 0 for odd lengths
(i.e., remove the zero-padding special-case), keeping the rest of
getStrokeDasharray logic (remainLines and emptyLines) unchanged.
- Around line 407-453: The odd-length dash handling is wrong: normalize the
input pattern by duplicating the array when its length is odd (replace linesUnit
= [...lines, 0] with linesUnit = lines.length % 2 !== 0 ? [...lines, ...lines] :
lines) and then use that normalized array everywhere in getStrokeDasharray
(compute lineLength from the normalized array, use normalized array for count,
remainLines loop and for repeat), ensuring repeat(linesUnit, count) and
subsequent logic reference the duplicated pattern instead of the original lines
so the SVG-spec cycle length is correct.
|
From Claude:
|
Bundle ReportChanges will increase total bundle size by 1.94kB (0.07%) ⬆️. This is within the configured threshold ✅ Detailed changes
Affected Assets, Files, and Routes:view changes for bundle: recharts/bundle-cjsAssets Changed:
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #7022 +/- ##
==========================================
- Coverage 90.12% 90.12% -0.01%
==========================================
Files 526 526
Lines 39183 39182 -1
Branches 5423 5423
==========================================
- Hits 35312 35311 -1
Misses 3862 3862
Partials 9 9 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Summary
strokeLinecap="round"or"square"totalLengthas the dasharray gap instead oftotalLength - curLength, ensuring the cycle always exceeds the path length so no repeat can occurFixes #6941
Why this approach
The flickering is caused by the SVG renderer independently parsing the dasharray values from strings back to floats. Even though
curLength + (totalLength - curLength)equals exactlytotalLengthin JavaScript, the SVG renderer'sparseFloat("X") + parseFloat("Y")can differ by a ULP, causing the pattern cycle to end a hair before the path endpoint. WithstrokeLinecap="round", the resulting repeat dash renders a visible dot.We considered two alternatives:
toFixed(8)): Reduces the probability but doesn't eliminate it, requires choosing a magic number of decimal places, and changes the format of clean integer valuesUsing
totalLengthas the gap makes the cyclecurLength + totalLength, which is always strictly greater than the path length (whencurLength > 0). This eliminates the entire class of problem with no visual difference — any gap that extends past the end of the path is invisible since there's nothing to render beyond it.Test plan
strokeLinecap="round",strokeWidth={4},dot={false}🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
Documentation
Tests