diff --git a/packages/plugin-charts/src/AdvancedChartImpl.tsx b/packages/plugin-charts/src/AdvancedChartImpl.tsx index 6177c84eb1..06953658be 100644 --- a/packages/plugin-charts/src/AdvancedChartImpl.tsx +++ b/packages/plugin-charts/src/AdvancedChartImpl.tsx @@ -170,6 +170,11 @@ export default function AdvancedChartImpl({ // Only emit the prop when explicitly disabled, so the default (animated) // behavior is byte-for-byte unchanged for every existing caller. const animProps = isAnimationActive === false ? { isAnimationActive: false as const } : {}; + // When the entrance animation is off there is no stuck-at-0 tween to heal, so + // tell ChartContainer to skip its settle re-mount — avoids a needless 1-frame + // reflow on the dashboard's first paint (#2756). Animated callers keep the + // heal; this object is empty for them, leaving their markup unchanged. + const containerProps = isAnimationActive === false ? { disableSettleRemount: true } : {}; const [isMobile, setIsMobile] = React.useState(false); // Recharts' top-level onClick payload: { activeLabel, activePayload, ... } @@ -358,7 +363,7 @@ export default function AdvancedChartImpl({ } }); return ( - + } /> + } /> @@ -447,8 +452,8 @@ export default function AdvancedChartImpl({ fill: resolveColor(palette[idx % palette.length]), })); return ( - - } {...treemapClickProps}> + + } {...treemapClickProps}> @@ -468,7 +473,7 @@ export default function AdvancedChartImpl({ return
; } return ( - + + @@ -504,6 +509,7 @@ export default function AdvancedChartImpl({ stroke={color} fill={color} fillOpacity={0.6} + {...animProps} /> ); })} @@ -515,7 +521,7 @@ export default function AdvancedChartImpl({ // Scatter chart if (chartType === 'scatter') { return ( - + ); @@ -564,7 +571,7 @@ export default function AdvancedChartImpl({ // Combo chart (mixed bar + line on same chart) if (chartType === 'combo') { return ( - + @@ -612,7 +619,7 @@ export default function AdvancedChartImpl({ const gslug = (c: string) => 'g' + c.replace(/[^a-zA-Z0-9]/g, ''); return ( - + {gradColors.map((c) => ( diff --git a/packages/plugin-charts/src/ChartContainerImpl.settleRemount.test.tsx b/packages/plugin-charts/src/ChartContainerImpl.settleRemount.test.tsx index 38367bf54f..30b6f6052e 100644 --- a/packages/plugin-charts/src/ChartContainerImpl.settleRemount.test.tsx +++ b/packages/plugin-charts/src/ChartContainerImpl.settleRemount.test.tsx @@ -145,4 +145,29 @@ describe('ChartContainer — settle re-mount (dashboard-chart-empty-first-render expect(mountCount).toBe(1); // no re-mount, no loop }); + + // #2756: dashboard charts render with `isAnimationActive={false}`, so there is + // no entrance-animation tween to heal. `disableSettleRemount` must fully + // suppress the settle re-mount — even at a positive, stable box — so the first + // paint is never followed by a needless ResponsiveContainer reflow. + it('never re-mounts when disableSettleRemount is set, even after settling at a non-zero box', () => { + act(() => { + render( + + + , + ); + }); + expect(mountCount).toBe(1); + + // A real, settled positive box would normally trigger exactly one re-mount… + fireResize(320, 240); + act(() => { + vi.advanceTimersByTime(500); + }); + + // …but the flag opts out of it entirely: the observer never even armed. + expect(mountCount).toBe(1); + expect(roCallback).toBeNull(); // no ResizeObserver was created + }); }); diff --git a/packages/plugin-charts/src/ChartContainerImpl.tsx b/packages/plugin-charts/src/ChartContainerImpl.tsx index 469c9643e8..496ac0ce84 100644 --- a/packages/plugin-charts/src/ChartContainerImpl.tsx +++ b/packages/plugin-charts/src/ChartContainerImpl.tsx @@ -48,12 +48,20 @@ function ChartContainer({ className, children, config, + disableSettleRemount, ...props }: React.ComponentProps<"div"> & { config: ChartConfig children: React.ComponentProps< typeof ResponsiveContainer >["children"] + /** + * Skip the settle re-mount below. Set by callers that render their series with + * `isAnimationActive={false}` (e.g. dashboard charts, see #2756): with no + * entrance-animation tween there is nothing to "heal", so re-mounting would + * only cost a needless 1-frame ResponsiveContainer reflow on first paint. + */ + disableSettleRemount?: boolean }) { const uniqueId = React.useId() const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` @@ -78,9 +86,17 @@ function ChartContainer({ // box). Headless/jsdom/happy-dom renders report a 0×0 box, so `settleNonce` // stays 0 and those tests see a single, ordinary render. See // dashboard-chart-empty-first-render. + // + // NOTE (#2756): the settle re-mount only *heals* an interrupted entrance + // animation — it bets that a clean re-mount replays the tween to completion. + // In a live react-grid-layout dashboard that bet doesn't hold (the re-mount + // itself can land back in the grid/measurement churn), so dashboard charts + // instead render with `isAnimationActive={false}` and pass + // `disableSettleRemount` — there is no tween to heal and no reflow to pay for. const containerRef = React.useRef(null) const [settleNonce, setSettleNonce] = React.useState(0) React.useEffect(() => { + if (disableSettleRemount) return const el = containerRef.current if (el == null || typeof ResizeObserver === "undefined") return @@ -106,7 +122,7 @@ function ChartContainer({ if (timer != null) clearTimeout(timer) observer.disconnect() } - }, []) + }, [disableSettleRemount]) return ( diff --git a/packages/plugin-dashboard/src/DashboardGridLayout.tsx b/packages/plugin-dashboard/src/DashboardGridLayout.tsx index 08b3071118..39e15ba994 100644 --- a/packages/plugin-dashboard/src/DashboardGridLayout.tsx +++ b/packages/plugin-dashboard/src/DashboardGridLayout.tsx @@ -194,6 +194,8 @@ export const DashboardGridLayout: React.FC = ({ xAxisKey: xAxisKey, series: [{ dataKey: effectiveYField }], colors: CHART_COLORS, + // Deterministic first paint inside the grid (#2756). + isAnimationActive: false, className: "h-full" }; } @@ -207,6 +209,8 @@ export const DashboardGridLayout: React.FC = ({ xAxisKey: xAxisKey, series: [{ dataKey: yField }], colors: CHART_COLORS, + // Deterministic first paint inside the grid (#2756). + isAnimationActive: false, className: "h-full" }; } diff --git a/packages/plugin-dashboard/src/DashboardRenderer.tsx b/packages/plugin-dashboard/src/DashboardRenderer.tsx index b89f4eaba0..15a43873f5 100644 --- a/packages/plugin-dashboard/src/DashboardRenderer.tsx +++ b/packages/plugin-dashboard/src/DashboardRenderer.tsx @@ -529,6 +529,9 @@ const DashboardRendererInner = forwardRef diff --git a/packages/plugin-dashboard/src/__tests__/DatasetWidget.animation.test.tsx b/packages/plugin-dashboard/src/__tests__/DatasetWidget.animation.test.tsx new file mode 100644 index 0000000000..64d678ea93 --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/DatasetWidget.animation.test.tsx @@ -0,0 +1,60 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #2756 — dashboard charts must render at final geometry on the FIRST committed + * frame. Recharts' entrance animation is a requestAnimationFrame tween that + * starts at height 0 and, inside react-grid-layout's mount-time measurement + * churn, can freeze there — the axes/labels paint but the bars never draw until + * an unrelated re-render. #2727's settle re-mount tried to heal that live and + * didn't. The deterministic fix: dashboard chart widgets pass + * `isAnimationActive: false`, so there is no tween to freeze. + * + * This asserts the wiring at the source — the chart schema DatasetWidget hands + * to the renderer carries the flag — captured via a stubbed SchemaRenderer. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, waitFor } from '@testing-library/react'; + +let lastChartSchema: any = null; + +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + SchemaRenderer: (props: any) => { + lastChartSchema = props.schema; + return null; + }, +})); + +import { DatasetWidget } from '../DatasetWidget'; + +afterEach(() => { + cleanup(); + lastChartSchema = null; +}); + +describe('DatasetWidget — dashboard chart animation (#2756)', () => { + it('hands the chart renderer isAnimationActive: false so bars draw on first paint', async () => { + const src = { queryDataset: vi.fn(async () => ({ + rows: [ + { status: '合作中', count: 5 }, + { status: '已流失', count: 3 }, + { status: '潜在', count: 4 }, + ], + })) }; + + render( + , + ); + + // Once data resolves the chart branch renders through SchemaRenderer. + await waitFor(() => expect(lastChartSchema).not.toBeNull()); + expect(lastChartSchema.type).toBe('chart'); + expect(lastChartSchema.chartType).toBe('bar'); + // The fix: the entrance-animation tween is turned off. + expect(lastChartSchema.isAnimationActive).toBe(false); + }); +});