From 1c1602db8735fb2b76a0dd5396c1cd894f87d7c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vi=CC=81ctor=20Falco=CC=81n?= Date: Tue, 7 Jul 2026 20:31:51 +0200 Subject: [PATCH] test(chart): guard the tooltip effect against the render-loop regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unit test for computeTooltipPosition covers the flip/clamp math, but the math was never broken — the crash came from the effect re-running on its own setPos. Add a component test (both review agents' top request) that renders ChartTooltipPortal and asserts the positioning effect does NOT re-run when the component re-renders with an unchanged coordinate, and DOES re-run when the coordinate changes. Verified it fails against the pre-fix dependency-less effect. ChartTooltipPortal is exported for this, matching how StackedBarShape is exported for its test. --- .../ui/chart-tooltip-portal.test.tsx | 58 +++++++++++++++++++ resources/js/components/ui/chart.tsx | 1 + 2 files changed, 59 insertions(+) create mode 100644 resources/js/components/ui/chart-tooltip-portal.test.tsx diff --git a/resources/js/components/ui/chart-tooltip-portal.test.tsx b/resources/js/components/ui/chart-tooltip-portal.test.tsx new file mode 100644 index 00000000..0ba6e2eb --- /dev/null +++ b/resources/js/components/ui/chart-tooltip-portal.test.tsx @@ -0,0 +1,58 @@ +import { fireEvent, render } from '@testing-library/react'; +import * as React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { ChartTooltipPortal } from './chart'; + +/** + * Renders the portal inside a `.recharts-wrapper` (the ancestor its layout + * effect looks up) and exposes a button that bumps unrelated state to force a + * re-render without touching `coordinate`. + */ +function Harness({ coordinate }: { coordinate: { x: number; y: number } }) { + const [, setTick] = React.useState(0); + + return ( +
+ + + tooltip + +
+ ); +} + +describe('ChartTooltipPortal', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + // The positioning effect reads the wrapper's rect, so a call to + // getBoundingClientRect is a proxy for "the effect ran". + it('does not recompute its position on a re-render with an unchanged coordinate (regression: PHP-LARAVEL-3B render loop)', () => { + const rectSpy = vi.spyOn(Element.prototype, 'getBoundingClientRect'); + const { getByText } = render(); + + const afterMount = rectSpy.mock.calls.length; + expect(afterMount).toBeGreaterThan(0); + + fireEvent.click(getByText('rerender')); + fireEvent.click(getByText('rerender')); + + // With the pre-fix dependency-less effect this count would climb on + // every render and the setPos feedback loop would eventually throw + // "Maximum update depth exceeded". + expect(rectSpy.mock.calls.length).toBe(afterMount); + }); + + it('recomputes its position when the coordinate changes', () => { + const rectSpy = vi.spyOn(Element.prototype, 'getBoundingClientRect'); + const { rerender } = render(); + + const afterMount = rectSpy.mock.calls.length; + + rerender(); + + expect(rectSpy.mock.calls.length).toBeGreaterThan(afterMount); + }); +}); diff --git a/resources/js/components/ui/chart.tsx b/resources/js/components/ui/chart.tsx index f01acb9a..886200cd 100644 --- a/resources/js/components/ui/chart.tsx +++ b/resources/js/components/ui/chart.tsx @@ -686,6 +686,7 @@ export { ChartContainer, ChartTooltip, ChartTooltipContent, + ChartTooltipPortal, ChartLegend, ChartLegendContent, ChartStyle,