diff --git a/packages/victory-area/src/area.tsx b/packages/victory-area/src/area.tsx index f50850242..cba33c134 100644 --- a/packages/victory-area/src/area.tsx +++ b/packages/victory-area/src/area.tsx @@ -9,6 +9,7 @@ import { VictoryCommonPrimitiveProps, LineHelpers, VictoryCommonThemeProps, + roundPathString, } from "victory-core"; const defined = (d) => { @@ -147,7 +148,7 @@ export const Area: React.FC = (initialProps) => { { key: `${id}-area`, style: Object.assign({}, style, { stroke: areaStroke }), - d: areaFunction(data), + d: roundPathString(areaFunction(data)), desc, tabIndex, }, @@ -163,7 +164,7 @@ export const Area: React.FC = (initialProps) => { { key: `${id}-area-stroke`, style: Object.assign({}, style, { fill: "none" }), - d: lineFunction(data), + d: roundPathString(lineFunction(data)), }, sharedProps, ), diff --git a/packages/victory-core/src/victory-util/index.ts b/packages/victory-core/src/victory-util/index.ts index 2788c32ad..02fa70efd 100644 --- a/packages/victory-core/src/victory-util/index.ts +++ b/packages/victory-core/src/victory-util/index.ts @@ -12,6 +12,7 @@ export * as Hooks from "./hooks"; export * as Immutable from "./immutable"; export * as LabelHelpers from "./label-helpers"; export * as LineHelpers from "./line-helpers"; +export * from "./round-path-string"; export * as Log from "./log"; export * as PointPathHelpers from "./point-path-helpers"; export * as Scale from "./scale"; diff --git a/packages/victory-core/src/victory-util/round-path-string.test.ts b/packages/victory-core/src/victory-util/round-path-string.test.ts new file mode 100644 index 000000000..d1c1debef --- /dev/null +++ b/packages/victory-core/src/victory-util/round-path-string.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest"; + +import { roundPathString } from "./round-path-string"; + +describe("roundPathString", () => { + it("normalizes floating point precision differences", () => { + expect(roundPathString("M60,240L252.50000000000003,67.2")).toBe( + "M60,240L252.5,67.2", + ); + }); + + it("leaves integer coordinates untouched", () => { + expect(roundPathString("M0,0L100,200Z")).toBe("M0,0L100,200Z"); + }); + + it("rounds to 3 decimals by default", () => { + expect(roundPathString("M1.123456,2.987654")).toBe("M1.123,2.988"); + }); + + it("honors a custom precision", () => { + expect(roundPathString("M1.123456,2.987654", 1)).toBe("M1.1,3"); + }); + + it("handles negative numbers and scientific notation", () => { + // -1.5e2 = -150 -> rounds to -150 (integer, untouched); 3.14159265 -> 3.142 + expect(roundPathString("M-1.5e2,0L3.14159265,0")).toBe("M-150,0L3.142,0"); + }); +}); diff --git a/packages/victory-core/src/victory-util/round-path-string.ts b/packages/victory-core/src/victory-util/round-path-string.ts new file mode 100644 index 000000000..e72f58521 --- /dev/null +++ b/packages/victory-core/src/victory-util/round-path-string.ts @@ -0,0 +1,29 @@ +/** + * Rounds the numeric coordinates in an SVG path `d` string to a fixed number + * of decimal places. + * + * d3-shape can emit values like `252.50000000000003` on one platform/build and + * `252.5` on another (e.g. server vs. client in a Next.js app), which causes + * React hydration mismatches because the serialized `d` attribute differs. + * Rounding every coordinate to `precision` decimals makes the output + * deterministic across environments while being visually indistinguishable at + * rendered scale. + * + * @example + * roundPathString("M60,240L252.50000000000003,67.2") + * // => "M60,240L252.5,67.2" + */ +export function roundPathString(d: string, precision = 3): string { + return d.replace( + /(-?\d*\.?\d+(?:[eE][-+]?\d+)?)/g, + (num) => { + const parsed = Number(num); + // Leave integers untouched to keep the string minimal. + if (Number.isInteger(parsed)) { + return String(parsed); + } + const rounded = parseFloat(parsed.toFixed(precision)); + return String(rounded); + }, + ); +} diff --git a/packages/victory-line/src/curve.tsx b/packages/victory-line/src/curve.tsx index 83000ef17..b2f6a6c2c 100644 --- a/packages/victory-line/src/curve.tsx +++ b/packages/victory-line/src/curve.tsx @@ -10,6 +10,7 @@ import { StringOrCallback, NumberOrCallback, VictoryCommonPrimitiveProps, + roundPathString, } from "victory-core"; const evaluateProps = (props) => { @@ -47,7 +48,7 @@ export const Curve: React.FC = (initialProps) => { const lineFunction = LineHelpers.getLineFunction(props); const defaultTransform = polar && origin ? `translate(${origin.x}, ${origin.y})` : undefined; - const d = lineFunction(props.data); + const d = roundPathString(lineFunction(props.data)); return React.cloneElement(props.pathComponent!, { ...props.events,